Skip to the content.

How it works

Architecture deep-dive for someone who wants to write non-trivial rules or debug an unexpected decision.



1. End-to-end flow

flowchart LR
  A[LLM] -->|requests a tool| CC[Claude Code]
  CC -->|PreToolUse, JSON on stdin| H[pre-hook.js]
  H --> BA[build AST]
  BA --> I[interpret, apply rules]
  I --> D[Decision]
  D -->|JSON on stdout| CC
  CC -->|allow / deny / ask| A
  CC -->|PostToolUse, JSON on stdin| PH[post-hook.js]
  H & PH --> AL[audit log]

2. Tool call → AST

buildAst switches on tool_name and lifts the relevant fields into a typed node. For Bash, it first loads command descriptor files via loadCommandDescriptors(projectDir) from ~/.claude/permissions.d/commands/ (home) and .claude/permissions.d/commands/ (project, wins on conflict). The resulting Map<string, ICommandDescriptor> is threaded into parseBash, which uses it to determine flag arity (whether a flag consumes the next token as its value) and positional kinds (path vs. string). Without a descriptor for a command, all flags default to arity 0.

parseBash runs a hand-written recursive descent parser: a flat lexer produces a token stream, then grammar functions (parseSequence / parseAnd / parseOr / parsePipe / parseStatement / parseCommand) call each other recursively to build a left-associative sub-AST of Command leaves connected by BinOp nodes (pipe, and, or, seq). The lexer also normalises newlines and a bare & to a ; separator, so a command after a newline or backgrounded with & is parsed as its own statement rather than swallowed as an argument.

Comments are stripped by the lexer. A # at a token boundary (start of input, or just after whitespace, a newline, or an operator) begins a comment that is discarded to the end of the line, exactly as Bash treats it; a # in the middle of a word (e.g. foo#bar) is kept literally. This applies wherever the comment appears, not just at the start of a line, so a trailing comment like echo hi # note parses as just echo hi, and anything inside the comment (including what looks like a $(...) substitution) never reaches the AST or gets evaluated. Because comment-only lines and blank lines collapse with the surrounding separators, the only way to reach a completely empty Command (empty binary, no args/env/redirects) is an input that is nothing but a comment or whitespace; that degenerate case falls through to the default ask (see §4).

parseStatement recognises the block constructs:

Leading token Node Shape
for for_loop for VAR in ITEMS; do BODY; done
while / until while_loop (until flag) while COND; do BODY; done
if if_statement if COND; then BODY [elif COND; then BODY]* [else BODY]; fi
case case_statement case WORD in PATTERN) BODY ;; ... esac
( group (subshell) ( LIST )
{ group (brace) { LIST; }

Conditions, bodies, branches, and clauses are each parsed as full sub-sequences, so operators, pipelines, and nested blocks inside them build normal sub-trees. An elif chain is represented as a nested if_statement in the elseBranch field. Embedded command substitutions$(...) and backtick `...` — found in any word, redirect target, or env value of a command are parsed and attached to that command’s substitutions array (arithmetic $(( ... )) is excluded), so the inner commands are evaluated for permissions instead of being treated as opaque text. After parsing, buildAst applies transformXargsNodes to the sub-tree: every Command leaf with binary: "xargs" is replaced by an IXargsNode intermediate node whose child is the parsed subcommand.

Worked AST examples for every construct live under examples/bash/ (compact, Bash-only) and examples/ast/ (full tool-call AST), each paired with a .md Mermaid diagram. Both sets are regenerated by bun run gen:examples and verified by the smoke suites.

For find . | xargs grep -l "pattern":

graph TD
  Bash["bash<br/>raw: find . | xargs grep -l &quot;pattern&quot;"] --> Pipe["binop<br/>op: |"]
  Pipe --> Find["command<br/>binary: find<br/>cmd: ."]
  Pipe --> Xargs["xargs<br/>options: {l: true}<br/>raw: xargs grep -l &quot;pattern&quot;"]
  Xargs --> Grep["command<br/>binary: grep<br/>options: {l: true}"]

For cd /etc && rm -rf /:

graph TD
  Bash["bash<br/>raw: cd /etc &amp;&amp; rm -rf /"] --> And["and"]
  And --> Cd["command<br/>binary: cd<br/>cmd: /etc"]
  And --> Rm["command<br/>binary: rm<br/>options: { r: true, f: true }<br/>cmd: /"]

For if diff -q a b >/dev/null 2>&1; then echo same; else echo differ; fi - the condition and both branches become children of the if_statement:

graph TD
  If["if_statement"] --> Cond["command<br/>binary: diff<br/>options: { q: true }<br/>cmd: [a, b]"]
  If --> Then["command<br/>binary: echo<br/>cmd: same"]
  If --> Else["command<br/>binary: echo<br/>cmd: differ"]

For an Edit tool call - there is no Bash sub-tree; the AST is a single typed leaf:

graph TD
  E["edit<br/>file_path: /home/u/myapp/.env<br/>old_string: KEY=old<br/>new_string: KEY=new"]

Source files: src/parse-bash.ts, src/build-ast.ts.

3. Walking the AST with an Environment

The interpreter threads an immutable Environment ({ cwd, cwdResolved, env }) down through nodes. At each node it calls a visitor (which runs the rules), collects the visitor’s env update, then recurses into children with the updated env. Env is always cloned - never mutated.

Sequence diagram for cd /etc && rm -rf / (starting cwd /home/u):

sequenceDiagram
  participant W as Walker
  participant Cd as cd leaf
  participant Rm as rm leaf

  Note over W: env0 = {cwd: /home/u}
  W->>Cd: visit with env0
  Cd-->>W: cdRule returns env updater (cwd → /etc)
  Note over W: envOut from cd = {cwd: /etc}
  W->>Rm: visit with {cwd: /etc}
  Rm-->>W: blockRmRfRoot returns deny
  Note over W: bubble deny upward through &&

Operator env semantics

Operator Left sees Right sees Env returned to parent
seq (;) parent env env after walking left env after walking right
and (&&) parent env env after walking left env after walking right
or (\|\|) parent env parent env (LHS may not have run) parent env (conservative)
pipe (\|) parent env parent env (each side is a subshell) parent env

or and pipe discard subtree env changes; seq and and propagate left→right→up.

Block construct env semantics

Construct Children walked Env into children Env returned to parent
for_loop body once per item, with env[variable] set to that item parent env + the per-iteration loop variable parent env (loop-local changes do not leak)
while_loop condition, then body once condition sees parent env; body sees the env after the condition env after the condition (iteration count is indeterminate)
if_statement condition, then thenBranch, then elseBranch (if present) condition sees parent env; both branches see the env after the condition env after the condition (the taken branch is indeterminate, so branch changes do not propagate)
case_statement every clause body each clause sees parent env (clauses are alternatives) parent env
group (subshell) body parent env parent env (a subshell isolates env changes)
group (brace) body parent env env after the body (a brace group runs in the current shell)
command substitutions each entry in substitutions parent env parent env (substitutions run in subshells; their env is discarded)

Every branch / clause / body is walked even though only some run at execution time: the analysis cannot know which path is taken, so a deny anywhere inside denies the whole construct (strictest-wins aggregation, same as any other intermediate node). A denial inside a command substitution likewise denies the command that contains it.

4. Per-node rule evaluation

At each node the visitor runs all registered rules in order. The flowchart below shows one node’s evaluation:

flowchart TD
  Start["visitor visits node"] --> R1["rule 1"]
  R1 -->|abstain| R2["rule 2"]
  R2 -->|allow → record| R3["rule 3"]
  R3 -->|deny → SHORT-CIRCUIT| Out["return deny"]
  R3 -.->|or continue| R4["... rule N"]
  R4 --> Out2["return strictest non-deny"]

Per-rule actions in detail:

  1. deny - immediately short-circuits; no later rules run. The deny decision and the rule name are recorded.
  2. ask - recorded and protected. Later allow rules cannot downgrade it.
  3. allow - recorded only if nothing stricter (ask or deny) has been seen yet. Ties (same rank) go to the latest rule, so the explanation cites the most recently matched rule.
  4. abstain - skipped entirely; does not affect the running annotation.
  5. If no rule produced a concrete decision, the visitor returns abstain.

Rank order for strictest-wins: abstain (0) < allow (1) < ask (2) < deny (3).

runningEnv - cross-rule env visibility

Rules at the same node share a runningEnv. Each rule that returns a scopedEnv or persistent env update mutates runningEnv for subsequent rules at this node. This lets envPrefixRule install FOO=bar into runningEnv so that a later permission rule at the same leaf can read env.env.FOO. Persistent env updates also propagate to siblings; scopedEnv updates do not.

5. Bubble-up at intermediate nodes

After visiting an intermediate node itself, the interpreter aggregates child outcomes and layers the visitor’s result on top.

Phase 1 — aggregate children:

Condition Result
Any child is deny deny
All children are allow allow
Otherwise ask

Phase 2 — layer the visitor’s own decision on top:

Visitor decision Result
deny deny (overrides everything)
ask ask (overrides all-allow children)
allow allow (overrides ask from children)
abstain Keep Phase 1 result

Worked examples:

Command What happens Result
cd /etc && rm -rf / rm leaf → deny; bubbles through && deny
git status single leaf → allow (via allowGitReadOnly); propagates through bash root allow
git status \| wc -l children = [allow, ask]; not all-allow → ask ask
git status && git diff both children → allow; all-allow allow
npm test && rm -rf / rm leaf → deny; wins over allow from npm deny

6. Built-in rules

These rules handle Bash semantics. Most only update env as a side effect and abstain on the decision, so they never block a call on their own. The exceptions are envSetRule and exportRule: a bare assignment or export runs no command, so they allow it outright (as well as updating env).

Rule File Matches Decision Env effect
cdRule src/rules/builtin/cd.ts cd <path> abstain Updates env.cwd persistently via && / ; propagation
envPrefixRule src/rules/builtin/env-prefix.ts FOO=bar cmd (non-empty binary + envPrefix) abstain Installs prefix vars into env.env for this command only (scopedEnv - transient)
envSetRule src/rules/builtin/env-set.ts FOO=bar with no binary allow Updates env.env persistently
exportRule src/rules/builtin/export.ts export FOO=bar [BAZ=qux …] allow Updates env.env persistently
xargsRule src/rules/builtin/xargs.ts IXargsNode (any xargs command) abstain None – child decision propagates

Built-ins are registered first in src/rules/index.ts so their env updates land in runningEnv before permission rules read them - e.g. NODE_ENV=production npm start makes NODE_ENV visible to a permission rule that wants to deny production runs.

7. User extensibility

TypeScript rules

A rule is a single function (node: AstNode, env: Environment, call: ToolCall) => RuleOutcome. Place it in its own file under src/rules/, add it to the array in src/rules/index.ts, write a paired test under src/test/rules/, then rebuild (bun run bundle).

Rules should:

YAML rules

Drop a .claude/permissions.yaml in your project root (or ~/.claude/permissions.yaml for user-global rules). You can also split rules across multiple files under .claude/permissions.d/*.yaml (and the home equivalent) — each drop-in file becomes its own isolated layer. YAML rules are compiled to Rule functions at startup and appended to the registry after the semantic built-ins. No rebuild required - just /reload-plugins.

See CONFIGURATION.md for the full conditions table and glob semantics.

Registry ordering and conflict resolution

Rules are evaluated through a layered delegation chain:

Hook (interpret.ts) → RuleRegistry → RuleLayer | FileLayer → Rule

The layers in evaluation order:

  1. Built-in layer (RuleLayer) — cd, env-prefix, env-set, export. Static; never reloads. Runs first so env state is correct when YAML rules evaluate it.
  2. Home main layer (FileLayer) — compiled from ~/.claude/permissions.yaml once at hook startup. Returns [] when HOME is unset or the file is absent.
  3. Home drop-in layers (one FileLayer per file) — every ~/.claude/permissions.d/*.yaml or .yml, sorted alphabetically. Each file is its own isolated layer.
  4. Project main layer (FileLayer) — compiled from .claude/permissions.yaml (relative to CLAUDE_PROJECT_DIR) once at hook startup. Returns [] when CLAUDE_PROJECT_DIR is unset or the file is absent.
  5. Project drop-in layers (one FileLayer per file) — every .claude/permissions.d/*.yaml or .yml under CLAUDE_PROJECT_DIR, sorted alphabetically.

All YAML config files are compiled independently — none overrides another. All rules from all files are evaluated. RuleRegistry.runRules iterates the layers in order, threads the persistent env from each layer’s result into the next, and applies strictest-wins across layers. A deny in any layer short-circuits the remaining layers, so a permissions.d/aws.yaml deny wins over an allow in a sibling drop-in or the project main file evaluated later.

The plugin ships with no default YAML rules. All permission decisions come from the user’s config files. Within each layer, strictest-wins applies: a deny short-circuits later rules, and an ask cannot be downgraded by a later allow at the same node.

8. Audit log

Every hook invocation writes structured entries to .claude/permissions-log/ inside the project root (CLAUDE_PROJECT_DIR). Files are partitioned by hour in local time:

.claude/permissions-log/
└── YYYY-MM/
    └── DD/
        ├── HH.json   # JSON Lines — one entry per line, machine-readable
        └── HH.log    # plain text — human-readable summary

Entry types

Type Written by When
tool_request pre-hook.js Once per invocation, before any rule runs — captures the raw tool call
rule_match pre-hook.js Once per non-abstaining rule at any AST node — records rule name, decision, and matched cmd
aggregation pre-hook.js Once per intermediate node — records children decision, own decision, and combined result
final_decision pre-hook.js Once per invocation, just before returning — the authoritative allow / deny / ask
tool_execution post-hook.js Once per allowed tool execution — captures the tool response and whether it reported an error

Retention

The three most recent calendar months are kept. Months older than that are pruned automatically on each hook invocation. Files within a kept month are never deleted.

9. Shared analysis core (src/analyze.ts)

Both the REPL and the MCP server use the same analyzePermission function from src/analyze.ts rather than calling decide() directly. This guarantees they produce identical results to the live hook.

analyzePermission(input, cwd, projectDir) does three things:

  1. Parse inputparseToolCallInput converts a user string into a ToolCall. A bare string becomes a Bash call. Prefixed strings (read <path>, write <path>, edit <path>, webfetch <url>, tool <name>) become the corresponding typed tool call.
  2. Build registrybuildAnalysisRegistry constructs the same three-layer RuleRegistry the live hook uses (built-ins → home config → project config), temporarily setting CLAUDE_PROJECT_DIR so the file loaders resolve paths correctly.
  3. Run and capturedecide() is called with a CapturingAuditLogger that records every rule match, aggregation, and final decision. The logger’s entries become the trace array in the returned IAnalysisResult.

10. Permission REPL (src/repl.ts)

The REPL is a thin shell around analyzePermission. It has two modes:

Neither mode touches the live hook or writes to the audit log. The CapturingAuditLogger inside analyzePermission captures all trace entries in memory and they are discarded after printing.

11. Permission Analyzer MCP server (src/mcp-server.ts)

The MCP server exposes a single tool, analyze_permission, over stdio using the @modelcontextprotocol/sdk StdioServerTransport. Claude Code registers it via .mcp.json and calls it automatically when you ask permission questions in natural language.

When analyze_permission is called:

  1. analyzePermission(command, cwd, projectDir) is called with the arguments from the tool input (defaulting cwd and projectDir to CLAUDE_PROJECT_DIR or process.cwd()).
  2. The result is formatted as a text block: decision, reason, and a filtered trace. config_load and tool_request entries are stripped because they appear on every call and do not help explain why a rule matched.
  3. The text block is returned as the tool response. Claude reads it and explains the result in natural language.

The server is bundled into plugin/dist/mcp-server.js for distribution. Plugin users get it automatically via plugin/.mcp.json; repository developers use the repo-root .mcp.json which points at the TypeScript source directly.