Example

Block a force push with a tool_call handler

Use a tool_call extension to refuse destructive git commands before they run, while ordinary commands pass through untouched.

A tool_call handler runs before the tool it names, so it can refuse the call. The guard on this page blocks destructive git commands — force pushes, reset --hard, and git clean -f — and when there is no UI to ask, it refuses rather than allowing silently. The cases run through a faux provider in a real session, so you see the guard, not a real model.

Run it

The guard is the whole file, reproduced exactly:

import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";

const BLOCKED = [
	/\bgit\s+push\b[^\n]*(--force|-f)\b/,
	/\bgit\s+reset\s+--hard\b/,
	/\bgit\s+clean\b[^\n]*-[a-z]*f/,
];

export default function (pi: ExtensionAPI) {
	pi.on("tool_call", async (event, ctx) => {
		if (event.toolName !== "bash" && event.toolName !== "powershell") return;

		const raw = JSON.stringify(event.input);
		const hit = BLOCKED.find((re) => re.test(raw));
		if (!hit) return;

		if (!ctx.hasUI) {
			// No way to ask. Refuse rather than allow silently.
			return { block: true, reason: `Guard blocked: ${hit.source}` };
		}

		const ok = await ctx.ui.confirm(
			"This command rewrites history or destroys work. Allow?",
			raw.slice(0, 200),
		);
		if (ok) return;

		return { block: true, reason: "Blocked by review-guard extension" };
	});
}

Each pattern tests JSON.stringify(event.input), so a force push buried in a longer command still matches. When ctx.hasUI is false the handler blocks at once; with a UI it confirms first, and every block carries a reason. Unlike Chapter 11’s guard — which blocked a force push unconditionally and matched only bash and edit — this one covers powershell, reset --hard and git clean, and asks when a person is available. Load it via pi --extension ./guard.ts.

Expected outcome

This is observed: guard.test.ts drives each command into a real session; touch ran.flag is included so “was it blocked?” is a file that exists or does not, not an inference.

Scripted call Result
touch ran.flag && git push --force origin main isError: true, text matches /Guard blocked/, and ran.flag is never created
touch ran.flag isError: false, and ran.flag exists — the ordinary command is left alone
git reset --hard HEAD~1 isError: true
touch ran.flag && git push -f origin main via guard-status.ts isError: true, reason names --force-with-lease, no file
any command, when a tool_call handler throws isError: true, no file — it fails closed

Mechanism and limitations

Documented: tool_call fires before execution and accepts { block: true, reason }; ctx.hasUI covers interactive and RPC dialogs, ctx.mode === "tui" terminal-only UI. It also states that a tool_call handler failure blocks the tool as a fail-safe, while a tool execution failure becomes an error result. Handlers run in extension load order, and tool calls from one assistant message may run in parallel.

Observed: guard.test.ts drives the real handler through a real AgentSession with a scripted provider and asserts every row above, including that a blocked or throwing handler leaves no file behind. guard-status.ts adds a /guard-status command whose counter resets in session_start (once per session), not in the factory.

Limitations: this is a heuristic, not a boundary. It matches a serialised string, so git push origin +main rewrites a ref without matching and echo "git push --force" matches without doing anything; it needs a call to exist, so a tool the model cannot see is never intercepted (Chapter 17); and a guard that throws on every call is a total outage — catch inside the handler. The model is faux, so this shows the code around the model, not that a real model would obey.

Full source and test .

Understand this example

Copy this prompt into your AI tool. No code runs here.

Apply this example

Copy this prompt into your AI tool. No code runs here.