Skip to main content

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>
params.sessionId
string
The session making the request.
params.uri
string
Absolute file path to read.
content
string
required
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>
params.sessionId
string
The session making the request.
params.uri
string
Absolute file path to write.
params.content
string
The content to write (UTF-8).
(empty)
object
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

Build docs developers (and LLMs) love