@mcowger/pi-control0.6.1
Action-based filesystem policies for pi tool calls.
Untested on omp
This is a pi package. No one has reported whether it works on omp yet. If you try it, add your result below.
Reports 0
No reports yet.
Readme
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
- Eval Classification
- 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
To test an unreleased version from the monorepo checkout, install the package directory:
pi install ./packages/pi-control
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 commands are parsed with the prebuilt Tree-sitter Bash grammar 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 - Path-like arguments — tokens like
~,/tmp/foo, or./barchecked against location policies
Each pipeline or logical stage (|, &&, ;) is evaluated independently. The most restrictive action across all stages and discovered targets wins.
If Tree-sitter fails to load, pi-controls falls back to a simple tokenizer and ordinary CWD policy evaluation still applies.
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.
Eval Classification
Inline code evals — python -c, node -e, bun -e, deno eval, tsx -e, bash -c, and heredoc-fed interpreters — are invisible to path- and pattern-based policy. When the optional top-level decisions block is configured, each eval's source is sent to the OpenRouter Decisions API (POST {url}, plain fetch, no SDK), which answers capability/scope questions about the code. Absent the block, the feature is fully off: bash enforcement is location policy only, with zero network calls.
Scope. Only bash stages that execute inline code through a covered interpreter are classified. Ordinary shell commands, script-file and module runs (python script.py, python -m pytest), non-bash tools, and commands with an explicit session or saved approval never touch the API. Eval-shaped invocations with no recoverable source (python -c "$CODE", curl … | python3) make no API call and fall back to unavailableAction.
Verdict engine. The model answers seven questions — destructive, network, exec, obfuscated, and pipeline-wide inference_call (boolean), plus write_scope and read_scope (choice questions scoped against the cwd, e.g. a write inside the project vs. one to /etc). A deterministic rule table maps the answers to allow / ask / deny: destructive, exfil-shaped (sensitive read + network), and concealed-capability (obfuscated + network/exec) combinations deny; out-of-scope writes, sensitive reads, and uncertainty ask; routine in-scope capabilities (including network- or subprocess-use alone) allow. A weighted-score backstop catches accumulated weak signals when no rule fires — it can only ever ask, never deny. Full question text, rule IDs, and weights live in docs/plans/2026-09-22-decisions-eval-classification.md.
Combination. The eval verdict combines upgrade-only with the location verdict via the usual most-restrictive rule: the model can escalate an allow to ask/deny, but can never downgrade a location ask/deny.
Auth. The bearer token is read from the env var named by tokenEnv (default OPENROUTER_API_KEY); the token itself never appears in config. Set a credit limit on the key — a runaway agent with an unlimited key can spend the whole balance.
Cost and latency. Roughly one sub-second request per eval source (~$0.00002 in testing). Repeat evals are served from a session cache, and approved commands skip the API entirely.
Tuning and logging. Thresholds, buckets, and all backstop weights are config knobs (single weights can be overridden per project). Every classification appends a full trace to pi-controls.log: the exact state/questions sent, raw answers plus token/cost usage, applied weights and thresholds, the rule fired or score breakdown, and the final verdict — the dataset for future tuning.
Calibration methodology
The questions, criteria, and rules above were not written from best guesses. They were tuned over seven rounds against 260 human verdicts on real inline evals mined from 10 days of actual pi sessions (18,894 bash calls → 1,417 unique evals), split into train and holdback sets:
- Each round classified the labeled train set via the live API, diffed verdicts against human labels, and adjusted questions and criteria first — weights and thresholds were never touched (benign scores sit 4–28 against the 40 line).
- Label evidence overturned design twice: two proposed questions (external, then credentialed, egress) were added and later removed when labels showed routine admin checks allow — including a same-day revert of an overreaching filename rule.
- Tuning stopped at ~97% agreement against a measured ~3% human label-error rate (8 flipped labels of 260, plus 2 quarantined for redaction skew) and ±2 run-to-run model variance — the noise ceiling, where further tuning would fit noise. Residuals are documented, not tuned around.
- Holdback scored 48/50 on first measurement, matching train with no generalization gap.
The full round-by-round history, residual log, and stopping rule live in the design doc appendix.
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. |
decisions |
DecisionsConfig | null |
No | Eval classification via the Decisions API (see Eval Classification). null or absent = disabled. |
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. |
Decisions fields
| Field | Type | Required | Description |
|---|---|---|---|
url |
string |
No | Full endpoint URL. Defaults to https://openrouter.ai/api/alpha/decisions. |
tokenEnv |
string |
No | Env var name holding the bearer token. Defaults to OPENROUTER_API_KEY. |
model |
string |
No | Model requested from the Decisions router. Defaults to typesafe/jev-1.13. |
timeoutMs |
number |
No | Per-request timeout in ms. Defaults to 15000. |
maxSourceBytes |
number |
No | Sources beyond this are truncated (flagged truncated:true). Defaults to 32768. |
unavailableAction |
"allow" | "ask" | "deny" |
No | Eval shape detected but source not recoverable. Defaults to "ask". |
errorAction |
"allow" | "ask" | "deny" |
No | API/network/auth/timeout/malformed failure. Defaults to "ask". |
yesThreshold |
number |
No | Boolean probability at/above this → yes. Defaults to 0.7. |
noThreshold |
number |
No | Boolean probability at/below this → no (between → uncertain). Defaults to 0.3. |
choiceConfidence |
number |
No | Choice top-label probability needed for a confident label. Defaults to 0.6. |
riskyMassThreshold |
number |
No | Below confidence, risky-label mass at/above this → uncertain (ask). Defaults to 0.35. |
backstopThreshold |
number |
No | Weighted score at/above this → ask. Defaults to 40. |
weights |
Record<string, number> |
No | Per-signal weights (destructive, obfuscated, network, exec, writeSensitive, writeOutside, writeUnknown, readSensitive, readUnknown). Deep-merged, so single weights can be overridden. |
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". |
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 # Tree-sitter Bash parsing with tokenizer 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