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.
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.