Build your own Claude Code
Hooks·28 min

Decide, modify, inject

0 / 1
# grade this stage (from your course directory)
npx theinference test --stage decide-and-modify --watch

The matcher decided whether a hook fires; the exit code decided whether it crashed. Now comes the payload: a PreToolUse hook that exits 0 prints a JSON decision on stdout, and your job is to apply it to the pending tool call. This is where hooks earn their keep — a hook can block the call with a reason, rewrite its arguments before it runs, or inject context the model sees next turn. One small function folds all three into a clean effect the loop can act on.

Three fields, three powers. decision: "block" (with a reason) stops the tool before any side effect — the model gets the reason back and adapts. updatedInput replaces the tool's arguments: the hook saw { command: "rm" }, decided it was fine but should be { command: "ls" }, and the rewritten input is what actually runs. additionalContext injects a string into the next turn without touching the call itself — how a PostToolUse lint hook says "lint passed" or feeds errors back. Your function normalizes whatever the hook returned into one predictable HookEffect.

What to build

Your task

Implement applyPreToolUse

In src/hooks.ts, implement applyPreToolUse(out, toolInput). out is the hook's parsed JSON ({ decision?, reason?, updatedInput?, additionalContext? }); toolInput is the tool call's current arguments. Return a HookEffect:

  • Block — if out.decision === "block": return { allowed: false, input: toolInput, injectedContext: null, reason: out.reason ?? "blocked by hook" }. The input is left unchanged (a blocked call never runs, so there's nothing to rewrite) and no context is injected.
  • Allow (the default) — otherwise: return allowed: true, with input = out.updatedInput ?? toolInput (rewrite if the hook supplied one, else pass the original through untouched), injectedContext = out.additionalContext ?? null, and reason: null.

Then make program.ts hook-apply <fixture> map applyPreToolUse(x.output, x.input) over case.cases and print { effects }.

src/hooks.ts
export interface HookOutput {
  decision?: "block" | "allow";
  reason?: string;
  updatedInput?: Record<string, unknown>;
  additionalContext?: string;
}

export interface HookEffect {
  allowed: boolean;
  input: Record<string, unknown>;
  injectedContext: string | null;
  reason: string | null;
}

/** Apply a PreToolUse hook's output to a pending tool call. */
export function applyPreToolUse(
  out: HookOutput,
  toolInput: Record<string, unknown>,
): HookEffect {
  // block: allowed=false, input=toolInput (unchanged), reason=out.reason ?? "blocked by hook"
  // else:  allowed=true,  input=out.updatedInput ?? toolInput, injectedContext=out.additionalContext ?? null
}
A block must run before the side effect — and rewrite must reach the tool

Two failure modes the grader catches. Block too late: if allowed:false but the loop already executed the tool, the brake did nothing — block has to gate execute, so the side effect never happens. Rewrite that doesn't land: if the hook returns updatedInput: { command: "ls" } but the loop runs the original command: "rm", the rewrite was cosmetic. The contract is strict: input is the bytes the tool actually receives — the rewritten ones when present, the originals otherwise.

Block wins; updatedInput is ignored on a block

A blocked call leaves input as the original toolInput, even if out.updatedInput is also present — there's nothing to rewrite for a tool that won't run, and surfacing a phantom rewrite would mislead the loop. Only the allow branch consults updatedInput. Same for additionalContext: it's injected only when the call is allowed; a block carries its reason instead. One decision per hook, no mixed signals.

How it's graded

adapter mode, stdout_json_match. program.ts hook-apply runs applyPreToolUse(x.output, x.input) over each case and prints { effects }. The fixture covers all three powers: a block ({decision:'block',reason:'no rm'} over {command:'rm -rf /'}), a rewrite ({updatedInput:{command:'ls'}} over {command:'rm'}), and an inject ({additionalContext:'lint passed'} over {path:'a.ts'}). Expected: the blocked call is allowed:false with its original input and reason, the rewrite shows input:{command:'ls'}, and the inject shows injectedContext:'lint passed' — each field compared exactly.

How the grader runs it
./your_program.sh hook-apply fixtures/case.json
# cases: block {command:"rm -rf /"} | rewrite -> {command:"ls"} | inject "lint passed"
# -> {"effects":[
#      {"allowed":false,"input":{"command":"rm -rf /"},"injectedContext":null,"reason":"no rm"},
#      {"allowed":true,"input":{"command":"ls"},"injectedContext":null,"reason":null},
#      {"allowed":true,"input":{"path":"a.ts"},"injectedContext":"lint passed","reason":null}]}