Skip to content

Commit 62c5991

Browse files
committed
fix(apply_patch): enforce workspace containment and secret-guard on raw reads
readRawFile bypassed every ToolPlugin (pathEscapePlugin, secretGuardPlugin, authzPlugin, permissionPlugin) since apply_patch's applyOp calls it outside the posixTools middleware chain entirely. requireRelativePath only rejects absolute paths, not `../` traversal, so an Update File op naming a path like ../../.env with an insertion-only hunk (no context match required) could read a secret and hand it to write_file via Move to — exfiltration, not just an unauthorized read. readRawFile now reuses the same containment and secret-file authorities the plugin chain already uses instead of reimplementing them: resolveWorkspacePath (symlink-aware realpath containment, from path-restriction.ts) and isSensitivePath (the secret-guard denylist). Containment honors the same skipPermissions/yolo escape hatch pathEscapePlugin does; the secret check never bypasses, matching secretGuardPlugin's own unconditional behavior. Both are hard failures with a clear error, not a silent fallthrough. Added regression tests for the attack shapes: ../ traversal out of the workspace, a symlinked directory leading outside the workspace, a secret-guard path (.env) under skipPermissions, and ../ traversal to a secret file — all via the insertion-only hunk shape that made the unauthorized read exploitable.
1 parent 8a163f5 commit 62c5991

4 files changed

Lines changed: 166 additions & 11 deletions

File tree

‎src/agent/apply-patch-diff.test.ts‎

Lines changed: 102 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
import { describe, expect, test } from "bun:test";
2-
import { mkdtemp, rm, writeFile } from "node:fs/promises";
2+
import { mkdir, mkdtemp, rm, symlink, writeFile } from "node:fs/promises";
33
import { tmpdir } from "node:os";
44
import { join } from "node:path";
55
import { createPosixTools } from "@intx/tools-posix";
@@ -24,11 +24,14 @@ async function invokeApplyPatch(tools: AgentTool[], input: string) {
2424
);
2525
}
2626

27-
async function makeApplyPatch(cwd: string): Promise<AgentTool[]> {
27+
async function makeApplyPatch(
28+
cwd: string,
29+
options: { skipPermissions?: boolean } = {},
30+
): Promise<AgentTool[]> {
2831
const gate = createPermissionGate({
2932
approvals: [],
3033
interactive: false,
31-
skipPermissions: true,
34+
skipPermissions: options.skipPermissions ?? true,
3235
auto: false,
3336
cwd,
3437
});
@@ -51,7 +54,7 @@ async function makeApplyPatch(cwd: string): Promise<AgentTool[]> {
5154
return createCodexToolProxies({
5255
isCodex: true,
5356
runTool,
54-
readRawFile: createCodexReadRawFile(cwd),
57+
readRawFile: createCodexReadRawFile(cwd, gate),
5558
runManageTasks: async () => ({ content: "ok" }),
5659
});
5760
}
@@ -148,3 +151,98 @@ describe("apply_patch Update File matches raw content, not read_file's numbered
148151
}
149152
});
150153
});
154+
155+
describe("apply_patch Update File refuses reads outside the sanctioned workspace (CL-6966 follow-up)", () => {
156+
// Insertion-only hunk (no context to match): the shape that makes an
157+
// unauthorized raw read exploitable rather than merely wrong, since it
158+
// requires no content match to "succeed" and hands the read content
159+
// straight to write_file via Move to.
160+
const insertionOnlyMoveInput = (path: string, moveTo: string) =>
161+
[
162+
"*** Begin Patch",
163+
`*** Update File: ${path}`,
164+
`*** Move to: ${moveTo}`,
165+
"@@",
166+
"+",
167+
"*** End Patch",
168+
].join("\n");
169+
170+
test("../ traversal out of the workspace is refused", async () => {
171+
const parent = await mkdtemp(join(tmpdir(), "apply-patch-cl6966-parent-"));
172+
const cwd = join(parent, "workspace");
173+
await mkdir(cwd);
174+
try {
175+
await writeFile(join(parent, "victim.txt"), "outside secret\n");
176+
const tools = await makeApplyPatch(cwd, { skipPermissions: false });
177+
178+
const result = await invokeApplyPatch(
179+
tools,
180+
insertionOnlyMoveInput("../victim.txt", "leaked.txt"),
181+
);
182+
183+
expect(result.isError).toBe(true);
184+
expect(String(result.content)).toMatch(/escapes working directory/i);
185+
} finally {
186+
await rm(parent, { recursive: true, force: true });
187+
}
188+
});
189+
190+
test("a symlinked directory leading outside the workspace is refused", async () => {
191+
const parent = await mkdtemp(join(tmpdir(), "apply-patch-cl6966-symlink-"));
192+
const cwd = join(parent, "workspace");
193+
const outside = join(parent, "outside");
194+
await mkdir(cwd);
195+
await mkdir(outside);
196+
try {
197+
await writeFile(join(outside, "victim.txt"), "outside secret\n");
198+
await symlink(outside, join(cwd, "escape-link"));
199+
const tools = await makeApplyPatch(cwd, { skipPermissions: false });
200+
201+
const result = await invokeApplyPatch(
202+
tools,
203+
insertionOnlyMoveInput("escape-link/victim.txt", "leaked.txt"),
204+
);
205+
206+
expect(result.isError).toBe(true);
207+
expect(String(result.content)).toMatch(/escapes working directory/i);
208+
} finally {
209+
await rm(parent, { recursive: true, force: true });
210+
}
211+
});
212+
213+
test("a secret-guard path (.env) is refused even with skipPermissions", async () => {
214+
const cwd = await mkdtemp(join(tmpdir(), "apply-patch-cl6966-secret-"));
215+
try {
216+
await writeFile(join(cwd, ".env"), "API_KEY=super-secret\n");
217+
// skipPermissions: true (yolo) — secret-guard has no bypass, unlike containment.
218+
const tools = await makeApplyPatch(cwd, { skipPermissions: true });
219+
220+
const result = await invokeApplyPatch(tools, insertionOnlyMoveInput(".env", "leaked.txt"));
221+
222+
expect(result.isError).toBe(true);
223+
expect(String(result.content)).toMatch(/sensitive file/i);
224+
// The secret must never have reached the workspace under a new name.
225+
expect(await Bun.file(join(cwd, "leaked.txt")).exists()).toBe(false);
226+
} finally {
227+
await rm(cwd, { recursive: true, force: true });
228+
}
229+
});
230+
231+
test("../ traversal to a secret file is refused (secret-guard applies to relative paths too)", async () => {
232+
const parent = await mkdtemp(join(tmpdir(), "apply-patch-cl6966-secret-parent-"));
233+
const cwd = join(parent, "workspace");
234+
await mkdir(cwd);
235+
try {
236+
await writeFile(join(parent, ".env"), "API_KEY=super-secret\n");
237+
const tools = await makeApplyPatch(cwd, { skipPermissions: false });
238+
239+
const result = await invokeApplyPatch(tools, insertionOnlyMoveInput("../.env", "leaked.txt"));
240+
241+
expect(result.isError).toBe(true);
242+
expect(String(result.content)).toMatch(/sensitive file|escapes working directory/i);
243+
expect(await Bun.file(join(cwd, "leaked.txt")).exists()).toBe(false);
244+
} finally {
245+
await rm(parent, { recursive: true, force: true });
246+
}
247+
});
248+
});

‎src/agent/codex-read-raw-file.ts‎

Lines changed: 62 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,18 +1,75 @@
11
/**
22
* Raw (non-`cat -n`) file reads for apply_patch's Update File leg (CL-6966).
3-
* Mirrors the error mapping in @intx/tools-posix's read-file.js so failures
4-
* read the same way `read_file` would report them.
3+
*
4+
* `applyOp` calls this directly from outside the posixTools middleware chain
5+
* — no ToolPlugin ever sees `op.path` here, unlike the write leg which still
6+
* goes through the full pathEscapePlugin / secretGuardPlugin / authzPlugin /
7+
* permissionPlugin stack (see buildCorePosixToolPlugins in
8+
* posix-tool-plugins.ts). `requireRelativePath` in codex-apply-patch.ts only
9+
* rejects absolute paths — it does nothing about `../` traversal — so this
10+
* reader must apply the same containment and secret-file checks itself, or
11+
* an Update File op naming e.g. `../../.env` (with a no-op insertion hunk,
12+
* which requires no context match) can read a secret and hand it straight to
13+
* write_file as an exfiltration primitive.
14+
*
15+
* Reuses the existing containment and secret-file authorities rather than
16+
* reimplementing them: `resolveWorkspacePath` (symlink-aware realpath
17+
* containment, the same function pathEscapePlugin calls) and `isSensitivePath`
18+
* (the secretGuardPlugin denylist). Both are hard denials — the latter has no
19+
* yolo/allowOutside bypass, matching secretGuardPlugin's own unconditional
20+
* behavior.
521
*/
622

723
import { readFile } from "node:fs/promises";
824
import { resolve } from "node:path";
925
import { hasCode } from "@intx/types";
26+
import { resolveWorkspacePath } from "../permission/path-restriction.js";
27+
import { createWorktreeRootsProvider } from "../permission/worktree-roots.js";
28+
import { isSensitivePath } from "../plugins/secret-guard-plugin.js";
29+
import type { PermissionGate } from "../permission/gate.js";
1030
import type { CodexReadRawFile } from "./codex-tool-proxies.js";
1131

12-
/** `path` is a workspace-relative path (apply_patch rejects absolute paths). */
13-
export function createCodexReadRawFile(cwd: string): CodexReadRawFile {
32+
/** `path` is workspace-relative (apply_patch's parser rejects absolute paths). */
33+
export function createCodexReadRawFile(
34+
cwd: string,
35+
permissionGate: PermissionGate,
36+
): CodexReadRawFile {
37+
const rootsProvider = createWorktreeRootsProvider(cwd);
38+
const allowOutside = (): boolean => permissionGate.getSkipPermissions();
39+
1440
return async (path) => {
15-
const absolutePath = resolve(cwd, path);
41+
// Secret-file denylist first, on the raw path — this must hold regardless
42+
// of containment or yolo, exactly like secretGuardPlugin's hard deny.
43+
if (isSensitivePath(path)) {
44+
return {
45+
content: `Access to sensitive file blocked by policy: ${path}`,
46+
isError: true,
47+
};
48+
}
49+
50+
const resolved = resolveWorkspacePath(cwd, path, rootsProvider);
51+
let absolutePath: string;
52+
if (resolved !== undefined) {
53+
absolutePath = resolved;
54+
} else if (allowOutside()) {
55+
// Mirrors pathEscapePlugin's own allowOutside fallback (yolo mode).
56+
absolutePath = resolve(cwd, path);
57+
} else {
58+
return {
59+
content: `Path escapes working directory: ${path}`,
60+
isError: true,
61+
};
62+
}
63+
64+
// Re-check the resolved, symlink-realpath'd form too: a symlink can name
65+
// something innocuous while pointing at a sensitive real path.
66+
if (isSensitivePath(absolutePath)) {
67+
return {
68+
content: `Access to sensitive file blocked by policy: ${path}`,
69+
isError: true,
70+
};
71+
}
72+
1673
try {
1774
const buf = await readFile(absolutePath);
1875
if (buf.includes(0)) {

‎src/agent/tools.ts‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -388,7 +388,7 @@ export async function createAgentToolset(args: AgentToolsetArgs): Promise<AgentT
388388
...createCodexToolProxies({
389389
isCodex: args.isCodex === true,
390390
runTool,
391-
readRawFile: createCodexReadRawFile(cwd),
391+
readRawFile: createCodexReadRawFile(cwd, permissionGate),
392392
runManageTasks,
393393
}),
394394
);

‎src/subagent/run.ts‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -377,7 +377,7 @@ export async function runSubAgent(params: RunSubAgentParams): Promise<string> {
377377
...createCodexToolProxies({
378378
isCodex: isCodexProviderName(params.provider.providerName),
379379
runTool,
380-
readRawFile: createCodexReadRawFile(params.cwd),
380+
readRawFile: createCodexReadRawFile(params.cwd, permissionGate),
381381
runManageTasks,
382382
allowDelete: allowDeleteFromCapabilities(params.capabilities),
383383
allowShell: allowShellFromCapabilities(params.capabilities),

0 commit comments

Comments
 (0)