Skip to content

fix(hooks): require approval before running hooks from a committed .wt.toml - #134

Merged
timvw merged 1 commit into
mainfrom
fix/hook-trust
Aug 19, 2026
Merged

fix(hooks): require approval before running hooks from a committed .wt.toml#134
timvw merged 1 commit into
mainfrom
fix/hook-trust

Conversation

@timvw

@timvw timvw commented Aug 19, 2026

Copy link
Copy Markdown
Owner

Closes #129.

.wt.toml lives in the working tree, so it is committed and travels with the repository. wt merged its [hooks] table into the effective hook config and ran it through sh -c with no prompt and no opt-in — cloning an untrusted repo and running wt create was arbitrary code execution as the invoking user. git does not transfer its own hooks on clone for this reason, and direnv requires direnv allow before sourcing a committed .envrc. WT_HOOKS_DISABLED=1 does not count: it is opt-out, and only helps someone who already knows about the risk.

What changes

Hooks now carry the config layer that supplied them, and anything from a repo-level .wt.toml needs approval before it runs:

⚠ These commands come from /path/to/repo/.wt.toml (not trusted):

  → [post_create] npm install
    [pre_remove]  ./scripts/teardown.sh

  → runs now; trusting the file covers the rest too.

Use the arrow keys to navigate: ↓ ↑ → ←
? Run these hooks?:
  ▸ Skip these commands
    Run once
    Run, and trust this .wt.toml until it changes
wt trust          # approve this repository's .wt.toml hooks
wt trust --list   # show every approval on this machine
wt untrust        # revoke this repository's approval

Approval is pinned to (repository, sha256 of .wt.toml) and both must match. An edit — a pull that adds a post_create, a branch whose .wt.toml differs — asks again; an identical .wt.toml in another repository is not covered, since make setup is only as safe as the Makefile next to it. Approvals live in ~/.config/wt/trust.toml, never in the repository and never in .git/config, which a repo handed over as a directory also owns.

The prompt shows every command the file contributes, not just the batch about to run, because approving persists trust for the whole file — a benign post_create must not be able to buy silent consent for an unseen pre_remove. Commands and paths are escaped for display so a .wt.toml cannot redraw the prompt that is asking about it.

Approving all hook execution

hooks_policy (config file or WT_HOOKS_POLICY) chooses how much is gated:

Value Behaviour
prompt-untrusted default — user-owned hooks run, repo hooks need approval
prompt-all confirm every hook batch, including your own
trusted-only never prompt; already-trusted and user-owned hooks run, the rest are skipped
off run no hooks at all

prompt-all covers what trust alone cannot: your own post_checkout = ["npm install"] runs whatever lifecycle scripts sit in the repo you are standing in. It is deliberately not read from .wt.toml — a repository picking how closely wt scrutinises that same repository's hooks would put the lock on the inside of the door.

Failure behaviour

Declining skips the hooks and warns; it never aborts the operation, including for pre-hooks. Refusing to create a worktree because a repository asked to run something you declined would make the safe answer the expensive one. With no terminal to ask on — scripts, CI, --format json — the answer is skip, unless WT_HOOKS_APPROVE_ALL=1 says otherwise.

Notes on the design

  • The hash is taken from the exact bytes decoded at config load, not from a fresh read when the hook fires, so there is no window in which the file is swapped between check and run.
  • The trust key is the git common dir (shared by every worktree of a repo), but only when git worktree list actually registers this checkout against it — otherwise a directory shipping a .git file pointing at a repo you trust would inherit its approval. It falls back to the working tree's own path.
  • That key is resolved at config load, because wt remove fires post_remove after git has deleted the directory wt was standing in.

Testing

  • 20 new unit tests in cmd/trust_test.go, including the security: committed .wt.toml executes arbitrary shell from untrusted repos #129 repro, trust invalidation on edit, non-transfer between repos, the spoofed-common-dir case (real git), and the deleted-cwd case.
  • 2 new e2e scenarios in e2e/scenarios/hooks.yaml.
  • Full suite: go test ./... green, e2e 241 passed / 0 failed / 13 skipped.
  • Reviewed with codex over 6 rounds until it returned no new findings.

Docs

README.md, docs/configuration.md, llms.txt and the Claude Code skill all document trust and hooks_policy.

@codecov

codecov Bot commented Aug 19, 2026

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 58.23666% with 180 lines in your changes missing coverage. Please review.
✅ Project coverage is 39.68%. Comparing base (3d1f2c1) to head (02875aa).

Files with missing lines Patch % Lines
cmd/trust.go 42.03% 108 Missing and 23 partials ⚠️
cmd/hooks.go 71.83% 47 Missing and 2 partials ⚠️
Additional details and impacted files

Impacted file tree graph

@@            Coverage Diff             @@
##             main     #134      +/-   ##
==========================================
+ Coverage   37.73%   39.68%   +1.94%     
==========================================
  Files          33       34       +1     
  Lines        3506     3901     +395     
==========================================
+ Hits         1323     1548     +225     
- Misses       2089     2242     +153     
- Partials       94      111      +17     
Files with missing lines Coverage Δ
cmd/config.go 82.56% <100.00%> (+9.78%) ⬆️
cmd/config_cmd.go 67.16% <100.00%> (+1.53%) ⬆️
cmd/root.go 71.42% <100.00%> (+0.64%) ⬆️
cmd/hooks.go 71.42% <71.83%> (-18.32%) ⬇️
cmd/trust.go 42.03% <42.03%> (ø)

... and 1 file with indirect coverage changes

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.

…t.toml

.wt.toml lives in the working tree, so it is committed and travels with the
repository. wt merged its [hooks] table into the effective hook config and ran
it through `sh -c` with no prompt and no opt-in, which made cloning an untrusted
repo and running `wt create` arbitrary code execution as the invoking user
(#129). git does not transfer its own hooks on clone for this reason, and direnv
requires `direnv allow` before sourcing a committed .envrc; wt had neither
protection. WT_HOOKS_DISABLED=1 does not count: it is opt-out, and only helps
someone who already knows about the risk.

Hooks now carry the config layer that supplied them, and anything that arrived
from a repo-level .wt.toml needs approval before it runs. Approval is pinned to
(repository, sha256 of .wt.toml): an edit — a pull that adds a post_create, a
branch whose .wt.toml differs — asks again, and an identical .wt.toml in another
repository is not covered, since `make setup` is only as safe as the Makefile
next to it. Approvals live in ~/.config/wt/trust.toml, never in the repository
and never in .git/config, which a repo handed over as a directory also owns.

Declining skips the hooks and warns; it never aborts the operation, including
for pre-hooks. Refusing to create a worktree because a repository asked to run
something you declined would make the safe answer the expensive one. With no
terminal to ask on — scripts, CI, --format json — the answer is skip, unless
WT_HOOKS_APPROVE_ALL=1 says otherwise.

hooks_policy (config file or WT_HOOKS_POLICY) chooses how much is gated:
prompt-untrusted (default), prompt-all, trusted-only, off. prompt-all covers
what trust alone cannot — your own post_checkout npm install runs whatever
lifecycle scripts sit in the repo you are standing in. It is deliberately not
read from .wt.toml: a repository picking how closely wt scrutinises that same
repository's hooks would put the lock on the inside of the door.

Adds `wt trust`, `wt trust --list` and `wt untrust`.

Closes #129
@timvw
timvw enabled auto-merge (squash) August 19, 2026 18:51
@timvw
timvw merged commit ecfc80f into main Aug 19, 2026
16 checks passed
@timvw
timvw deleted the fix/hook-trust branch August 19, 2026 18:51
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.

security: committed .wt.toml executes arbitrary shell from untrusted repos

1 participant