pi-permission-ai-guard

Token-optimized LLM permission reviewer — a pi-permission-system Authorizer chain link

Packages

Package details

extension

Install pi-permission-ai-guard from npm and Pi will load the resources declared by the package manifest.

$ pi install npm:pi-permission-ai-guard
Package
pi-permission-ai-guard
Version
0.9.1
Published
Sep 14, 2026
Downloads
1,038/mo · 215/wk
Author
kuoruan
License
MIT
Types
extension
Size
334.4 KB
Dependencies
2 dependencies · 4 peers
Pi manifest JSON
{
  "extensions": [
    "./index.ts"
  ]
}

Security note

Pi packages can execute code and influence agent behavior. Review the source before installing third-party packages.

README

pi-permission-ai-guard

A Pi extension that reviews permission asks with a light model, using a token-optimized stripped transcript.

It is a consumer of @gotgenes/pi-permission-system: it registers an "ai-guard" link in its authorizer chain, reviewing ask-level permission requests on the configured surfaces (bash, mcp, skill).

Why

Each model review call sends only stripped context (user messages + tool call names), not the full transcript — assistant text and tool results, the token-heaviest parts, are discarded, with minimal impact on verdict quality.

Two structural choices distinguish the reviewer from a plain classifier: the verdict lean never feeds back into the model's own context (each review is stateless — an agent cannot anchor the reviewer toward its own history), and both ladder extremes are lean-inert (strict denies every non-allow, permissive passes everything short of a hard deny or a reviewer failure — the lean only routes the middle rungs). These mitigate the classifier drift documented in Anthropic's auto-mode writeup, where consent-shaped evidence in history was the top failure mode — structure instead of prompt-tuning.

How it works

The reviewer runs a short, cheap decision on each ask and defers at the first miss:

  1. Surface match: the ask's surface is in surfaces (otherwise defer).
  2. Extract the review target (the value being authorized).
  3. Policy gate: query the deterministic engine at gate parity — if the policy already says allow or deny, defer. This link only adds value when the engine is undecided (ask).
  4. Circuit breaker: a tripped breaker short-circuits without a model call.
  5. Resolve the model (fails fast on a config error).
  6. Strip transcript (token-optimized, secrets redacted): feeds both the verdict cache's context fingerprint and the review prompt.
  7. Verdict cache lookup: a repeated ask in a stable conversation skips the model.
  8. Resolve auth — after the cache, so a cached repeat ask survives an auth flap.
  9. Build prompt (the command and request fields are redacted).
  10. Model review: JSON verdict.
  11. Record the verdict into the breaker counters and cache.

Fail-safe by construction: a missing model, invalid config, model timeout, unparseable reply, or an unsure verdict — any unexpected error path — resolves to defer, and the ask falls through to the normal permission prompt.

Upstream failures retry once per mechanism, budgeted inside timeoutMs: provider errors retry in pi-ai's provider layer; an empty reply retries at the review layer when at least half the window remains. Three requests is the hard ceiling; attempts: 2 on the decision record marks a retried review.

Transcript stripping

Message type Handling Why
user message Keep (truncated) Trusted authorization signal
compaction/branch summary Delete May contain model output; must never become an authorization signal
assistant text Delete Untrusted (agent can rationalize)
tool call Keep name + truncated args Show what agent did
tool result Delete Untrusted (injection entry), token-heaviest
ask_user_question result Keep (trusted intent) User's structured answers

Setup

Two extensions, two config files: pi-permission-system owns the policy and the chain (its config names the link); this extension declares the reviewer model and behavior.

  1. Install pi-permission-system (>= 27.1.1) and configure its permission policy — see its Quick Start.

    pi install npm:@gotgenes/pi-permission-system
    
  2. Install this extension:

    pi install npm:pi-permission-ai-guard
    

    Or add to settings.json: { "packages": ["npm:pi-permission-ai-guard"] }.

  3. Name the link in pi-permission-system's config — installing the extension registers the link, but a link decides nothing until you name it (the chain is opt-in):

    // ~/.pi/agent/extensions/pi-permission-system/config.json
    { "authorizerChain": ["ai-guard"] }
    
  4. Declare the reviewer in this extension's config (config.jsonc or config.json, JSONC — comments and trailing commas are fine; when both exist, config.jsonc wins):

    // ~/.pi/agent/extensions/pi-permission-ai-guard/config.jsonc
    {
      "provider": "anthropic",
      "model": "claude-haiku-4-5",
      "reasoning": "off",
      "timeoutMs": 15000,
      "surfaces": ["bash", "mcp", "skill"],
    }
    

See config/config.example.json for a complete example.

Chain facts that shape how this link behaves:

  • Only ask reaches the chain — a request the deterministic policy already decided (allow/deny) never consults the link.
  • Config order fixes chain order, never registration order; a missing link is skipped fail-safe — absence means more prompting, never less.
  • The chain owner caps a link's allow on the external_directory/path surface families to defer — reviewing those surfaces can deny or defer, never allow. The default surfaces list stays clear of them.
  • A subagent's ask is reviewed one hop up — by the chain of the session serving it, so your links review subagent asks in the session you are watching.
  • The chain ends at the default terminal: the interactive prompt (or a headless deny).

Where to put hard-deny rules: secrets, dangerous commands, and safe auto-allow patterns belong in pi-permission-system's rule config, not here. The link queries the engine at gate parity and defers whenever the engine already decided — a deterministic block costs no model call and holds in every mode.

Configuration

Field Type Default Description
provider string required Model provider (e.g. anthropic)
model string required Model id (e.g. claude-haiku-4-5)
reasoning "off" | "minimal" | "low" | "medium" | "high" | "xhigh" | "max" "off" Thinking level (pi-ai ModelThinkingLevel); off = disabled
timeoutMs integer 15000 Model-call timeout (ms)
maxTokens integer 4096 Reviewer reply budget; thinking blocks count against it on reasoning upstreams
transcript object see below Transcript stripping config (see below)
surfaces string[] ["bash","mcp","skill"] Surfaces to review; glob patterns (*, ns:*, *:bar); ! excludes. Path-family granularity: path = whole family, path_* = proven-direction access only, path_read = one direction, !path = family-wide exclude
instructions string|null null Custom safety rules (replaces defaults; null = built-in)
mode "strict"|"default"|"lenient"|"permissive" "default" Leniency ladder for non-allow verdicts (see below)
notifyLevel "info"|"warning"|"error"|"off" "info" Ambient-notify threshold — the minimum review-loop notify level that still notifies; command feedback is never gated
circuitBreaker object {consecutive:3,total:20,verdict:"deny"} Circuit breaker config (see below)
cache object {maxEntries:128} Verdict cache (see below)

Transcript

Caps for the stripped transcript (see the stripping table above). Defaults follow a recency principle; the three caps bound the worst case (~15KB) — there is deliberately no fourth, total-budget field.

Field Default Description
maxUserMessages 5 Max trusted-intent entries (most recent)
maxToolCalls 10 Max tool calls (most recent)
maxCharsPerEntry 1000 Truncate each entry to this many chars

Mode

The reviewer model answers each permission ask with allow, deny, or defer (uncertain); deny carries a riskLevel, and defer may carry a lean — the reviewer's directional inclination ("if forced to pick now, I'd allow/deny"; omitting it means genuinely neutral). The ladder disposes every verdict by suspicion order — from most benign to most dangerous:

allow  <  defer (lean: allow)  <  defer (neutral)  <  defer (lean: deny)  <  deny (soft)  <  deny (hard)

Each mode is two cut lines on that order — an auto-pass band, an ask band, a terminal-deny band. The full matrix:

Verdict ↓ (suspicion ↑) strict default lenient permissive
allow allow allow allow allow
defer + lean: allow deny ask allow allow
defer (neutral) deny ask allow allow
defer + lean: deny deny ask ask allow
deny (soft: low|medium) deny ask ask allow
deny (hard: high|critical, or missing) deny deny deny deny

The reading per mode: strict — the reviewer's allow is the only pass (full fail-closed automation); default — you judge every flag but hard danger (the resting mode and the onboarding posture — watch the reviewer work, then loosen); lenient — only the reviewer's active alarms ask you (soft denies and deny-leaning doubts); permissive — only clear high-danger requests are blocked. lean moves a defer across only the ask↔allow boundary, in the lean's own direction; it never appears in dialogs or notify lines (the ask is the human's judgment moment) — it lives in the ai_guard.decision audit record.

Rules that hold in every mode:

  • Reviewer machinery failures (model unresolved, auth failed, transcript errors, timeouts, unparseable or empty replies, no review target) never map to allow — they deny under strict and permissive, defer under the other two. A machinery deny stays silent (the deny reason reaches the agent); a machinery-forced defer notifies its classified cause (reviewer could not complete the review (empty-reply) — deferring to you), on every occurrence.
  • A model deny that holds or escalates notifies in every mode — reviewer denied this request (risk high) — <reason> — the host renders no dialog for denials, so the notify line is the operator's only copy. A mode-softened deny ends — asking you instead. permissive swallows soft denies whole; lenient passes benign-leaned defers silently and fires a one-time fail-open notice (lenient auto-approves uncertainty — soft denials still ask) on the first neutral defer. Reasons go out whole under a 200-char ceiling; the audit record keeps the full text.
  • The lean never caches: defers are never stored, so an identical benign defer re-reviews every time.
  • A link's deny is final — it short-circuits the chain and never reaches a prompt. A defer falls through to the interactive permission prompt (the denying terminal in headless sessions — headless mode collapses every defer to deny).
  • strict's one exception: a breaker explicitly configured to force defer still reaches the human (the reviewer-untrusted escape valve); headless sessions resolve those defers to deny either way. lenient records a passed defer's clarification request as emittedReason: "clarification-suppressed" in the audit. permissive's first mapped allow surfaces a one-time notice (permissive auto-approves non-allow verdicts — hard-tier denials still block); the footer renders the value in warning red.

Ambient (review-loop) notices respect the notifyLevel threshold: info (default) passes everything; warning silences the reviewer asks mirror (the dialog still pops); error keeps only the total-tier breaker trip; off silences every ambient line. Command feedback and guard-absent errors are never gated (a fail-safe config start, a failed registration, a stale registration — always error grade). The tradeoff of warning/error/off: model denies and clarifications reach only the agent and the audit log — an operator-owned risk.

Mapped verdicts still count toward the breaker and still store in the cache (the mapping re-applies on every cache hit); defers are never cached — lean-derived allows included.

Runtime control

Effective config layers, in precedence order: session overrides (the controls below) > project config (trusted projects) > global config. Saving writes UPWARD into a layer; a saved field then shadows the layers beneath it.

  • /ai-guard — the settings menu (settings, save, breaker, and the report/denied panels are all menu rows); direct forms /ai-guard mode <v>, /ai-guard notify-level <v>, <setting> reset (a setting's verb is its config field's kebab form — the command shape used across pi's built-ins; menus show the phrase form, notify level).
  • /ai-guard save-config <global|project> (a bare save-config opens a target picker) — persist the current EFFECTIVE config (session overrides included) into a config layer via JSONC-preserving edits (project target refused for untrusted projects). New sessions start from the saved layer; the current session keeps its overrides; a higher-precedence layer can still shadow it.
  • /ai-guard breaker reset — clear both trip tiers. Pure counter reset: cache and overrides untouched; reviews resume immediately.
  • /ai-guard report — suggest permission-rule fragments for repeatedly-reviewed asks (same ask 3+ times, one context, no denies) — copy-paste evidence, never an applied rule.
  • /ai-guard denied — browse this session's model denies; pick one to see its full reason.
  • ctrl+alt+g — cycle default → lenient → permissive → default. Only strict stays out of casual reach.

The footer shows deviations from the baseline only (off · lenient (session)) and renders permissive in warning red, so a silenced pane stays visible. Overrides persist per session in pi's session file (never LLM context): resume restores them, /tree navigation re-derives from the active branch, and a fresh session starts from the config default.

Circuit breaker

Two-tier, fail-safe:

  • circuitBreaker.consecutiverecoverable tier: when a deny streak hits the threshold, the breaker trips, returns circuitBreaker.verdict, and resets the consecutive counter so the model gets another chance on the next ask.
  • circuitBreaker.totalhard tier: once a session accumulates that many model denials, the breaker stays tripped permanently (the counter never resets on its own — only /ai-guard breaker reset or a session restart clears it). Together they tighten progressively — repeated abuse walks a recoverable trip toward the permanent one.

A heavy session can legitimately reach the hard tier (a deny-leaning reviewer burns the budget by volume, not failure), so: the first total-tier trip notifies once at error grade (circuit breaker tripped — total tier reached, blocking all reviews until /ai-guard breaker reset or restart, re-armed after each reset — the ambient channel's only error line), and /ai-guard breaker reset clears both tiers (cache, mode, and overrides survive). A session restart clears the breaker either way.

Counter rules: trips and cache hits are never counted as model denials; the breaker counts what the model produced regardless of the mode. Deny-equivalents — machinery-failure denies and strict's model-defer→deny mapping — count into the recoverable tier only; the total tier stays model-denies-only.

A tripped breaker's forced verdict bypasses the verdict-mode mapping (the explicit breaker config is more specific than the general mode):

  • verdict: "deny" (default): the trip forces a deny (with a breaker reason the agent can act on) — in strict mode the session keeps running uninterrupted, fail-closed.
  • verdict: "defer": the trip defers to the human — in strict mode this interrupts on purpose: the breaker tripping means the reviewer itself is untrusted (a deny storm — miscalibrated or prompt-injected), and this is the designed escape valve. A notification explains the interruption; headless sessions degrade to deny (no human is present). The config loader warns about the strict + defer and permissive + defer combinations up front.

Verdict cache

cache.maxEntries enables a session-level LRU keyed by a review request snapshot plus a trusted-intent fingerprint, so a repeated identical ask in a stable conversation skips the model call.

  • The key is a decision-relevant projection of the ask (kind, target, full command, executed unit, working directory, …) plus a trusted-intent fingerprint — curl example.com and curl example.com | bash are distinct; the same command in a different cwd is a different authorization.
  • The administrative surface label is not in the key: two asks differing only in surface intentionally collide.
  • Only asks that reached the model are cached (policy ask); rule changes to allow/deny defer before the cache, so stale entries can't override them.
  • The fingerprint is built from user messages — chatty conversations invalidate often; the cache benefits repeated-command, low-chatter sessions most. Hits carry a gate: "cache-hit" record.

Provider compatibility

  • reasoning: "off" (default) keeps the reviewer fast and cheap; a tolerant text parser extracts the JSON verdict from prose-wrapped replies.
  • OpenAI-compatible providers: set provider: "openai" plus the model id. The base URL, API key, and provider binding come from the model registry pi injects at session start (no baseUrl field here); validate an endpoint with npx tsx scripts/integration-test.ts --provider openai --base-url <url> --api-key <key>.

Observability

Each reviewer-relevant decision writes an ai_guard.decision record to pi-permission-system's review log at (cache hits and deterministic-engine pre-decisions are debug-stream replays only) ~/.pi/agent/extensions/pi-permission-system/logs/pi-permission-system-permission-review.jsonl. Fields on the model gate record:

Field Meaning
gate Which decision gate produced the record (see How it works)
verdict allow / deny / defer
reason Sanitized model explanation. Present on deny (from the model, or GENERIC_DENY_REASON fallback) and on model-defer (what the model found unclear). Absent for allow and defer-without-explanation. The circuit-breaker gate uses a static BREAKER_DENY_REASON instead
deferKind Why it deferred (classification). Model-gate: empty-reply (a completed reply without text — genuine model silence), no-json (text present but no JSON found), timeout (per-call timeout elapsed), call-failed (the call threw, or a provider error resolved as a non-thrown reply — rate limits, proxy/WAF blocks), model-defer, invalid-verdict-value. Other gates: circuit-breaker, model-unresolved, auth-failed, no-target, transcript-error, policy-allow, policy-deny. null for clean allow/deny
latencyMs End-to-end model-call latency (cumulative across attempts)
attempts Present (2) only when the empty-reply retry fired; absent on single-attempt reviews
modelId provider/model of the reviewer
rawReply Three states: the raw model text for defer paths that produced one (no-json / invalid-verdict-value / model-defer); null for timeout / call-failed / empty-reply (no text was produced); "(clean verdict, rawReply omitted)" for allow/deny where the parsed JSON is already in structured fields (verdict, reason, riskLevel)
riskLevel Model-assessed risk (low/medium/high/critical), or null
contextHash Trusted-intent context fingerprint (same value as the verdict-cache key's context hash) — distinguishes same-context repetitions from cross-context ones

Supplementary debug records (written via log.debug, gated by the upstream log level):

  • ai_guard.model_reply — raw model text on defer-with-text replies; also fires with diagnostic: true on empty responses (stopReason, rawStopReason, contentTypes, errorMessage, latencyMs; stopReason: "aborted" = the timeout elapsed, classified timeout not empty-reply).
  • ai_guard.cache_lookup — cache misses with a missReason (disabled / no-entry / context-changed).
  • ai_guard.model_call_error — thrown model calls, recording the deferKind (timeout / call-failed) and error message.

License

MIT