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>
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