11import { 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" ;
33import { tmpdir } from "node:os" ;
44import { join } from "node:path" ;
55import { createPosixTools } from "@intx/tools-posix" ;
66import { createToolRunner } from "@intx/agent" ;
77import type { AgentTool } from "@intx/agent" ;
88
99import { createCodexToolProxies , type CodexRunTool } from "./codex-tool-proxies.js" ;
10+ import { createCodexReadRawFile } from "./codex-read-raw-file.js" ;
1011import { buildCorePosixToolPlugins } from "./posix-tool-plugins.js" ;
1112import { 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 ( / e s c a p e s w o r k i n g d i r e c t o r y / 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 ( / e s c a p e s w o r k i n g d i r e c t o r y / 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 ( / s e n s i t i v e f i l e / 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 ( / s e n s i t i v e f i l e | e s c a p e s w o r k i n g d i r e c t o r y / 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+ } ) ;
0 commit comments