原始内容
pi-control
A pi extension that enforces action-based policies on tool calls, scoped by filesystem location.
When the agent tries to run a bash command, read a file, write to a path, or call any other tool, pi-controls checks which policy governs that location and either allows, nudges, logs, asks for confirmation, or denies the call — before execution.
Table of Contents
- Installation
- Quick Start
- Config File Locations
- Core Concepts
- Rule Matching and Specificity
- Multi-Target Resolution
- Bash Command Parsing
- Safe Command Patterns
- Examples
- Protect production configs
- Audit-only mode
- Interactive gate on destructive commands
- Allow git, block everything else
- GitHub tool lockdown
- Per-project policy with global fallback
- Layered home and project policies
- Redirect-aware bash policies
- Mixed restrictiveness across pipeline stages
- Nudge toward better tools
- Nudge timeout — escalating ignored nudges
- Agent timeout as a safety net
- Config Reference
- Development
Installation
pi-controls is published to npm and installed via pi's built-in package manager.
Global install (all projects)
pi install npm:@mcowger/pi-control
The extension is active in every pi session.
Project-local install
pi install npm:@mcowger/pi-control -l
This records the package in .pi/settings.json in the current directory. Only active when pi runs from that project.
Pinning to a specific version
Append @<version> to pin a published version. Pinned packages are excluded from pi update.
pi install npm:@mcowger/pi-control@1.0.0
Git sources can also be pinned to a tag, branch, or commit when testing unreleased changes:
pi install git:github.com/mcowger/pi-control@main
Updating
pi update # update all packages
pi update npm:@mcowger/pi-control # update this package only
Removing
pi remove npm:@mcowger/pi-control
pi remove npm:@mcowger/pi-control -l # project-local
Behavior With No Config
If no config file is found at startup, pi-controls fails open — all tool calls proceed unrestricted. A warning notification is shown in the pi UI to make clear the extension is active but unconfigured:
[pi-controls] No config found — all tool calls are unrestricted. Create ~/.pi/agent/extensions/pi-controls.jsonc to enforce policies.
The startup entry in ~/.pi/agent/extensions/pi-controls.log will show loaded: 0 policies, 0 locations, defaultPolicy=null.
Quick Start
Create ~/.pi/agent/extensions/pi-controls.json:
{
"policies": {
"strict": {
"defaultAction": "deny",
"rules": [
{ "action": "allow", "tool": "read" },
{ "action": "allow", "tool": "bash", "pattern": "git *" },
{ "action": "ask", "tool": "bash", "pattern": "git push *" },
{ "action": "deny", "tool": "bash", "pattern": "rm *" }
]
}
},
"locations": {
"/home/user/work": "strict"
}
}
Any tool call made while the agent's target is inside /home/user/work now follows the strict policy. Reads are allowed, git commands are allowed (pushes need confirmation), rm is denied, and everything else is denied by the defaultAction.
Config File Locations
pi-controls loads config from two places and deep-merges them. Project-local wins on conflict.
| Scope | Path |
|---|---|
| Global | ~/.pi/agent/extensions/pi-controls.jsonc |
| Project-local | .pi/extensions/pi-controls.jsonc (walks up from CWD) |
Config files use JSONC (JSON with Comments), so // and /* */ comments are supported. Plain .json is also accepted as a fallback.
See examples/sample.jsonc for a fully annotated starting point.
This means you can define your base policies globally and override or extend them per project.
Global (~/.pi/agent/extensions/pi-controls.jsonc):
{
"policies": {
"default": {
"defaultAction": "allow",
"rules": [
{ "action": "ask", "tool": "bash", "pattern": "rm *" }
]
}
},
"locations": {
"/home/user": "default"
}
}
Project-local (.pi/extensions/pi-controls.jsonc at project root):
{
"policies": {
"strict": {
"defaultAction": "deny",
"rules": [
{ "action": "allow", "tool": "read" },
{ "action": "allow", "tool": "bash", "pattern": "git *" }
]
}
},
"locations": {
"/home/user/work/myproject": "strict"
}
}
At runtime both default and strict are available. /home/user/work/myproject uses strict; the rest of /home/user uses default.
Core Concepts
Policies
A policy is a named set of rules with a defaultAction that applies when no rule matches.
{
"policies": {
"my-policy": {
"defaultAction": "deny",
"rules": [ ... ]
}
}
}
Policies are referenced by name from locations. You can define as many as you need — one per project, one per risk tier, etc.
Rules
Each rule has:
| Field | Required | Description |
|---|---|---|
action |
always | "allow", "nudge", "ask", "deny", or "log" |
tool |
always | Tool name or glob (e.g. "bash", "github_*", "*") |
pattern |
bash only | Glob matched against the full command string |
message |
nudge only | Reminder injected into the tool result when action is "nudge" |
pattern is only evaluated when tool is "bash". For all other tools, the location boundary is the only scope — no pattern is needed or used.
message is required when action is "nudge" and ignored for all other actions.
{ "action": "allow", "tool": "read" }
{ "action": "nudge", "tool": "read", "message": "Prefer pluck_read for repo files." }
{ "action": "deny", "tool": "write" }
{ "action": "ask", "tool": "bash", "pattern": "git push *" }
{ "action": "log", "tool": "github_*" }
{ "action": "deny", "tool": "*" }
Actions
| Action | Behavior |
|---|---|
allow |
Silent permit. Tool call proceeds with no interruption. |
nudge |
Tool call proceeds, and the message is prepended to the tool result so the LLM sees it before any output. A warning is also shown in the pi UI. Use this to guide the agent toward better alternatives without blocking it. Pair with nudgeTimeout to auto-escalate to deny when the agent repeatedly ignores the hint. |
log |
Tool call proceeds, but a notification is shown in the pi UI. Useful for auditing. |
ask |
Execution pauses and pi asks for confirmation. Approved → proceeds. Denied → blocked, and the LLM receives a reason message. |
deny |
Tool call is blocked immediately. The LLM receives a reason message. |
defaultAction follows the same behaviors and is used when no rule in the policy matches the current tool call. "nudge" is not valid as a defaultAction — it requires a message field which only makes sense on explicit rules.
Agent Timeout
The agent timeout is a sliding-window circuit breaker. When an agent accumulates too many denied tool calls in a short period — a sign it may be going rogue — the next denied call is automatically escalated from a silent deny to an interactive ask. This gives you a chance to step in and redirect the agent rather than letting it spin against a wall of blocks.
{
"agentTimeout": {
"maxDenies": 3, // trigger after this many denies…
"windowSeconds": 60 // …within this rolling window
}
}
How it works:
- Every time a tool call results in
deny, the event is recorded with a timestamp. - Before executing the deny, pi-controls checks whether the count of deny events within the last
windowSecondsseconds has reachedmaxDenies. - If yes, the action is escalated to
ask: a confirmation dialog appears so you can approve the call, redirect the agent, or block it manually. - The window is sliding — old events age out automatically. No explicit reset is needed; the circuit breaker naturally disarms once the deny rate drops.
- Escalation continues on every subsequent denied call until the window empties.
Escalation note: the ask dialog shown during escalation is the standard pi confirmation prompt. If you approve, the tool call proceeds. If you deny it, the agent receives a block message just as it would from a normal deny.
agentTimeout is optional. If absent or null, no escalation happens and all denies remain silent.
Nudge Timeout
The nudge timeout is a per-rule sliding-window circuit breaker. When the agent ignores a nudge for the same rule too many times in a short period, the next occurrence is escalated from a soft nudge to a hard deny. The deny reason includes the original nudge message so the LLM knows exactly what it kept ignoring, plus an explicit instruction to change approach. After escalation the per-rule counter resets, giving the agent a chance to recover.
{
"nudgeTimeout": {
"maxNudges": 3, // escalate after this many ignored nudges for the same rule…
"windowSeconds": 60 // …within this rolling window
}
}
How it works:
- Each nudge rule has its own sliding-window counter, keyed by tool name (for non-bash rules) or
tool:pattern(for bash rules).readandgrepnudges are tracked independently;cat *andgrep *bash nudges are tracked independently. - Every time a nudge fires for a rule, its counter is incremented.
- When the count within
windowSecondsreachesmaxNudges, the call is hard-denied instead of nudged. The deny reason contains the original nudge message and the text: "You MUST switch approach now." - The per-rule counter resets after escalation. The agent gets another
maxNudgeschances before the next deny, rather than being permanently locked out. - The window is sliding — old nudge events age out automatically.
Example escalation sequence with maxNudges: 3:
| Call # | Action |
|---|---|
1st read |
nudge — reminder injected, tool proceeds |
2nd read |
nudge — reminder injected, tool proceeds |
3rd read |
deny — hard block with strong message, counter resets |
4th read |
nudge — counter was reset, cycle starts over |
nudgeTimeout is optional. If absent or null, nudges never escalate.
Feedback Messages
When pi-controls acts on a tool call, it shows a notification in the pi UI and (for deny/ask) sends a reason message to the LLM.
Nudge (single line, tool proceeds):
pi-controls: nudge [policy] — Prefer pluck_read for repo files — outline mode + semantic context.
Log (tool proceeds, audited):
pi-controls: log [policy]: write — path: "/home/user/project/src/main.ts"
Ask (confirmation prompt shown to user):
pi-controls: ask [policy]: bash — git push origin main
Deny (bash with pattern match):
pi-controls: deny [policy]: bash (git commit -m "...") — pattern: "git commit *"
LLM receives: Access denied by policy: bash (...) — pattern: "git commit *". Avoid the blocked pattern in any retry.
Deny (non-bash tool or path restriction):
pi-controls: deny [policy]: write — blocked path: "/etc/secrets"
LLM receives: Access denied by policy: write — blocked path: "/etc/secrets". The restriction is on the PATH — not on the tool. Do NOT retry with a different tool.
Inform mode (would-block preview, nothing actually blocked):
pi-controls: would-deny [policy]: git commit -m "..." — pattern: "git commit *"
Path labels in notifications:
blocked path— only ondeny, where the path is genuinely inaccessiblepath— onlogandask, where the call is still proceeding or pending approval
Locations
A location maps a filesystem path to a policy name. The most specific (longest) matching path wins.
{
"locations": {
"/home/user/work/secret-project": "strict",
"/home/user/work": "relaxed",
"/home/user": "permissive"
}
}
A tool call targeting /home/user/work/secret-project/src/main.ts matches all three locations, but /home/user/work/secret-project is longest, so strict applies.
The special key "$cwd" resolves dynamically to whatever directory pi was started from:
{
"locations": {
"$cwd": "strict", // matches the directory pi was launched in
"/tmp": "open"
}
}
Fallback: if no location matches, the global defaultPolicy is used. If that is also unset (or null), the call proceeds unrestricted (fail-open).
{
"defaultPolicy": "relaxed"
}
Rule Matching and Specificity
Rules within a policy do not have an explicit order. Instead, pi-controls scores each matching rule by specificity and picks the winner automatically.
Specificity = number of literal characters before the first wildcard.
| Pattern | Score |
|---|---|
"git commit *" |
11 |
"git *" |
4 |
"*" |
0 |
"github_create_pull_request" |
26 (no wildcard) |
"github_*" |
7 |
Example: given these two rules in the same policy:
{ "action": "allow", "tool": "bash", "pattern": "git *" },
{ "action": "ask", "tool": "bash", "pattern": "git commit *" }
Running git commit -m "fix":
- Both patterns match.
"git commit *"scores 11,"git *"scores 4.- Score 11 wins → ask.
Running git status:
- Only
"git *"matches (score 4). - Result → allow.
Tiebreaker: when two rules have the same specificity score, the least-disruptive action wins: allow > nudge > ask > deny > log. You never accidentally block something more than the rules intend.
{ "action": "allow", "tool": "bash", "pattern": "git *" },
{ "action": "deny", "tool": "bash", "pattern": "git *" }
Both score 4. Tiebreaker: allow wins.
{ "action": "nudge", "tool": "bash", "pattern": "grep *", "message": "prefer rg" },
{ "action": "deny", "tool": "bash", "pattern": "grep *" }
Both score 5. Tiebreaker: nudge wins (less disruptive than deny).
Multi-Target Resolution
When a bash command touches files in multiple locations — through redirect targets — each location's policy is evaluated independently. The most restrictive action across all of them wins.
Restrictiveness order: deny > ask > log > nudge > allow
Example config:
{
"policies": {
"strict": { "defaultAction": "deny", "rules": [] },
"relaxed": { "defaultAction": "allow", "rules": [] }
},
"locations": {
"/home/user/project": "strict",
"/tmp": "relaxed"
}
}
Command: cat /home/user/project/secrets.txt > /tmp/out.txt
- The redirect target
/tmp/out.txt→relaxed→ allow - The source file
/home/user/project/secrets.txt→strict→ deny - Most restrictive: deny
Even though /tmp is relaxed, the fact that the command touches a strict location locks the whole operation.
Bash Command Parsing
Bash, Python, JavaScript, and TypeScript are parsed with the prebuilt Tree-sitter WASM grammars distributed by @vscode/tree-sitter-wasm.
From each shell stage, pi-controls extracts:
- Command name + arguments — used for pattern matching against bash rules
- File redirect targets — paths like
> /tmp/out.txtor>> log.txtchecked against location policies - fd-to-fd redirects like
2>&1— recognized and skipped because they do not target files - Heredoc and here-string source — associated with interpreter invocations when static
- Inline interpreter source — Python
-c, Node-e/--eval/-p, and Bun-e/--eval
Each pipeline or logical stage (|, &&, ;) is evaluated independently. The most restrictive action across all stages and discovered targets wins.
Interpreter source analysis
Source supplied to Python, Node, or Bun is inspected for common filesystem operations. Literal paths are fed into the same location and path-protection checks as ordinary Bash arguments. Static env, bash -c, and sh -c wrappers are unwrapped recursively.
Examples detected by this layer include:
python3 - <<'PY'
from pathlib import Path
Path("/outside/project/out.txt").write_text("data")
PY
node -e 'require("fs").writeFileSync("/outside/project/out.txt", "data")'
bun -e 'const p: string = "/outside/project/out.txt"; Bun.write(p, "data")'
Runtime-provided pure helpers are recognized without treating them as third-party code: this includes common Python standard-library utilities such as re, io.StringIO, json, and string transformations, plus selected Node and Bun runtime helpers. Broader runtime modules are not blanket exemptions: known filesystem and process operations (for example os.replace, io.open, Node fs.open, child_process, and Bun.spawn) are still detected and sent through policy evaluation. Static .json loads are reported as reads rather than as unanalyzed-module warnings.
When a Bash call resolves entirely to allow rules that already set "allowUnanalyzed": true, the unknown-action fallback is skipped: the trust decision has already been made, so no confirmation prompt appears. Other layers (path protection, location policy) still apply.
Interpreter fallbacks now offer an extra Trust this pattern choice. It writes a global allow rule with allowUnanalyzed: true for the matched command pattern, so future identical calls proceed without a prompt. Persistent policy, project, and trust selections are scoped to the resolved policy; multi-policy calls show a follow-up selector.
Analysis is deliberately conservative. Dynamic paths, unknown calls, unknown imports, subprocess execution, eval, parser errors, script files, unavailable standard-input source, and exceeded resource limits produce the configured unknownAction. The default is ask; if no approval is available, the call is blocked. Set it to deny for unattended environments.
When you explicitly trust a narrow class of unanalyzed interpreter invocations, add "allowUnanalyzed": true to its matching allow Bash rule. This bypasses only the interpreter-analysis fallback for that rule; normal rule matching and cross-cutting path protection still apply. For example, to trust package scripts while keeping direct Bun script files subject to analysis:
{
"action": "allow",
"tool": "bash",
"pattern": "bun run*",
"allowUnanalyzed": true
}
Simple source whose calls and effects are fully understood remains silent under an allowing policy, for example python3 -c 'print(1)' or node -e 'console.log("ok")'.
This is static policy analysis, not an execution sandbox. Code can be arbitrarily dynamic, so unsupported or ambiguous constructs are never treated as proof of safety.
If Tree-sitter fails to load, pi-controls falls back to an incomplete tokenizer. Ordinary commands still use CWD policy evaluation, while interpreter-shaped input is treated conservatively rather than failing open.
Safe Command Patterns
pi-controls ships a built-in preset, "$safe-bash", that expands to ~90 allow rules for non-mutating bash commands. Use it anywhere in a rules array instead of listing the patterns by hand.
The preset covers:
| Category | Examples |
|---|---|
| File reading | cat *, head *, tail *, xxd * |
| File metadata | ls *, stat *, du *, df *, find * |
| Search | grep *, rg *, ag * |
| Text processing | wc *, sort *, diff *, jq *, yq * |
| Git (read-only) | git status, git log *, git diff *, git blame * |
| System info | echo *, env, which *, ps *, uname * |
| Package info | npm list *, pip show *, bun pm ls * |
The list is intentionally conservative. Commands that can mutate files under certain flags (e.g. sed -i, awk with output redirection) are excluded.
Usage
Place "$safe-bash" as an entry in rules. It mixes freely with regular rule objects and expands in place:
{
"policies": {
"readonly": {
"defaultAction": "deny",
"rules": [
{ "action": "allow", "tool": "read" },
{ "action": "allow", "tool": "grep" },
{ "action": "allow", "tool": "find" },
{ "action": "allow", "tool": "ls" },
{ "action": "deny", "tool": "write" },
{ "action": "deny", "tool": "edit" },
{ "action": "allow", "tool": "bash", "pattern": "$safe-bash" } // expands to ~90 rules
]
}
}
}
See examples/sample.jsonc for a complete working example, and src/utils/safe-commands.ts for the full pattern list.
Examples
Protect production configs
Block all writes inside a sensitive config directory, but allow reads.
{
"policies": {
"config-readonly": {
"defaultAction": "deny",
"rules": [
{ "action": "allow", "tool": "read" },
{ "action": "allow", "tool": "grep" },
{ "action": "allow", "tool": "find" },
{ "action": "allow", "tool": "ls" },
{ "action": "deny", "tool": "write" },
{ "action": "deny", "tool": "edit" },
{ "action": "deny", "tool": "bash", "pattern": "* > *" },
{ "action": "deny", "tool": "bash", "pattern": "* >> *" }
]
}
},
"locations": {
"/etc/myapp": "config-readonly"
}
}
Audit-only mode
Log every tool call in a directory without blocking anything. Useful when first introducing controls to an existing project.
{
"policies": {
"audit": {
"defaultAction": "log",
"rules": []
}
},
"locations": {
"/home/user/work": "audit"
}
}
Every tool call targeting /home/user/work will surface a notification in the pi UI and proceed. No rules needed — defaultAction: "log" handles everything.
Interactive gate on destructive commands
Require confirmation before any rm, chmod, or truncate command, but let everything else through silently.
{
"policies": {
"cautious": {
"defaultAction": "allow",
"rules": [
{ "action": "ask", "tool": "bash", "pattern": "rm *" },
{ "action": "ask", "tool": "bash", "pattern": "rm -rf *" },
{ "action": "ask", "tool": "bash", "pattern": "chmod *" },
{ "action": "ask", "tool": "bash", "pattern": "truncate *" },
{ "action": "ask", "tool": "bash", "pattern": "dd *" }
]
}
},
"locations": {
"/home/user": "cautious"
}
}
Note: "rm -rf *" (score 8) is more specific than "rm *" (score 3), so both rules can coexist and both produce ask. The tiebreaker doesn't matter here — they have the same action.
Allow git, block everything else
A strict allowlist policy: only git commands and file reads are permitted. Everything else is denied.
{
"policies": {
"git-only": {
"defaultAction": "deny",
"rules": [
{ "action": "allow", "tool": "read" },
{ "action": "allow", "tool": "grep" },
{ "action": "allow", "tool": "find" },
{ "action": "allow", "tool": "ls" },
{ "action": "allow", "tool": "bash", "pattern": "git status" },
{ "action": "allow", "tool": "bash", "pattern": "git log *" },
{ "action": "allow", "tool": "bash", "pattern": "git diff *" },
{ "action": "allow", "tool": "bash", "pattern": "git add *" },
{ "action": "allow", "tool": "bash", "pattern": "git commit *" },
{ "action": "ask", "tool": "bash", "pattern": "git push *" },
{ "action": "deny", "tool": "bash", "pattern": "git push --force *" }
]
}
},
"locations": {
"/home/user/work": "git-only"
}
}
Force pushes are denied outright. Regular pushes require confirmation. All other git subcommands are allowed. Any non-git bash command is caught by defaultAction: "deny".
GitHub tool lockdown
Block all GitHub MCP tools to prevent the agent from opening PRs, creating issues, or merging branches without explicit approval.
{
"policies": {
"no-github": {
"defaultAction": "allow",
"rules": [
{ "action": "deny", "tool": "github_*" }
]
},
"github-with-approval": {
"defaultAction": "allow",
"rules": [
{ "action": "ask", "tool": "github_create_pull_request" },
{ "action": "ask", "tool": "github_merge_pull_request" },
{ "action": "deny", "tool": "github_delete_*" },
{ "action": "log", "tool": "github_*" }
]
}
},
"locations": {
"/home/user/experiments": "no-github",
"/home/user/work": "github-with-approval"
}
}
In experiments, all github_* tools are denied (score 7 for "github_*").
In work, the specific tools github_create_pull_request and github_merge_pull_request score 26 and 26 respectively, beating the catch-all "github_*" (score 7). Delete operations are denied. All other GitHub tools are logged and allowed.
Per-project policy with global fallback
Set a permissive global fallback so unrecognized paths don't get blocked, while applying a strict policy to specific projects.
{
"policies": {
"strict": {
"defaultAction": "deny",
"rules": [
{ "action": "allow", "tool": "read" },
{ "action": "allow", "tool": "grep" },
{ "action": "allow", "tool": "bash", "pattern": "git *" },
{ "action": "ask", "tool": "bash", "pattern": "git push *" },
{ "action": "deny", "tool": "write" },
{ "action": "deny", "tool": "edit" }
]
},
"open": {
"defaultAction": "allow",
"rules": []
}
},
"locations": {
"/home/user/work/critical-service": "strict"
},
"defaultPolicy": "open"
}
Any path inside /home/user/work/critical-service gets the strict policy. Everything else — /tmp, /home/user/scratch, etc. — falls through to open (fully permissive).
Without defaultPolicy, any path that doesn't match a location would be unrestricted anyway (fail-open). Setting defaultPolicy: "open" makes that intent explicit.
Layered home and project policies
Apply a moderate policy to the whole home directory, and a stricter one to a specific project. The most specific location always wins.
{
"policies": {
"moderate": {
"defaultAction": "allow",
"rules": [
{ "action": "ask", "tool": "bash", "pattern": "rm *" },
{ "action": "ask", "tool": "bash", "pattern": "sudo *" },
{ "action": "log", "tool": "write" }
]
},
"strict": {
"defaultAction": "deny",
"rules": [
{ "action": "allow", "tool": "read" },
{ "action": "allow", "tool": "bash", "pattern": "git *" },
{ "action": "deny", "tool": "bash", "pattern": "rm *" }
]
}
},
"locations": {
"/home/user": "moderate",
"/home/user/work/production": "strict"
}
}
| Path | Policy | rm /tmp/foo result |
|---|---|---|
/home/user/scratch/test.ts |
moderate |
ask |
/home/user/work/production/src/main.ts |
strict |
deny |
/var/log/app.log |
(no match, no defaultPolicy) | allow (fail-open) |
Redirect-aware bash policies
Policies apply not just to the command itself, but to any files it writes via redirects. This catches commands that would smuggle data out of a restricted location.
{
"policies": {
"confidential": {
"defaultAction": "deny",
"rules": [
{ "action": "allow", "tool": "read" },
{ "action": "allow", "tool": "bash", "pattern": "cat *" }
]
},
"open": {
"defaultAction": "allow",
"rules": []
}
},
"locations": {
"/home/user/secrets": "confidential",
"/tmp": "open"
}
}
cat /home/user/secrets/key.pem— source is inconfidential→ allow (matchescat *)cat /home/user/secrets/key.pem > /tmp/key.pem— redirect target/tmp/key.pemis inopen(allow), but source is inconfidential(allow viacat *). Most restrictive: allow. The cat is permitted.cp /home/user/secrets/key.pem /tmp/key.pem—cpdoesn't match any rule inconfidential→defaultAction: deny→ deny.
Note: pi-controls extracts redirect targets (
>,>>,<, etc.) from the bash AST. It does not track the contents of files or data flowing through pipes — only where the command writes to explicitly.
Mixed restrictiveness across pipeline stages
Each stage in a piped or &&-chained command is evaluated independently. The most restrictive result across all stages applies to the entire command.
{
"policies": {
"safe": {
"defaultAction": "deny",
"rules": [
{ "action": "allow", "tool": "bash", "pattern": "grep *" },
{ "action": "allow", "tool": "bash", "pattern": "cat *" },
{ "action": "deny", "tool": "bash", "pattern": "curl *" }
]
}
},
"locations": {
"/home/user/work": "safe"
}
}
| Command | Stage results | Final |
|---|---|---|
cat file.txt | grep foo |
allow, allow | allow |
curl https://example.com | grep secret |
deny, allow | deny |
grep pattern file.txt && curl https://log-server.io |
allow, deny | deny |
The curl stage is denied, which locks the entire pipeline regardless of what the other stages do.
Nudge toward better tools
Allow a tool call but inject a reminder into the result so the LLM is guided toward a preferred alternative — without blocking it outright. This is useful for steering agents toward domain-specific or more efficient tools without hard enforcement.
{
"policies": {
"guided": {
"defaultAction": "allow",
"rules": [
{ "action": "nudge", "tool": "read", "message": "Prefer pluck_read for repo files — it provides outline mode and semantic context." },
{ "action": "nudge", "tool": "grep", "message": "Prefer pluck_grep for content search — it understands code structure." },
{ "action": "nudge", "tool": "bash", "pattern": "grep *", "message": "Prefer rg (ripgrep) over grep — faster and .gitignore-aware." }
]
}
},
"locations": {
"$cwd": "guided"
}
}
When the agent calls read, it still gets the file contents — but the tool result also contains:
[pi-controls nudge] Prefer pluck_read for repo files — it provides outline mode and semantic context.
A warning notification is also shown in the pi UI. The LLM can act on the hint immediately or on its next turn.
Nudge vs. other actions:
- Unlike
log, nudge surfaces the message inside the tool result where the LLM sees it directly, not just in the UI. - Unlike
deny, nudge never blocks — the agent always gets its result. - Unlike
ask, nudge requires no human interaction.
Restrictiveness: nudge is treated as less restrictive than log when multiple location policies are combined. If one location says nudge and another says deny for the same tool call, deny wins.
Nudge timeout — escalating ignored nudges
If an agent keeps using a discouraged tool despite repeated nudges, escalate automatically to a hard deny after a configurable threshold.
{
"policies": {
"guided": {
"defaultAction": "allow",
"rules": [
{ "action": "nudge", "tool": "read", "message": "Prefer pluck_read for repo files — outline mode + semantic context, far cheaper than a raw read." },
{ "action": "nudge", "tool": "grep", "message": "Prefer pluck_grep for content search — ripgrep behavior, kept inside the index." },
{ "action": "nudge", "tool": "bash", "pattern": "cat *", "message": "Prefer pluck_read over cat for repo files (raw:true for exact bytes)." },
{ "action": "nudge", "tool": "bash", "pattern": "grep *", "message": "Prefer pluck_grep over grep for repo text search." }
]
}
},
"locations": {
"$cwd": "guided"
},
"nudgeTimeout": {
"maxNudges": 3,
"windowSeconds": 60
}
}
After 3 ignored nudges for the same rule within 60 seconds, the next call is hard-denied with a reason like:
[pi-controls] Access denied by policy: read — blocked path: "/home/user/project/src/main.ts".
The restriction is on the PATH "/home/user/project/src/main.ts" — not on the tool.
Do NOT retry with a different tool; all access to these paths is blocked.
You were repeatedly warned: "Prefer pluck_read for repo files — outline mode + semantic context,
far cheaper than a raw read." You MUST switch approach now.
Each nudge rule escalates independently. The read counter and the bash:grep * counter are separate — an agent that ignores read nudges does not burn up the grep counter, and vice versa.
After escalation the counter resets, so the agent gets another window of maxNudges chances rather than being permanently locked.
Agent timeout as a safety net
Catch a rogue agent automatically: if it racks up three denied calls in a minute, escalate the next one to a manual confirmation instead of silently blocking it.
{
"policies": {
"cautious": {
"defaultAction": "deny",
"rules": [
{ "action": "allow", "tool": "read" },
{ "action": "allow", "tool": "bash", "pattern": "git *" },
{ "action": "ask", "tool": "bash", "pattern": "git push *" }
]
}
},
"locations": {
"$cwd": "cautious"
},
"agentTimeout": {
"maxDenies": 3,
"windowSeconds": 60
}
}
With this config, if the agent hits three denied calls within 60 seconds — for example, trying rm, curl, and pip install in quick succession — the fourth denied call becomes an ask. You see the confirmation dialog, can review what the agent is attempting, and either allow it or block it. The escalation continues on every subsequent deny until the deny rate drops below the threshold.
Pair this with a strict defaultAction: "deny" policy to maximize the benefit: the agent gets blocked early, and the circuit breaker kicks in before it burns too many turns.
Config Reference
Top-level fields
| Field | Type | Required | Description |
|---|---|---|---|
policies |
Record<string, Policy> |
No | Named policies available for use in locations. |
locations |
Record<string, string> |
No | Maps filesystem paths to policy names. |
approvalRules |
Rule[] |
No | Allow rules saved by Allow for Project or Allow Globally. Global and project-local lists are combined. These rules override ordinary location-policy rules but never pathProtection. |
defaultPolicy |
string | null |
No | Policy to apply when no location matches. null or absent = fail-open. |
agentTimeout |
AgentTimeout | null |
No | Circuit breaker: escalate deny → ask when the deny rate exceeds the threshold. null or absent = disabled. |
nudgeTimeout |
NudgeTimeout | null |
No | Circuit breaker: escalate nudge → deny when the same nudge rule is ignored too many times. null or absent = disabled. |
interpreterAnalysis |
InterpreterAnalysis | null |
No | Conservative analysis of Python, Node, Bun, and nested shell source. Defaults to enabled; null disables it. |
InterpreterAnalysis fields
| Field | Type | Default | Description |
|---|---|---|---|
enabled |
boolean |
true |
Enable interpreter source analysis. |
unknownAction |
"ask" | "deny" |
"ask" |
Action when source, effects, or target paths cannot be fully analyzed. |
maxSourceBytes |
number |
262144 |
Maximum embedded source size accepted for analysis. |
maxDepth |
number |
4 |
Maximum recursive bash -c/sh -c wrapper depth. |
maxNodes |
number |
10000 |
Maximum syntax-tree nodes visited per embedded source. |
AgentTimeout fields
| Field | Type | Required | Description |
|---|---|---|---|
maxDenies |
number |
Yes | Number of denied calls within windowSeconds that triggers escalation. |
windowSeconds |
number |
Yes | Rolling window size in seconds. Events older than this are ignored. |
NudgeTimeout fields
| Field | Type | Required | Description |
|---|---|---|---|
maxNudges |
number |
Yes | Number of nudges for the same rule within windowSeconds before escalating to deny. |
windowSeconds |
number |
Yes | Rolling window size in seconds. Events older than this are ignored. |
Policy fields
| Field | Type | Required | Description |
|---|---|---|---|
defaultAction |
"allow" | "ask" | "deny" | "log" |
Yes | Action when no rule matches. |
rules |
Rule[] |
Yes | Ordered list of rules (order does not affect matching — specificity does). |
Rule fields
| Field | Type | Required | Description |
|---|---|---|---|
action |
"allow" | "nudge" | "ask" | "deny" | "log" |
Yes | What to do when this rule matches. |
tool |
string |
Yes | Tool name or glob. Wildcards: * (any chars), ? (one char). |
pattern |
string |
bash only | Glob matched against the full command string. Only used when tool is "bash". |
message |
string |
nudge only | Reminder text prepended to the tool result (so the LLM sees it first) and shown in the pi UI. Required when action is "nudge". |
allowUnanalyzed |
boolean |
No | For an allow Bash rule, bypasses the interpreter-analysis fallback when its source cannot be analyzed. Use only for a narrowly trusted command pattern, such as "bun run*". |
policy |
string |
approval rules only | Limits an interactive approval to one named policy. Omit it only for a deliberately policy-agnostic manual approval. |
Persisting an approval
An ask prompt can offer Allow for Project and Allow Globally in addition to the one-time and session choices. Both save a generated allow rule to approvalRules:
- Allow for Project writes
.pi/extensions/pi-controls.jsonc, creating it under the current working directory when no project config exists. - Allow Globally writes
<agentDir>/extensions/pi-controls.jsonc.
A saved rule is immediately active for the current session and is loaded on later sessions. When a call resolves to more than one policy, choosing either persistent option opens a follow-up selector; choose the one policy that should receive the saved rule.
Glob syntax
Both tool and pattern support * and ? wildcards:
| Pattern | Matches |
|---|---|
"bash" |
exactly bash |
"github_*" |
github_create_pr, github_list_issues, … |
"git *" |
git status, git commit -m "x", git push origin main, … |
"git commit *" |
git commit -m "x", git commit --amend, … |
"rm *" |
rm foo, rm -rf /tmp, … |
"*" |
everything |
In pattern, * matches any character including spaces, slashes, and flags — it matches the entire remainder of the command string, not just a single word.
Development
bun install # install dependencies
bun test # run all tests
bun run check # lint with Biome
bun run format # format with Biome
Tests live in tests/ and use bun:test. Each utility module has its own test file.
src/
index.ts # Extension entry point; registers tool_call and tool_result handlers
config.ts # Config schema and ConfigLoader setup
hooks/
tool-call.ts # tool_call handler; exports pendingNudges map for nudge injection
utils/
path.ts # Path normalization and ~ expansion
location.ts # Path → policy resolution
matching.ts # Rule matching, specificity scoring, action resolution
bash-ast.ts # bash-parser wrapper with regex fallback
deny-tracker.ts # Sliding-window counter used by both agentTimeout and nudgeTimeout circuit breakers
tests/
hooks/
tool-call.test.ts
utils/
path.test.ts
location.test.ts
matching.test.ts
bash-ast.test.ts
deny-tracker.test.ts