Repository navigation
fix(hooks): keep rtk init --codex inside the project and off the user's files - #4119
Conversation
📊 Automated PR Analysis
SummaryFixes two bugs in Review Checklist
Analyzed automatically by wshm · This is an automated analysis, not a human review. |
cfb1ff8 to
edde6d7
Compare
pszymkowiak
left a comment
There was a problem hiding this comment.
Reviewed by building the branch and running both the develop (0924356b) and PR binaries in throwaway directories with HOME/CODEX_HOME isolated. The two reported regressions are fixed and well tested, but two gaps were reproduced independently three times, so requesting changes.
Verified fixed
- Uninstall on a user-authored
RTK.md: develop deletes it; this PR keeps it with "Kept RTK.md … remove it yourself". - Install over a user-authored
RTK.md: develop overwrites silently; this PR moves it toRTK.md.bakand writes the marked payload. Install → uninstall round trip removes only the rtk-owned file. Second install creates no extra.bak. - Single-level symlink escapes (
.codex -> /elsewhere,.codex/hooks.json.bak -> /elsewhere/x): refused on install with exit 1, hook step skipped on uninstall whileRTK.mdand theAGENTS.mdreference are still cleaned.
1. A two-link chain still writes outside the project (ensure_inside_root, src/hooks/init.rs:4197-4222)
The guard calls fs::read_link once. If .codex/hooks.json.bak -> ../mid and mid -> <outside>/evil.json where evil.json does not exist yet, canonicalize fails on the dangling target, canonicalize_path_for_comparison falls back to the deepest existing ancestor plus the literal tail, which is <project>/mid, and starts_with(root) passes. fs::copy in backup_and_atomic_write (:706-716) then follows both links:
$ mkdir .codex
$ echo '{"hooks":{"PreToolUse":[{"hooks":[{"type":"command","command":"echo attacker"}]}]}}' > .codex/hooks.json
$ ln -s ../mid .codex/hooks.json.bak
$ ln -s $OUT/evil.json mid # $OUT exists, evil.json does not
$ rtk init --codex
Hook: .codex/hooks.json (registered) # exit 0, no warning
$ cat $OUT/evil.json
{"hooks":{"PreToolUse":[{"hooks":[{"type":"command","command":"echo attacker"}]}]}}
Variants checked: final target already existing → refused; single link to a dangling outside path → refused; middle link outside the project → refused. Only the in-project dangling middle hop gets through. The same chain also passes on develop, so this is not introduced here, but the description says the paths "are now required to resolve inside the project" and this is the same primitive as the case that was fixed. The realistic scenario is a cloned repo shipping .codex/hooks.json plus the two links pointing at ~/.codex/hooks.json on a machine where it does not exist yet: a Codex hook that runs shell commands, written by rtk init.
Suggested fix, either is enough: resolve the leaf with read_link in a bounded loop (re-anchoring relative hops) and check every hop, or never let the backup copy follow a destination link (remove_file any existing hooks.json.bak symlink before fs::copy, or write the backup through the same tempfile + rename path as atomic_write). A test with this exact chain would pin it.
Related, lower priority: AGENTS.md is deliberately not guarded (:2803-2805), so AGENTS.md -> <outside file> still gets @RTK.md appended outside the project. Worth a line in the description at least.
2. The ownership check in --global mode orphans every RTK.md written by a released rtk (:1273-1282, :2877)
rtk init -g --codex has shipped since v0.33.0, and v0.46.0 through v0.48.0 write hooks/codex/rtk-awareness.md to ~/.codex/RTK.md with no marker (# RTK - Rust Token Killer (Codex CLI) first line); develop writes the unmarked # Command output payload. The marker check runs in both scopes, nothing branches on global. With the exact v0.48.0 payload in $CODEX_HOME/RTK.md:
$ rtk init --codex --global --uninstall
Kept RTK.md: …/.codex/RTK.md (it carries no rtk-owned marker; remove it yourself if you want it gone)
$ rtk init --codex --global
your existing RTK.md was saved to …/.codex/RTK.md.bak
develop removes the file on uninstall. The leftover is inert once the AGENTS.md reference is stripped and the .bak is created only once, so the impact is a stray file and a misleading message, but it is the exact "orphaned on uninstall" outcome the description gives as the reason for choosing a marker. Inside ~/.codex/ the name is unambiguous: if global || rtk_md_is_rtk_authored(path) fixes it, or recognise the two legacy first lines as rtk-authored (that would also cover project-mode users on develop).
Minor
is_rtk_authored_md(:230-232) also accepts thertk-instructionsmarker, but no release ever wrote that block into a file namedRTK.md(checked v0.30 → v0.48); a user who moved it there from CLAUDE.md next to their own notes loses the whole file on uninstall. Restricting tortk-ownedon the first non-blank line would be tighter.
Everything else is in good shape: free_backup_path works on the OsString, uninstall degrades to skipping only the hook, fixtures now carry the real marked payload, CI green on all three OSes.
cdbf482 to
dcdf247
Compare
…'s files `--codex` is the one init mode whose paths are the project root, where `RTK.md` is a name RTK does not own. Uninstall removed any file sitting there, so a project holding its own `RTK.md` lost it with no content check, backup or prompt; init overwrote one just as quietly. The file now says whose it is: init writes an `rtk-owned` line above the awareness payload and both sides read it on the first non-blank line, so a payload from another release is still recognised as RTK's -- comparing against the running build's copy would orphan RTK's own file on the first upgrade and leave a numbered backup on every one after -- while a marker a user pasted into the middle of their own notes does not hand the rest of the file over with it. A file without the line is the user's: init moves it to a free `.bak` sibling, uninstall keeps it. Installs predating the line are recognised too, so the change does not strand them. Up to v0.48.0 the payload opened with a heading naming RTK and the mode, which is enough on its own. The releases after it wrote the shared awareness text, whose headings name neither and which a user's own notes may open with, so those are matched whole instead, by a digest of the bytes that shipped -- frozen, because rewording the awareness text must not change which files uninstall recognises as RTK's own. That test is for the project root only. Under `--global` the file sits in the Codex home RTK created it in, where nothing else claims the name and every release before the marker wrote it there unmarked; reading the marker there would keep all of those files forever and back one up on the next install. The project paths are relative names RTK joins itself, so a symlinked `.codex` sent the new `hooks.json` write out of the project entirely -- and a planted `hooks.json.bak` took the existing hooks out through `fs::copy`, which follows a symlink at the destination, even with `.codex` a real directory. Both paths are now resolved link by link, up to a bound that makes a cycle terminate, and refused when the end of the chain lands outside. Resolving one link was not enough: `canonicalize` gives up at the first target that does not exist -- which it will, since RTK creates these files -- and hands back the unresolved path as if it sat where the chain broke, so neither a second hop nor a link above the file was ever looked at. A link standing where the file itself goes also has to sit inside the project rather than merely point there, since a write that cannot resolve its path replaces that link instead of following it. Only the hook is given up when the check refuses: `AGENTS.md` and `RTK.md` are demonstrably where uninstall left them, and refusing to clean them leaves artifacts with no command that removes them. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
dcdf247 to
f4ad97d
Compare
|
Thanks — all three are fixed, and your reproductions were exact in every case. The head has moved since your review, from 1. Two-link chain. Two rules, deliberately separate.
Resolution is the shared resolver. It separates three facts a bare Your exact layout against Of your two suggested fixes I took the guard rather than unlinking the Your chain is pinned by 2. Fixed two ways rather than one. The bare headings are not whitelisted on their own: a user's notes may legitimately open with either, and taking their file is the worse error. Minor. On your two related notes, both confirmed and both filed rather than folded in, since they are pre-existing on
Every fix is pinned by a test verified to fail when that fix alone is reverted. |
2fea0e5 to
5ac8af0
Compare
pszymkowiak
left a comment
There was a problem hiding this comment.
Re-verified on 5ac8af08 (now on develop 727ee6e6), same method as round 1: built the head, reproduced against the develop binary, three independent passes. Local gate: fmt and clippy clean, 3920 tests pass.
Finding 1, containment: fixed. The round-1 layout exits 1 with nothing written outside; develop still escapes. Tried to get past the new resolver with a 3-hop chain, a chain at exactly the 16-hop bound and one past it, a middle link through an in-project directory symlinked outside, .codex itself pointing at an existing and at a dangling outside path, hooks.json itself as a dangling chain, an unreadable link mid-chain, and a .. chain that leaves and re-enters. All refused. A normal install under /tmp (symlinked ancestor on macOS) still succeeds. Only residue is the in-project .bak -> ../AGENTS.md clobber, which is #4157.
Finding 2, ownership: fixed. Rebuilt the ground truth from the tags: v0.31.0 through v0.48.0 share one byte-identical payload (recognised by the frozen heading), v0.49.0 and develop write the three awareness payloads whose digests are in the list. Upgrade from v0.48.0, v0.49.0 and develop at both scopes, plus CRLF and BOM variants: uninstall removes, install writes no .bak. User notes opening with # RTK or carrying an rtk-instructions block survive uninstall (develop deleted them). Residue: a v0.49.0 payload the user appended to, or whose trailing newline an editor stripped, is treated as the user's, which is the safe direction.
Follow-ups, none blocking
uninstall_codex(init.rs:1341-1367): when the hook path fails the guard, uninstall cleansRTK.md/AGENTS.md, prints "Not checked: the Codex hook … may still be registered", and returnsOk(()), sortk init --codex --uninstall && …cannot tell the hook survived. Cleaning what you can is right; exiting non-zero afterwards would be too. Your call.- The
MAX_BACKUP_ATTEMPTScomment ("the two callers had before they shared this") and the refactor title ("both init paths") refer to the Pi/OMP caller, which is not on this branch; at head there is one caller of the resolver and one of the slot picker. test_rtk_md_ownership_is_carried_by_the_file_not_by_the_buildwill fail with a bare assertion on the next awareness-text edit; the remedy lives only in a comment two lines up. A message saying "drop these asserts, do not add a digest" would save the next editor a detour.backup_path_for(unnumbered, overwritten every run, follows a destination link) andnumbered_backup_pathnow coexist; the seven JSON writers still use the former. That is where #4157 lives.
Approving. Both fixes are pinned by tests that discriminate the fixed behaviour (the chain test asserts the canonical final target, which canonicalize cannot produce; the refusal tests assert no file was created), and no non-codex init mode reaches the new helpers.
…nt-aware The containment walk resolved symlink chains with a loop of its own, and the `.bak` picker chose a slot by name alone. Both are shaped here so the Pi/OMP ownership work can build on them rather than growing a second copy of a security-relevant walk that can drift out of agreement with this one. A bare `Option` conflates three facts: "not a symlink", "a link I could not read", and "I gave up". Conflating the first two lets a `readlink` failure on a path `lstat` just called a symlink be compared as if it had resolved, so `SymlinkHop` and `SymlinkChain` keep them apart and `Resolution` carries the distinction out to the caller. The hop bound is inclusive: it counts hops followed, and an exclusive range stopped one short of the length the documentation promised. Containment keeps a check the resolver cannot make. `atomic_write` cannot canonicalize a chain whose end does not exist, so it writes at the path as the filesystem reads it, replacing the last link rather than following it -- which makes every link along the site's own chain a place the write can land, not just the far end. A link outside the project that currently points back in is one an attacker may own and re-aim, so the chain is judged link by link. Ancestor components are traversed rather than sited: whole directory trees hang off one on macOS, and siting those would refuse every absolute target under `/var`. `free_backup_slot` probes with `fs::read` so an unreadable slot counts as taken rather than being overwritten, and reuses a backup whose content already matches so a provisioning loop cannot consume a slot per run. Names are built by appending to the `OsString`: `with_extension` replaces an extension instead of extending it. `BackupSlot` keeps an unreadable source apart from a preserved one, because only a caller that copies needs that answer -- a rename carries content RTK cannot read. `CwdGuard` restores the working directory on the way out. Restoring by hand needs the call under test to return rather than panic, so one failing assertion left every later test inside a deleted `TempDir`. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
5ac8af0 to
f6e41c9
Compare
|
Thanks for the second pass — the adversarial set in particular (3-hop, the bound and one past it, All four follow-ups are placed. Two landed in the PR before merge, two are tracked: 2 and 3 — fixed in The 4 — #4157, which I widened from the single function to the pattern: choose a Checking it against the Pi/OMP work turned up a second site with the same escape, and a detail worth recording: a slot picker that probes content does not close it. The control is in the issue too, because it narrows the fix: a link to an existing readable file is skipped, so the probe closes three of four cases and only the dangling one slips. You had already noted this PR is unaffected because its backup moves rather than copies. That is now written down as an invariant — 1 — #4164. Filed rather than folded into #4157, since that one is pre-existing on I left the exit code itself open in the issue rather than picking one. A non-zero exit from |
Fixes the third blocker from the release review on #3979, plus the symlink regression beside it. Both come from #3552, which added
rtk init --codex.Part of a set of six, one per originating PR: #3681, #3552 (this), #3265, #3772, #3857, #3941.
Blocker 2 —
init --codex --uninstalldeletes an unrelatedRTK.md--codexis the one init mode whose paths are the project root, whereRTK.mdis a name RTK does not own. Uninstall removed whatever file sat there — no content check, no backup, no prompt:Install was as quiet, overwriting a user-authored
RTK.mdin place.The file now says whose it is
Init writes an ownership line above the awareness payload and both sides read it, on the first non-blank line:
A marker rather than a content comparison, because the payload changes between releases: checking against the running build's copy stops recognising RTK's own file the moment the wording moves on — orphaning it on uninstall and leaving a numbered
.bakon every upgrade after.Only the first non-blank line counts. RTK writes its claim at the top of a file it wrote whole, so a marker — or an RTK-written block — that a user pasted into the middle of their own notes does not hand the rest of the file over with it:
Installs predating the marker are recognised too, so the change strands none of them:
# RTK - Rust Token Killer (Codex CLI), which names both RTK and the mode and which nothing writes any more. That heading alone is enough.# RTK,# Command output) name neither and which a user's own notes may legitimately open with. Keying on those would cost someone their file, so those payloads are matched whole instead, against frozen SHA-256 digests of the bytes that shipped. Frozen rather than compared against this build's copy: rewording the awareness text must not change which files uninstall recognises as RTK's own.Carriage returns are dropped before hashing, so a checkout that rewrote the line endings still matches, and a leading BOM is ignored.
--globalis a different questionIn global scope
RTK.mdlives in$CODEX_HOME, a directory RTK created it in and nothing else claims the name in. Reading the marker there would strand every file written by a released rtk:The ownership test is now scoped: in the Codex home the file is RTK's unconditionally, at the project root only the marker or the frozen legacy heading settles it.
.codex/hooks.jsonfollowed symlinks out of the projectThe project paths are relative names RTK joins itself, so a symlinked
.codexsent the newhooks.jsonwrite somewhere the user never named:A planted
hooks.json.bakdid the same throughfs::copy, which follows a symlink at the destination, even with.codexa perfectly ordinary directory — so the existing hooks file was carried out of the project.Resolving one link was not enough, and a chain of two got through — a hole that is pre-existing, not introduced here: it passes on
developas well. With.codex/hooks.json.bak -> ../midandmid -> <outside>/evil.jsonwhose target does not exist yet,canonicalizegives up at the dangling end and reports the whole chain as sitting where it broke, so the middle hop looked like an ordinary in-project path:Both paths are now resolved link by link, up to a bound that makes a cycle terminate, and refused when the end of the chain lands outside the project. The walk covers a link above the file, not only the file's own, since
canonicalizecannot see past a dangling one wherever it sits, and..after a component that does not exist yet is folded in rather than compared as written. A link standing where the file itself goes also has to sit inside the project rather than merely point there, because a write that cannot resolve its path replaces that link instead of following it; a link further up is always traversed, so it is judged by where it leads — whole directory trees hang off one on macOS, where/varis a symlink. The refusal names the real destination:What is guaranteed, and what is not
The guarantee is about
.codex/hooks.jsonand thehooks.json.bakwritten beside it: those two are required to resolve inside the project, and install writes nothing at all when they do not.AGENTS.mdandRTK.mdare deliberately not guarded, and git stores symlinks, so a cloned repository can ship either pointing outside the clone. This is documented rather than blocked, becauseatomic_writefollowing a user-maintained symlink is behaviour existing tests pin:AGENTS.mdis followed and left in place, so the@RTK.mdreference is appended to whatever it names. That line is inert text, where.codex/hooks.jsonregisters a hook that runs shell commands — which is why the hook is the path that gets the guard.RTK.mdpointing at a file RTK did not write has the link moved toRTK.md.bak; nothing outside is touched. Pointing at a file RTK did write, it is followed and rewritten in full.The Codex section of
docs/guide/getting-started/supported-agents.mdstates all three, so the behaviour is discoverable before someone installs into a repository they have not read.Only the hook is given up when the check fails. Refusing the whole uninstall left
RTK.mdand the@RTK.mdreference behind with no command that removes them —--globalacts on~/.codex, which is not where they are. Install still refuses outright rather than half-configure.Not changed, deliberately
The third item in that group — "Codex hook asserts
permissionDecision: \"allow\"unconditionally, the deny branch is unreachable" — is left alone.permissions::load_rules_for(Host::Codex)returns empty rule sets by design, soDenyandAllowRewriteare unreachable by construction, andhooks/codex/README.mddocuments that Codex requires the protocol-level allow forupdatedInputand that its own approval and sandbox checks still run on the rewritten command. That is a documented design decision with its reasoning written down, not a regression.Verification
Ownership: carried by the file rather than by the build (a payload from another release, an edited copy of RTK's own file, a leading BOM, CRLF endings, a reindented marker, a whitespace-only first line, the frozen legacy heading, each shipped payload, and an RTK block sitting inside the user's own notes, which stays the user's); a user-authored
RTK.mdsurviving uninstall and being moved aside by install; numbered backups not reusing a slot; dry-run moving nothing; a file RTK cannot read left alone; the Codex home owning itsRTK.mdwith no marker of any kind.Containment, on Unix: a symlinked
.codex, a symlinkedhooks.json.bak, a two-link chain, a dangling symlinked ancestor, a target climbing out with.., a link that sits outside while pointing back in, an absolute target travelling through a symlinked ancestor back into the project, an in-project chain that must still be accepted, and a cycle that must terminate.The guard's call sites are covered by driving the commands, not the helper: project-scope install and uninstall over a real symlinked
.codexand over a plantedhooks.json.bak, and over a user-authoredRTK.md. Every one of these was checked by mutation — deleting or inverting the code it covers makes that test, and only that test, fail.AGENTS.mdandRTK.mdstill resolve through a user-maintained symlink, and uninstall still cleans the project when the hook path is given up, saying on stdout that it did not check the hook rather than reporting a clean uninstall.Three existing fixtures that wrote a placeholder as
RTK.mdnow write the real marked payload, since their intent was "uninstall removes RTK's own file". The Codex section ofdocs/guide/getting-started/supported-agents.mddocuments the ownership rules and the containment refusal.cargo fmt --all,cargo clippy --all-targetsandcargo test --allare green, rebased on currentdevelop.🤖 Generated with Claude Code