mirror of
https://bitbucket.org/siakitem/my-pi.git
synced 2026-10-26 10:45:22 +00:00
refactor(permission-system): simplify policy and autoreview flow
This commit is contained in:
@@ -2,105 +2,68 @@
|
||||
|
||||
## Config File Locations
|
||||
|
||||
Pi Permission System has an ordinary JSONC policy and an independent global hard-guard JSONC policy:
|
||||
Pi Permission System reads two user-level JSONC files:
|
||||
|
||||
| Purpose | Scope | Path |
|
||||
| ------- | ----- | ---- |
|
||||
| Ordinary policy + runtime | Global | `~/.pi/agent/extensions/pi-permission-system/policy.jsonc` |
|
||||
| Ordinary policy override | Project | `<cwd>/.pi/extensions/pi-permission-system/policy.jsonc` |
|
||||
| Ordinary policy + runtime | Global only | `~/.pi/agent/extensions/pi-permission-system/policy.jsonc` |
|
||||
| Hard guards | Global only | `~/.pi/agent/extensions/pi-permission-system/hard-guards.jsonc` |
|
||||
|
||||
All paths respect `PI_CODING_AGENT_DIR`. The bundle creates missing global files once with owner-only permissions. It does not overwrite an existing file. If `policy.jsonc` is absent but the old global `config.json` exists, the bundle copies the old content into `policy.jsonc` and leaves the source file intact.
|
||||
`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.
|
||||
|
||||
Project policy overrides global ordinary policy and per-agent YAML frontmatter overrides both. Project policy and project agent definitions are loaded only when Pi reports that project as trusted.
|
||||
Hard guards are evaluated first. Their matching `deny` decisions cannot be overridden by ordinary policy, session approvals, per-agent rules, or AutoReview.
|
||||
|
||||
Hard guards are deliberately outside this merge. They are global-only and evaluated first, without session rules. A matching hard `deny` is final and cannot be loosened by project policy, per-agent policy, a session grant, or AutoReview. A hard-file `allow` can only form a narrower exception to another pattern in that same hard-guard file. `ask` is invalid in the hard-guard file.
|
||||
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.
|
||||
|
||||
> `~/.pi/agent` is the default. When `PI_CODING_AGENT_DIR` is set, Pi uses that directory instead.
|
||||
## Decision and Pattern Precedence
|
||||
|
||||
## Merge Precedence
|
||||
For ordinary policy, the decision flow is:
|
||||
|
||||
**Precedence order (later wins):**
|
||||
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`.
|
||||
|
||||
1. Global config file
|
||||
2. Project config file
|
||||
3. Global agent frontmatter
|
||||
4. Project agent frontmatter
|
||||
Within one pattern map, the most specific matching pattern wins even if `"*"` appears later. Specificity is compared in this order:
|
||||
|
||||
The `permission` object uses deep-shallow merge: string-vs-string replaces; both-object shallow-merges pattern maps; string-vs-object the override wins entirely.
|
||||
Scalar fields (`debugLog`, `permissionReviewLog`, `yoloMode`, `doublePressToConfirm`, `forwardingTimeoutMs`, `promptMaxRows`, `promptFieldMaxWidth`) use simple replacement.
|
||||
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.
|
||||
|
||||
**Invalid higher-precedence scope fails closed.**
|
||||
If a non-global scope (project config, global agent frontmatter, or project agent frontmatter) is present but fails to load or validate, it no longer contributes an empty scope that silently inherits the lower scope's rules.
|
||||
Instead the effective policy is floored so nothing resolves more permissively than `ask`: every `allow` (including one inherited from a lower scope) is clamped to `ask`, while `deny` and `ask` are unchanged.
|
||||
So a global `bash: allow` cannot remain effective behind a project scope that was meant to deny bash but contains a typo — bash prompts until the invalid config is fixed.
|
||||
A validation warning plus a distinct fail-closed notice are emitted, and a fix + reload restores the intended policy.
|
||||
An invalid **global** scope does not trigger the clamp — it is the lowest precedence, so nothing more permissive is inherited when it fails.
|
||||
This clamp is deny-preserving and, like `yoloMode`, applied at composition; when `yoloMode` is on it re-permits the floored `ask` back to `allow`, since yolo is an explicit full-permissive opt-in.
|
||||
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
|
||||
|
||||
```jsonc
|
||||
{
|
||||
"$schema": "https://raw.githubusercontent.com/gotgenes/pi-packages/main/packages/pi-permission-system/schemas/permissions.schema.json",
|
||||
|
||||
// Runtime knobs
|
||||
"debugLog": false,
|
||||
"permissionReviewLog": true,
|
||||
"yoloMode": false,
|
||||
"doublePressToConfirm": true,
|
||||
"forwardingTimeoutMs": 600000,
|
||||
"piInfrastructureReadPaths": [],
|
||||
|
||||
// Non-bash tools that carry shell semantics
|
||||
"shellTools": {
|
||||
"exec_command": { "commandArgument": "cmd", "workdirArgument": "workdir" }
|
||||
},
|
||||
|
||||
// Unmatched ordinary asks are reviewed before the terminal human prompt.
|
||||
"$schema": "./schemas/permissions.schema.json",
|
||||
"authorizerChain": ["auto-review"],
|
||||
"autoReview": {
|
||||
"enabled": true,
|
||||
"provider": "openai-codex",
|
||||
"model": "codex-auto-review",
|
||||
"reasoning": "low",
|
||||
"timeoutMs": 90000,
|
||||
"includeBaselinePolicy": true
|
||||
},
|
||||
"analysis": {
|
||||
"enabled": true,
|
||||
"threshold": 500,
|
||||
"retainAnalyzedBatches": 2,
|
||||
"timeoutMs": 90000
|
||||
},
|
||||
|
||||
// Flat permission policy
|
||||
"permission": {
|
||||
"*": "ask", // universal fallback
|
||||
"*": "allow",
|
||||
"path": {
|
||||
"*": "allow",
|
||||
"*.env": "deny",
|
||||
"*.env.*": "deny",
|
||||
"*.env.example": "allow"
|
||||
"**/.env": "deny",
|
||||
"**/.env.*": "deny",
|
||||
"~/.ssh/**": "deny",
|
||||
"*": "allow"
|
||||
},
|
||||
"read": "allow",
|
||||
"write": "deny",
|
||||
"edit": "deny",
|
||||
"bash": {
|
||||
"git *": "ask",
|
||||
"git status": "allow",
|
||||
"npm *": { "action": "deny", "reason": "Use pnpm instead" }
|
||||
},
|
||||
"mcp": { "mcp_status": "allow" },
|
||||
"skill": { "*": "ask" },
|
||||
"external_directory": "ask"
|
||||
"git status*": "allow",
|
||||
"git diff*": "allow",
|
||||
"*": "ask"
|
||||
}
|
||||
},
|
||||
"autoReview": {
|
||||
"enabled": true
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
> **Note:** Trailing commas are **not** supported.
|
||||
> If parsing fails, the extension falls back to `ask` for all categories.
|
||||
|
||||
> **Note:** Trailing commas are not supported. If parsing fails, ordinary policy fails safely to `ask`.
|
||||
## Built-in AutoReview and Count-Based Analysis
|
||||
|
||||
`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` or `deny` verdict is final for that request. `defer` continues to the next authorizer and ultimately the human permission dialog.
|
||||
@@ -127,7 +90,6 @@ A successful proposal advances the checkpoint, is retained according to `retainA
|
||||
| `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. |
|
||||
| `piInfrastructureReadPaths` | `[]` | Extra directories to auto-allow for reads, bypassing the `external_directory` gate. Supports `~`/`$HOME`/`${HOME}` expansion and wildcard patterns (`*`, `?`). |
|
||||
| `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/extensions/pi-permission-system/logs/`.
|
||||
@@ -171,23 +133,6 @@ Non-TUI contexts render the same facts under the same budget, without the colour
|
||||
|
||||
Non-TUI contexts (RPC / frontend-driven sessions) keep the single-select prompt and are unaffected by `doublePressToConfirm`.
|
||||
|
||||
### `piInfrastructureReadPaths` patterns
|
||||
|
||||
Each entry is either a plain directory prefix or a wildcard pattern.
|
||||
Plain entries match any path that starts with the given directory (after `~`/`$HOME`/`${HOME}` expansion).
|
||||
Wildcard entries use `*` (any characters, including `/`) and `?` (exactly one character).
|
||||
`*` and `**` are equivalent — both cross directory boundaries.
|
||||
|
||||
Example — allow reads from a Homebrew-managed Pi install at any version:
|
||||
|
||||
```jsonc
|
||||
{
|
||||
"piInfrastructureReadPaths": [
|
||||
"/opt/homebrew/**/@earendil-works/pi-coding-agent/**"
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
### `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.
|
||||
@@ -213,11 +158,11 @@ Each key is a tool name; its value maps the tool's input arguments (the keys of
|
||||
| `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, path, or external-directory policy already denies it. Only `"ask"` is accepted; the setting cannot weaken a hard deny.
|
||||
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 the command's relative paths resolve against, and the working directory itself is gated by `external_directory` when it falls outside the session's working directory.
|
||||
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.
|
||||
|
||||
Merge semantics: `shellTools` merges by tool name and then by descriptor field across global → project. Project values can replace argument names, but omitting a globally configured `decisionFloor: "ask"` does not remove it. Because no weaker floor value is valid, a project cannot downgrade that global review boundary.
|
||||
`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.
|
||||
@@ -248,27 +193,9 @@ Three invariants govern the chain:
|
||||
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).
|
||||
|
||||
The chain owner caps every link with a **bounded-delegation checkpoint**. The `path` surface is always excluded. On `external_directory`, my-pi permits an `allow` for the built-in read-only path tools (`read`, `find`, `grep`, `ls`); write, edit, bash, extension/unknown tools, and missing tool identity are downgraded to `defer`.
|
||||
Deny and defer are never capped.
|
||||
The gate surface remains authoritative: a `write` blocked by a `path` rule is capped even though the displayed tool name is `write`.
|
||||
This holds for an ask forwarded up from a subagent exactly as it does for a local one.
|
||||
See [migration/0635-forwarded-ask-delegation-envelope.md](migration/0635-forwarded-ask-delegation-envelope.md).
|
||||
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.
|
||||
|
||||
When a **subagent** raises the ask, the chain runs one hop up.
|
||||
The subagent forwards the request to the session serving it, and that session resolves it against its own rules and then runs *its* chain over the same evidence — so your configured links do review a subagent's asks, in the session you are watching.
|
||||
The subagent itself resolves no links (an extension cannot register one in a child session at all), and records `authorizer_chain_delegated` in the review log to say so.
|
||||
|
||||
Three review-log records make the chain observable, all keyed by the ask's `requestId`:
|
||||
|
||||
| Record | Meaning |
|
||||
| ------------------------------------ | -------------------------------------------------------------------------------------------------------- |
|
||||
| `authorizer_chain_resolved` | the links consulted on this ask, recorded before they run — a link that defers otherwise leaves no trace |
|
||||
| `authorizer_chain_delegated` | the ask came from a relaying subagent node; the named links were deliberately not run here |
|
||||
| `authorizer_chain_unregistered_link` | a configured name had no registered link — a real misconfiguration; the ask still reaches the terminal |
|
||||
|
||||
Extension authors: register a link from a `permissions:ready` handler via `getPermissionsService().registerAuthorizer(name, authorize)`; the callback receives the ask details and a narrow, session-scoped `PermissionQuery` (`checkPermission` / `getToolPermission`) so it can consult the deterministic engine at gate parity.
|
||||
Registration returns a disposer, and only one link may hold a given name.
|
||||
For a complete working example, see [`@gotgenes/pi-permission-model-judge`](https://github.com/gotgenes/pi-packages/tree/main/packages/pi-permission-model-judge): it registers a `model-judge` link on `permissions:ready` that reviews `external_directory` asks and auto-denies mistyped paths with a corrective reason.
|
||||
Extension authors register a link from `permissions:ready` via `getPermissionsService().registerAuthorizer(name, authorize)`. For integration details, see [Cross-Extension API](cross-extension-api.md).
|
||||
|
||||
---
|
||||
|
||||
@@ -311,13 +238,12 @@ A string value is a catch-all for that surface.
|
||||
}
|
||||
```
|
||||
|
||||
Unknown or absent tools are not required in the config.
|
||||
If a tool is not registered at runtime, this extension blocks it before permission checks run.
|
||||
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` using the same last-match-wins wildcard semantics as bash command patterns.
|
||||
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`.
|
||||
@@ -548,27 +474,18 @@ A path embedded in a long option (e.g. `--file=/tmp/patterns` in `grep --file=/t
|
||||
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.
|
||||
|
||||
Four orthogonal layers compose with most-restrictive-wins:
|
||||
Three orthogonal layers compose with most-restrictive-wins:
|
||||
|
||||
| Layer | Question | Applies to |
|
||||
| ----------------------- | --------------------------------------- | ---------------- |
|
||||
| `path` | Is this specific path pattern allowed? | All tools + bash |
|
||||
| `external_directory` | Is accessing outside CWD ok? | All tools + bash |
|
||||
| Per-tool patterns | Is this path ok for this specific tool? | Individual tools |
|
||||
| `bash` command patterns | Is this command ok? | Bash only |
|
||||
| Bash command rules | Is this command allowed? | Bash only |
|
||||
|
||||
**Which surface for "allow this directory"?**
|
||||
Use `path` to **deny** sensitive files everywhere (`.env`, `~/.ssh/*`); use `external_directory` to **allow** a directory outside the working tree (a cache, a sibling project).
|
||||
Because the layers compose with most-restrictive-wins, a `path` allow cannot loosen an `external_directory: ask` boundary — `ask` is more restrictive than `allow`, so the prompt still fires.
|
||||
Adding `"~/.cargo/registry": "allow"` to the `path` surface therefore does **not** stop the outside-CWD prompt; put the rule on `external_directory` instead (see below).
|
||||
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.
|
||||
|
||||
Configs without a `path` key behave identically to before — the gate does not fire.
|
||||
When no `path` key is present, the universal fallback (`permission["*"]`) applies: `"*": "allow"` keeps the gate transparent, while `"*": "deny"` would deny all file access via every surface including `path`.
|
||||
|
||||
> **Ordering matters.**
|
||||
> Rules use last-match-wins.
|
||||
> `{ "*.env": "deny", "*": "allow" }` allows `.env` because `"*"` is last and matches everything.
|
||||
> Put the catch-all first: `{ "*": "allow", "*.env": "deny" }`.
|
||||
Pattern declaration order is not a broad override mechanism. `{ "*.env": "deny", "*": "allow" }` still denies `.env` because `*.env` is more specific.
|
||||
|
||||
#### `.env` recipe
|
||||
|
||||
@@ -606,117 +523,6 @@ Conversely, a per-tool deny still blocks even when the `path` surface allows:
|
||||
|
||||
Here `read` calls pass the `path` gate but are blocked by the `read` tool gate.
|
||||
|
||||
### `external_directory` Surface
|
||||
|
||||
Controls access to paths outside the active working directory.
|
||||
Use a pattern map to allow specific directories without opening all external access:
|
||||
|
||||
```jsonc
|
||||
{
|
||||
"permission": {
|
||||
"external_directory": {
|
||||
"*": "ask",
|
||||
"~/development/*": "allow"
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
`external_directory` is evaluated before the normal tool permission check.
|
||||
For example, `read: "allow"` can permit ordinary reads while `external_directory: "ask"` still requires confirmation before reading `../outside.txt` or an absolute path outside `ctx.cwd`.
|
||||
Optional-path search tools (`find`, `grep`, `ls`) skip this check when no `path` is provided.
|
||||
|
||||
#### Allow an outside-CWD cache directory
|
||||
|
||||
When an agent keeps reading a local cache outside the working tree — `~/.cargo/registry`, `~/.npm`, `~/go/pkg/mod` — and you want to stop confirming it every time, allow that directory on the `external_directory` surface:
|
||||
|
||||
```jsonc
|
||||
{
|
||||
"permission": {
|
||||
"external_directory": {
|
||||
"*": "ask",
|
||||
"~/.cargo/registry/*": "allow"
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
The trailing `*` is required and it crosses subdirectory boundaries: `*` is a greedy match (not a single path segment), so `~/.cargo/registry/*` allows every file beneath the directory, however deep.
|
||||
Do not write `~/.cargo/registry/**` — `**` is not a distinct globstar, and a single `*` already recurses.
|
||||
A bare `~/.cargo/registry` (no `*`) matches only the directory entry itself, not the files inside it, which is the usual reason a hand-written allow rule appears to do nothing.
|
||||
The pattern is stored and displayed as written (`~/.cargo/registry/*`) in logs and approval dialogs.
|
||||
|
||||
For caches you only ever **read**, `piInfrastructureReadPaths` is a lighter alternative — it auto-allows read-only tools (`read`, `find`, `grep`, `ls`) and bypasses the gate entirely, but it does not cover `write`/`edit` or bash.
|
||||
Use `external_directory` when the allowance must apply to every tool.
|
||||
|
||||
Bash commands are also covered: the extension parses the command and applies the same gate to every token that resolves outside `ctx.cwd`.
|
||||
Quoting is understood, so `ls "$HOME/x"` and `ls $HOME/x` are treated alike.
|
||||
|
||||
What the bash projection resolves:
|
||||
|
||||
- Absolute, home-relative (`~/`), parent-traversal (`../`), and separator-bearing tokens, plus redirect targets (`> out.txt`) and values embedded in long options (`--file=/tmp/patterns`).
|
||||
- The plain shell variables `$HOME` / `${HOME}` and `$PWD` / `${PWD}`, so `$HOME/x` is gated exactly as `~/x` and the literal absolute spelling, whether or not the target exists.
|
||||
- Relative tokens, against the working directory produced by folding literal current-shell `cd` commands.
|
||||
- A bare token (`cat id_rsa`) when it names an existing filesystem entry.
|
||||
|
||||
What it deliberately does not resolve: any other variable (`$CONFIG_DIR`), a command substitution (`$(cmd)`), an expansion carrying an operator (`${HOME:-/tmp}`), and a variable reached through an assignment (`CURRENT="$HOME"; ls "$CURRENT"`).
|
||||
A non-literal `cd` (`cd "$DIR"`) makes the working directory unknown, after which relative tokens are kept literal rather than resolved against a guess.
|
||||
Commands whose payload is opaque (`bash -c`, `eval`, `sudo`, `xargs`) are floored to `ask` instead of projected.
|
||||
The governing record is [ADR 0009](https://github.com/gotgenes/pi-packages/blob/main/packages/pi-permission-system/docs/decisions/0009-bash-path-projection-completeness-contract.md), which states what the projection guarantees and which gaps are accepted residuals rather than bugs.
|
||||
|
||||
(The separate `bash` command-pattern surface does evaluate commands nested inside substitutions and subshells; see that section.) OS device paths (`/dev/null`, `/dev/stdin`, `/dev/stdout`, `/dev/stderr`) are always excluded.
|
||||
|
||||
#### Symlinked paths
|
||||
|
||||
A `path`, `external_directory`, or per-tool file-pattern rule (`read`/`write`/`edit`/`grep`/`find`/`ls`) matches the path **as the agent references it** and the OS-resolved (symlink-followed) path.
|
||||
This matters on macOS, where `/tmp` is a symlink to `/private/tmp`: a rule keyed on `/tmp/*` allows access via `/tmp` even though the access resolves to `/private/tmp`, and a rule keyed on `/private/tmp/*` works too.
|
||||
|
||||
```jsonc
|
||||
{
|
||||
"permission": {
|
||||
"external_directory": {
|
||||
"*": "ask",
|
||||
"/tmp/*": "allow"
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
The same dual-form matching protects the `path` surface and the per-tool file patterns: a `path` (or `read`/`write`/`edit`/`grep`/`find`/`ls`) deny on `~/.ssh/*` or `*.env` also catches a symlink whose resolved target matches the pattern, so a sensitive file cannot be reached through an aliasing symlink.
|
||||
For `external_directory`, the decision of whether a path is outside the working directory always uses the resolved form, so the gate still fires for every outside-CWD access; only which allow/deny/ask pattern matches considers both forms.
|
||||
|
||||
#### Pi Infrastructure Read Auto-Allow
|
||||
|
||||
Read-only tools (`read`, `find`, `grep`, `ls`) targeting Pi infrastructure directories are automatically allowed without triggering the gate, even when `external_directory` is `ask` or `deny`.
|
||||
Infrastructure directories include:
|
||||
|
||||
1. The agent config directory (`~/.pi/agent/` or `$PI_CODING_AGENT_DIR`)
|
||||
2. Git-cloned global packages (`<agentDir>/git/`)
|
||||
3. The global `node_modules` root (auto-discovered from the extension's own install path; falls back to `npm root -g` when running from a local development checkout)
|
||||
4. Pi's own install directory (auto-discovered via the coding-agent `getPackageDir()` API, so Pi's bundled docs and examples are readable regardless of install layout)
|
||||
5. Project-local Pi packages (`<cwd>/.pi/npm/` and `<cwd>/.pi/git/`)
|
||||
6. Any paths listed in `piInfrastructureReadPaths`
|
||||
|
||||
Write tools (`write`, `edit`) to infrastructure paths are **not** auto-allowed and still go through the gate.
|
||||
|
||||
On Windows, path matching for `external_directory`, `path`, and the path-bearing tools is case-insensitive and tolerant of either separator (`\` or `/`), matching the case-insensitive filesystem.
|
||||
The separator folding applies to the rule pattern **and** to the value it is matched against, so either side may be written with either separator.
|
||||
A mixed-case allow override such as `~/AppData/Roaming/npm/node_modules/@earendil-works/pi-coding-agent/*` therefore matches a lowercased, backslash-normalized path value, and a forward-slash rule such as `"/dev/null"` matches a value that is also spelled with forward slashes.
|
||||
POSIX matching remains case-sensitive and does not fold separators.
|
||||
|
||||
#### Git Bash / MSYS paths on Windows
|
||||
|
||||
On Windows, Pi executes bash commands through Git Bash, so a bash token that looks like a POSIX absolute path carries MSYS mount semantics rather than native `node:path.win32` semantics.
|
||||
The `external_directory` and `path` gates interpret bash tokens accordingly (tool-input paths for `read`/`write`/`edit` keep native Windows semantics, since those tools resolve them through Node's filesystem):
|
||||
|
||||
- The safe device paths (`/dev/null`, `/dev/stdin`, `/dev/stdout`, `/dev/stderr`) are recognized as MSYS devices rather than filesystem paths, so they never trigger the `external_directory` gate — the same exclusion that holds on POSIX.
|
||||
The cross-cutting `path` surface still governs them on both platforms: if a `path` rule matches the token, it decides.
|
||||
A device is therefore allow-listed the way any other path is, written as typed — `path: { "/dev/null": "allow" }`.
|
||||
- MSYS drive mounts (`/c/…`, `/d/…`) are translated to their Windows equivalent (`C:\…`), so a project file referenced through a mount is matched against its real Windows path and an in-CWD mount is not flagged.
|
||||
- Every other POSIX-absolute token (`/tmp/foo`, `/usr/bin`) has an install-dependent target this extension cannot resolve deterministically (Git Bash mounts `/tmp` to `%TEMP%`, MSYS2 to its own root), so it is treated as an external path matched and displayed exactly as typed, never rewritten to `C:\tmp\foo`.
|
||||
|
||||
To allow-list such a path, write the rule using the path as typed — for example `external_directory: { "/tmp/*": "allow" }` — and the Windows separator folding above makes the forward-slash rule match the Git Bash token.
|
||||
|
||||
### Home Directory Expansion in Patterns
|
||||
|
||||
Pattern keys in any permission surface can start with `~/`, `$HOME/`, or `${HOME}/` (or be exactly `~`, `$HOME`, or `${HOME}`).
|
||||
@@ -726,15 +532,15 @@ A prefix is recognized only when it stands alone or precedes a separator, so a l
|
||||
```jsonc
|
||||
{
|
||||
"permission": {
|
||||
"external_directory": {
|
||||
"*": "ask",
|
||||
"~/development/*": "allow"
|
||||
"path": {
|
||||
"~/development/*": "allow",
|
||||
"*": "ask"
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
The pattern is stored and displayed as written (e.g. `~/development/*`) in logs and approval dialogs.
|
||||
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.
|
||||
@@ -918,7 +724,7 @@ It allows a curated set of commands whose only effect is to read or report — n
|
||||
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`/`external_directory` gate.
|
||||
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`.**
|
||||
|
||||
Reference in New Issue
Block a user