fix(targets): guard writers against ancestor-symlink traversal on install#1100
Open
tmchow wants to merge 1 commit into
Open
fix(targets): guard writers against ancestor-symlink traversal on install#1100tmchow wants to merge 1 commit into
tmchow wants to merge 1 commit into
Conversation
…tall The leaf-node symlink guards from #1089/#1094 inspect only the final path component, so a user who repoints a whole managed store dir (or an ancestor of it) at a personal fork -- e.g. `~/.codex/skills/<plugin>` -> fork -- still had every cleanup/write on install operate THROUGH the link into the fork. Add a realpath-based containment check (isPathWithinRoot / storeRootEscapesManagedRoot in managed-artifacts.ts) and apply it in the codex, opencode, and pi writers: a store that resolves outside the target root is skipped wholesale -- cleanup sweeps and writes alike -- and recorded as owning nothing in the install manifest. The shared and per-writer legacy-artifact movers and codex's previous-skill-store rm gain the same guard at their destructive ops. isPathInside (codex) now reuses the shared isContainedPath predicate. Antigravity is out of scope: it has no leaf-ownership contract at all, so adding only ancestor-containment there would be a misleading half-guard; it needs the #1089/#1094 leaf contract first. Co-Authored-By: Claude Code <noreply@anthropic.com>
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.
Summary
Follow-up to the leaf-node symlink preservation shipped in #1089 (Pi) and #1094 (Codex/OpenCode). Those guards inspect only the leaf path component (
isPreservedSymlink→lstatthe final node). A user who repoints a whole managed store directory — or any ancestor of it — at a personal fork slips past them, becauselstat/fs.rm/copy follow every ancestor symlink. The write then operates through the link, into the fork.This was the "ancestor-symlink traversal" class disclosed as out-of-scope in #1094's scope notes. This PR closes it for the three writers that already have the leaf-ownership contract (codex, opencode, pi).
Concrete example: user runs
ln -s ~/my-fork ~/.codex/skills/compound-engineering. Before this PR,install --to codexwouldfs.rmand re-copy skill dirs inside~/my-fork, clobbering their fork. After: the store is detected as escaping the codex root, skipped wholesale, and recorded as owning nothing.Fix
A realpath-based containment check in
managed-artifacts.ts:realpathNearestExisting(p)— realpath of the nearest existing ancestor (a not-yet-created descendant can't add a symlink hop, so this suffices and works before a fresh store exists).isContainedPath(rootReal, targetReal)— pure predicate; now also reused by codex's existingisPathInside, removing a duplicated relative-path check.isPathWithinRoot(root, target)— realpaths both sides (so a config root legitimately relocated as a whole still reads as contained) and appliesisContainedPath.storeRootEscapesManagedRoot(root, storeDir)— warns + returns true when a store escapes; the caller then skips every operation on that store.Applied per writer against the target root (
~/.codex, the OpenCode root, the Pi store parent):cleanupPreviousManagedCodexSkillStore(a recursivefs.rm) gain the same containment check at their destructive op.moveLegacyArtifactToBackupgains the containment guard at itsfs.rename, gated by the existingskipSymlinkGuardescape hatch (so the one intentional symlink-relocator in thecleanupcommand is exempt). This one line covers the OpenCode/Kiro legacy sweeps and the standalonecleanupcommand's move paths.Escaped stores use the same
preserved/continueidiom #1094 established, so the manifest never claims a path the install didn't write.Verification
src/stashed, all 4 new ancestor tests fail — the assertionexists(fork/<written-path>)istrue(the fork gets written through) without the guard,falsewith it.bun test: 1930 pass / 0 fail; existing fix(pi): preserve user-managed symlinks and unmanaged paths on install #1089/fix(targets): extend user-symlink preservation to codex and opencode writers #1094 leaf-preservation tests pass unmodified (leaf-symlink cases short-circuit atisPreservedSymlinkbefore the new ancestor check).bun run release:validatein sync.Scope decisions
managedDirmanifest/legacy-backupwrites (writinginstall-manifest.jsonthrough a symlinkedmanagedDir) remain unguarded — lower stakes, since that path only writes CE-owned metadata, not user source. Noted here so it's tracked rather than rediscovered.ensureDirthrow (pre-existing crash, no data loss) — unchanged from fix(pi): preserve user-managed symlinks and unmanaged paths on install #1089/fix(targets): extend user-symlink preservation to codex and opencode writers #1094.