跪拜 Guibai
← All articles
Claude · OpenAI · AI Programming

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

By 寅时码 ·
Read original on juejin.cn ↗ Google Translate ↗ Alt translation

Prefix-based permission matching is the default safety mechanism in two of the most widely used AI coding agents, and it has a structural blind spot that any compound command can exploit. A single hook script that runs before every Bash command closes this gap across both tools without waiting for vendor fixes, and the approach generalizes to any tool that supports PreToolUse hooks.

Summary

Claude Code and Codex both enforce permissions through prefix matching — `Bash(git push:*)` only triggers when the command literally starts with `git push`. That means `cd /other/repo && git push`, `git -C /other/repo push`, or `X=1 sudo fdisk -l` all sail through because the first token is something harmless. The same gap exists for `rm`, `mkfs`, pipes to shell, and reading sensitive files.

A single PreToolUse hook script, written in Bun and TypeScript for sub-15ms cold starts, intercepts every Bash command before execution. It strips quoted data so `grep "reboot|halt"` isn't falsely blocked, identifies the real command position past environment variables and `sudo`, and returns `allow`, `deny`, or `ask` based on the actual operation and its targets. The same script works for both Claude Code and Codex, with runtime differences handled: Codex's `ask` is documented but rejected at runtime, so dangerous operations escalate to `deny` there.

An optional AST mode using tree-sitter-bash handles edge cases the regex version can't — escaped pipes, heredocs — and falls back to regex when the parser is unavailable. The full implementation, including post-write formatting hooks and per-tool configuration, is published as open-source dotfiles.

Takeaways
Claude Code and Codex match permissions by command prefix only — `cd /repo && git push` bypasses a `git push` rule because the first token is `cd`
`git -C /repo push`, `--work-tree`, and `--git-dir` all hide the real subcommand behind an option, evading prefix rules
Claude Code has undocumented compound-command detection for `rm` but not for `git push`, `mkfs`, or pipes to shell
A PreToolUse hook receives the full command string before execution and can return `allow`, `deny`, or `ask`
Codex's schema accepts `ask` but the runtime rejects it as unsupported — dangerous operations must escalate to `deny` on Codex
The regex-based hook strips quoted content with equal-length spaces to preserve index alignment, then scans only at command positions after skipping env vars, `sudo`, and `doas` wrappers
`rm` rules check the deletion target path (expanding `~`, normalizing, checking for `.git` and system directories) rather than blocking all `rm` usage
An AST mode using tree-sitter-bash correctly distinguishes `curl x | bash` from `echo a \| bash` and handles heredocs; it falls back to regex on parse failure
Bun + TypeScript was chosen over Python (50–100ms startup) and Bash (no native JSON, fragile quoting) for ~14ms cold starts and zero-dependency JSON parsing
OpenCode has its own tree-sitter-based permission system and cannot use these hooks, but its built-in rules cover most of the same ground
Conclusions

Prefix matching is not a minor implementation detail — it is the entire permission model, and it fails on any command that doesn't put the dangerous verb first, which is trivially easy to construct

Claude Code's special-case detection for `rm` in compound commands proves the engineering team knows this is a problem, but chose not to generalize the fix, leaving `git push` and `mkfs` unprotected

Codex shipping an `ask` value in its schema that the runtime rejects is a documentation-code mismatch that has persisted across multiple releases — anyone relying on the docs for security configuration gets a false sense of safety

The decision to check `rm` targets rather than block all `rm` is the right architectural instinct: permission rules should evaluate intent from arguments, not just command names, and this principle extends naturally to `git`, `systemctl`, and `env`

Using equal-length spaces to preserve index alignment between the sanitized and original command strings is a clever trick that makes hit reporting precise without a full parser

Bun's 14ms cold start versus Python's 50–100ms matters because the hook runs on every single command — the latency is user-facing and cumulative across a session

Concepts & terms
PreToolUse hook
A hook that runs before an AI coding agent executes a tool (like Bash). The hook receives the full command context and returns a JSON decision — allow, deny, or ask — that the agent must respect before proceeding.
Prefix matching
Claude Code and Codex's permission model: a rule like `Bash(git push:*)` only triggers if the command string starts with `git push`. Any command that puts something else first (cd, env vars, sudo) bypasses the rule entirely.
tree-sitter-bash
A Bash parser built on the Tree-sitter framework, used by code editors for syntax highlighting. It produces a concrete syntax tree that distinguishes real commands from quoted strings, heredocs, and escaped characters — things regex cannot reliably tell apart.
Command position
The point in a shell command string where an actual executable name appears — after separators like `;`, `&&`, `||`, `|`, or newlines, and past wrappers like environment variable assignments, `sudo`, or `doas`. Scanning only at command positions prevents flagging command names that appear as arguments to other programs.
Source: juejin.cn ↗ Google Translate ↗ Backup ↗