refactor(hooks): consolidate the hook decision behind one shared decide - #3955
Conversation
`rtk rewrite`'s exit-code protocol is a public contract -- the claude and cursor shell hooks, the opencode and pi TypeScript plugins, the hermes Python adapter and openclaw all branch on it -- but no Rust test ever called `run()`. `rewrite_cmd`'s own `exit_code_protocol` module asserts against a locally re-implemented `expected_exit_code()` table, so the real mapping could change without a single failure, including the rtk-ai#1155 invariant that a `Default` verdict must exit 3 and never 0. Add an integration test that spawns the binary in a sandboxed HOME/XDG_CONFIG_HOME/CLAUDE_CONFIG_DIR with project-level permission rules, and pins the actual (exit code, stdout) pairs for allow, ask, deny, compound deny, passthrough, default, compound rewrite, fd-dup redirect, unattestable constructs and heredocs. Alongside it, pin the two decision paths against each other on one corpus. `rtk rewrite` and `rtk hook claude` answer the same question through two independently written flows; the corpus asserts they agree, and a separate test pins the one place they don't -- an already-RTK-prefixed command, which the in-process hook defers on and `rtk rewrite` reports as an ask-rewrite with the command unchanged. Modelled on registry.rs's `segmenter_consistency` module. Also pin `rtk hook check`, which had no test at all. It calls `rewrite_command` directly with no verdict and none of the hooks' gates, so it reports a rewrite for command substitutions and file redirects that both hook paths refuse to touch. No source change: this characterizes today's behavior so the decision-flow consolidation can be shown to preserve it. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
📊 Automated PR Analysis
SummaryConsolidates the duplicated hook decision logic used by 'rtk hook ' and 'rtk rewrite' into a single shared 'decide' function in src/hooks/decision.rs. As a side effect, 'rtk hook check' is fixed to route through the same rule-consulting decision path (including honoring --agent) instead of calling registry::rewrite_command directly, which previously caused it to disagree with the real hook paths. Review Checklist
Analyzed automatically by wshm · This is an automated analysis, not a human review. |
|
Ran a differential against develop: LGTM, approving. The consolidation is real, and routing Two notes on the description, no code change needed: The "Before:" example doesn't reproduce. On develop, The ~6k property-fuzz cases aren't in the diff. No property-testing dep in Minor: |
b070c26 to
79f501e
Compare
`rtk hook <agent>` and `rtk rewrite` asked the same question -- may this command be rewritten, and may the rewrite be auto-allowed? -- through two independently written copies of the same four steps, over two isomorphic enums (`HookDecision` and `RewriteOutcome`). A fix to the gate order, or a new construct to refuse, had to be made twice. rtk-ai#3704 consolidated raw-command lexing and named this duplication as its follow-up. Move the decision into `hooks::decision`. What legitimately differs between the callers stays outside it: `decide_with_params` takes both the permission verdict and the rewrite parameters, so each host consults its own rules and no test answers differently on a machine whose config.toml excludes a command (rtk-ai#3146); `decide` is the wrapper that reads config for production callers. The identity-rewrite policy is likewise applied by the caller that wants it: `decide_for_agent` is the composition every hook shares, while `decide` alone is what the `rtk rewrite` CLI renders. That policy is the one place the two paths disagree. `get_rewritten` suppressed a rewrite that changed nothing; `rewrite_cmd` had no such check and reported it as a normal rewrite. Rather than silently picking a side, the suppression is now an explicit `suppress_identity` applied at `hook_cmd`'s single seam, with the difference and its one observable consequence documented where it lives. Resolving it is a deliberate behavior change and is not part of this refactor. `get_rewritten`'s heredoc check is dropped as a duplicate rather than as the guarantee itself: `rewrite_command` refuses heredocs through its own `has_heredoc` (registry.rs:601,616), which is what the hook path was asking a second time. It was also unreachable for the common forms, since a `<<` operand reads as a file target and `contains_unattestable_construct` returns first -- the exception being `<< /dev/null`, which that gate lets past and the registry still refuses (rtk-ai#3980). `rewrite_cmd::run` is reduced to exit-code rendering and `hook_cmd`'s eight response builders are unchanged -- `HookDecision` keeps its name, so they match on the shared type without edits. `rewrite_cmd`'s unattestable-construct tests are dropped as verbatim duplicates of the shared module's that exercised nothing in that file. No behavior change. The characterization tests added in the previous commit pass unmodified, and the out-of-crate suites are unchanged from their pre-refactor baselines: hooks/claude/test-rtk-rewrite.sh 58/66 (the 8 are the pre-existing audit-log gap, `rtk rewrite` never having logged), hermes 18 passed, scripts/test-all.sh 105/13/5. `rtk rewrite "git status"` benchmarks at 6.7ms. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
`rtk hook check` called `registry::rewrite_command` directly, with no permission verdict and none of the gates the hooks apply. It therefore reported a rewrite for command substitutions, file redirects and heredocs that both hook paths refuse to touch -- the diagnostic disagreed with the thing it exists to diagnose, and did so in the direction that matters, by claiming RTK would rewrite a command it deliberately leaves alone. Route it through `hooks::decision` so it answers the same question, and report a deny rule distinctly from "no rewrite" rather than collapsing both into one message. Both still exit 1. That makes the answer agent-dependent, so `--agent` stops being discarded. `AgentPath` records what actually differs between agents, which is whose permission rules their hook consults: the six that decide in-process via `rtk hook <agent>` use their own host's rules, the five whose plugin shells out to `rtk rewrite` get Claude's (that entry point cannot be told who is asking), and the six that install only a rules file have no hook and so no rules at all. Every install target resolves -- including `codex` and `openclaw`, which are install flags rather than `AgentTarget` variants -- and only a genuine typo is rejected. What does *not* differ is a rewrite that changed nothing: every agent discards it, the in-process hosts in `hook_cmd` and the others in their own plugin, since `hooks/opencode/rtk.ts`, `hooks/pi/rtk.ts` (shared with omp) and hermes' `__init__.py` all gate on `rewritten != command`. `AgentPath` suppresses it for every variant. Only the bare `rtk rewrite` CLI reports the no-op, and no agent consumes that answer raw. Consulting no rules and ignoring `--agent` is what made the diagnostic contradict every host: under a Claude deny rule for `git status` it reported `rtk git status` while `rtk hook claude` refused the command outright; it reported a rewrite for `rtk git status`, which no agent applies; and since `--agent` selected nothing, the answer described no host in particular. The expectations pinned in the characterization commit are updated here, in the same commit, so the behavior change is visible as a diff rather than as a test that quietly stopped asserting. That harness now also asserts the hook exits 0 and never panics: a crash produces empty stdout, which would let every "expect no output" assertion pass vacuously. The agent list is derived from `AgentTarget::value_variants()` so a new variant fails the test instead of silently becoming unanswerable. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
79f501e to
6eb915b
Compare
|
Thanks — all three checked out, and the third one turned out to be the interesting one. The "Before:" example. You're right, and it was worse than one wrong row: I built develop and ran it, and two of the three contradictions I listed were artifacts of intermediate states on this branch rather than anything develop does.
The The fuzz cases. Correct — a throwaway harness driving the built binary, never committed. Rather than drop the claim I've labelled it as not in the diff and not re-runnable by you or CI, and noted that the invariants it covered are the ones the committed tests assert.
So the layering is the reverse of "caught incidentally":
Which is why Your The exemption exists for discarded output; applied to a heredoc it is matching a delimiter that happens to be spelled The only code change since your approval is that doc comment in |
Upstream rtk-ai#3955 moved the shared hook decision into hooks/decision.rs after this branch was cut, so the git exclusion for Claude Code managed worktrees (rtk-ai#3864) is re-applied there as decide_for_agent_at, with the Claude hook threading the payload cwd through to it. The hand-written CHANGELOG entry is dropped: the changelog is now generated by release-please from the fix(hooks) commit message. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Follow-up to #3704, which consolidated raw-command lexing and closed with:
rtk rewrite's behaviour is unchanged — deliberatelyThe obvious generalisation is to make
rtk rewritebehave like the hooks and defer on a rewrite that changes nothing. This PR does not do that, and the divergence is kept on purpose:rtkcommandrtk hook <agent>rtk rewriteThat is the documented contract from #241, which created
rtk rewrite: "Handle already-rtk commands (exit 0, identical output)", still pinned bytest_run_already_rtk_returns_some. The CLI answers "what is the RTK form of this command"; for an already-prefixed command that form is itself, and whether that counts as a change is the caller's question. Every delegate already asks it —hooks/opencode/rtk.ts,hooks/pi/rtk.ts(shared with omp),hooks/hermes/rtk-rewrite/__init__.pyandopenclaw/index.tsall gate onrewritten != command.So
suppress_identityis applied bydecide_for_agent, which the hooks share, and not bydecide, which the CLI renders. The exit-code protocol is untouched.What changed
rtk hook <agent>andrtk rewritecarried two independently written copies of the same four steps over two isomorphic enums (HookDecision,RewriteOutcome), so a fix to the gate order or a new construct to refuse had to be made twice.src/hooks/decision.rs— onedecide, taking the permission verdict and the rewrite parameters rather than looking them up, so each host consults its own rules and tests stay independent of the developer's machine (Rewrite tests fail when local permission settings allow git commands #3146).decide_for_agentadds the no-op suppression every hook applies.rewrite_cmd::runis reduced to exit-code rendering;hook_cmd's eight response builders are untouched.rtk hook checknow routes through the same decision (the one intended behaviour change, below).Related issues
rtk hook checkdisagrees withrtk hook claudeon commands containing a file redirect. Fixed here; all six shapes in that report now agree, and2>&1still rewrites on both.hook checkdoes not expose the actual host permission decision. Partially addressed: the diagnostic now consults the host's real rules instead of ignoring them, but it still collapses allow and ask into one answer. The remaining half is a follow-up, commented on that issue.rtk hook check— the one behaviour changeIt called
registry::rewrite_commanddirectly, with no verdict and none of the gates, so it reported a rewrite for command substitutions, file redirects and heredocs that both hook paths refuse — the diagnostic disagreed with the thing it exists to diagnose, in the direction that matters.Consulting rules makes the answer host-dependent, so
--agentstops being discarded.AgentPathrecords what actually differs: the six agents deciding in-process use their own host's rules, the five whose plugin shells out tortk rewriteget Claude's, and the six installing only a rules file have none. All 17 install targets resolve; only a typo is rejected.On develop the diagnostic consults no rules and ignores
--agent, so it contradicts every host. Under a Claude deny rule forgit statusit printsrtk git status, whilertk hook clauderefuses the command outright.Verification
cargo fmt --all·cargo clippy --all-targetsclean ·cargo test --allgreen; each commit builds and tests independently.(exit code, stdout)pairs end-to-end —rtk rewrite's exit codes had no Rust test at all, only a hand-copied table inexit_code_protocolthat never calledrun(). Those tests pass unmodified across the refactor.rtk hook check.updatedInputidentical to its input, and — the one that matters — a rewrite is never auto-allowed unless an allow rule covers every segment (security: hook auto-allow bypasses agent permission model for rewritten commands #1155, security: compound command permission escalation — single allowed segment grants auto-allow to entire chain #1213).hooks/claude/test-rtk-rewrite.sh58/66, hermes 18 passed,scripts/test-all.sh105/13/5.track_tee_readis called under exactly upstream's conditions on both paths — verified by differential-testing the recall store against the upstream binary.rtk hook checkdeliberately does not record, matching upstream.Not in scope
rtk rewritedefer on the identity case (above).rewrite_cmdjudges every subprocess-path agent by Claude's rules, sincertk rewritecannot be told who is asking.hook_cmd's tests read the developer'sconfig.toml(26) and~/.clauderules (5); pre-existing, same count on the base commit, and fixing it means giving all six hosts the injection seam only three have.🤖 Generated with Claude Code