@johansja/pi-permission-gate
LLM-powered safety gate for pi: classifies bash and MCP tool calls by risk before execution, with CWD-aware judgments.
Package details
Install @johansja/pi-permission-gate from npm and Pi will load the resources declared by the package manifest.
$ pi install npm:@johansja/pi-permission-gate- Package
@johansja/pi-permission-gate- Version
0.9.2- Published
- Sep 7, 2026
- Downloads
- 1,645/mo · 160/wk
- Author
- johansja
- License
- MIT
- Types
- extension
- Size
- 52.5 KB
- Dependencies
- 0 dependencies · 2 peers
Pi manifest JSON
{
"extensions": [
"./pi-permission-gate.ts"
]
}Security note
Pi packages can execute code and influence agent behavior. Review the source before installing third-party packages.
README
pi-permission-gate
LLM-powered safety gate for pi. Instead of maintaining regex patterns, a fast model judges each bash command and MCP tool call by risk level before execution — with CWD-aware context so project-local operations are treated as less risky than system-wide equivalents.
Install
pi install npm:@johansja/pi-permission-gate
Or from git (pinned-ref friendly):
pi install git:github.com/johansja/pi-permission-gate
Try without installing:
pi -e npm:@johansja/pi-permission-gate
Update:
pi update --extensions
How it works
Each tool_call for bash or mcp is classified by a fast/cheap model via ctx.modelRegistry.complete(). The model returns {risk, reason}. Risk is compared to your blockLevel threshold:
- safe — auto-allowed (read-only:
ls,cat,git status,git log, …) - low — reversible/CWD-scoped (
rm -rf ./build,npm install,git commit,git checkout, …) - medium — significant/external (
git push,kubectl apply,helm install,npm publish, …) or credential disclosure — exposing live secrets into the transcript (cat ~/.ssh/id_rsa,cat .env, literal tokens in the command text) or shipping file contents off-host (curl --post-file …) - high — destructive/irreversible (
sudo,rm -rf /etc,DROP TABLE,git push --force,shutdown, …)
At or above blockLevel → confirm via TUI prompt (or block in headless). Below → allow. safe is always allowed even at blockLevel=safe (carve-out prevents threshold-0 false blocks).
CWD is passed to the model so rm -rf ./build is low but rm -rf /etc is high — no post-hoc heuristics.
Credential disclosure draws a use vs leak line: in-place use at the credential's own service or established infrastructure (kubectl, aws CLI) and config-metadata reads stay low — sending credential values to unrelated hosts, or leaking them into the transcript, gets a checkpoint.
Identical commands in the same pi process reuse the cached verdict (keyed on CWD + command). The cache stores the classifier's opinion, never a permission — threshold logic re-applies per call. Parse-failure and empty-response verdicts are never cached. Denying a confirm keeps the entry: the denied command re-prompts deterministically, instead of paying a fresh classify that could sample below threshold and slip through silently.
How this differs from Claude Code's auto mode
Same mechanism (a model classifier reviews actions before execution), different product:
- Human-in-the-loop by default. Auto mode replaces the human — its classifier allows or blocks autonomously, and you re-enter only after repeated blocks. This gate triages: safe/low run silently, medium/high confirm with you.
- It adds a checkpoint where none exists. pi executes everything natively — no permission popups, no sandbox. Claude Code ships modes, allow/deny rules, hooks, and a sandbox; auto mode is one layer among many there. Here, this gate is the layer.
- Any classifier model.
permissionGate.modelaccepts any provider — including OAuth-only ones (Claude Pro/Max, ChatGPT Plus, Copilot) and env-scoped configs; the runtime resolves auth and endpoints. Auto mode's classifier is Anthropic-controlled.
What it deliberately does not do: it judges the command, not the agent's intent — it does not read the conversation, so it cannot catch prompt-injection-driven actions that are neither destructive, secret-exposing, nor MCP writes. That residual risk is accepted; pi's own security model treats prompt injection as inherent local-agent risk. Also inherent to the design: classifying sends the full command text — including any literal secrets — to the classifier provider, and the decision log stores commands locally.
Configuration (precedence: settings.json > default)
~/.pi/agent/settings.json:
{
"permissionGate": {
"model": "anthropic/claude-sonnet-4-5",
"blockLevel": "low",
"maxTokens": 4096,
"temperature": 0
}
}
| Field | Default | Description |
|---|---|---|
model |
session model | Model for classification (provider/modelId or bare id) |
blockLevel |
low |
Minimum risk to block: low | medium | high |
fallback |
confirm |
If LLM fails: allow | block | confirm |
maxTokens |
4096 |
Max tokens for the classification call |
temperature |
unset | Sampling temperature (e.g. 0 or 0.1) |
reasoningEffort |
unset | Reasoning effort for the classifier: minimal|low|medium|high|xhigh|max, mapped through the model's thinkingLevelMap. Always-thinking models like Kimi-K3 default to max server-side, so set "low" for fast gates |
Retry and timeout (retry.provider)
The gate's retry/timeout budget comes from pi's own retry.provider block — the same config that governs chat turns — read once per tool call and forwarded into complete(). See ADR 0006 (amends 0004/0005).
{
"retry": {
"provider": {
"maxRetries": 5
}
}
}
retry.provider field |
Default | Description |
|---|---|---|
maxRetries |
0 |
Retries on transient HTTP 429/5xx and per-attempt timeout. Note the default: with no retry.provider block the gate makes a single attempt, then fallback applies. |
maxRetryDelayMs |
60000 |
Ceiling on server-requested Retry-After. If the server requests a longer delay, retryProviderRequest throws (→ fallback) — it does not clamp-and-retry. Exponential backoff (no Retry-After header) is hardcoded by pi-ai (min(0.5·2ⁿ, 8)s), independent of this field. 429 and 503 are treated identically. |
timeoutMs |
SDK default | Per-attempt timeout in ms. Timeout is retried alongside 429/5xx; not a whole-session envelope. See ADR 0005. |
The budget is shared with chat turns — tuning it for one tunes both. Note the gate consumes it synchronously per tool call: a large maxRetries × timeoutMs product delays every command during a provider incident before fallback fires.
Agent-level retry.* (enabled/maxRetries/baseDelayMs) does not apply to the gate: it wraps whole chat turns in pi's agent loop, which extension complete() calls never enter.
Migrating from <0.7.0: permissionGate.maxRetries, permissionGate.maxRetryDelayMs, and permissionGate.timeout were removed and are silently ignored (previously defaulted to 3, 5000, and 10000). permissionGate.thinkingLevel was also removed — it never reached the model (ctx.modelRegistry.complete() routes to the provider's stream, which drops reasoning; the clamping streamSimple path is not exposed to extensions), and the classifier always runs the model's intrinsic reasoning. Move maxRetries to retry.provider.maxRetries — set it explicitly if you want any retries, since the pi default is 0 — and maxRetryDelayMs/timeoutMs likewise if you don't want the pi/SDK defaults.
blockLevel semantics
| Level | Blocks | Allows |
|---|---|---|
low |
low, medium, high | safe (safest, most confirms) |
medium |
medium, high | safe, low |
high |
high | safe, low, medium (fewest confirms) |
fallback semantics (LLM call failed)
| Policy | UI | Headless |
|---|---|---|
allow |
allow | allow |
block |
block | block |
confirm |
confirm (unknown risk) | block (headless can't prompt; fail-closed) |
Default confirm is safety-favoring: headless classifier-failures fail-closed, not fail-open.
Logging
Decisions are appended to ~/.pi/pi-permission-gate.jsonl (timestamp, command, risk, blockLevel, decision, reason; raw LLM response attached only on parse failure, capped at 2000 chars).
Troubleshooting
Parse-failure storms (Could not parse LLM verdict on most commands): the classifier model must answer with the exact JSON verdict; the log (see Logging below) attaches the raw response on parse failures so you can see what the model actually returned.
Common cause: the gateway, not the model. pi-ai sends reasoning models' system prompts as role: "developer" on providers it doesn't recognize, and some OpenAI-compatible gateways only honor prompt contracts as role: "system" (observed on DeepSeek/GLM chat templates; Kimi-K3 complies under either role). The model then ignores the JSON instruction and answers with markdown analysis. The fix lives in your model config (~/.pi/agent/models.json), not gate-side:
{
"providers": {
"your-gateway": {
"models": [
{
"id": "your-model",
"compat": { "supportsDeveloperRole": false }
}
]
}
}
}
Also worth checking: maxTokens too small for a reasoning model's thinking budget (empty or truncated verdicts — the log shows finish=length details), and transient 429/503s (configure retry.provider.maxRetries, see above).
Development
npm install
npm test
Tests cover the risk taxonomy, the fallback × hasUI decision matrix, the threshold safe-edge carve-out, and the hardened JSON verdict parser (reasoning models wrapping JSON in prose). No build step — pi loads .ts via tsx at runtime.
License
MIT