@tylerho/pi-guard
v0.1.0
Published
Prompts for human confirmation before risky git writes, PR publishing, and recursive rm in the bash tool.
Downloads
74
Readme
@tylerho/pi-guard
Prompts for human confirmation before risky git writes, PR publishing, and recursive rm in the bash tool.
Install
pi install npm:@tylerho/pi-guard
Guard
Intercepts dangerous shell commands before the bash tool executes and asks a human to confirm. Three independent guards trip on git write operations, gh pr publishing, and recursive rm; every match pauses on a confirmation modal, and a declined prompt blocks the tool call with Blocked by guard: <reason>. Toggles per guard via /guard, persisted in settings.json. The prompt routing also spans subagents: a headless child session's guard prompt bubbles up to the interactive session's modal so a human still decides.
Key concepts
- Fires on the
tool_callevent, bash only.index.tssubscribes totool_call, narrows withisToolCallEventType("bash", event), and runsevaluateBashGateover the command string. Returnundefined→ the call proceeds; return{ block: true, reason }→ pi blocks it before execution. - Three matchers, one contract. Every guard is a
Guard { id, label, match(command): string | null }(types.ts);matchreturnsnullwhen the command is untouched, otherwise the reason string shown in the prompt. The matchers are regex-based, unanchored — they fire anywhere in a compound command (git add -A; git pushstill trips) and tolerate intervening global flags (git -C /repo commit -m x):- git (
git.ts):commit,push,reset,mergesubcommands. Regex allows global flags (-C path,--git-dir=x,-c k=v) betweengitand the subcommand. Read-only commands (status,log,diff,show,remote -v) pass. Reasons: "committing", "pushing", "resetting", "merging". - pr (
pr.ts):gh pr createalways flags ("creating a PR");gh pr editflags only when a body/title flag is present —--body(covers--body-file),--title, or short-b/-t/-F("editing a PR description").gh pr view/list/checkout/merge/diffandgh pr editwith only--add-label/--add-reviewerpass. - rm (
rm.ts):rmat a command boundary (optionalsudoprefix or absolute path like/bin/rm) AND a recursive flag (any bundled short flag containingr/R, e.g.-rf/-fr/-Rf/-rfv, or--recursive). Non-recursivermand substring lookalikes (rmdir,trm,confirm -r) pass. Notably also flagsgit rm -r.
- git (
- Prompt routing is three-way (
routePromptindecision.ts):hasUI→ ask locally viactx.ui.confirm; no UI but a parent UI registered → ask the parent (see bridge); neither → headless, decided by the configured fallback (default deny). The prompt title isGuardlocally andGuard — subagent commandwhen routed to the parent; body isConfirm before <reasons joined with " and ">:\n\n <command>. - Subagent bridging (
bridge.ts). At the interactive session'ssession_start,ctx.uiis captured into a module-levelparentUi. Headless child sessions (subagents, workflow agents — allctx.hasUI === false) route their guard prompts throughconfirmOnParent, which serializes on a promise queue so concurrent subagents never collide on the modal; a rejected confirm doesn't stall the queue. No parent UI → resolvesfalse(blocks). - Settings live in the shared
settings.json, not a guard-specific file:globalSettingsPath()=join(getAgentDir(), "settings.json"), under theguardkey.writeGuardSettingpatches only that key and preserves unrelated keys.PI_DISABLE_GUARDSenv var (truthy:1/true/yes/on) force-disables all three toggles regardless of the file.parseGuardSettingsis tolerant — wrong-typed fields fall back to defaults.
API
No tools (registerTool) and no shortcuts are registered. Surface:
Command: /guard
pi.registerCommand("guard", ...) — toggle or inspect the guards. Args are whitespace-split into [id] [action].
| Args | Behavior |
|---|---|
| (none) or status | Notify overall status line: guards — git: on, pr: on, rm: on (headless fallback: deny) |
| <id> or <id> status | Notify one guard's state, e.g. git guard is on |
| <id> on / <id> off | Write the toggle to settings.json and notify git guard ON / git guard OFF |
| unknown <id> | Warning: Unknown guard "<id>". Use git, pr, or rm. |
| bad action | Warning: Usage: /guard <id> on|off|status |
id must be one of the GUARD_IDS = ["git", "pr", "rm"] (GuardId type). headlessFallback is only editable by hand-editing settings.json.
Events
| Event | Handler |
|---|---|
| session_start | If ctx.hasUI, captures ctx.ui via setParentUi so headless child sessions can route guard prompts to this session's modal. |
| tool_call | For bash events only (isToolCallEventType("bash", event)): loads settings, runs evaluateBashGate(event.input.command, settings, { hasUI, hasParent }, { confirmLocal, confirmParent }). Returns { block: true, reason: "Blocked by guard: <reasons>" } when declined/unapproved, undefined (pass) otherwise. |
Config
~/.pi/agent/settings.json→guardkey:{ "git": boolean, "pr": boolean, "rm": boolean, "headlessFallback": "deny" | "allow" }. Defaults: alltrue,headlessFallback: "deny". Hand-editheadlessFallbackto"allow"to let fully headless sessions (no UI anywhere) run guarded commands without confirmation.- Env var
PI_DISABLE_GUARDS(truthy) — overrides the file, turns all three toggles off.
Module exports (internal surface)
Only index.ts is auto-loaded (top-level *.ts). All modules below are imported by it; nothing imports the guard extension from elsewhere.
- index.ts — default export
(pi: ExtensionAPI) => void(the only export).GUARD_IDS,isGuardId, andstatusLineare module-private helpers, not exported. - src/types.ts —
GuardId = "git" | "pr" | "rm";interface Guard { id, label, match(command): string | null }. - src/registry.ts —
GUARDS: readonly Guard[](git, pr, rm — order determines reason order). - src/git.ts / src/pr.ts / src/rm.ts — default-exported
Guardimplementations (matcher regexes + reasons, see Key concepts). - src/decision.ts —
guardReasons(command, settings): string[];routePrompt(ctx: PromptContext): Route(Route = { route: "prompt-local" } | { route: "prompt-parent" } | { route: "headless"; allow: boolean });evaluateBashGate(command, settings, env: GateEnv, hooks: GateHooks): Promise<GateResult>whereGateResult = { block: true; reason: string } | undefined; typesGateHooks { confirmLocal, confirmParent },PromptContext { hasUI, hasParent, fallback }. - src/bridge.ts —
setParentUi(ui: ConfirmUi | undefined),hasParentUi(): boolean,confirmOnParent(title, body): Promise<boolean>(serialized;falsewith no parent UI);interface ConfirmUi { confirm(title, body): Promise<boolean> }. - src/settings.ts —
GuardSettings,HeadlessFallback,DEFAULT_GUARD_SETTINGS,parseGuardSettings(value): GuardSettings,loadGuardSettings(path, env = process.env),writeGuardSetting(path, patch: Partial<GuardSettings>),globalSettingsPath().
Examples
Agent proposes a force push. The agent calls
bashwithgit push --force origin main. The git guard matches (pushing), a modal appears:Guard — Confirm before pushing:\n\n git push --force origin main. User declines → the tool result readsBlocked by guard: pushingand the agent must not proceed without approval.Disable/enable per command.
/guard git off→git guard OFF(persisted tosettings.json)./guard status→guards — git: off, pr: on, rm: on (headless fallback: deny).Subagent tripping a guard. A spawned subagent (headless,
hasUI: false) runsrm -rf build. Itstool_callhandler routes the prompt to the parent interactive session's modal, titledGuard — subagent command; the human's answer decides. If the parent session is gone (noparentUi), the headless fallback applies —denyblocks,allowpasses.Scripted/CI bypass.
PI_DISABLE_GUARDS=1 pi …(or exporting it) force-disables all three guards at load time, overridingsettings.json.
