跪拜 Guibai
← Back to the summary

Claude Code and Codex Prefix Permissions Are Broken — One Hook Script Fixes Both

One Hook Blocks Most Permission "Loopholes" in Claude Code and Codex

Claude Code's permission rules match by command prefix.

For example: you configure a rule Bash(git push:*) to make the AI ask you before every push. But if the command doesn't start with git push, the rule doesn't apply.

For instance, cd /other/repo && git push can bypass it because it starts with cd.

I've encountered this many times myself—every time it does cd /path/xx && git ... or git -C xxx ..., it runs commands I wanted to verify myself directly. Someone in the community also raised Issue-59498: subagent pushed to several remote repos this way.

Ultimately, prefix matching only recognizes the first word of a command. Move the dangerous part to the second statement, or prepend a cd, git -C, or an environment variable before the command name, and it becomes invisible. Same with Codex. OpenCode's permissions are more granular, but it doesn't accept Claude Code's Hook system, so you can't port it over.

Fortunately, both Claude Code and Codex have hooks.

A hook is an opening left by the tool at certain key points (like "about to execute a command"): at that point, it first runs your specified program, passes the current context (in this case, the full command) to you, and then decides the next step based on what your program returns.

For permissions, there are only three return values: allow, deny, or ask (pop up a dialog to ask you).

When cd /other && git push reaches my script, I see git push inside it, return an ask, and it stops and waits for my confirmation—what prefix rules can't match, I catch with code.

Final effect

mark-shot-20260721-172747.png

All three permission systems are imperfect

Capability Claude Code Codex OpenCode
Command pattern ask/deny
Claude Code-style Hook ✅ but deny only reliable for Bash (#27833) No hook, only TS plugins, essentially similar
Hook popup confirmation (ask) ❌ schema has it, runtime doesn't recognize it ⚠️ permissions can ask, plugin path has issues
Compound command decomposition ✅ tree-sitter parses each one
git -C cross-repo ⚠️ visible, but rules still need manual supplementation
Reading .env etc. ✅ prefix only ✅ default deny

Claude Code and Codex are stuck on command parsing and the ask interface; OpenCode is more complete but doesn't accept Claude Code's Hook system, and the plugin path still has open issues.

Claude's implicit special handling for rm

ClaudeCode has a fallback for rmecho x && rm -rf /important will be blocked because Claude Code separately implemented compound command detection for rm;

The same detection wasn't given to git push. Why rm has it and git push doesn't—no documentation, no switch.

There are several related issues: #59498, #16561, #46868, #33340.

This stupid problem took me half a day to troubleshoot, really annoying!!

Same with git -C

In git -C /other/repo push, -C is inserted between git and the subcommand, the prefix is no longer git push, and the rule misses. Same with --work-tree and --git-dir. These aren't difficult; Claude Code and Codex just didn't handle them.

Solution: one set of rules, works for both

Both Claude Code and Codex have a PreToolUse hook: before command execution, run my script, get the full command, parse it, and return a JSON snippet telling it to allow, deny, or ask for confirmation.

{
  "hookSpecificOutput": {
    "hookEventName": "PreToolUse",
    "permissionDecision": "ask",
    "permissionDecisionReason": "git write operation, confirmation needed"
  }
}

What I want is one set of rules managing multiple tools simultaneously (Claude Code, Codex; OpenCode doesn't accept this hook).

If I write separate sets for each, they'll diverge eventually. The current implementation has two entry points sharing the same path, Git, decision, and runtime modules:

[!TIP] Tree-Sitter is what code highlighting uses for parsing, so it can recognize syntax

The script runs before every command, so the default entry prioritizes speed and zero dependencies; switch to the AST entry when you need more accurate handling of Shell edge cases like escaping.

The tricky part is that ClaudeCode and Codex, while seemingly sharing the same protocol, don't behave the same at runtime. Codex's schema also has ask, but it only accepts it—the runtime doesn't recognize it:

Test environment: codex-cli 0.144.5, verified 2026-07-18 (was like this since 0.141.0, unchanged).

The enum in the source code schema.rs does accept ask, but output_parser.rs at runtime directly returns

PreToolUse hook returned unsupported permissionDecision:ask—docs say it can pop up, code doesn't recognize it.

Feature support comparison:

Claude Code Codex 0.144.5
deny (Bash commands)
deny (file write apply_patch) ❌ not effective (#27833)
ask popup confirmation ❌ unsupported
Read matcher
PostToolUse post-write processing Write/Edit/MultiEdit apply_patch

Identify Codex by one field—turn_id, unique to it. After distinguishing, treat them differently:

Choosing your runtime: why Bun + TypeScript

The official examples are all Python and Bash. I didn't use either, why?

Bash is unsuitable for writing logic.

  1. Parsing JSON requires pulling in jq (another dependency), matching relies on grep -qE, a few conditionals become a mess of nested if [ ... ]; then ... fi. After a week of maintenance, you won't want to look at it yourself.

  2. It also has N ways to write the same thing—multiple approaches for one task, each hiding landmines. Assignment must be written rigidly as x=1; dare to add a space as x = 1, and it executes x as a command;

  3. Checking if a string is empty, [ ], [[ ]], test—pick whichever you like, but in [ $x = foo ], forget to quote the variable and when the value is empty, it's a syntax error.

  4. Quotes are an even bigger disaster zone: single quotes parse nothing, can't even escape a single quote itself; double quotes have $, backticks, \ each with their own quirks; plus $'...', $"..."—writings you've probably never seen.


Python? Readability is a joke. On one hand shouting "Readability counts," on the other cramming [x.strip() for x in lines if x and not x.startswith('#')] into one line—do hipster coders really enjoy squashing lines? Dense like that, what readability do you have?

And slow startup: CPython initialization plus import json measured at 50–100ms minimum.


Finally chose Bun + TypeScript. Cold start about 14ms (benchmark: running TS natively, Bun 14ms, Node+tsx 280ms), Bun.stdin.text() reads stdin directly, JSON.parse() parses directly. The regex entry needs zero dependencies installed; the AST entry additionally depends on web-tree-sitter and tree-sitter-wasms.

Type annotations, regex literals—these are also built-in. The hook runs before every command, and this cold start difference is tangible experience—so the runtime is worth choosing carefully.

Refining the script

First, the default regex entry. What it needs to solve isn't "does a dangerous word appear," but three more specific problems:

  1. Is this word really a command, or just plain text inside quotes?
  2. After the command is prefixed with environment variables, sudo, or compound commands, can it still be recognized?
  3. After blocking, can it directly tell me exactly which segment triggered it?

The initial rules were straightforward: scan the entire command, block whenever patterns like mkfs, | bash, git push appeared.

Disk commands       /\b(fdisk|mkfs|wipefs)\b/
Pipe to shell       /\|\s*(sh|bash|zsh)\b/
Git write ops       /\bgit\s+(add|commit|push|reset|...)\b/

This catches cd /x && git push, but also treats data as commands and arguments as commands. The real trouble starts here.

One: Don't treat text inside quotes as commands

For example, I just want to search for two words in logs:

grep -E "reboot|halt" /var/log/syslog

Here, reboot and halt are search text, | is grep regex "or"—no command gets executed. But a full-text scan treats them as shutdown commands; similarly, grep "| bash" file gets misjudged as "pipe to Shell."

The solution is to first generate a "scan copy": preserve quotes and Shell structure, but replace the content inside quotes with equal-length spaces.

grep -E "reboot|halt" /var/log/syslog
            ↓
grep -E "           " /var/log/syslog

This way, reboot|halt inside quotes disappears; in a real curl x | bash, the pipe is outside quotes and still gets detected.

But you can't blindly clear all quotes. In bash -c "mkfs /dev/sda", the content inside quotes isn't data—it's a command body handed to Bash for execution:

bash -c "mkfs /dev/sda"

So the processing order is: first find command bodies like sh -c, bash -c, expose them to the scanner; then clear the remaining quoted content. The former still gets blocked, the latter no longer false-positives.

Two: Only match at "command position"

Just finding the word mkfs still isn't enough: mkfs in grep mkfs log.txt is an argument, not a program to execute.

What you really need to find is the "command position": the start of the entire input, or after ;, &&, ||, |, newline. After finding the start, you also need to skip wrappers allowed before the command:

X=1 mkfs /dev/sda                    # environment variable assignment
sudo -i fdisk /dev/sda               # sudo without argument option
sudo -u root reboot                  # -u also consumes next item root
sudo --user=root reboot              # long option inline argument
doas -u root rm /etc/passwd          # doas same

The easiest to miss here is sudo -u root reboot: -u needs an argument, root is the target user, the real command is still the following reboot. So you can't simply skip all -xxx; you must distinguish which options consume the next item, and handle long options, inline arguments, and the -- terminator.

The final result: all the dangerous commands above can be recognized, while the following—where the same word only appears in arguments—can pass normally:

grep mkfs log.txt
jq '.format' package.json

Three: Tell me exactly which segment was blocked

The goal is to achieve the effect in the image, knowing what triggered the block

mark-shot-20260721-172747.png

Compound commands can be very long. If the result just says "dangerous deletion," you still have to manually find which segment triggered it:

For example, with a command as long as in the image, you can't quickly locate it—this is where the advantage shows

20260530-131116.jpg

The earlier use of "equal-length spaces" when clearing quotes was precisely to keep the scan copy and original command at the same indices. Wherever the scanner hits in the copy at which character position, you can go back to the same position in the original command, then truncate to the next Shell separator.

Dangerous file deletion|Hit: rm -rf .git
Git write operation|Hit: git push

The script also doesn't stop at the first hit; it collects all results, sorts by position in the original command, deduplicates, and lists them all at once. If any one of them is deny, the entire command is ultimately deny:

rm -rf .git && reboot && mkfs /dev/sda && git push

Dangerous file deletion|Hit: rm -rf .git
System shutdown/reboot command|Hit: reboot
Disk format/partition command|Hit: mkfs /dev/sda
Git write operation|Hit: git push

Rules aren't one-size-fits-all by command name

Here's another key choice: detecting a command doesn't mean all its uses are dangerous.

Take rm for example: blocking everything makes deleting node_modules also repeatedly pop up; allowing everything misses home directory and Git history. So the rule checks the deletion target: first expand ~, $HOME, then normalize the path against the current working directory, and finally determine if it falls within a dangerous scope:

rm -rf node_modules dist   → allow
rm -rf ~                   → block (home directory)
rm -rf ./x/.git            → block (.git)

Variables like $TARGET whose values are only known at runtime can't be computed, treated as ordinary targets and allowed—an intentional gap; plugging it completely would require actually running shell, not worth it.

Other rules also differentiate by actual intent:

So the final principle isn't "block on seeing a dangerous command name," but: first confirm it's in an executable position, then judge the real intent of this invocation based on arguments.

AST version: let Shell structure speak for itself

No matter how much the regex version patches, it's still fundamentally guessing Shell syntax on strings. For example, the following two both appear to have | bash, but their actual meanings are completely different:

curl x | bash       # real pipe: deny
echo a \| bash      # escaped literal: allow

Same with heredoc—reboot appearing in the body doesn't mean it executes as a command.

So I added an AST entry: deny-compound-bypass-ast.ts

It uses web-tree-sitter to load tree-sitter-bash, breaks commands into nodes like command, pipeline, redirect, process_substitution, then applies the same security classification on the real command nodes.

The AST version isn't a separate set of rules. The repo is now split by responsibility:

.claude/hooks/
├── deny-compound-bypass.ts       # zero-dependency regex entry
├── deny-compound-bypass-ast.ts   # AST-first, regex fallback entry
├── deny-compound-bypass.test.ts  # end-to-end tests shared by both entries
└── lib/
    ├── ast-engine.ts             # tree-sitter-bash traversal and classification
    ├── regex-engine.ts           # regex scanning
    ├── runtime.ts                # input parsing, Claude/Codex decision and output
    ├── shell.ts                  # Shell tokenization, quotes, command start
    ├── paths.ts                  # sensitive paths and dangerous rm targets
    ├── git.ts                    # Git write operations and read-only whitelist
    ├── reasons.ts                # prompt messages
    └── types.ts                  # shared types

AST initialization, WASM missing, syntax errors, or overly long commands won't cause the hook to fail outright: when the AST collector returns unavailable, the entry automatically calls regex-engine.ts. This is intentional fail-closed—better to fall back to the existing stricter judgment than silently pass due to parser issues.

Install dependencies, run both versions' tests:

cd ~/.claude/hooks
bun install
bun run deny-compound-bypass.test.ts

To enable the AST version, just swap the entry in the registration command:

bun run ~/.claude/hooks/deny-compound-bypass-ast.ts

Currently my Claude Code and Codex configs still register the regex entry; the AST version is kept as an optional, more accurate implementation.

Registration: each tool hangs its own

Claude Code —— ~/.claude/settings.json. Bash(*), Read(*) set to allow, judgment entirely handed to hook; PreToolUse blocks commands, PostToolUse does post-write formatting. In matcher, combine tool names with pipes:

{
  "permissions": { "allow": ["Bash(*)", "Read(*)"], "defaultMode": "bypassPermissions" },
  "hooks": {
    "PreToolUse": [
      { "matcher": "Bash|Read", "hooks": [{ "type": "command", "command": "bun run ~/.claude/hooks/deny-compound-bypass.ts" }] }
    ],
    "PostToolUse": [
      { "matcher": "Write|Edit", "hooks": [{ "type": "command", "command": "bun run ~/.claude/hooks/post-write-code.ts" }] }
    ]
  }
}

Codex —— ~/.codex/hooks.json. It has no Read matcher, PreToolUse only hangs Bash; for file writes, Codex uses apply_patch, going through PostToolUse:

{
  "hooks": {
    "PreToolUse": [
      { "matcher": "Bash", "hooks": [{ "type": "command", "command": "bun run ~/.claude/hooks/deny-compound-bypass.ts" }] }
    ],
    "PostToolUse": [
      { "matcher": "^apply_patch$", "hooks": [{ "type": "command", "command": "bun run ~/.claude/hooks/post-write-code.ts", "timeout": 30 }] }
    ]
  }
}

For sensitive files like .env, Codex can't block via hook's Read; you need to do filesystem deny in ~/.codex/config.toml's permission profile.

What is PostToolUse? A hook after file modification completes, automatically runs ESLint + LSP fix after writing. The inputs fed by the two tools differ:

Normalize into one file list, and one set of downstream logic suffices. (Also a pitfall #32667: Codex's PostToolUse hook not reading stdin causes broken pipe; reading to EOF with await Bun.stdin.text() happens to dodge it.)

OpenCode —— ~/.config/opencode/opencode.jsonc. It can't accept this hook system, but fortunately comes with its own permission system and formatter, essentially building in both "block commands" and "post-write formatting": permissions written directly as rules, formatting bound to commands by extension.

{
  "permission": {
    "bash": { "*": "allow", "rm *": "ask", "git push*": "ask", "mkfs *": "deny" },
    "read": { "*": "allow", ".env*": "ask", "~/.ssh/**": "ask" }
  },
  "formatter": {
    "eslint": { "command": ["eslint", "--fix", "$FILE"], "extensions": [".ts", ".tsx", ".vue"] }
  }
}

It decomposes compound commands itself via tree-sitter; only git -C still needs a more specific rule added in permission.bash.

Actual results

# Block: sudo + env var dual prefix, still caught
$ whoami && X=1 sudo -i fdisk -l
→ Disk command|Hit: X=1 sudo -i fdisk -l

# Block: only dangerous targets blocked, dist allowed
$ pnpm i && rm -rf ./legacy/.git
→ Dangerous deletion|Hit: rm -rf ./legacy/.git

# Allow: reboot inside quotes is data, not command
$ strings ./bin | grep -iE "reboot|halt"
→ Normal execution

The regex entry still has a few unfillable edge cases:

The first two are overly strict (false block, but safe), and the AST version can already correctly allow them; the last depends on runtime variable values, which static AST also can't compute. How to choose between the two entries is straightforward: value zero dependencies and startup speed, use the regex version; value Shell structure accuracy, use the AST version.

image.png

Complete code in my dotfiles (beixiyo/dotfiles):

Entries: regex version deny-compound-bypass.ts, AST version deny-compound-bypass-ast.ts;

Core implementation: lib/ (AST / regex engine, runtime decisions, Shell / Git / path rules) + shared tests for both versions + package.json;

Post-write formatting post-write-code.ts;

Registration and per-tool configs .claude/settings.json, .codex/hooks.json, opencode.jsonc.

Reference links

Claude Code

Codex (OpenAI)

OpenCode

Shell parsing