Build your own Claude Code
The permission engine·30 min

The rule matcher

0 / 1
# grade this stage (from your course directory)
npx theinference test --stage rule-matcher --watch

Before precedence can mean anything, you need to answer one question: does this rule match this call? Rules in settings.json are strings shaped Tool(specifier) — Bash(npm run *), Read(./src/**), WebFetch(domain:example.com) — or a bare Bash that matches every invocation of the tool. This step builds the matcher; the next two layer precedence and modes on top of it.

The rule grammar
Bash(npm run *)      command glob — * matches anything, spaces are literal
Bash(npm*)           no boundary — "npm install" matches, "npm" matches
Read(./src/**)       gitignore-style path glob — ** spans directories
WebFetch(domain:example.com)   host of the request URL equals the domain
Bash                 bare tool — matches EVERY Bash call (no specifier)

What to build

Your task

Implement matchRule

Implement matchRule(rule, call) in src/permissions.ts. Parse the rule into a tool name and an optional specifier. If the rule's tool ≠ call.tool, return false. A bare tool (no (...)) matches any call. Otherwise match per tool: Bash — glob call.input.command against the spec (* → .*, everything else literal, including spaces); Read/Edit/Write — glob call.input.path against the spec as a gitignore-style path pattern (strip a leading ./ on both sides, ** spans /, * does not); WebFetch — the spec is domain:<host>, match the host of call.input.url. The tests/adapters.ts adapter just forwards to it.

tests/adapters.ts
import { matchRule, type ToolCall } from '../src/permissions'

export function runMatchRule(rule: string, call: ToolCall): boolean {
  return matchRule(rule, call)
}
Reuse your Glob matcher; respect the space boundary

You already wrote a gitignore-style glob in Chapter 02 for the Glob tool — reuse it for Read/Edit/Write path specs (the **-spans-slashes vs *-stops-at-slash distinction is identical). For Bash, the spec is matched against the whole command string with spaces taken literally: Bash(npm run *) matches npm run build but not npmrun, while Bash(npm*) has no such boundary and matches npm install too.

Escape regex metacharacters before substituting `*`

When you compile a Bash spec to a regex, escape every regex metacharacter in the literal parts (., +, (, ), |, …) first, then turn * into .*. A spec like Bash(node script.js) must treat the . as a literal dot, not "any character" — otherwise node scriptXjs would wrongly match. Anchor with ^…$ so a rule matches the whole command, not a substring.

How it's graded

Process mode runs program.ts match-rules over a fixture of {rule, call} cases and compares the boolean vector to expected.json. The cases cover every grammar form plus the boundary traps: Bash(npm run *) vs npmrun, Read(./src/**) matching src/a/b.ts but not lib/x.ts, a bare Bash matching all, and a WebFetch host mismatch. Every case must be classified exactly.