Skip to content

fix: harden hook filtering, approvals store, and template expansion#2841

Merged
max-sixty merged 8 commits into
mainfrom
clawpatch-hooks-pr
May 20, 2026
Merged

fix: harden hook filtering, approvals store, and template expansion#2841
max-sixty merged 8 commits into
mainfrom
clawpatch-hooks-pr

Conversation

@max-sixty
Copy link
Copy Markdown
Owner

@max-sixty max-sixty commented May 20, 2026

Correctness and safety fixes for worktrunk's hook-filtering, command-approval, and template-expansion code, surfaced by an automated clawpatch review of the project's threat-model surfaces and then independently re-reviewed against the reviewing-code checklist — all came back SOLID / Clean-design with zero critical issues.

Fixes

  • Hook name filters no longer leak across sourceswt hook pre-merge project:deploy user:lint previously matched filter names across the project/user split, so a name given for one source could select an unintended hook from the other. The approval gate and the executor now share one source-scoped predicate, so the executor cannot run a command the gate did not approve.
  • Template variables are detected by parsing the template, not substring matching{{ vars["env"] }} and bare {{ vars }} were missed by the old contains("vars.") check.
  • Undefined variables in {% if %} predicates are now a clear error instead of being silently ignored.
  • The approvals trust store is written atomically — a crash mid-write can no longer corrupt or truncate it.
  • The approvals trust store rejects unknown keys — a typo'd section header previously dropped approvals silently; now it is a hard error.
  • Approvals-file migration is locked and validated before it runs, closing a check-vs-write race.
  • A .typos.toml allowlist entry for an intentional targte test fixture.

Scope

Pure code — the clawpatch review tooling and its state files are intentionally excluded. One related finding (deprecated-variable rewriting that could mis-match an approved command) is fixed more completely on a sibling branch and is deliberately not duplicated here.

Testing

cargo run -- hook pre-merge --yes — 3790 tests pass, lints clean.

This was written by Claude Code on behalf of Maximilian Roos

max-sixty and others added 8 commits May 20, 2026 12:52
A mixed-source invocation like `wt hook pre-merge project:deploy
user:lint` collapsed all parsed filters into a flat name set and applied
it uniformly to every source. With overlapping command names in both
configs, project `lint` and user `deploy` were also retained — and under
`--yes` an unrequested project hook could execute simply because it
shared a name with a user-scoped filter, bypassing the per-source
approval intent.

`ParsedFilter` gains `matches_command`, combining source-matching with
name-matching. `filter_step_by_name` and `approve_hooks_filtered` now
filter source-aware: a command is kept only when a filter both matches
its source and (has an empty name or equals the command name).

Adds a mixed source/name regression test asserting only the two
explicitly selected hooks run.

Co-Authored-By: Claude <noreply@anthropic.com>
`handle_command_error` checks `interrupt_exit_code()` before the
`FailureStrategy` match, so a signal-derived child exit aborts the loop
even under `FailureStrategy::Warn`. The existing Warn unit test only
used `signal: None`, and the signal integration tests cover `for_each`
and a pre-merge FailFast path — none exercise the Warn arm. A refactor
moving the interrupt check below the match would silently reintroduce
the swallowing bug while passing the current suite.

Adds `test_handle_command_error_warn_signal_aborts`: a SIGTERM-derived
`ChildProcessExited` with `FailureStrategy::Warn` must return
`AlreadyDisplayed { exit_code: 143 }`, not `Ok(())`.

Co-Authored-By: Claude <noreply@anthropic.com>
`validate_template` caught undefined variables only through a trial
render. Under MiniJinja's `SemiStrict` undefined behavior, an undefined
value in a truthiness test (`{% if targte %}`) is permitted and renders
as false, so a typo in a project-supplied command's conditional passed
validation and silently dropped or selected the wrong command fragment.

Before the render pass, parse the template and diff
`undeclared_variables(false)` against the scope's available variables
(plus `vars`). Any leftover name — including one used only as an `if`
predicate — is reported as an undefined-value error. The render pass
still runs afterward for syntax, filter, and render-time errors.
`{% set %}` / `{% for %}` targets are excluded by `undeclared_variables`,
so loop and assignment templates still validate.

Adds a regression test for `{% if targte %}` in pre-merge scope.

Co-Authored-By: Claude <noreply@anthropic.com>
`Approvals::save_to` used `std::fs::write`, which truncates the target
before writing. A disk-full error, crash, or reader racing the rewrite
could leave an empty or partial `approvals.toml`; an empty file
deserializes to the default (empty) trust store, silently dropping every
approval.

Write to a temp file in the same directory, flush and `sync_all`, then
`persist` (atomic rename) over `approvals.toml`. A failed write now
leaves the original file untouched — favoring the failing variant over
silent data loss, per the Data Safety policy.

Adds a Unix test asserting a save into a read-only directory errors and
leaves the prior approvals intact.

Co-Authored-By: Claude <noreply@anthropic.com>
`Approvals` and `ApprovedProject` accepted unknown TOML fields. A
schema-valid but semantically wrong file — `[project."..."]` instead of
`[projects."..."]`, or `approved-command` instead of
`approved-commands` — parsed successfully into an empty or partial
trust store, silently dropping approvals.

Add `#[serde(deny_unknown_fields)]` to both structs so a misspelled key
aborts the load instead of degrading to "nothing approved". The fix
applies only to `approvals.toml`; the `config.toml` fallback
deserializes `UserConfig`, which is unaffected.

Adds tests for an unknown top-level table and an unknown per-project
key.

Co-Authored-By: Claude <noreply@anthropic.com>
`copy_approved_commands_to_approvals_file` returned a benign `Ok(None)`
for any existing `approvals.toml` without checking it was parseable.
When `wt config update` then removed legacy `approved-commands` from
config.toml, a corrupt `approvals.toml` left the user with neither a
usable trust store nor the legacy fallback. The `exists()` check was
also outside the approvals lock, so a concurrent approval could create
the file between the check and the migration write and be overwritten.

Acquire the approvals config lock before the existence check, and
validate that an existing `approvals.toml` parses before returning
`Ok(None)` — an unparseable file now errors so `config update` aborts
before rewriting config.toml.

Adds a test for an invalid existing `approvals.toml`.

Co-Authored-By: Claude <noreply@anthropic.com>
`expand_template` loaded git `vars` state only when the raw template
contained the substring `vars.`. Supported MiniJinja access forms —
`{{ vars["env"] }}` and bare `{{ vars }}` — have no `vars.` substring,
so the state was never injected and rendering failed as undefined or
fell through to defaults.

Detect the top-level `vars` reference with
`tmpl.undeclared_variables(false)` after parsing the template, matching
the AST-based approach the Code Quality guidance mandates over string
detection. This supports every access form and drops the
false-positive concern that motivated the substring check.

Adds expansion tests for `{{ vars["env"] }}` bracket access and a
`{% if vars %}` conditional.

Co-Authored-By: Claude <noreply@anthropic.com>
`test_validate_template_scope_rejects_out_of_scope_vars` uses the
misspelling `targte` as an out-of-scope variable fixture. The
`crate-ci/typos` pre-commit hook autocorrected it to `target` on every
run, turning the fixture into a valid pre-merge variable and breaking
the test (`validate_template` returned `Ok`, the `unwrap_err()`
panicked).

Whitelist `targte` in `.typos.toml` alongside the existing intentional
test typos (`deplyo`, `comit`, `siwtch`, `brnach`).

Co-Authored-By: Claude <noreply@anthropic.com>
@max-sixty max-sixty merged commit 04a8f5c into main May 20, 2026
36 checks passed
@max-sixty max-sixty deleted the clawpatch-hooks-pr branch May 20, 2026 20:27
max-sixty added a commit that referenced this pull request May 20, 2026
Conflict in src/config/expansion.rs test_expand_template_vars_data: kept #2841's new `vars["env"]` and `{% if vars %}` assertions, updated all three to B1's ShellEscapeMode::Literal signature.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
max-sixty added a commit that referenced this pull request May 20, 2026
The merge of main pulled in a new `HookType::PostStart` reference (a
test added by #2841); this branch renamed that variant to `PostCreate`.
Textual auto-merge took main's line verbatim, breaking the build.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
max-sixty added a commit that referenced this pull request May 21, 2026
…2851)

Correctness and data-safety fixes for worktrunk's config
deprecation/migration layer and hook-log layout — surfaced by an
automated `clawpatch` review and re-reviewed against the
`reviewing-code` checklist.

## Fixes

- **Deprecated config sections are no longer dropped when the parent is
an inline table.** With `commit = { stage = "tracked" }` alongside a
deprecated `[commit-generation]`, migration removed the old section but
silently failed to write `[commit.generation]` — `as_table_mut()`
returns nothing for an inline table, so the nested insert was skipped
after the source had already been removed. Migration now converts an
inline-table parent to a standard table before inserting. Real data loss
without this.

- **Deprecated template variables are rewritten only inside template
tags.** The old `\bword\b` regex rewrote any occurrence of a deprecated
name — literal command text (`echo repo_root`), `{% set %}` locals,
member access — not just genuine `{{ … }}` references. That could
corrupt a command on migration, and could collapse two distinct commands
to the same normalized string so an un-approved command matched an
approved one. The rewrite is now gated on `minijinja`'s parser
(`undeclared_variables`) plus a delimiter-aware scan, so only real
deprecated references inside `{{ }}`/`{% %}` are rewritten. This is the
fix #2841 deliberately deferred to a sibling branch.

- **Repo-wide internal logs are written as top-level files.**
Branch-agnostic internal-operation logs were written into a top-level
`internal/` directory, violating the layout invariant that top-level
directories are per-branch trees — `wt config state` would miscategorize
`internal/` as a branch. They now write to `internal-{op}.log` files
alongside the other shared logs.

- **System config is routed through the deprecation gate.** System
config used a silent migrate path; it now goes through
`check_and_migrate` like user config, so a deprecation in system config
surfaces warnings consistently.

## Scope

Pure code — the clawpatch review tooling and its state files are
excluded.

## Testing

`cargo run -- hook pre-merge --yes` — 3825 tests pass, lints clean.

> _This was written by Claude Code on behalf of Maximilian Roos_

---------

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