Skip to content

fix(hooks): structurally close the approval-boundary TOCTOU class#2806

Merged
max-sixty merged 2 commits into
mainfrom
approval-plan
May 19, 2026
Merged

fix(hooks): structurally close the approval-boundary TOCTOU class#2806
max-sixty merged 2 commits into
mainfrom
approval-plan

Conversation

@max-sixty
Copy link
Copy Markdown
Owner

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.toml twice: once at the approval gate to build the prompt, and again at execution when register/execute_hook re-read load_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 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. On main the 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 (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, not a review invariant. Rendering stays deferred (post-* hooks legitimately need post-operation context like the merge commit) but consumes the frozen CommandConfig list, 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 cached Repository: pre-commit, post-commit, pre-switch, wt hook <type>, aliases. This scope boundary is documented in the commands::hooks module 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-state HookPlanApprovedHookPlan (constructible only via approve/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::hooks module doc — the canonical "which .config/wt.toml a 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 any Approvals load or project-id resolution, so a malformed approvals.toml no longer aborts a command with nothing to authorize, and wt merge --no-hooks no 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 --yes green (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, plus hook_plan unit 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

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>
Copy link
Copy Markdown
Collaborator

@worktrunk-bot worktrunk-bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Comment thread src/commands/hook_plan.rs Outdated
Comment thread src/commands/hook_plan.rs Outdated
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>
@max-sixty max-sixty merged commit 84fb34c into main May 19, 2026
34 checks passed
@max-sixty max-sixty deleted the approval-plan branch May 19, 2026 19:13
max-sixty added a commit that referenced this pull request May 19, 2026
…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>
max-sixty added a commit that referenced this pull request May 19, 2026
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>
max-sixty added a commit that referenced this pull request May 19, 2026
…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>
max-sixty added a commit that referenced this pull request May 19, 2026
…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>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants