fix(hooks): structurally close the approval-boundary TOCTOU class - #2806
Conversation
Project hook commands were selected from `.config/wt.toml` twice: once at the approval gate (to build the prompt) and again at execution (`load_project_config()` re-read). Between the two reads a merge moves the target ref, an auto-rebase rewrites the feature config, a removal scrubs the worktree, or `git worktree add` materializes a `--create` worktree, so the second read could select a command the user never approved. On a fresh `git clone && wt <op>` that is remote code execution. Replace the point-fix snapshot threading with one structural fix: the gate selects the command set exactly once into an immutable, type-state `ApprovedHookPlan` (new `src/commands/hook_plan.rs`). Covered executors consume only that value via `execute_planned_hook` / `register_planned` and hold no `ProjectConfig`/`Repository` for selection, so re-derivation is a compile error rather than a review invariant. Rendering stays deferred (post-`*` hooks need post-operation context) but consumes the frozen `CommandConfig` list, never config. Covered: pre-merge, post-merge, pre-remove, post-remove, post-switch, pre-start, post-start. pre-commit, post-commit, pre-switch, `wt hook <type>`, and aliases keep the existing path — they have no state mutation between gate and execution. Deletes the snapshot apparatus: `RemoveResult::removed_project_config`, `register_with_project_config`, `collect_remove_hook_commands`, `collect_merge_commands`, `removal_hooks_approved`, `approve_or_skip_with_config`. Behavior parity: an empty plan (`--no-hooks`, declined, or no project config) runs no project hooks; the merge approval prompt still lists pre-commit/post-commit; the picker's read-only gate drops only the unapproved project pipelines (strictly better than the old all-or-nothing verify boolean). The empty-plan fast path returns before any `Approvals` load or project-id resolution, so a malformed `approvals.toml` or an unresolvable identifier no longer aborts a command with nothing to authorize; `wt merge --no-hooks` no longer parses the destination config. Preserved: the removal data-safety re-validation (`ensure_clean` / HEAD-moved), the Ctrl-C signal policy, source-scoped hook filtering, and post-migration config selection. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
worktrunk-bot
left a comment
There was a problem hiding this comment.
The structural fix reads well — type-state from HookPlan to ApprovedHookPlan, executors that hold no ProjectConfig/Repository for selection, anchor-keyed exact lookup that returns &[] on mismatch rather than silently running a different worktree's hook. Anchor invariants line up for each covered hook: merge's feature_root and destination_path are computed from the same target_worktree_path/current_wt.root() shape in handle_merge and finish_after_merge; remove threads the same RemoveResult::worktree_path / main_path from gate to executor; switch uses plan.worktree_path() at the gate and the same value via result.path() / worktree_path in execute_switch and spawn_switch_background_hooks; prune resolves through the shared cached list_worktrees() slice. The behaviour-parity claims hold too — wt merge --no-hooks skips both feature_cfg and dest_cfg reads, and the empty-plan fast path in HookPlan::approve returns before Approvals::load / project_id resolution. The test_post_merge_hook_from_merged_feature_config_does_not_run regression is well-framed (a user-source control as the causal bound, with the injected project-source command provably never selected because the destination's gate-time config has nothing in it).
One CI-blocking issue from the Windows 🧪 Pre-merge hooks step: HookPlan::approve_readonly is #[cfg(unix)], but two module-level rustdoc intra-doc links target it. Under RUSTDOCFLAGS='-Dwarnings' cargo doc --no-deps --document-private-items on Windows, the item doesn't exist and rustdoc fails the build:
error: unresolved link to `HookPlan::approve_readonly`
--> src\commands\hook_plan.rs:40:38
|
40 | //! interactive / `--yes` gate) or [`HookPlan::approve_readonly`] (the picker,
| ^^^^^^^^^^^^^^^^^^^^^^^^^^ the struct `HookPlan` has no field or associated item named `approve_readonly`
Same error at line 255. Demoting both to plain code spans is the smallest fix; the docstring on approve_readonly itself stays intra-linkable via search since it's still defined on HookPlan for Unix builds, and Unix readers lose nothing because the immediate context already names the method.
The two [`HookPlan::approve_readonly`] links break `cargo doc` on Windows (-D rustdoc::broken-intra-doc-links): the method is #[cfg(unix)] so it doesn't exist there. Demote to plain code spans — a code span is never link-resolved, on any platform. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…Context (#2811) Follow-up to #2808. That PR fixed the Windows `.git/config` race in `wt step prune` by adding a prune-local `RwLock` parameter to `try_remove`, pushing it to 8 parameters and a `#[allow(clippy::too_many_arguments)]` — deliberately deferred as an optional standalone cleanup to keep the race fix minimal. Of `try_remove`'s 8 arguments, only `candidate` varies; the other 7 are identical at all three call sites in `step_prune` (two in the `rx` loop, one for `deferred_current`). This bundles them into a `RemovalContext<'a>` struct of borrows, constructed once and passed by reference, following the existing `CommandContext` / `RemovedWorktreeOutputContext` / `TemplateContext` pattern. Each call site collapses from a 9-line argument block to one line, the loop-invariant relationship is now expressed in the type rather than re-asserted by hand three times, and the `#[allow]` is removed (clippy is clean without it). The B-prime concurrency semantics are intact: the struct carries `&RwLock<()>` like the other borrows, and `try_remove` still acquires the `check_lock` write guard as the first statement of its body, held over the whole body — load-bearing for the Windows fix. Pure refactor, no behavior change. Rebased onto main after #2806 (the approval-boundary TOCTOU fix), which renamed `try_remove`'s `run_hooks: bool` to `hook_plan: &ApprovedHookPlan`; `RemovalContext` carries that field unchanged. Follow-up commit: the struct refactor incidentally pushed `handle_remove_output(...)` past rustfmt's `fn_call_width` (the `ctx.`-prefixed args), splitting it multi-line and moving the trailing `)?;` onto its own line — which LLVM-cov flags as a `codecov/patch` miss despite the line executing (line 205 is covered and 205→210 is straight-line). Reading the `Copy` fields into locals keeps that call byte-identical to its covered form on main, so the diff stays a faithful no-behavior-change refactor. --------- Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Resolves a CHANGELOG.md conflict introduced by the v0.52.0 release (#2817) landing on main while this branch was open; the fsmonitor reap entry now sits under `## Unreleased` above the released `## 0.52.0` section. src/output/handlers.rs auto-merged cleanly — the `stop_fsmonitor_daemon` call still runs synchronously on the foreground removal path after the #2806 hook-plan refactor. Co-Authored-By: Claude <noreply@anthropic.com>
…2816) The root `CLAUDE.md` loads into every conversation, so its ~510 lines were paid on every session. This cuts it to ~190 lines without losing any documented behavior or invariant. Three things happened: derivable/stale content was removed (the JSON Output Format pointer, just a redirect to `wt list --help`), prose was tightened throughout, and task-specific detail was pushed down to the sibling `CLAUDE.md` that loads where that work happens — the root keeps a one-line pointer. **Relocations** (substance preserved at the destination, root keeps a pointer): - Testing commands, the `mock-stub` filtered-run gotcha, Claude Code web setup, the shell/PTY nextest SIGTTOU suspension, and codecov investigation mechanics → `tests/CLAUDE.md` - Doc sync taxonomy (the three categories + PRIMARY SOURCE), three-context help-text authoring, the `.gitattributes` `linguist-generated=false` exemption, config-TOML double-comment rule → `docs/CLAUDE.md` - Plugin layout, the Codex no-hooks re-enablement conditions, the accepted `wt-switch-create` tradeoff → `plugins/worktrunk/CLAUDE.md` (its back-pointers into the root were made self-contained so nothing dangles) - The add-a-CLI-command recipe → `src/commands/CLAUDE.md` **Kept in the root, tightened but substance intact:** the governing invariants — Data Safety, the Command Execution Principles (`shell_exec::Cmd`, structured output, network policy, signal handling), Project Commands Run Only After Approval (verbatim — the #2806 TOCTOU fix depends on its wording), the codecov merge gate, Config Deprecation, accessor naming, and the Worktree Model. A third commit consolidates the doc-sync explanation in `docs/CLAUDE.md`: the new "Doc sync taxonomy" section was overlapping with the file's existing "Command documentation" area on "edit `src/cli/mod.rs`" and `test_docs_are_in_sync`. Now the taxonomy is the single entry point and "Command page generation" reads as the category-1 mechanism details. Every CLAUDE.md heading quoted in a code comment is preserved verbatim so the breadcrumbs still resolve under a case-sensitive grep: `Plugin Layout` (`src/commands/config/codex.rs`), `Project Commands Run Only After Approval` (`src/commands/hook_plan.rs`, `src/commands/picker/mod.rs`), `Network Access` (`src/git/repository/config.rs`), and `Signal Handling` (`src/git/error.rs`, `src/commands/run_pipeline.rs`, `src/commands/command_executor.rs`). The first push downcased the latter two; the automated reviewer caught it and the second commit restored Title Case across all Command Execution Principles subheadings. `test_docs_are_in_sync` passes (docs-only change; no code touched). --------- Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…s scalar
Three data-loss edges in `compute_migrated_content`:
- `commit = "x"` + `[commit-generation]`: `doc.contains_key("commit")` was
true so no table was created, then `doc["commit"].as_table_mut()` was None,
so the migrated `[commit.generation]` was never inserted — but the source
was already removed. Same shape for `switch = "x"` + `[select]`.
- `args = [1, "--ok"]`: the string filter silently discarded the non-string
element while removing `args`.
Add `can_host_subtable` (parent absent or table) and gate source removal on
it for both top-level and project-level commit-generation and for select.
Require every `args` element be a string before merging; otherwise preserve
`args` unchanged. Regression tests for all three.
Reconstructed against current main: the original clawpatch implementation
sat on a divergent #2783 baseline and its 4-commit Group C stack would not
3-way-merge cleanly on top of #2806/#2783 main without hand-merging
config-migration logic — the exact code where silent corruption is the
defect. Re-derived as one focused change.
Co-Authored-By: Claude <noreply@anthropic.com>
…fig preservation (#2788) ## Summary Four standalone correctness fixes, each independent of the others, each with a regression test. Originally this branch was a large approval-boundary hardening PR (the "Group B" work); since then #2806 landed on `main` and structurally closed that entire TOCTOU class, deleting the point-fix scaffolding this branch had built. The branch has been rebaselined onto current `main` and reduced to the leftover orthogonal fixes that #2806 doesn't cover. ## What's in this PR | Fix | Why | |---|---| | Non-Unicode argv panic (`src/help.rs`, `src/main.rs`, `src/invocation.rs`) | `std::env::args()` panics on non-UTF-8 argv; the help renderer, command-line logger, and the three `invocation.rs` accessors (`binary_name`, `invocation_path`, `was_invoked_with_explicit_path`) all used it. Switched to `args_os()` + `to_string_lossy()`, closing the class. Regression test in `tests/integration_tests/custom.rs`. | | Format-flag rejection on write state actions (`src/main.rs`) | `guard_format_on_write` called `clap::Error::exit()`, killing the process before the verbose diagnostic and output epilogue ran. Now returns a `Result` so the conflict error flows through normal error handling. Parameterised regression test (flag before/after, all four state kinds) in `tests/integration_tests/config_state.rs`. | | `StateWrite` compiler-enforced format-on-write guard (`src/cli/config.rs`, `src/cli/mod.rs`, `src/main.rs`) | Follows the above. The hand-placed `guard_format_on_write(...)` at 5 write-arm sites was opt-in — a future write variant could silently accept `--format`. Replaced with a `StateWrite` trait co-located with the action enums; exhaustive (no-wildcard) `write_verb()` impls make the classification a compile-time obligation. Behaviour byte-identical. | | Don't drop deprecated config when the canonical parent is scalar (`src/config/deprecation.rs`) | Three data-loss edges in `compute_migrated_content`: `commit = "x"` + `[commit-generation]` had nowhere to insert `[commit.generation]`, same shape for `switch = "x"` + `[select]`, and `args = [1, "--ok"]` silently filtered the non-string element while removing `args`. Added a `can_host_subtable` guard and an all-strings check. Regression tests for all three. | ## Why the diff is small now The big "approval-boundary hardening" Group B that gave this PR its original title was a point-fix-per-call-site approach to the post-approval config-reload TOCTOU. After surfacing the structural alternative, a separate session built it as #2806, which landed on `main` and made the point-fixes redundant (it deletes most of the scaffolding they added). Force-pushed to rebaseline rather than carry the dead weight; the previous draft's review threads are on the prior history (`0fd7d4db8`) which is no longer the branch HEAD. The "human security review of Group B" gate that kept this PR a draft is now moot — Group B's RCE-class code is on `main` via #2806's own review, not here. ## Test plan - [x] `cargo build` + `cargo clippy --bins --features shell-integration-tests` clean - [x] Survivor tests pass on the new base (argv, format-flag, StateWrite, deprecation — 119 config_state + 150 deprecation tests, plus the new regression cases) - [x] Required CI green: `test (linux/macos/windows)`; `codecov/patch` 100% > _This was written by Claude Code on behalf of Maximilian Roos_ --------- Co-authored-by: Claude <noreply@anthropic.com>
…2853) The `nix-flake` CI job was red on `main`: the unit test `commands::hook_plan::tests::approve_readonly_drops_unapproved_project_keeps_user` panicked with `Failed to create config directory: Permission denied`. The test called `approve_command(.., None)`. The `None` for `approvals_path` makes `Approvals` resolve the path through `approvals_path()`, which — in the lib crate compiled in non-test mode — points at the real `~/.config/worktrunk/` directory. `approve_command` then `create_dir_all`s it. Outside a sandbox `$HOME` is writable, so the test silently passed while writing a stray `[projects.proj]` entry into the user's `approvals.toml`. The nix build sandbox has no writable config dir, so it panicked instead. This was latent on `main` since #2806 (which added the test). It only started tripping the gate now because the `nix-flake` job runs when a PR touches `Cargo.{toml,lock}`, and #2849 was the first to do so — so `nix-flake` failed there for a reason unrelated to that PR's change. The fix points `approve_command` at a `tempfile::tempdir()`-backed approvals path via the `Some(&path)` parameter it already accepts — no process-env mutation (`tests/CLAUDE.md` bans it), matching the in-file pattern in `src/config/approvals.rs`'s own tests. Same class of fix as #2615 and #2624. The two sibling tests in `hook_plan.rs` only *read* via `Approvals::load()`, which returns the default when no file exists — never creating a directory or writing — so they pass in the sandbox unchanged and were left as-is. Verified by reproducing the sandbox condition locally (test binary run with `HOME` / `XDG_CONFIG_HOME` pointed at a non-writable dir): the target test fails before the fix and passes after, and all three `hook_plan` tests still pass under a plain `cargo test`. Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…2853) The `nix-flake` CI job was red on `main`: the unit test `commands::hook_plan::tests::approve_readonly_drops_unapproved_project_keeps_user` panicked with `Failed to create config directory: Permission denied`. The test called `approve_command(.., None)`. The `None` for `approvals_path` makes `Approvals` resolve the path through `approvals_path()`, which — in the lib crate compiled in non-test mode — points at the real `~/.config/worktrunk/` directory. `approve_command` then `create_dir_all`s it. Outside a sandbox `$HOME` is writable, so the test silently passed while writing a stray `[projects.proj]` entry into the user's `approvals.toml`. The nix build sandbox has no writable config dir, so it panicked instead. This was latent on `main` since #2806 (which added the test). It only started tripping the gate now because the `nix-flake` job runs when a PR touches `Cargo.{toml,lock}`, and #2849 was the first to do so — so `nix-flake` failed there for a reason unrelated to that PR's change. The fix points `approve_command` at a `tempfile::tempdir()`-backed approvals path via the `Some(&path)` parameter it already accepts — no process-env mutation (`tests/CLAUDE.md` bans it), matching the in-file pattern in `src/config/approvals.rs`'s own tests. Same class of fix as #2615 and #2624. The two sibling tests in `hook_plan.rs` only *read* via `Approvals::load()`, which returns the default when no file exists — never creating a directory or writing — so they pass in the sandbox unchanged and were left as-is. Verified by reproducing the sandbox condition locally (test binary run with `HOME` / `XDG_CONFIG_HOME` pointed at a non-writable dir): the target test fails before the fix and passes after, and all three `hook_plan` tests still pass under a plain `cargo test`. Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Why
Project-defined hook commands (
pre-*/post-*) are arbitrary code shipped in a repo the user may have just cloned. They were selected from.config/wt.tomltwice: once at the approval gate to build the prompt, and again at execution whenregister/execute_hookre-readload_project_config(). Between the two reads, the operation itself mutates state — a merge moves the target ref, an auto-rebase rewrites the feature config, a removal scrubs the worktree,git worktree addmaterializes a--createworktree — so the second read could select a command the user never approved. On a freshgit clone && wt <op>that is remote code execution. Onmainthe post-merge path was entirely unpinned; the others used point-fix config snapshots that the executor could still re-resolve around.Approach
The gate selects the command set exactly once and freezes it into an immutable, type-state
ApprovedHookPlan(newsrc/commands/hook_plan.rs). Covered executors consume only that value viaexecute_planned_hook/register_plannedand hold noProjectConfig/Repositoryfor selection, so re-derivation is a compile error, not a review invariant. Rendering stays deferred (post-*hooks legitimately need post-operation context like the merge commit) but consumes the frozenCommandConfiglist, never config.Covered (gate and execution separated by a state mutation):
pre-merge,post-merge,pre-remove,post-remove,post-switch,pre-start,post-start. Deliberately not covered — they have no gate→exec mutation window and share the gate's cachedRepository:pre-commit,post-commit,pre-switch,wt hook <type>, aliases. This scope boundary is documented in thecommands::hooksmodule spec.Clean cutover: the point-fix snapshot apparatus is deleted (
RemoveResult::removed_project_config,register_with_project_config,collect_remove_hook_commands,collect_merge_commands,removal_hooks_approved,approve_or_skip_with_config) — no parallel path, no compatibility flag.Reviewer orientation
src/commands/hook_plan.rs— the whole model:HookPlanBuilder(sole config→commands point), type-stateHookPlan→ApprovedHookPlan(constructible only viaapprove/approve_readonly/empty),lookup/render_planned. Start here.merge.rs/main.rs/step/prune.rs/worktree/switch.rs/picker/mod.rs— the five gates that build a plan.output/handlers.rs/worktree/finish.rs— the executors that consume it.commands::hooksmodule doc — the canonical "which.config/wt.tomla hook reads" spec, rewritten for the plan model including why the uncovered set is safe (shared never-invalidated config cache).Behavior parity is preserved: an empty plan (
--no-hooks, declined, or no project config) runs no project hooks; the merge approval prompt is unchanged (still lists pre-commit/post-commit); the picker's read-only gate drops only unapproved project pipelines (strictly better than the old all-or-nothing verify boolean). The empty-plan fast path returns before anyApprovalsload or project-id resolution, so a malformedapprovals.tomlno longer aborts a command with nothing to authorize, andwt merge --no-hooksno longer parses the destination config. The removal data-safety re-validation, the Ctrl-C signal policy, and source-scoped filtering are untouched.Testing
cargo run -- hook pre-merge --yesgreen (3751 tests), clippy + pre-commit clean. New regression tests:test_post_merge_hook_from_merged_feature_config_does_not_run(the TOCTOU itself, causally bounded),test_remove_no_project_hooks_ignores_malformed_approvals,test_merge_no_hooks_ignores_malformed_destination_config, plushook_planunit tests (frozen lookup, read-only filter, source-group ordering). Reviewed across eight structurally-distinct passes (adversarial, generalization, evidential, subtraction, metric, classification, holistic) plus a Codex review whose two P2 findings are fixed and locked with the malformed-config tests.🤖 Generated with Claude Code