fix(apply_patch): read raw file content for Update File matching - #581
Merged
TheGreatAxios merged 2 commits intoAug 23, 2026
Conversation
apply_patch's Update File op read the target via the real read_file tool, whose output is cat -n formatted (line numbers prefixed). That numbered text was then matched against the patch's raw context lines, which can never succeed — every Update op with context lines failed. Add a readRawFile callback (plain fs read, no display formatting) used only for the Update leg's content-matching input; write_file still goes through the full posixTools pipeline for the actual mutation.
…aw 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.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Closes CL-6966.
Reproduction
Ran apply_patch's real production proxy path (createCodexToolProxies -> posixTools.run, same wiring as src/agent/tools.ts) against a real file with an Update File patch containing one context line before and after the change. It failed exactly as predicted:
read_file's real output — both the guard plugin (src/plugins/read-file-guard-plugin.ts) and the underlying @intx/tools-posix implementation — is
cat -nformatted (padStart(6) + "\t" + line). applyUpdateHunks matches the patch's raw context lines against that numbered text via findLineFrom/findSequence, so the match can never succeed.Fix: raw reads, but not unguarded ones
Added
CodexReadRawFile, a callback separate fromrunToolthat reads a file's raw content directly (nocat -nformatting), mirroring the precedent already in this codebase for exactly this purpose (verify-plugin.ts reads raw content for its before/after diff rather than routing through read_file). apply_patch's Update leg now callsreadRawFile(op.path)for the content it matches hunks against; the write still goes through the full posixTools pipeline (path escape, secret guard, authz, permission gate) unchanged.Security review caught a real hole in the first version of this fix:
applyOpcallsreadRawFilefrom outside the posixTools middleware chain entirely, so none of pathEscapePlugin / secretGuardPlugin / authzPlugin / permissionPlugin ever sawop.path.requireRelativePathin codex-apply-patch.ts only rejects absolute paths, not../traversal. Combined withapplyUpdateHunks's insertion-only branch (oldLines.length === 0, which requires no context match), an Update File op like:would read a secret file's raw content unconditionally and hand it to write_file, landing it in an in-bounds file under a new name — exfiltration, not just an unauthorized read.
src/agent/codex-read-raw-file.tsnow reuses the same containment and secret-file authorities the plugin chain already uses, rather than reimplementing them:resolveWorkspacePathfromsrc/permission/path-restriction.ts— the same symlink-aware realpath containment checkpathEscapePlugincalls. Honors the sameskipPermissions/yolo escape hatchpathEscapePlugindoes (an operator who has explicitly opted into yolo can still go outside the workspace on the read leg, same as the write leg already allows).isSensitivePathfromsrc/plugins/secret-guard-plugin.ts— the same denylist (.env,id_rsa,*.pem,.ssh/,.aws/credentials, etc.)secretGuardPluginuses. This check is unconditional — no yolo bypass — matchingsecretGuardPlugin's own behavior. Checked on both the raw input path and the resolved/realpath'd absolute path, since a symlink could otherwise alias an innocuous-looking name to a sensitive real target.Both are hard failures with a clear, distinct error message (
Path escapes working directory: .../Access to sensitive file blocked by policy: ...) — never a silent fallthrough to the old broken behavior.Wired the same helper at both mount sites:
src/agent/tools.ts(primary) andsrc/subagent/run.ts(sub-agents), both now passing the session'spermissionGatethrough so the yolo check stays consistent with the write leg.Add File / Delete File
Confirmed unaffected: only the
updatebranch ofapplyOpcallsreadRawFile— Add and Delete never read file content to match context. Existing Add/Delete tests pass unchanged.Test stub
src/agent/apply-patch-diff.test.tshad aread_filestub (returning raw content directly) with a comment naming CL-6966, explicitly noting its Update test was NOT proof the production path worked. Removed the stub —makeApplyPatchnow uses the realcreateCodexReadRawFilehelper with unstubbedposixTools.runfor everything else, so the existing "Update File shows the diff" test now genuinely exercises the fixed production path.Regression tests (attack shapes)
All in
src/agent/apply-patch-diff.test.ts, run through the real production path, each using the insertion-only hunk shape (@@/+with no context) that made the unauthorized read exploitable in the first place, and each asserting the Update op is refused:../ traversal out of the workspace is refused— Update File targeting../victim.txt,Move to: leaked.txt. AssertsisErrorand a "escapes working directory" error.a symlinked directory leading outside the workspace is refused— a symlink inside the workspace pointing at an outside directory; Update File through the symlink. Same assertion —resolveWorkspacePath's realpath containment catches it.a secret-guard path (.env) is refused even with skipPermissions— Update File targeting.envunderskipPermissions: true(yolo). Asserts a "sensitive file" error and thatleaked.txtwas never created.../ traversal to a secret file is refused—../.env, combining both checks; asserts refusal and that nothing lands in the workspace.Gate (foreground)
bun run lint,bun run typecheck,bun run buildall clean.bun run test: 5350 pass, 0 fail (ran the full suite twice in the foreground to rule out flakiness; two TUI layout tests failed intermittently under parallel load in one run — confirmed pre-existing/unrelated by reproducing the same flake with this branch's changes stashed, and by each test passing cleanly in isolation).