Overview
File system capabilities allow agents to read and write files in the client’s environment. These operations require careful security considerations to prevent unauthorized access.
Advertising Capabilities
Declare file system support during initialization:
await connection.initialize({
protocolVersion: acp.PROTOCOL_VERSION,
clientCapabilities: {
fs: {
readTextFile: true, // Enable file reading
writeTextFile: true, // Enable file writing
},
},
});
Only advertise capabilities you actually implement. The agent will call these methods if you declare support.
readTextFile()
Reads content from a text file in the client’s file system.
Method Signature
async readTextFile(
params: ReadTextFileRequest
): Promise<ReadTextFileResponse>
The session making the request.
Absolute file path to read.
The file contents as a UTF-8 string.
Basic Implementation
import { readFile } from "node:fs/promises";
import * as acp from "@agentclientprotocol/acp";
class MyClient implements acp.Client {
async readTextFile(
params: acp.ReadTextFileRequest
): Promise<acp.ReadTextFileResponse> {
try {
const content = await readFile(params.uri, "utf-8");
return { content };
} catch (error) {
if (error.code === "ENOENT") {
throw new acp.RequestError.resourceNotFound(params.uri);
}
throw new Error(`Failed to read file: ${error.message}`);
}
}
}
Secure Implementation
Add path validation and sandboxing:
import { readFile } from "node:fs/promises";
import { resolve, relative } from "node:path";
import * as acp from "@agentclientprotocol/acp";
class SecureClient implements acp.Client {
constructor(private allowedRoot: string) {}
private isPathAllowed(path: string): boolean {
const normalizedPath = resolve(path);
const normalizedRoot = resolve(this.allowedRoot);
const rel = relative(normalizedRoot, normalizedPath);
// Path must be within root and not use ..
return !rel.startsWith("..") && !path.isAbsolute(rel);
}
async readTextFile(
params: acp.ReadTextFileRequest
): Promise<acp.ReadTextFileResponse> {
// Validate path
if (!this.isPathAllowed(params.uri)) {
throw new Error(`Access denied: ${params.uri}`);
}
// Check file size before reading
const stats = await stat(params.uri);
const MAX_SIZE = 10 * 1024 * 1024; // 10MB
if (stats.size > MAX_SIZE) {
throw new Error(`File too large: ${params.uri}`);
}
try {
const content = await readFile(params.uri, "utf-8");
return { content };
} catch (error) {
if (error.code === "ENOENT") {
throw acp.RequestError.resourceNotFound(params.uri);
}
throw new Error(`Failed to read file: ${error.message}`);
}
}
}
writeTextFile()
Writes content to a text file in the client’s file system.
Method Signature
async writeTextFile(
params: WriteTextFileRequest
): Promise<WriteTextFileResponse>
The session making the request.
Absolute file path to write.
The content to write (UTF-8).
Returns an empty object {} on success.
Basic Implementation
import { writeFile, mkdir } from "node:fs/promises";
import { dirname } from "node:path";
import * as acp from "@agentclientprotocol/acp";
class MyClient implements acp.Client {
async writeTextFile(
params: acp.WriteTextFileRequest
): Promise<acp.WriteTextFileResponse> {
try {
// Ensure directory exists
await mkdir(dirname(params.uri), { recursive: true });
// Write file
await writeFile(params.uri, params.content, "utf-8");
return {};
} catch (error) {
throw new Error(`Failed to write file: ${error.message}`);
}
}
}
Secure Implementation
Add validation, backups, and atomic writes:
import { writeFile, mkdir, copyFile, stat } from "node:fs/promises";
import { dirname, join } from "node:path";
import * as acp from "@agentclientprotocol/acp";
class SecureClient implements acp.Client {
constructor(
private allowedRoot: string,
private backupDir: string
) {}
async writeTextFile(
params: acp.WriteTextFileRequest
): Promise<acp.WriteTextFileResponse> {
// Validate path
if (!this.isPathAllowed(params.uri)) {
throw new Error(`Access denied: ${params.uri}`);
}
// Validate content size
const MAX_SIZE = 10 * 1024 * 1024; // 10MB
if (params.content.length > MAX_SIZE) {
throw new Error("Content too large");
}
try {
// Create backup if file exists
try {
await stat(params.uri);
const backupPath = join(
this.backupDir,
`${basename(params.uri)}.${Date.now()}.bak`
);
await copyFile(params.uri, backupPath);
} catch (error) {
// File doesn't exist, no backup needed
}
// Ensure directory exists
await mkdir(dirname(params.uri), { recursive: true });
// Atomic write: write to temp file, then rename
const tempPath = `${params.uri}.tmp`;
await writeFile(tempPath, params.content, "utf-8");
await rename(tempPath, params.uri);
return {};
} catch (error) {
throw new Error(`Failed to write file: ${error.message}`);
}
}
}
Security Considerations
Path Sandboxing
Restrict file access to specific directories:
class SandboxedClient {
private allowedPaths: string[];
constructor(allowedPaths: string[]) {
this.allowedPaths = allowedPaths.map(p => resolve(p));
}
private isPathAllowed(path: string): boolean {
const normalizedPath = resolve(path);
return this.allowedPaths.some(allowedPath => {
const rel = relative(allowedPath, normalizedPath);
return !rel.startsWith("..") && !path.isAbsolute(rel);
});
}
}
File Type Restrictions
Restrict which file types can be accessed:
const ALLOWED_EXTENSIONS = new Set([
".ts", ".js", ".tsx", ".jsx",
".json", ".md", ".txt",
".css", ".scss", ".html",
]);
function isFileTypeAllowed(path: string): boolean {
const ext = extname(path).toLowerCase();
return ALLOWED_EXTENSIONS.has(ext);
}
const BLOCKED_PATTERNS = [
/\.env/,
/\.secret/,
/credentials/i,
/password/i,
/private.*key/i,
];
function isSensitiveFile(path: string): boolean {
const filename = basename(path);
return BLOCKED_PATTERNS.some(pattern => pattern.test(filename));
}
Size Limits
Enforce file size limits:
const MAX_FILE_SIZE = 10 * 1024 * 1024; // 10MB
async function checkFileSize(path: string) {
const stats = await stat(path);
if (stats.size > MAX_FILE_SIZE) {
throw new Error(`File too large: ${path} (${stats.size} bytes)`);
}
}
Binary File Detection
Prevent reading binary files as text:
import { readFile } from "node:fs/promises";
function isBinary(buffer: Buffer): boolean {
// Check first 8KB for null bytes
const sample = buffer.slice(0, 8192);
return sample.includes(0);
}
async function readTextFileSafe(path: string): Promise<string> {
const buffer = await readFile(path);
if (isBinary(buffer)) {
throw new Error(`File is binary: ${path}`);
}
return buffer.toString("utf-8");
}
Error Handling
Standard Errors
Use standard error codes:
import * as acp from "@agentclientprotocol/acp";
async function readTextFile(params: acp.ReadTextFileRequest) {
try {
const content = await readFile(params.uri, "utf-8");
return { content };
} catch (error) {
// File not found
if (error.code === "ENOENT") {
throw acp.RequestError.resourceNotFound(params.uri);
}
// Permission denied
if (error.code === "EACCES") {
throw new acp.RequestError(
-32000,
"Permission denied",
{ uri: params.uri }
);
}
// Other errors
throw acp.RequestError.internalError({ message: error.message });
}
}
User Notifications
Notify users about file operations:
class NotifyingClient implements acp.Client {
async writeTextFile(
params: acp.WriteTextFileRequest
): Promise<acp.WriteTextFileResponse> {
// Show notification
this.showNotification({
type: "info",
message: `Writing file: ${basename(params.uri)}`,
});
try {
await writeFile(params.uri, params.content, "utf-8");
// Show success
this.showNotification({
type: "success",
message: `Saved ${basename(params.uri)}`,
});
return {};
} catch (error) {
// Show error
this.showNotification({
type: "error",
message: `Failed to save ${basename(params.uri)}: ${error.message}`,
});
throw error;
}
}
}
Version Control Integration
For editors with git integration:
import { exec } from "node:child_process";
import { promisify } from "node:util";
const execAsync = promisify(exec);
class GitAwareClient implements acp.Client {
async writeTextFile(
params: acp.WriteTextFileRequest
): Promise<acp.WriteTextFileResponse> {
// Write file
await writeFile(params.uri, params.content, "utf-8");
// Auto-stage if in git repo
try {
await execAsync(`git add "${params.uri}"`);
} catch {
// Not in a git repo or git not available
}
return {};
}
}
Testing
import { describe, it, expect, beforeEach, afterEach } from "vitest";
import { mkdtemp, rm } from "node:fs/promises";
import { tmpdir } from "node:os";
import { join } from "node:path";
describe("File System Operations", () => {
let tempDir: string;
let client: MyClient;
beforeEach(async () => {
tempDir = await mkdtemp(join(tmpdir(), "acp-test-"));
client = new MyClient(tempDir);
});
afterEach(async () => {
await rm(tempDir, { recursive: true });
});
it("should write and read text file", async () => {
const path = join(tempDir, "test.txt");
const content = "Hello, world!";
// Write
await client.writeTextFile({
sessionId: "test",
uri: path,
content,
});
// Read
const result = await client.readTextFile({
sessionId: "test",
uri: path,
});
expect(result.content).toBe(content);
});
it("should reject paths outside sandbox", async () => {
await expect(
client.readTextFile({
sessionId: "test",
uri: "/etc/passwd",
})
).rejects.toThrow("Access denied");
});
it("should handle missing files", async () => {
await expect(
client.readTextFile({
sessionId: "test",
uri: join(tempDir, "missing.txt"),
})
).rejects.toThrow();
});
});
See Also