Security How-to

Don't refuse the command, rewrite it: PreToolUse updatedInput in Claude Code

A deny hook makes the agent stop and try something else. A rewrite hook fixes the command and lets the work continue. Ten minutes to build, with the failure cases shown.

Part of the hooks reading guide

A stack of blank glass panels with a thin gold bar passing through the middle layer and emerging narrower

A deny hook has a cost nobody budgets for: the agent has to notice the refusal, work out what it was meant to do instead, and try again. Sometimes it picks a worse route. Sometimes it picks the same command with the flags shuffled.

For a certain class of command there’s a better move. The intent is fine and the flags are the problem: git push -f where --force-with-lease was what anyone wanted, or npm install that runs every dependency’s lifecycle scripts on a laptop that holds your cloud credentials. A PreToolUse hook can fix those in flight and let the work continue.

The mechanism is updatedInput. According to the Claude Code hooks reference , a PreToolUse hook can return it directly under hookSpecificOutput, and it replaces the tool’s arguments before the tool runs. This piece builds a small Bash rewriter, runs it, and covers where it breaks.

What the hook receives and returns

Claude Code passes the hook a JSON event on stdin. For a Bash call, the part that matters looks like this:

{
  "hook_event_name": "PreToolUse",
  "tool_name": "Bash",
  "tool_input": {
    "command": "git push -f origin main",
    "description": "Force push main to origin"
  }
}

To rewrite it, the hook exits 0 and prints JSON on stdout. Exit 0 is what makes Claude Code read that JSON; per the docs, exit 2 blocks the call and treats stderr as the reason, and any other non-zero code is a non-blocking error where the call goes ahead untouched.

{
  "hookSpecificOutput": {
    "hookEventName": "PreToolUse",
    "permissionDecision": "allow",
    "updatedInput": { "command": "git push --force-with-lease origin main" },
    "additionalContext": "A hook changed your command before it ran."
  }
}

Three details in that output decide whether it works.

First, updatedInput replaces the whole input object. Leave out description or timeout and they’re gone, so the safe pattern is to spread the original and override only what changed.

Second, permissionDecision is yours to choose. allow skips the permission prompt for the rewritten command, ask shows the rewritten command to the user, and the docs say deny and ask rules in the permission system are still evaluated either way. Permission rules also run against the input your hook returns, not the original, so a Bash(git push *) rule sees the rewritten text.

Third, additionalContext is optional but earns its place. Without it, Claude believes its original command ran. With it, Claude gets a note next to the tool result and can say what happened instead of reporting a flag it never used.

The hook

Save this as .claude/hooks/rewrite-bash.py. It does two rewrites, and exits quietly with no output for every other command, which means “no opinion”.

#!/usr/bin/env python3
"""PreToolUse hook for Bash: rewrite risky commands into safer ones."""
import json
import re
import sys

event = json.load(sys.stdin)
if event.get("tool_name") != "Bash":
    sys.exit(0)

tool_input = event["tool_input"]
command = tool_input["command"]
notes = []

# 1. A forced push becomes a lease-protected push.
if re.search(r"\bgit push\b", command):
    new = re.sub(r"(?<=\s)(--force|-f)(?=\s|$)", "--force-with-lease", command)
    if new != command:
        notes.append("--force was rewritten to --force-with-lease")
        command = new

# 2. Installs skip lifecycle scripts unless the command already says so.
if re.search(r"\bnpm (install|i|ci)\b", command) and "--ignore-scripts" not in command:
    command = re.sub(r"\bnpm (install|i|ci)\b", r"npm \1 --ignore-scripts", command, count=1)
    notes.append("--ignore-scripts was added to npm install")

if not notes:
    sys.exit(0)

print(json.dumps({
    "hookSpecificOutput": {
        "hookEventName": "PreToolUse",
        "permissionDecision": "allow",
        "permissionDecisionReason": "; ".join(notes),
        "updatedInput": {**tool_input, "command": command},
        "additionalContext": "A hook changed your command before it ran: " + "; ".join(notes) + ".",
    }
}))

Make it executable and register it in .claude/settings.json:

{
  "hooks": {
    "PreToolUse": [
      {
        "matcher": "Bash",
        "hooks": [
          { "type": "command", "command": "python3 \"$CLAUDE_PROJECT_DIR\"/.claude/hooks/rewrite-bash.py" }
        ]
      }
    ]
  }
}

Before involving an agent, feed it a fake event. A hook is a program with a JSON contract, and it’s quicker to test it as one:

echo '{"tool_name":"Bash","tool_input":{"command":"git push -f origin main","description":"push"}}' \
  | python3 .claude/hooks/rewrite-bash.py
{"hookSpecificOutput": {"hookEventName": "PreToolUse", "permissionDecision": "allow", "permissionDecisionReason": "--force was rewritten to --force-with-lease", "updatedInput": {"command": "git push --force-with-lease origin main", "description": "push"}, "additionalContext": "A hook changed your command before it ran: --force was rewritten to --force-with-lease."}}

Note that description survived. That’s the spread doing its job.

Watching it work

The real test is a push that should fail. Set up a local bare remote, let a teammate push a commit your clone hasn’t seen, then amend your own commit so the histories diverge. A plain git push -f would overwrite the teammate’s work. Then ask Claude to do exactly that:

claude -p --model haiku "Run exactly: git push -f origin main . Then report in one sentence what happened and which flag the push actually used."

The tool call Claude issued, as the transcript records it, still reads {"command": "git push -f origin main", ...}. The output the command produced is the interesting part (one line of local-path negotiation noise trimmed):

Exit code 1
To ../remote.git
 ! [rejected]        main -> main (stale info)
error: failed to push some refs to '../remote.git'

stale info is the lease check firing: the remote had a commit the local clone didn’t know about, so git refused to overwrite it. Claude’s own summary, thanks to additionalContext, was accurate: the push was rejected, main on origin was unchanged, and the push used --force-with-lease, not -f. The remote log afterwards still held the teammate’s commit.

The npm case works the same way. With a package.json whose postinstall script writes a file, asking Claude to run npm install . leaves no file behind, and Claude reports that the hook added --ignore-scripts. Be clear about what that does and doesn’t buy: it stops dependencies’ install-time scripts, which is a well-known route for supply-chain attacks, but it also breaks packages that legitimately need a build step at install time. If the project depends on those, use ask instead of allow and let a person see the rewritten command.

What Claude Code 2.1.300 checks

Release 2.1.300 tightened this path. Per the changelog , updatedInput from PreToolUse and PermissionRequest hooks is now validated like Claude’s own input: unknown keys are dropped, and input the tool rejects denies the call.

Both halves are easy to check with a hook that returns a fixed value. Returning {"command": "echo hello", "bogus": 1} for any Bash call prints hello, and the extra key does nothing. Returning {"cmd": "echo hello"}, a typo in the key name, gives the model this result and runs nothing:

PreToolUse hook for Bash returned updatedInput that failed schema validation: Bash failed due to the following issue:
The required parameter `command` is missing
The tool input from the model was valid; the hook's rewrite of it was not.

That’s the right failure mode. A rewrite hook with a bug blocks the command rather than running something half-formed. It’s still worth a unit test with a fake event for every rewrite, because the failure is silent from your side until Claude hits it.

Where regex rewriting falls apart

The script is deliberately small, and the gaps are the reason to read the rest before copying it. Feed it these:

git push -fu origin main      => (unchanged)
git push origin +main         => (unchanged)
git -C sub push -f            => (unchanged)
git push --force-with-lease   => (unchanged)
echo "git push -f"            => (unchanged)

The first three are real force pushes that slip through. Combined short flags (-fu), the +refspec syntax and git -C <dir> push all defeat the patterns. The fourth is correct, since it’s already safe. The fifth is unchanged only by accident: the string sits inside an echo, and the pattern skips it because a closing quote follows -f, not whitespace. Add a space before the quote and it would be rewritten.

The pattern has a deeper limit too. A hook that edits a command string is editing a string, not parsing a shell. Compound commands (cd x && git push -f), subshells and variable expansion all give the regex room to miss. Two ways to harden it:

  • Use shlex.split on the command and operate on tokens, handling &&, ; and | as separators. Rewriting is far safer on a token list than on raw text.
  • Keep a deny hook next to the rewriter for the cases you can’t rewrite confidently. Precedence across hooks is deny over defer over ask over allow, so a deny from the second hook wins. The earlier PreToolUse deny hook for force pushes is that second layer, and the piece on policy hooks that fail open covers what happens when the hook itself crashes.

A rewrite is also not a security boundary. If the agent can write a script file and run it, the hook sees bash run.sh and nothing else. Branch protection on the remote stays the backstop for force pushes, and a sandbox is the backstop for installs.

Where a rewrite is the wrong tool

Rewrite when the intent is clear and a safer spelling exists: --force to --force-with-lease, adding --ignore-scripts, adding --dry-run to a first run, wrapping a command in timeout 300, pointing a test runner at --run so it doesn’t sit in watch mode.

Deny when there is no safe version of the action, such as writing to a production database or deleting a branch. A rewrite that quietly changes meaning is worse than a refusal, because the agent and the reader both believe something different happened. That’s the argument for additionalContext every time, and for ask whenever a rewrite changes what the command does rather than how carefully it does it.

One more caution from public bug reports: the rewrite was reported silently ignored in claude -p mode in a March 2026 issue (since closed) and on the desktop app’s Bash tool in a July 2026 issue (still open when checked). The test above ran on Claude Code 2.1.300 in -p mode, so the first is not what happens there, but run the fake-event check and one real command in each environment your team uses before trusting it.

First ten minutes

Copy the script and settings into a repo you can afford to experiment in. Run the fake-event command above and confirm the rewritten JSON. Then ask Claude for a harmless git push -f against a scratch remote and read the result.

Once that works, add one rewrite that fixes a mistake your own agent keeps making, and write the fake-event test for it first.