The rule matcher
# grade this stage (from your course directory)
npx theinference test --stage rule-matcher --watchBefore 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.
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
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.
import { matchRule, type ToolCall } from '../src/permissions'
export function runMatchRule(rule: string, call: ToolCall): boolean {
return matchRule(rule, call)
}
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.
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.
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.