Skip to content

Commit 1800fc2

Browse files
Merge pull request #581 from corbitsdev/cl-6966-apply_patch-update-file-reads-line-numbered-read_file-output
fix(apply_patch): read raw file content for Update File matching
2 parents d501535 + b5585e0 commit 1800fc2

7 files changed

Lines changed: 330 additions & 59 deletions

File tree

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

Lines changed: 138 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,13 @@
11
import { describe, expect, test } from "bun:test";
2-
import { mkdtemp, readFile, 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";
66
import { createToolRunner } from "@intx/agent";
77
import type { AgentTool } from "@intx/agent";
88

99
import { createCodexToolProxies, type CodexRunTool } from "./codex-tool-proxies.js";
10+
import { createCodexReadRawFile } from "./codex-read-raw-file.js";
1011
import { buildCorePosixToolPlugins } from "./posix-tool-plugins.js";
1112
import { createPermissionGate } from "../permission/gate.js";
1213

@@ -23,11 +24,14 @@ async function invokeApplyPatch(tools: AgentTool[], input: string) {
2324
);
2425
}
2526

26-
async function makeApplyPatch(cwd: string): Promise<AgentTool[]> {
27+
async function makeApplyPatch(
28+
cwd: string,
29+
options: { skipPermissions?: boolean } = {},
30+
): Promise<AgentTool[]> {
2731
const gate = createPermissionGate({
2832
approvals: [],
2933
interactive: false,
30-
skipPermissions: true,
34+
skipPermissions: options.skipPermissions ?? true,
3135
auto: false,
3236
cwd,
3337
});
@@ -36,23 +40,6 @@ async function makeApplyPatch(cwd: string): Promise<AgentTool[]> {
3640
plugins: buildCorePosixToolPlugins({ cwd, permissionGate: gate }),
3741
});
3842
const runTool: CodexRunTool = async (name, args) => {
39-
// read_file's real tool output is cat -n formatted (line numbers), which
40-
// is not the raw content applyUpdateHunks needs — in production this
41-
// means Update File hunks generally fail to match context (filed as
42-
// CL-6966, Urgent; not this issue's bug to fix). This stub bypasses that
43-
// known defect by returning raw content, so the "Update File shows the
44-
// diff" test below is NOT proof that apply_patch Update works end to end
45-
// — it only proves the diff-surfacing added here is correct once the op
46-
// succeeds. Add File / Delete File below do not depend on read_file and
47-
// are real, unstubbed coverage.
48-
if (name === "read_file") {
49-
const path = String((args as { path?: unknown }).path ?? "");
50-
try {
51-
return { content: await readFile(join(cwd, path), "utf8") };
52-
} catch (err) {
53-
return { content: err instanceof Error ? err.message : String(err), isError: true };
54-
}
55-
}
5643
const result = await posixTools.run(
5744
{ id: "codex-proxy", name, arguments: args },
5845
new AbortController().signal,
@@ -62,14 +49,49 @@ async function makeApplyPatch(cwd: string): Promise<AgentTool[]> {
6249
...(result.isError === true ? { isError: true } : {}),
6350
};
6451
};
52+
// Real production path (CL-6966): Update File matches patch context against
53+
// raw file content, not read_file's cat -n formatted output.
6554
return createCodexToolProxies({
6655
isCodex: true,
6756
runTool,
57+
readRawFile: createCodexReadRawFile(cwd, gate),
6858
runManageTasks: async () => ({ content: "ok" }),
6959
});
7060
}
7161

72-
describe("apply_patch surfaces the changed region", () => {
62+
describe("apply_patch Update File matches raw content, not read_file's numbered output (CL-6966)", () => {
63+
test("multi-hunk Update File succeeds through the real production path", async () => {
64+
const cwd = await mkdtemp(join(tmpdir(), "apply-patch-cl6966-"));
65+
try {
66+
await writeFile(
67+
join(cwd, "app.py"),
68+
"def greet():\n print('hi')\n\n\ndef farewell():\n print('bye')\n",
69+
);
70+
const tools = await makeApplyPatch(cwd);
71+
const input = [
72+
"*** Begin Patch",
73+
"*** Update File: app.py",
74+
"@@ def greet():",
75+
"- print('hi')",
76+
"+ print('hello')",
77+
"@@ def farewell():",
78+
"- print('bye')",
79+
"+ print('goodbye')",
80+
"*** End Patch",
81+
].join("\n");
82+
83+
const result = await invokeApplyPatch(tools, input);
84+
85+
expect(result.isError).not.toBe(true);
86+
const written = await Bun.file(join(cwd, "app.py")).text();
87+
expect(written).toBe(
88+
"def greet():\n print('hello')\n\n\ndef farewell():\n print('goodbye')\n",
89+
);
90+
} finally {
91+
await rm(cwd, { recursive: true, force: true });
92+
}
93+
});
94+
7395
test("Update File shows the diff", async () => {
7496
const cwd = await mkdtemp(join(tmpdir(), "apply-patch-diff-"));
7597
try {
@@ -129,3 +151,98 @@ describe("apply_patch surfaces the changed region", () => {
129151
}
130152
});
131153
});
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: 89 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,89 @@
1+
/**
2+
* Raw (non-`cat -n`) file reads for apply_patch's Update File leg (CL-6966).
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.
21+
*/
22+
23+
import { readFile } from "node:fs/promises";
24+
import { resolve } from "node:path";
25+
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";
30+
import type { CodexReadRawFile } from "./codex-tool-proxies.js";
31+
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+
40+
return async (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+
73+
try {
74+
const buf = await readFile(absolutePath);
75+
if (buf.includes(0)) {
76+
return { content: `refusing to read binary file: ${path}`, isError: true };
77+
}
78+
return { content: buf.toString("utf8") };
79+
} catch (err) {
80+
if (hasCode(err)) {
81+
if (err.code === "ENOENT") return { content: `file not found: ${path}`, isError: true };
82+
if (err.code === "EACCES") return { content: `permission denied: ${path}`, isError: true };
83+
if (err.code === "EISDIR")
84+
return { content: `path is a directory: ${path}`, isError: true };
85+
}
86+
return { content: err instanceof Error ? err.message : String(err), isError: true };
87+
}
88+
};
89+
}

src/agent/codex-tool-mount.test.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -130,6 +130,7 @@ describe("Codex tool proxy mount", () => {
130130
const proxies = createCodexToolProxies({
131131
isCodex: true,
132132
runTool: async () => ({ content: "ok" }),
133+
readRawFile: async () => ({ content: "ok" }),
133134
runManageTasks: async () => ({ content: "ok" }),
134135
});
135136
expect(proxies.map((t) => t.definition.name)).toEqual(["apply_patch", "shell", "update_plan"]);
@@ -152,6 +153,7 @@ describe("Codex tool proxy mount", () => {
152153
const proxies = createCodexToolProxies({
153154
isCodex: true,
154155
runTool: async () => ({ content: "ok" }),
156+
readRawFile: async () => ({ content: "ok" }),
155157
runManageTasks: async () => ({ content: "ok" }),
156158
allowDelete: allowDeleteFromCapabilities(docsCapabilities),
157159
allowShell: allowShellFromCapabilities(docsCapabilities),
@@ -167,6 +169,7 @@ describe("Codex tool proxy mount", () => {
167169
const proxies = createCodexToolProxies({
168170
isCodex: false,
169171
runTool: async () => ({ content: "ok" }),
172+
readRawFile: async () => ({ content: "ok" }),
170173
runManageTasks: async () => ({ content: "ok" }),
171174
allowDelete: allowDeleteFromCapabilities({ mode: "allow", tools: BUILD_TOOLS }),
172175
allowShell: allowShellFromCapabilities({ mode: "allow", tools: BUILD_TOOLS }),

0 commit comments

Comments
 (0)