Skip to content

fix(apply_patch): read raw file content for Update File matching - #581

Merged
TheGreatAxios merged 2 commits into
mainfrom
cl-6966-apply_patch-update-file-reads-line-numbered-read_file-output
Aug 23, 2026
Merged

fix(apply_patch): read raw file content for Update File matching#581
TheGreatAxios merged 2 commits into
mainfrom
cl-6966-apply_patch-update-file-reads-line-numbered-read_file-output

Conversation

@TheGreatAxios

@TheGreatAxios TheGreatAxios commented Aug 23, 2026

Copy link
Copy Markdown
Collaborator

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:

CodexApplyPatchError: failed to find expected lines in file:
line one
line two
line three
    at applyUpdateHunks (src/agent/codex-apply-patch.ts:228:13)
    at applyOp (src/agent/codex-tool-proxies.ts:250:15)

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 -n formatted (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 from runTool that reads a file's raw content directly (no cat -n formatting), 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 calls readRawFile(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: applyOp calls readRawFile from outside the posixTools middleware chain entirely, so none of pathEscapePlugin / secretGuardPlugin / authzPlugin / permissionPlugin ever saw op.path. requireRelativePath in codex-apply-patch.ts only rejects absolute paths, not ../ traversal. Combined with applyUpdateHunks's insertion-only branch (oldLines.length === 0, which requires no context match), an Update File op like:

*** Begin Patch
*** Update File: ../../.env
*** Move to: leaked.txt
@@
+
*** End Patch

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.ts now reuses the same containment and secret-file authorities the plugin chain already uses, rather than reimplementing them:

  • Containment: resolveWorkspacePath from src/permission/path-restriction.ts — the same symlink-aware realpath containment check pathEscapePlugin calls. Honors the same skipPermissions/yolo escape hatch pathEscapePlugin does (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).
  • Secret guard: isSensitivePath from src/plugins/secret-guard-plugin.ts — the same denylist (.env, id_rsa, *.pem, .ssh/, .aws/credentials, etc.) secretGuardPlugin uses. This check is unconditional — no yolo bypass — matching secretGuardPlugin'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) and src/subagent/run.ts (sub-agents), both now passing the session's permissionGate through so the yolo check stays consistent with the write leg.

Add File / Delete File

Confirmed unaffected: only the update branch of applyOp calls readRawFile — Add and Delete never read file content to match context. Existing Add/Delete tests pass unchanged.

Test stub

src/agent/apply-patch-diff.test.ts had a read_file stub (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 — makeApplyPatch now uses the real createCodexReadRawFile helper with unstubbed posixTools.run for 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. Asserts isError and 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 .env under skipPermissions: true (yolo). Asserts a "sensitive file" error and that leaked.txt was 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 build all 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).

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.
@linear-code

linear-code Bot commented Aug 23, 2026

Copy link
Copy Markdown

CL-6966

…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.
@TheGreatAxios
TheGreatAxios merged commit 1800fc2 into main Aug 23, 2026
5 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant