Decide which Pi mechanism a requirement needs
Build the review guard as a tool_call extension, and see why a force push is a prohibition while migrations/ is only a confirmation.
Two requirements that sound alike are different kinds of requirement. “Confirm before
anything under migrations/” asks a person. “No force pushes” must hold whether or
not a person is there to be asked. Only an extension can act on a tool call before it
runs, so both belong in one tool_call handler — but they need different code.
Run it
Use Pi 1.0.4 and one of the example repository’s test runners. The file below is the chapter’s canonical extension, reproduced exactly:
import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
export default function (pi: ExtensionAPI) {
pi.on("tool_call", async (event, ctx) => {
if (event.toolName !== "bash" && event.toolName !== "edit") return;
const raw = JSON.stringify(event.input);
// \b, not (^|\s): in the serialised input "git" follows a quote, not whitespace.
const forcePush = /\bgit\s+push\b.*(?:\s--force\b|\s-f\b|--force-with-lease\b)/.test(raw);
// An unconditional prohibition, not a question: no confirmation prompt, so
// there is nothing for a person to approve.
if (forcePush) return { block: true, reason: "Force pushes are not permitted by this repository's guard" };
// A different kind of rule. Ask, then proceed on the answer.
if (!/\bgit\s+push\b/.test(raw) && !/migrations\//.test(raw)) return;
if (ctx.hasUI && (await ctx.ui.confirm("Allow this call?", raw.slice(0, 120)))) return;
return { block: true, reason: "Blocked by guard extension" };
});
}Load it with pi -e ./guard.ts, then ask Pi to run a command in one of these shapes.
Expected outcome
| Call the model makes | Guard behaviour | Why |
|---|---|---|
echo git push origin main |
One confirmation; allowed on yes | An ordinary push is a confirmation |
git push --force origin main |
Blocked outright, no prompt | A force push is an unconditional prohibition |
touch migrations/001.sql |
One confirmation; on no the file is never created | A confirmation guard runs before the effect |
echo hello |
Untouched | The handler returns without deciding |
The echo … forms matter: the pattern matches the serialised event.input, so
echo git push is matched even though the command is harmless. That makes the
command safe to run while still exercising the branch — the only thing that can turn it
into an error is the guard itself.
Mechanism and limitations
Documented: exported ExtensionAPI declarations define pi.on("tool_call", …),
the handler’s return shape ({ block: true, reason }), and ctx.hasUI /
ctx.ui.confirm. Observed: guard.test.ts drives the real handler through an
AgentSession with a scripted provider and asserts each row above, including that a
blocked confirmation does not create the file. The regression test pins why the first
version was wrong: the pattern (^|\s)git\s+push never fired on a plain push, because
in {"command":"git push origin main"} the word git follows a quote, not whitespace.
It only appeared to work for compound commands such as cd x && git push.
This is not a boundary. It matches bash and edit and nothing else — a write
under migrations/, a multi_edit, or an extension’s own tool is not covered. It
matches a serialised string, so git push origin +main rewrites a ref without matching
the pattern, and echo "git push --force" matches it without doing anything. For a
requirement that must hold rather than usually happen, the answer is an
operating-system boundary, not a longer regular expression (Chapter 42). This example
does not show a real model choosing a command; it shows the code around the model.
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.