Build your own Claude Code
The permission engine·18 min

Enforce in the loop

0 / 1
# grade this stage (from your course directory)
npx theinference test --stage enforce-in-loop --watch

decide is only a brake if something actually steps on it. Right now your loop hands every tool_use straight to execute and the side effect runs. This step puts the gate in the path: wrap execute so it calls decide first, and on a 'deny' it returns an is_error tool_result and never touches the tool. The cassette plants a denied Bash(rm *); your job is to make sure the rm never runs while the loop still finishes its benign work.

What to build

Your task

Gate execute on decide

Wrap your execute with a permission gate that runs inside the loop. For each call, build { tool: name, input } and run decide(settings, call). If the verdict is 'deny', return immediately with { content: 'permission denied: ' + name, is_error: true } — do not delegate to the underlying tool, so no side effect happens. Otherwise call the real execute. The loop threads the is_error result back as a tool_result, the model sees the denial, and continues to a benign end_turn. (ask/allow both proceed in the grader's non-interactive run.)

tests/adapters.ts
import { decide, type PermissionSettings } from '../src/permissions'
import type { Execute, ToolOutput } from '../src/loop'

export function gate(settings: PermissionSettings, execute: Execute): Execute {
  return (name, input) => {
    const d = decide(settings, { tool: name, input })
    if (d === 'deny') {
      return { content: `permission denied: ${name}`, is_error: true } satisfies ToolOutput
    }
    return execute(name, input)
  }
}
Gate BEFORE the side effect, not after

The check has to short-circuit. If you let the tool run and then mark the result an error, the rm * already happened — the brake is decorative. Return the is_error result in place of calling the tool. Equally important: a deny must not throw or abort the loop. It is a normal, recoverable tool_result — the agent reads permission denied, adapts, and keeps going.

How it's graded

Cassette + adapter via program.ts enforce: the harness runs the loop with a settings.json carrying deny: ['Bash(rm *)'] against a cassette that scripts a Bash(rm *) turn then a benign completion. It asserts the denied tool is in the blocked list, that the planted file the rm targeted is still on disk (the side effect never happened), and that the loop reached a normal end_turn with the expected final text.