Harden deny rules against PreToolUse updatedInput rewrites: deny should match pre- or post-rewrite

Status Open
Reported on v2.1.241
Maintainer reply None cached
Activity 0 comments · opened Aug 25, 2026

Preflight Checklist

  • [x] I have searched existing requests and this feature hasn't been requested yet
  • [x] This is a single feature request (not multiple features)

Problem Statement

Filing as an enhancement rather than a bug: the current matching order is defensible for allow rules, and the ask is a narrow, asymmetric change to deny rules only.

A PreToolUse hook that returns hookSpecificOutput.updatedInput has its rewrite applied before the permission engine matches allow/deny rules, and the rules are matched against the rewritten command. A deny rule written against the command the user actually typed therefore never fires.

The failure is silent. There is no warning, the rule looks correct in the settings file, and under permission-mode auto a heuristic classifier frequently blocks the command anyway — so the deny list appears to work right up until the run where it doesn't.

Environment

  • Claude Code 2.1.241, Windows 11, Windows PowerShell 5.1.
  • A user-scope PreToolUse hook with matcher: "Bash" that rewrites commands to a token-compression wrapper: docker psrtk docker ps, git push --forcertk git push --force.
  • The hook returns only updatedInput. It does not return a permissionDecision.

Reproduction

Both directions were run; the result is bidirectional and conclusive.

A — deny the command as typed. Expected: blocked. Actual: it runs.

{ "permissions": { "defaultMode": "auto", "deny": ["Bash(docker*)", "PowerShell(docker*)"] } }

Prompt: "Use the Bash tool to run exactly this command: docker ps"

TOOL_USE  [Bash] {"command":"docker ps"}
TOOL_RES  (is_error=false) <12 live containers listed>

B — deny the rewritten form. The rule fires, and names the rewritten command.

{ "permissions": { "defaultMode": "auto", "deny": ["Bash(rtk docker*)", "PowerShell(rtk docker*)"] } }

Same prompt:

TOOL_RES  (is_error=true) Permission to use Bash with command rtk docker ps has been denied.

The same result reproduces for git branch -D <branch> (denied as Bash(git branch -D*), executed; denied as Bash(rtk git branch -D*), refused).

Two things that make this hard to observe

Both cost real investigation time and are worth documenting regardless of whether the proposal lands.

  1. The auto-mode classifier masks it. Under defaultMode: "auto", a denied-but-unmatched command is often stopped anyway by a classifier, reporting "Permission for this action was denied by the Claude Code auto mode classifier." It is non-deterministic: the same profile and prompt blocked on one run and executed on the next. A test that only checks "was the command refused?" cannot tell a deny rule from a coin flip. Three distinct refusal messages exist and mean different things:

| Message | Meaning |
| --- | --- |
| Permission to use <tool> with command <cmd> has been denied. | A deny rule matched |
| ...denied by the Claude Code auto mode classifier... | Heuristic; the rule did not match |
| This command requires approval | Fell through to a prompt; the rule did not match |

  1. matcher scope decides whether the rewrite happens at all. A Bash-only matcher does not fire for PowerShell tool calls, so whether the bypass occurs depends on which tool the model happens to choose. Two runs of an ostensibly identical test can legitimately disagree.

Impact

Any user combining a command-rewriting PreToolUse hook with a deny list has a silently inert deny list for exactly the command families the hook rewrites — which, for a compression hook, tends to be the high-traffic and destructive ones: git push, git branch -D, docker, kubectl, curl.

There is a quieter second effect: unmirrored allow rules also stop matching, so previously-allowed commands fall through to an approval prompt. On an interactive session that is friction — git add and git fetch asking for permission despite an explicit allow rule. On an unattended background session it is a stall with nobody there to answer the prompt.

Related issues

Searched before filing; this specific behaviour does not appear to be reported. Adjacent but distinct:

  • #28812 — permissionDecision: "allow" silently bypasses the permission system. Same hook family, different mechanism (an explicit decision, not a rewrite). Fixing that one does not fix this: with no permissionDecision at all, the rewrite alone is enough to slip past a deny rule.
  • #39344 — permissionDecision: "ask" silently overrides permissions.deny. Again a decision, not a rewrite.
  • #9185 — missing documentation for modifying tool inputs in PreToolUse hooks. Overlaps ask #3 below.

Proposed Solution

Asymmetric matching.

  1. Deny-either matching. A deny rule matches if either the pre-rewrite or the post-rewrite command matches it. This can only turn a currently-silent permit into a denial — which is what a plain reading of the deny rule asked for.

One configuration does change behaviour, and it is worth naming rather than glossing over: denying a command's typed form while allowing its rewritten form, e.g. deny: ["Bash(docker*)"] with allow: ["Bash(rtk docker*)"], as a deliberate "force everything through the wrapper" pattern. Today that executes, because the rewrite lands on the allow rule. Under deny-either the pre-rewrite form matches the deny, and deny beats allow, so it would be blocked. That pattern depends on a deny rule never firing, which is the behaviour this proposal treats as the defect. If it needs to keep working, the wrapper-forcing intent is better expressed by denying the rewritten form of what should not run and allowing the rest, or by having the hook decline to rewrite what the user does not want wrapped.

  1. Allow stays post-rewrite only. No change.
  1. Secondary, and happy to split these into separate issues if preferred — they support the change above rather than being separate features:
  • Document the ordering. State explicitly in the hooks reference that updatedInput is applied before permission matching and that rules see the rewritten command. One sentence would have prevented this entirely. (Overlaps #9185.)
  • Warn on unfireable deny rules. A startup warning already exists for a rule matching no known tool ("Permission deny rule X matches no known tool"). Consider extending that class of diagnostic to rules that cannot fire.

Alternative Solutions

Make all rules match the original command — considered and rejected. This would be worse, and it is why the request is asymmetric rather than a straight "match what the user typed". If rules matched pre-rewrite text, a buggy or malicious hook could rewrite an allowed command into a destructive one and have it inherit the original's allow — turning git status into git push --force while the permission engine still sees git status. Post-rewrite matching for allow is the safe choice and should stay exactly as it is. Allow should govern what will actually execute; deny should be unescapable by rewriting.

Mirror every rule in both forms — the workaround currently in use. Duplicate each rule for a rewritten family:

"deny":  ["Bash(git push --force*)", "Bash(rtk git push --force*)"],
"allow": ["Bash(git status*)",       "Bash(rtk git status*)"]

This works, but it is fragile: the mirrors are pinned to the wrapper's exact prefix, so if the hook ever emits something slightly different (rtk.exe, an absolute path, a rename, a newly rewritten command family) every mirror silently stops matching and the deny list is inert again, with no warning. Keeping it honest requires a scheduled probe that runs a denied command and asserts the rule message rather than merely that the command was refused.

Narrow the hook so it declines to rewrite denied families — rejected. It works, but it forfeits the compression the hook exists for on exactly the highest-traffic commands, and it requires the hook to duplicate the permission policy it cannot see.

Priority

Medium - Would be very helpful

Feature Category

Configuration and settings

Use Case Example

  1. I add a PreToolUse hook that rewrites shell commands to a token-compression wrapper, to cut token usage across many background sessions.
  2. Separately, I write a deny list for unattended worker sessions: no git push --force, no git branch -D, no docker, no kubectl.
  3. I test it: I ask a session to run docker ps. It is refused. The deny list looks like it works.
  4. It does not. The refusal came from the auto-mode classifier, not my rule. The hook had rewritten the command to rtk docker ps, which my Bash(docker*) rule never matched.
  5. On a later run the classifier does not intervene, and docker ps executes and returns live containers — with an explicit deny rule in place for it.
  6. Under this proposal, step 3 refuses via the rule, step 5 cannot happen, and no mirrored duplicates are needed.

The reason this is worth fixing rather than documenting alone: at no point does anything warn that the rule is inert, and the intermittent classifier makes the broken state look healthy under exactly the testing a careful user would do.

Additional Context

_No response_

View original on GitHub ↗