11import { mkdir , readFile , rm , writeFile } from "node:fs/promises" ;
2- import { dirname , isAbsolute , relative , resolve } from "node:path" ;
2+ import { dirname , resolve } from "node:path" ;
33import { stringTool } from "@intx/agent" ;
44import type { AgentTool } from "@intx/agent" ;
55import type { ToolDefinition } from "@intx/types/runtime" ;
66import { type } from "arktype" ;
77
8+ import { resolveWorkspacePath } from "../permission/path-restriction.js" ;
9+ import type { RootsProvider } from "../permission/worktree-roots.js" ;
10+ import {
11+ createExtraDeniedPathMatcher ,
12+ isSensitivePathResolved ,
13+ } from "../plugins/secret-guard-plugin.js" ;
814import {
915 applyUpdateHunks ,
1016 CodexApplyPatchError ,
@@ -31,28 +37,52 @@ type Planned =
3137 | { kind : "write" ; path : string ; content : string ; label : string }
3238 | { kind : "remove" ; path : string ; label : string } ;
3339
34- function contained ( cwd : string , path : string ) : string {
40+ export interface ApplyPatchGuard {
41+ allowOutside : ( ) => boolean ;
42+ rootsProvider : RootsProvider ;
43+ extraDeniedPaths ?: readonly string [ ] ;
44+ }
45+
46+ // apply_patch is mounted outside the posix plugin stack, so it re-enforces the
47+ // secret-guard denylist and the realpath workspace bound itself. A lexical
48+ // check alone would let a symlink inside the workspace lead out of it.
49+ function guardedPath (
50+ cwd : string ,
51+ path : string ,
52+ guard : ApplyPatchGuard ,
53+ isExtraDenied : ( value : string ) => boolean ,
54+ ) : string {
3555 const abs = resolve ( cwd , path ) ;
36- const rel = relative ( cwd , abs ) ;
37- if ( rel === "" || rel . startsWith ( ".." ) || isAbsolute ( rel ) ) {
56+ if ( isSensitivePathResolved ( abs ) || isExtraDenied ( abs ) ) {
57+ throw new CodexApplyPatchError (
58+ `Access to sensitive file blocked by policy: ${ path } ` ,
59+ ) ;
60+ }
61+ if (
62+ ! guard . allowOutside ( ) &&
63+ resolveWorkspacePath ( cwd , abs , guard . rootsProvider ) === undefined
64+ ) {
3865 throw new CodexApplyPatchError ( `path escapes the workspace: ${ path } ` ) ;
3966 }
4067 return abs ;
4168}
4269
43- async function plan ( cwd : string , op : PatchOp ) : Promise < Planned [ ] > {
70+ async function plan (
71+ op : PatchOp ,
72+ contained : ( path : string ) => string ,
73+ ) : Promise < Planned [ ] > {
4474 if ( op . type === "add" ) {
45- const path = contained ( cwd , op . path ) ;
75+ const path = contained ( op . path ) ;
4676 return [
4777 { kind : "write" , path, content : op . content , label : `A ${ op . path } ` } ,
4878 ] ;
4979 }
5080 if ( op . type === "delete" ) {
5181 return [
52- { kind : "remove" , path : contained ( cwd , op . path ) , label : `D ${ op . path } ` } ,
82+ { kind : "remove" , path : contained ( op . path ) , label : `D ${ op . path } ` } ,
5383 ] ;
5484 }
55- const source = contained ( cwd , op . path ) ;
85+ const source = contained ( op . path ) ;
5686 let original : string ;
5787 try {
5888 original = await readFile ( source , "utf8" ) ;
@@ -66,7 +96,7 @@ async function plan(cwd: string, op: PatchOp): Promise<Planned[]> {
6696 return [
6797 {
6898 kind : "write" ,
69- path : contained ( cwd , op . moveTo ) ,
99+ path : contained ( op . moveTo ) ,
70100 content,
71101 label : `M ${ op . moveTo } ` ,
72102 } ,
@@ -78,7 +108,15 @@ async function plan(cwd: string, op: PatchOp): Promise<Planned[]> {
78108 * Plans every op before touching disk so a bad hunk in the last file cannot
79109 * leave the earlier files half-patched.
80110 */
81- export function createApplyPatchTool ( cwd : string ) : AgentTool {
111+ export function createApplyPatchTool (
112+ cwd : string ,
113+ guard : ApplyPatchGuard ,
114+ ) : AgentTool {
115+ const isExtraDenied = createExtraDeniedPathMatcher (
116+ guard . extraDeniedPaths ?? [ ] ,
117+ ) ;
118+ const contained = ( path : string ) : string =>
119+ guardedPath ( cwd , path , guard , isExtraDenied ) ;
82120 return stringTool ( {
83121 definition : applyPatchDefinition ,
84122 handler : async ( rawArgs : Record < string , unknown > ) : Promise < string > => {
@@ -89,7 +127,7 @@ export function createApplyPatchTool(cwd: string): AgentTool {
89127 try {
90128 const patch = parseCodexApplyPatch ( parsedArgs . input ) ;
91129 const steps : Planned [ ] = [ ] ;
92- for ( const op of patch . ops ) steps . push ( ...( await plan ( cwd , op ) ) ) ;
130+ for ( const op of patch . ops ) steps . push ( ...( await plan ( op , contained ) ) ) ;
93131 for ( const step of steps ) {
94132 if ( step . kind === "write" ) {
95133 await mkdir ( dirname ( step . path ) , { recursive : true } ) ;
0 commit comments