@@ -1021,44 +1021,153 @@ function buildDiffSummary(filesTouched: readonly string[]): string | null {
10211021 : `${ filesTouched . length } files touched` ;
10221022}
10231023
1024- async function collectWorkspacePatch (
1024+ function quoteShell ( value : string ) : string {
1025+ return `'${ value . replaceAll ( "'" , `'\"'\"'` ) } '` ;
1026+ }
1027+
1028+ function normalizePatchPathPrefix ( path : string ) : string {
1029+ return path . replace ( / ^ \/ + / , "" ) . replace ( / \/ + $ / , "" ) ;
1030+ }
1031+
1032+ function rewriteSnapshotPatchPaths (
1033+ patch : string ,
1034+ beforeRoot : string ,
1035+ afterRoot : string ,
1036+ ) : string {
1037+ const beforePrefix = normalizePatchPathPrefix ( beforeRoot ) ;
1038+ const afterPrefix = normalizePatchPathPrefix ( afterRoot ) ;
1039+
1040+ return [
1041+ [ `a/${ beforePrefix } /` , "a/" ] ,
1042+ [ `b/${ beforePrefix } /` , "b/" ] ,
1043+ [ `a/${ afterPrefix } /` , "a/" ] ,
1044+ [ `b/${ afterPrefix } /` , "b/" ] ,
1045+ ] . reduce (
1046+ ( nextPatch , [ from , to ] ) => nextPatch . split ( from ) . join ( to ) ,
1047+ patch ,
1048+ ) ;
1049+ }
1050+
1051+ async function writeScopedSnapshot (
10251052 sandbox : Sandbox ,
10261053 workspaceRoot : string ,
1027- ) : Promise < string | null > {
1028- const insideGit = await sandbox . exec ( "git rev-parse --is-inside-work-tree" , {
1054+ snapshotRoot : string ,
1055+ allowedPaths : readonly string [ ] ,
1056+ forbiddenPaths : readonly string [ ] ,
1057+ ) : Promise < void > {
1058+ const command = [
1059+ "node <<'NODE'" ,
1060+ "const fs = require('fs');" ,
1061+ "const path = require('path');" ,
1062+ `const workspaceRoot = ${ JSON . stringify ( workspaceRoot ) } ;` ,
1063+ `const snapshotRoot = ${ JSON . stringify ( snapshotRoot ) } ;` ,
1064+ `const allowedPaths = ${ JSON . stringify ( [ ...allowedPaths ] ) } ;` ,
1065+ `const forbiddenPaths = ${ JSON . stringify ( [ ...forbiddenPaths ] ) } ;` ,
1066+ "const normalize = (value) => value.replaceAll('\\\\', '/').replace(/^\\.\\//, '').replace(/\\/+$/, '');" ,
1067+ "const matchesPrefix = (value, prefixes) => {" ,
1068+ " const normalizedValue = normalize(value);" ,
1069+ " if (prefixes.length === 0) return true;" ,
1070+ " return prefixes.some((prefix) => {" ,
1071+ " const normalizedPrefix = normalize(prefix);" ,
1072+ " if (!normalizedPrefix || normalizedPrefix === '.') return true;" ,
1073+ " return normalizedValue === normalizedPrefix || normalizedValue.startsWith(`${normalizedPrefix}/`);" ,
1074+ " });" ,
1075+ "};" ,
1076+ "const shouldCopy = (relativePath) => {" ,
1077+ " const normalizedPath = normalize(relativePath);" ,
1078+ " if (!matchesPrefix(normalizedPath, allowedPaths)) return false;" ,
1079+ " return !forbiddenPaths.some((prefix) => matchesPrefix(normalizedPath, [prefix]));" ,
1080+ "};" ,
1081+ "const ensureInsideWorkspace = (candidate) => {" ,
1082+ " const relativePath = normalize(path.relative(workspaceRoot, candidate));" ,
1083+ " if (relativePath === '' || (!relativePath.startsWith('../') && relativePath !== '..')) return;" ,
1084+ " throw new Error(`Scoped snapshot path escapes workspace: ${candidate}`);" ,
1085+ "};" ,
1086+ "const copyRecursive = (source, destination, relativePath) => {" ,
1087+ " ensureInsideWorkspace(source);" ,
1088+ " if (!shouldCopy(relativePath)) return;" ,
1089+ " const stat = fs.lstatSync(source);" ,
1090+ " if (stat.isDirectory()) {" ,
1091+ " fs.mkdirSync(destination, { recursive: true });" ,
1092+ " for (const entry of fs.readdirSync(source)) {" ,
1093+ " const childSource = path.join(source, entry);" ,
1094+ " const childDestination = path.join(destination, entry);" ,
1095+ " const childRelativePath = relativePath ? `${relativePath}/${entry}` : entry;" ,
1096+ " copyRecursive(childSource, childDestination, childRelativePath);" ,
1097+ " }" ,
1098+ " return;" ,
1099+ " }" ,
1100+ " fs.mkdirSync(path.dirname(destination), { recursive: true });" ,
1101+ " if (stat.isSymbolicLink()) {" ,
1102+ " const linkTarget = fs.readlinkSync(source);" ,
1103+ " try { fs.unlinkSync(destination); } catch {}" ,
1104+ " fs.symlinkSync(linkTarget, destination);" ,
1105+ " return;" ,
1106+ " }" ,
1107+ " fs.copyFileSync(source, destination);" ,
1108+ "};" ,
1109+ "fs.rmSync(snapshotRoot, { recursive: true, force: true });" ,
1110+ "fs.mkdirSync(snapshotRoot, { recursive: true });" ,
1111+ "const uniqueAllowedPaths = [...new Set(allowedPaths.map((value) => {" ,
1112+ " const normalized = normalize(value);" ,
1113+ " return normalized || '.';" ,
1114+ "}))];" ,
1115+ "for (const allowedPath of uniqueAllowedPaths) {" ,
1116+ " const relativePath = allowedPath === '.' ? '' : allowedPath;" ,
1117+ " const source = relativePath ? path.resolve(workspaceRoot, relativePath) : workspaceRoot;" ,
1118+ " ensureInsideWorkspace(source);" ,
1119+ " if (!fs.existsSync(source)) continue;" ,
1120+ " if (!relativePath) {" ,
1121+ " for (const entry of fs.readdirSync(source)) {" ,
1122+ " copyRecursive(path.join(source, entry), path.join(snapshotRoot, entry), entry);" ,
1123+ " }" ,
1124+ " continue;" ,
1125+ " }" ,
1126+ " copyRecursive(source, path.resolve(snapshotRoot, relativePath), relativePath);" ,
1127+ "}" ,
1128+ "NODE" ,
1129+ ] . join ( "\n" ) ;
1130+
1131+ const result = await sandbox . exec ( command , {
10291132 cwd : workspaceRoot ,
10301133 } ) . catch ( ( ) => null ) ;
1031- const insideGitExitCode = ( insideGit as { code ?: number ; exitCode ?: number } | null ) ?. code
1032- ?? ( insideGit as { code ?: number ; exitCode ?: number } | null ) ?. exitCode
1134+ const exitCode = ( result as { code ?: number ; exitCode ?: number } | null ) ?. code
1135+ ?? ( result as { code ?: number ; exitCode ?: number } | null ) ?. exitCode
10331136 ?? null ;
10341137
1035- if ( ! insideGit || insideGitExitCode !== 0 || insideGit . stdout . trim ( ) !== "true" ) {
1036- return null ;
1138+ if ( ! result || exitCode !== 0 ) {
1139+ const stderr = ( result as { stderr ?: string } | null ) ?. stderr ?. trim ( ) || "unknown error" ;
1140+ throw new RuntimeTaskError (
1141+ "PATCH_SNAPSHOT_FAILED" ,
1142+ `Could not snapshot scoped workspace state: ${ stderr } ` ,
1143+ ) ;
10371144 }
1145+ }
10381146
1147+ async function collectScopedWorkspacePatch (
1148+ sandbox : Sandbox ,
1149+ workspaceRoot : string ,
1150+ beforeRoot : string ,
1151+ afterRoot : string ,
1152+ ) : Promise < string | null > {
10391153 const result = await sandbox . exec (
1040- [
1041- "tracked=$(git diff --binary --relative HEAD -- 2>/dev/null || true)" ,
1042- "untracked=$(git ls-files --others --exclude-standard | while IFS= read -r file; do" ,
1043- " [ -n \"$file\" ] || continue" ,
1044- " git diff --binary --no-index -- /dev/null \"$file\" 2>/dev/null || true" ,
1045- "done)" ,
1046- "printf '%s%s' \"$tracked\" \"$untracked\"" ,
1047- ] . join ( "\n" ) ,
1154+ `git diff --binary --no-index ${ quoteShell ( beforeRoot ) } ${ quoteShell ( afterRoot ) } || true` ,
10481155 {
10491156 cwd : workspaceRoot ,
10501157 } ,
10511158 ) . catch ( ( ) => null ) ;
1052- const resultExitCode = ( result as { code ?: number ; exitCode ?: number } | null ) ?. code
1053- ?? ( result as { code ?: number ; exitCode ?: number } | null ) ?. exitCode
1054- ?? null ;
10551159
1056- if ( ! result || resultExitCode !== 0 ) {
1160+ if ( ! result ) {
10571161 return null ;
10581162 }
10591163
1060- const patch = result . stdout . trim ( ) ;
1061- return patch . length > 0 ? `${ patch } \n` : null ;
1164+ const patch = ( ( result as { stdout ?: string } ) . stdout ?? "" ) . trim ( ) ;
1165+ if ( ! patch ) {
1166+ return null ;
1167+ }
1168+
1169+ const rewritten = rewriteSnapshotPatchPaths ( patch , beforeRoot , afterRoot ) . trim ( ) ;
1170+ return rewritten . length > 0 ? `${ rewritten } \n` : null ;
10621171}
10631172
10641173async function persistAssistantText (
@@ -1844,8 +1953,12 @@ async function runSandboxTask(
18441953) : Promise < AgentTaskCompletion > {
18451954 const processId = `task-${ taskId } ` ;
18461955 const startedAt = now ( ) ;
1956+ const workspaceRoot = resolveWorkspaceRoot ( config . gitRepoUrl ) ;
1957+ const snapshotBase = `/tmp/filepath-task-snapshots/${ taskId } ` ;
1958+ const beforeSnapshotRoot = `${ snapshotBase } /before` ;
1959+ const afterSnapshotRoot = `${ snapshotBase } /after` ;
18471960 let process : Process | null = null ;
1848- let sandbox : Sandbox ;
1961+ let sandbox : Sandbox | null = null ;
18491962
18501963 try {
18511964 sandbox = await withSandboxStartupRetry ( env , taskId , agentId , {
@@ -1858,6 +1971,13 @@ async function runSandboxTask(
18581971 await nextSandbox . mkdir ( config . executionRoot , { recursive : true } ) ;
18591972 return nextSandbox ;
18601973 } ) ;
1974+ await writeScopedSnapshot (
1975+ sandbox ,
1976+ workspaceRoot ,
1977+ beforeSnapshotRoot ,
1978+ config . policy . allowedPaths ,
1979+ config . policy . forbiddenPaths ,
1980+ ) ;
18611981 } catch ( error ) {
18621982 const runtimeError = classifySandboxStartupError ( error ) ;
18631983 const failed = await finalizeTaskFailure ( env , {
@@ -1970,7 +2090,19 @@ async function runSandboxTask(
19702090 startedAt ,
19712091 finishedAt ,
19722092 ) ;
1973- result . patch = await collectWorkspacePatch ( sandbox , resolveWorkspaceRoot ( config . gitRepoUrl ) ) ;
2093+ await writeScopedSnapshot (
2094+ sandbox ,
2095+ workspaceRoot ,
2096+ afterSnapshotRoot ,
2097+ config . policy . allowedPaths ,
2098+ config . policy . forbiddenPaths ,
2099+ ) ;
2100+ result . patch = await collectScopedWorkspacePatch (
2101+ sandbox ,
2102+ workspaceRoot ,
2103+ beforeSnapshotRoot ,
2104+ afterSnapshotRoot ,
2105+ ) ;
19742106 if ( ! result . diffSummary ) {
19752107 result . diffSummary = buildDiffSummary ( result . filesTouched ) ;
19762108 }
@@ -2063,6 +2195,9 @@ async function runSandboxTask(
20632195 if ( heartbeatTimer ) {
20642196 clearInterval ( heartbeatTimer ) ;
20652197 }
2198+ await sandbox ?. exec ( `rm -rf ${ quoteShell ( snapshotBase ) } ` , {
2199+ cwd : workspaceRoot ,
2200+ } ) . catch ( ( ) => { } ) ;
20662201 await updateAgentExecutionState ( env , agentId , {
20672202 activeProcessId : null ,
20682203 cancelRequested : false ,
0 commit comments