fix(security): reject symlink-mode blobs in the worktree patch (closes #15)#19
Merged
Conversation
Closes #15 (HIGH). applyWorktreePatch captures the worktree diff with `git diff --cached --binary --output=<file>` and replays it with `git apply --index` in repoRoot. A symlink created in the (attacker/Codex- controlled) worktree — `ln -s ~/.ssh/authorized_keys ./steal` — is captured as a mode-120000 diff entry with the target path verbatim. `git apply --index` then recreates the symlink in repoRoot pointing at the attacker-chosen absolute host path. Subsequent cat/git-show/editor-open exfiltrates the file; >> appends. The byte-preserving `--output` transfer does not help — the symlink target is legitimately part of the diff. `git apply` rejects literal "../" filename paths, but symlink-mode entries bypass that guard: the repo-side filename is a normal path; only the symlink target points outside. Attack reproduced before the fix: `ln -s /etc/passwd ./steal` in the worktree produced a patch with `new file mode 120000`, and `git apply --index` recreated `<repoRoot>/steal -> /etc/passwd`. Fix: after writing the patch file, scan it for a `mode 120000` line. If found, do NOT apply — return {applied:false} with a message directing the user to inspect/copy the symlinks manually from the worktree. The leave-branch invariant is preserved (the worktree stays; nothing is destroyed). Regression test: plant a symlink in the worktree, call keep, assert applied === false and the symlink is NOT recreated in repoRoot. Verified: worktree 14/14; full npm test 157 pass / 4 pre-existing (unchanged). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_0189cGaohsYdpkB4otKZnpAt
axisrow
force-pushed
the
sec-patch-symlink-guard
branch
from
July 19, 2026 18:55
cc1bda4 to
a6658d8
Compare
Owner
Author
🔍 Local review (cycle 1) — security
Triage: 0 FIX in scope, 1 LOW applied (regex tightening). #15 closed. 15/15 worktree tests; full npm test 158 pass / 4 pre-existing. Local mode — merge on user trigger. |
This was referenced Jul 20, 2026
Closed
axisrow
added a commit
that referenced
this pull request
Jul 20, 2026
Redesign after Codex+Claude review found the two-phase --raw detection in the prior commit was TOCTOU-exploitable: patch was generated from the mutable index, then a separate --raw check read the index again — a background process (a compromised Codex run that can write to the worktree) mutated the index between the two calls, so the patch carried a symlink while --raw reported clean, and git apply replayed the symlink into repoRoot. Demonstrated at ~15% reliability with a plain bash busy-poll. In-scope for #15's threat model (the worktree IS the Codex workspace; Codex has write access and can spawn processes). Fix: freeze the staged index into an IMMUTABLE tree via git write-tree, then derive BOTH the patch and the symlink verdict from <baseCommit>..<tree> — never from the mutable index. The two reads now reference the same immutable object, so no window exists between them. Also add --no-ext-diff/--no-textconv so a poisoned diff.external driver (writable via the linked-worktree shared commondir) cannot affect patch generation. Symlink detection: git diff-tree -r --raw on the frozen tree reports modes for every changed entry; reject if either old or new mode column is 120000. Catches new/retargeted/mode-changed/renamed symlinks uniformly; not subject to diff.external/.gitattributes (structural mode data, not rendered text). Leave-branch invariant preserved (worktree stays on refusal). Regression tests (3, all green): - existing tracked symlink retargeted → applied:false (the #19 bypass). - new symlink (mode 120000) → caught. - regular file whose content mentions "mode 120000" → applied (no false positive). Verified: worktree 16/16; full npm test 159 pass / 4 pre-existing. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_0189cGaohsYdpkB4otKZnpAt
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 #15 (HIGH, security review of #12).
Vulnerability
applyWorktreePatchcaptures the worktree diff (git diff --cached --binary --output=<file>) and replays it in repoRoot (git apply --index). A symlink created in the worktree —ln -s ~/.ssh/authorized_keys ./steal— is captured as a mode-120000 diff entry with the target path verbatim.git apply --indexrecreates it in repoRoot pointing at the attacker-chosen host path → exfil (cat/git-show/editor) and append (>>) primitive.The byte-preserving
--outputtransfer does not help — the symlink target is legitimately part of the diff.git applyrejects literal../filename paths, but symlink-mode entries bypass that guard (the repo-side filename is normal; only the symlink target points outside).Attack reproduced before the fix:
ln -s /etc/passwd ./stealin the worktree produced a patch withnew file mode 120000, andgit apply --indexrecreated<repoRoot>/steal -> /etc/passwd.Fix
After writing the patch file, scan it for a
mode 120000line. If found, do NOT apply — return{applied:false}directing the user to inspect/copy symlinks manually from the worktree. Leave-branch preserved (worktree stays; nothing destroyed).Verified
applied:false, symlink NOT in repoRoot.git applyrecreated the symlink).worktree.test.mjs14/14; fullnpm test157 pass / 4 pre-existing (unchanged).Severity note (from #15): code-path-severity-once-wired — no production callers today; closes the hole before companion
--worktreewiring.