Skip to content

feat(init): route every init write through one guarded path - #4269

Open
KuSh wants to merge 5 commits into
rtk-ai:developfrom
KuSh:fix/init-shared-parent-dir
Open

KuSh wants to merge 5 commits into
rtk-ai:developfrom
KuSh:fix/init-shared-parent-dir

Conversation

@KuSh

@KuSh KuSh commented Sep 26, 2026 •

Copy link
Copy Markdown
Collaborator

Summary

Every rtk init write now goes through one path in src/hooks/init/write.rs, with the checks each agent used to do (or skip) around it. A single plan_write decides, for each write, what it lands on, what refuses it, what must be confirmed and what happens to the backup; the write, the check made before a consent prompt and the --dry-run preview all read that one plan.

Four commits, each standing on its own:

  1. fix(init): leave AGENTS.md alone on rtk init --codex --uninstall --dry-run — the dry run removed the rtk-instructions block for real.
  2. fix(init): patch a ~/.trae-cn that links to ~/.trae only once — the second pass backed RTK's own write up over the user's hooks.json.bak.
  3. fix(init): back up without following a symlink at the .bak — the copy goes to a temp file renamed over the .bak, so a planted symlink or hard link is replaced rather than written through. Fixes fs::copy onto a .bak path follows a symlink there, so a cloned repo can redirect rtk init writes outside the project #4157.
  4. feat(init): route every init write through one guarded path:
    • RTK's own files (RTK.md, GEMINI.md, hooks, extensions, plugins including Antigravity's, the Kilo Code rules, filters templates): atomic, no backup.
    • Configs RTK edits (settings.json, hooks configs, hooks.toml, Hermes' config.yaml): <name>.bak, then atomic. Gemini's settings.json now gets a backup too.
    • Instruction files RTK adds to (CLAUDE.md, AGENTS.md, Copilot instructions, .clinerules, .windsurfrules): <name>.bak (an earlier one is kept), then written in place, so owner, group, ACLs and hard links survive.
    • Missing parent directories are created once, in the shared path; the per-agent create_dir_all blocks are gone.
    • A file this user cannot write is refused before any prompt or backup. A Claude, Gemini or Vibe config RTK cannot patch is left to the manual instructions in every mode instead of aborting the run.
    • On Unix, new and replaced files get the permission bits a plain write would (they were all 0600).
    • A dangling symlink is followed only when a plain write would (its directory exists); otherwise it is refused, never replaced by a regular file.
    • In project mode (rtk init --codex writes through a symlinked AGENTS.md, so a cloned repo can redirect the write outside the project #4156): hook configs and extensions of Codex, Copilot, Trae, Droid, Pi, OMP and Antigravity that resolve outside the project are refused, generalising the guard Codex already had. Any other project write that resolves outside the project, or edits a hard-linked file in place, is named first; --auto-patch writes it, the default mode asks, --no-patch or a non-interactive run refuses it. --codex now accepts --auto-patch / --no-patch for this.

Fixes #4156. Fixes #4157.

Behaviour changes worth knowing

  • A non-interactive project rtk init stops when a file it would edit resolves outside the project (e.g. a CLAUDE.md linked to a shared copy), unless --auto-patch is given. A read-only file is refused in every mode.
  • Patched files leave a <name>.bak next to them.
  • A symlink into a directory that does not exist is refused instead of being replaced by a regular file.

Related PRs

Test plan

  • cargo fmt --all && cargo clippy --all-targets && cargo test --all on each commit and on the head (4041 tests)
  • cfg(windows) build cross-checked
  • init tests also run as namespaced root (unshare -r), for CI containers running as root
  • every behaviour added here has a test that fails when the behaviour is reverted
  • rtk init matrix against develop: 8576 forms (every agent, scope, --uninstall, --auto-patch / --no-patch, --dry-run, -v) over 16 setups (empty home, user content, legacy blocks, dangling links, links out of the project for hook and instruction files, a shared directory, read-only files, aliased Pi/OMP and Trae CN, …): no crash, no write under --dry-run, no write outside the project without --auto-patch, and every other difference is one listed above

🤖 Generated with Claude Code

@rtk-wshm-sync-bot

Copy link
Copy Markdown

wshm · Automated triage by AI

📊 Automated PR Analysis

✨ Type feature
🔴 Risk high

Summary

Consolidates every file write performed by rtk init (across all supported agents) into a single guarded path with unified logic for backups, permission checks, symlink safety, and project-boundary enforcement. Bundles three related bug fixes (dry-run leaving stray edits, backup clobbering user files, symlink-following during backup) with a larger refactor that also adds new safety behavior for project-mode writes resolving outside the project.

Review Checklist

  • Tests present
  • Breaking change
  • Docs updated

Linked issues: #4156, #4157


Analyzed automatically by wshm · This is an automated analysis, not a human review.

KuSh and others added 5 commits October 7, 2026 02:30
…ry-run`

The Codex uninstall removed the rtk-instructions block from AGENTS.md
without checking --dry-run, so a dry run printed "Nothing written" after
rewriting the file. It now reports the block it would remove.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
When ~/.trae-cn is a symlink to ~/.trae, `rtk init -g --agent trae`
treated the two hooks.json paths as separate files and patched the same
file twice. The second pass backed up RTK's own first write over the
user's hooks.json.bak, so the user's backup was lost. The alias is now
detected and the file patched once.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The backup taken before a config is patched was a plain copy onto
<name>.bak, and a copy opens its destination: a .bak that is a symlink
made it write outside the project, or into another project file, and a
hard link wrote into the file it shares. A repository can ship either.

The copy now goes to a temp file created with the source's permission
bits and renamed over the .bak, so whatever sits there is replaced
rather than written through. A .bak this user cannot write is still
refused, and where the directory takes no new file an existing .bak is
rewritten in place without following a link, unless it is a hard link
to the file or to another one.

Fixes rtk-ai#4157.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Each agent created its own directories before writing, some files went
through atomic_write and others through fs::write, several agents
carried their own copy of the backup-then-replace logic, and only Codex
kept its hook inside the project. Every init write now goes through one
of three shared functions, and the checks the call sites did around
them live there, decided once per write so that the write, the check
before a consent prompt and the --dry-run preview agree:

- RTK's own files, written whole by RTK (RTK.md, GEMINI.md, hook
  scripts, extensions, plugins including Antigravity's, the Kilo Code
  rules file, the filters templates), are replaced atomically through a
  temp file renamed over the target, with no backup.
- Config files RTK edits but does not own (settings.json, the hooks
  configs, Vibe's hooks.toml, Hermes' config.yaml) are backed up to
  <name>.bak, then replaced atomically: agents re-read them while they
  run. Gemini's settings.json now takes that backup too.
- Instruction files RTK adds to but does not own (CLAUDE.md, AGENTS.md,
  Copilot instructions, .clinerules, .windsurfrules) are backed up to
  <name>.bak, then written in place, so they keep their owner, group,
  ACLs and hard links; the cost is that a crash mid-write is recovered
  from the backup rather than avoided. An earlier <name>.bak -- the
  file before RTK touched it, or the user's own copy -- is kept rather
  than replaced.
  Where the directory takes no new file, the write goes ahead without a
  backup and says so; a config whose backup cannot be written is
  refused instead. A project file that resolves outside the
  project is not backed up into it.

A file patched twice in one run is backed up once, so the backup holds
the file as it was before RTK ran. All three create the missing parent
directories, so the per-agent create_dir_all blocks are gone. All
three refuse a file this user cannot write, as fs::write does, before
prompting or backing up. A Claude, Gemini or Vibe config RTK cannot
patch is not asked about and does not stop the run, --auto-patch
included: it is left to the manual instructions, as a declined prompt
is. On Unix a new or replaced file gets the permission bits a plain
write would, instead of 0600. A dangling symlink is followed exactly
when a plain write would (the directory it names exists); directories
are never created through a dangling link, and one whose directory does
not exist is refused instead of being replaced by a regular file (RTK
used to replace some such links and fail on others).

In project mode, writes stay inside the project (rtk-ai#4156):

- Hook configs and extensions (Codex, Copilot, Trae, Droid, Pi, OMP,
  Antigravity, and their backups) that resolve outside the project are
  refused before anything is read or written, on install and uninstall;
  a Copilot uninstall still removes its instructions block and then
  reports the hook it left in place. This generalises the guard Codex
  already had; the symlink walk moves from codex.rs to symlinks.rs, and
  Trae's CN alias check now uses the same resolver as the write.
- Any other project write that resolves outside the project, or (on
  Unix) that edits in place a file with other hard links, is named
  first; then --auto-patch writes it, the default mode asks, and
  --no-patch or a non-interactive run refuses it. The check runs where
  the write happens, so a file that needs no change is never asked
  about, a file written twice is asked about once, and --dry-run says
  what the real run would do. --codex now accepts --auto-patch and
  --no-patch for this.

A non-interactive project `rtk init` therefore stops when a file it
would edit resolves outside the project (for example a CLAUDE.md linked
to a shared copy), unless --auto-patch is given; a read-only file is
refused in every mode.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Each write site printed its own "[dry-run] would …" line after preview(),
and its own "[ok] …" or verbose line after the write. write_reported()
now takes the path, the write kind and a Report holding both messages,
and prints them itself:

- in a dry run, the "would" line (and the content under -v) is printed
  only when the real run would write; a refused or unconfirmed write no
  longer shows a "would refuse" line followed by a "would write" line
- after a real write, the done line goes to stdout or, for verbose-only
  lines, to stderr under -v, and the backup path is shown under -v for
  every write that took one
- the global filters template dry run now previews like the others

atomic_write, patch_config and patch_instructions are folded into
write_file(path, kind, content).

ProjectScope is marked #[must_use]: it is a guard, and a call that
does not bind it would end the scope before any write ran.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

1 participant