@gotgenes/pi-permission-system 38.0.1 → 39.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +28 -0
- package/README.md +19 -17
- package/docs/configuration.md +25 -11
- package/docs/migration/0955-pi-infrastructure-read-narrowed.md +74 -0
- package/package.json +1 -1
- package/schemas/permissions.schema.json +1 -1
- package/src/access-intent/access-path.ts +46 -8
- package/src/access-intent/input-normalizer.ts +3 -2
- package/src/access-intent/path-normalization.ts +10 -5
- package/src/config/config-schema.ts +1 -1
- package/src/config/extension-paths.ts +32 -4
- package/src/handlers/gates/external-directory.ts +50 -20
- package/src/handlers/gates/path.ts +4 -2
- package/src/handlers/gates/tool-call-gate-pipeline.ts +7 -6
- package/src/path/native-tool-target.ts +137 -0
- package/src/path/path-containment.ts +1 -1
- package/src/path/path-flavor.ts +46 -0
- package/src/path/path-normalizer.ts +40 -22
- package/src/path/pi-infrastructure-read.ts +23 -3
- package/src/presentation/path-ask-payload.ts +8 -4
- package/src/session/permission-session.ts +11 -7
package/CHANGELOG.md
CHANGED
|
@@ -5,6 +5,34 @@ All notable changes to this project will be documented in this file.
|
|
|
5
5
|
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
|
|
6
6
|
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
|
7
7
|
|
|
8
|
+
## [39.0.0](https://github.com/gotgenes/pi-packages/compare/pi-permission-system-v38.0.2...pi-permission-system-v39.0.0) (2026-10-03)
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
### Bug Fixes
|
|
12
|
+
|
|
13
|
+
* **pi-permission-system:** **breaking:** a targeted external_directory deny now blocks a Pi infrastructure read ([5c9dc88](https://github.com/gotgenes/pi-packages/commit/5c9dc88f57a68fe87ade2da50ad613b9b9fdcb82)), closes [#955](https://github.com/gotgenes/pi-packages/issues/955)
|
|
14
|
+
* **pi-permission-system:** **breaking:** the Pi infrastructure read bypass no longer covers the permission logs ([0ad3434](https://github.com/gotgenes/pi-packages/commit/0ad34344fb035211adc29317201d6f00cd8c7a76)), closes [#955](https://github.com/gotgenes/pi-packages/issues/955)
|
|
15
|
+
* **pi-permission-system:** **breaking:** the Pi infrastructure read bypass covers only Pi's harness entries, not all of ~/.pi/agent ([7826794](https://github.com/gotgenes/pi-packages/commit/7826794820b0a93257597b4cd14cd36de4193e84)), closes [#955](https://github.com/gotgenes/pi-packages/issues/955)
|
|
16
|
+
|
|
17
|
+
### Documentation
|
|
18
|
+
|
|
19
|
+
* **pi-permission-system:** document the narrowed Pi infrastructure read bypass ([7cdfb8f](https://github.com/gotgenes/pi-packages/commit/7cdfb8fb9222bc8917aaa057cc0106d5c6806030)), closes [#955](https://github.com/gotgenes/pi-packages/issues/955)
|
|
20
|
+
|
|
21
|
+
## [38.0.2](https://github.com/gotgenes/pi-packages/compare/pi-permission-system-v38.0.1...pi-permission-system-v38.0.2) (2026-10-02)
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
### Bug Fixes
|
|
25
|
+
|
|
26
|
+
* **pi-permission-system:** a path rule applies to the file a built-in tool opens, however the path is spelled ([33579c5](https://github.com/gotgenes/pi-packages/commit/33579c56f085ed982189e0f75764f60abb183f1e)), closes [#997](https://github.com/gotgenes/pi-packages/issues/997)
|
|
27
|
+
* **pi-permission-system:** an outside file spelled as a file URL still asks for external-directory access ([1d7df71](https://github.com/gotgenes/pi-packages/commit/1d7df71d590c32a2b3fee5f28462058d3775d662)), closes [#997](https://github.com/gotgenes/pi-packages/issues/997)
|
|
28
|
+
* **pi-permission-system:** a permissions-service path query sees the file the tool would open ([9d4f0f3](https://github.com/gotgenes/pi-packages/commit/9d4f0f3e29ff1224a50321c95ed0ddc65b6f1c20)), closes [#997](https://github.com/gotgenes/pi-packages/issues/997)
|
|
29
|
+
* **pi-permission-system:** a path prompt shows the file a rewritten spelling resolves to ([3e47dd3](https://github.com/gotgenes/pi-packages/commit/3e47dd32c32bb9e9b13b1469a983244ad6f01250)), closes [#997](https://github.com/gotgenes/pi-packages/issues/997)
|
|
30
|
+
|
|
31
|
+
### Documentation
|
|
32
|
+
|
|
33
|
+
* **pi-permission-system:** document that built-in tool paths are gated as the file Pi opens ([81b3eb1](https://github.com/gotgenes/pi-packages/commit/81b3eb1e2150100ab49cef9c6e759f000b2720e0)), closes [#997](https://github.com/gotgenes/pi-packages/issues/997)
|
|
34
|
+
* **pi-permission-system:** scope the $HOME expansion note to bash and extension tool paths ([8a31bf2](https://github.com/gotgenes/pi-packages/commit/8a31bf26e31ad2d1dacb5286bb576d3033687699)), closes [#997](https://github.com/gotgenes/pi-packages/issues/997)
|
|
35
|
+
|
|
8
36
|
## [38.0.1](https://github.com/gotgenes/pi-packages/compare/pi-permission-system-v38.0.0...pi-permission-system-v38.0.1) (2026-10-02)
|
|
9
37
|
|
|
10
38
|
|
package/README.md
CHANGED
|
@@ -75,6 +75,7 @@ See [docs/configuration.md](docs/configuration.md#inline-permission-dialog-tui)
|
|
|
75
75
|
The `path` surface is a cross-cutting gate that applies to **all** file access — Pi tools, bash commands, MCP calls, and extension tools alike.
|
|
76
76
|
Extension and MCP tools that operate on paths (via `input.path`, MCP's `input.arguments.path`, or a registered access extractor) are gated by default, so a `path` deny cannot be overridden by a per-tool allow — making it the right place to protect sensitive files like `.env` or `~/.ssh/*` from every tool at once.
|
|
77
77
|
A `path` pattern matches both the path as the agent references it and its canonical (symlink-resolved) form, so a deny still fires when a symlink aliases a sensitive target.
|
|
78
|
+
For Pi's built-in file tools, "the path as the agent references it" is the file the tool actually opens: Pi's resolver decodes a `file://` URL, turns Unicode spaces into spaces, and for `read` falls back to macOS's curly-quote and screenshot spellings, so a deny on the file on disk fires however the path was spelled.
|
|
78
79
|
|
|
79
80
|
For per-tool path patterns (`read`, `write`, `edit`, `find`, `grep`, `ls`), patterns are matched against the file path from `input.path`.
|
|
80
81
|
This lets you express rules like "allow reads but deny `.env` files" at the individual tool level.
|
|
@@ -221,23 +222,24 @@ Approve-and-steer, edit diffs, and risk explanations → a downstream package ov
|
|
|
221
222
|
|
|
222
223
|
## Documentation
|
|
223
224
|
|
|
224
|
-
| Document | Contents
|
|
225
|
-
| ------------------------------------------------------------------------------------------------------------------------------ |
|
|
226
|
-
| [docs/configuration.md](docs/configuration.md) | Full policy reference, runtime knobs, per-agent overrides, recipes
|
|
227
|
-
| [docs/session-approvals.md](docs/session-approvals.md) | Session-scoped rules, pattern suggestions, bash arity table
|
|
228
|
-
| [docs/cross-extension-api.md](docs/cross-extension-api.md) | Cross-extension service accessor, event bus integration, prompt and decision broadcasts
|
|
229
|
-
| [docs/subagent-integration.md](docs/subagent-integration.md) | The subagent adapter convention, permission forwarding, coexistence with subagent extensions
|
|
230
|
-
| [docs/guides/permission-frontmatter-for-subagent-extensions.md](docs/guides/permission-frontmatter-for-subagent-extensions.md) | Convention guide for subagent extension authors
|
|
231
|
-
| [docs/opencode-compatibility.md](docs/opencode-compatibility.md) | OpenCode compatibility — shared concepts, divergences, porting guide
|
|
232
|
-
| [docs/troubleshooting.md](docs/troubleshooting.md) | Common issues, diagnostic logging, threat model
|
|
233
|
-
| [docs/migration/legacy-to-flat.md](docs/migration/legacy-to-flat.md) | Migration from pre-v2 config layout
|
|
234
|
-
| [docs/migration/strict-config-validation.md](docs/migration/strict-config-validation.md) | Strict config validation (breaking) — rejected configs, and the cross-scope fail-closed clamp
|
|
235
|
-
| [docs/migration/0644-project-trust-gating.md](docs/migration/0644-project-trust-gating.md) | Project-trust gating (breaking) — project config loads only after project trust
|
|
236
|
-
| [docs/migration/0745-prompt-payload-contracts.md](docs/migration/0745-prompt-payload-contracts.md) | Prompt payload contracts (breaking) — the forwarded wire, the `ui_prompt` broadcast, and the deprecated preview caps
|
|
237
|
-
| [docs/migration/0746-review-log-fields.md](docs/migration/0746-review-log-fields.md) | Review-log fields (breaking) — `message` replaced by request facts, and the `reviewLogFieldMaxWidth` bound
|
|
238
|
-
| [docs/migration/0794-keyed-service-locator.md](docs/migration/0794-keyed-service-locator.md) | Keyed service locator (breaking) — `getPermissionsService(sessionId)`, and the repeating ready event
|
|
239
|
-
| [docs/migration/0796-remove-process-root-slot.md](docs/migration/0796-remove-process-root-slot.md) | Process-root slot removed (breaking) — `getRootPermissionsService()` and its publish/unpublish pair are gone
|
|
240
|
-
| [docs/migration/0810-per-pattern-approval-surfaces.md](docs/migration/0810-per-pattern-approval-surfaces.md) | Per-pattern approval surfaces (breaking) — `ForwardedSessionApproval.grants` replaces `surface` + `patterns`
|
|
225
|
+
| Document | Contents |
|
|
226
|
+
| ------------------------------------------------------------------------------------------------------------------------------ | ------------------------------------------------------------------------------------------------------------------------------------------- |
|
|
227
|
+
| [docs/configuration.md](docs/configuration.md) | Full policy reference, runtime knobs, per-agent overrides, recipes |
|
|
228
|
+
| [docs/session-approvals.md](docs/session-approvals.md) | Session-scoped rules, pattern suggestions, bash arity table |
|
|
229
|
+
| [docs/cross-extension-api.md](docs/cross-extension-api.md) | Cross-extension service accessor, event bus integration, prompt and decision broadcasts |
|
|
230
|
+
| [docs/subagent-integration.md](docs/subagent-integration.md) | The subagent adapter convention, permission forwarding, coexistence with subagent extensions |
|
|
231
|
+
| [docs/guides/permission-frontmatter-for-subagent-extensions.md](docs/guides/permission-frontmatter-for-subagent-extensions.md) | Convention guide for subagent extension authors |
|
|
232
|
+
| [docs/opencode-compatibility.md](docs/opencode-compatibility.md) | OpenCode compatibility — shared concepts, divergences, porting guide |
|
|
233
|
+
| [docs/troubleshooting.md](docs/troubleshooting.md) | Common issues, diagnostic logging, threat model |
|
|
234
|
+
| [docs/migration/legacy-to-flat.md](docs/migration/legacy-to-flat.md) | Migration from pre-v2 config layout |
|
|
235
|
+
| [docs/migration/strict-config-validation.md](docs/migration/strict-config-validation.md) | Strict config validation (breaking) — rejected configs, and the cross-scope fail-closed clamp |
|
|
236
|
+
| [docs/migration/0644-project-trust-gating.md](docs/migration/0644-project-trust-gating.md) | Project-trust gating (breaking) — project config loads only after project trust |
|
|
237
|
+
| [docs/migration/0745-prompt-payload-contracts.md](docs/migration/0745-prompt-payload-contracts.md) | Prompt payload contracts (breaking) — the forwarded wire, the `ui_prompt` broadcast, and the deprecated preview caps |
|
|
238
|
+
| [docs/migration/0746-review-log-fields.md](docs/migration/0746-review-log-fields.md) | Review-log fields (breaking) — `message` replaced by request facts, and the `reviewLogFieldMaxWidth` bound |
|
|
239
|
+
| [docs/migration/0794-keyed-service-locator.md](docs/migration/0794-keyed-service-locator.md) | Keyed service locator (breaking) — `getPermissionsService(sessionId)`, and the repeating ready event |
|
|
240
|
+
| [docs/migration/0796-remove-process-root-slot.md](docs/migration/0796-remove-process-root-slot.md) | Process-root slot removed (breaking) — `getRootPermissionsService()` and its publish/unpublish pair are gone |
|
|
241
|
+
| [docs/migration/0810-per-pattern-approval-surfaces.md](docs/migration/0810-per-pattern-approval-surfaces.md) | Per-pattern approval surfaces (breaking) — `ForwardedSessionApproval.grants` replaces `surface` + `patterns` |
|
|
242
|
+
| [docs/migration/0955-pi-infrastructure-read-narrowed.md](docs/migration/0955-pi-infrastructure-read-narrowed.md) | Pi infrastructure reads narrowed (breaking): only Pi's harness entries under `~/.pi/agent/`, never the logs, and a targeted deny now blocks |
|
|
241
243
|
|
|
242
244
|
## Development
|
|
243
245
|
|
package/docs/configuration.md
CHANGED
|
@@ -687,6 +687,12 @@ If it denies, the command is blocked without reaching subsequent gates — no wa
|
|
|
687
687
|
|
|
688
688
|
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).
|
|
689
689
|
|
|
690
|
+
For Pi's built-in file tools (`read`, `write`, `edit`, `ls`, `find`, `grep`), the path the agent references is the file the tool opens, not the literal argument.
|
|
691
|
+
Pi resolves that argument itself before touching the disk: it decodes a `file://` URL, turns Unicode spaces into ordinary spaces, translates a Git Bash, Cygwin, or WSL drive path (`/c/…`, `/cygdrive/c/…`, `/mnt/c/…`) on Windows, and for `read` alone falls back to the macOS screenshot (`\u202FPM`), NFD, and curly-quote (`’`) spellings of a name that does not exist.
|
|
692
|
+
The gates match the file that resolution names, so a deny keyed on the file on disk fires however it was spelled, and an ask shows the file under "resolves to" when the spelling was rewritten.
|
|
693
|
+
Pi does not expand `$HOME` or strip quotes from a tool's path, so neither do these gates: `read $HOME/.ssh/config` names `<cwd>/$HOME/.ssh/config`, the file the tool would open.
|
|
694
|
+
Extension and MCP tool paths keep their literal spelling.
|
|
695
|
+
|
|
690
696
|
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.
|
|
691
697
|
The most restrictive result across all tokens determines the outcome.
|
|
692
698
|
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.
|
|
@@ -825,7 +831,7 @@ The governing record is [ADR 0009](https://github.com/gotgenes/pi-packages/blob/
|
|
|
825
831
|
|
|
826
832
|
#### Symlinked paths
|
|
827
833
|
|
|
828
|
-
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.
|
|
834
|
+
A `path`, `external_directory`, or per-tool file-pattern rule (`read`/`write`/`edit`/`grep`/`find`/`ls`) matches the path **as the agent references it** (for a built-in file tool, the file Pi opens) and the OS-resolved (symlink-followed) path.
|
|
829
835
|
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.
|
|
830
836
|
|
|
831
837
|
```jsonc
|
|
@@ -844,15 +850,22 @@ For `external_directory`, the decision of whether a path is outside the working
|
|
|
844
850
|
|
|
845
851
|
#### Pi Infrastructure Read Auto-Allow
|
|
846
852
|
|
|
847
|
-
Read-only tools (`read`, `find`, `grep`, `ls`) targeting Pi infrastructure
|
|
848
|
-
Infrastructure
|
|
853
|
+
Read-only tools (`read`, `find`, `grep`, `ls`) targeting Pi infrastructure paths are automatically allowed without triggering the gate, even when `external_directory` is `ask` or a catch-all `deny`.
|
|
854
|
+
Infrastructure paths include:
|
|
855
|
+
|
|
856
|
+
1. Pi's harness entries under the agent config directory (`~/.pi/agent/` or `$PI_CODING_AGENT_DIR`): the directories `agents/`, `extensions/`, `git/`, `npm/`, `prompts/`, `skills/`, and `themes/`, and the files `settings.json`, `SYSTEM.md`, `APPEND_SYSTEM.md`, and `AGENTS.md`
|
|
857
|
+
2. 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)
|
|
858
|
+
3. 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)
|
|
859
|
+
4. Project-local Pi packages (`<cwd>/.pi/npm/` and `<cwd>/.pi/git/`)
|
|
860
|
+
5. Any paths listed in `piInfrastructureReadPaths`
|
|
861
|
+
|
|
862
|
+
Everything else under the agent config directory, such as `auth.json`, `models.json`, `mcp-oauth/`, and `sessions/`, goes through the gate like any other outside path.
|
|
863
|
+
This package's own logs directory (`extensions/pi-permission-system/logs/`) is never auto-allowed, even when a `piInfrastructureReadPaths` entry covers it.
|
|
849
864
|
|
|
850
|
-
|
|
851
|
-
|
|
852
|
-
|
|
853
|
-
|
|
854
|
-
5. Project-local Pi packages (`<cwd>/.pi/npm/` and `<cwd>/.pi/git/`)
|
|
855
|
-
6. Any paths listed in `piInfrastructureReadPaths`
|
|
865
|
+
A deny rule on `external_directory` or `external_directory_read` whose pattern names an infrastructure path outranks the auto-allow and blocks the read.
|
|
866
|
+
A catch-all `"*"` deny and the universal `permission["*"]` fallback do not, so a deny-by-default policy keeps its skill and package reads.
|
|
867
|
+
Rules are last-match-wins, so write the catch-all before the targeted deny.
|
|
868
|
+
See [the migration guide](migration/0955-pi-infrastructure-read-narrowed.md) for the change from earlier releases.
|
|
856
869
|
|
|
857
870
|
Write tools (`write`, `edit`) to infrastructure paths are **not** auto-allowed and still go through the gate.
|
|
858
871
|
|
|
@@ -1100,8 +1113,9 @@ A prefix is recognized only when it stands alone or precedes a separator, so a l
|
|
|
1100
1113
|
|
|
1101
1114
|
The pattern is stored and displayed as written (e.g. `~/development/*`) in logs and approval dialogs.
|
|
1102
1115
|
|
|
1103
|
-
Path **values** supplied by
|
|
1104
|
-
This means `~/...`, `$HOME/...`, `${HOME}/...`, and the fully-expanded absolute form all match a single home-anchored pattern:
|
|
1116
|
+
Path **values** supplied by bash commands and extension tools are expanded the same way.
|
|
1117
|
+
This means `~/...`, `$HOME/...`, `${HOME}/...`, and the fully-expanded absolute form all match a single home-anchored pattern: `cat ~/.ssh/config`, `cat $HOME/.ssh/config`, or `cat /Users/me/.ssh/config` is caught by a `"~/.ssh/*": "deny"` rule.
|
|
1118
|
+
Pi's built-in file tools expand only `~`, as Pi itself does: a `read` of `~/.ssh/config` or `/Users/me/.ssh/config` is caught, while a `read` of `$HOME/.ssh/config` opens `<cwd>/$HOME/.ssh/config` and is matched as that file.
|
|
1105
1119
|
|
|
1106
1120
|
---
|
|
1107
1121
|
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
# Migration guide: the Pi infrastructure read bypass is narrowed
|
|
2
|
+
|
|
3
|
+
Read-only tools (`read`, `find`, `grep`, `ls`) skip the `external_directory` gate for Pi infrastructure paths.
|
|
4
|
+
Before this change, that bypass covered **everything** under `~/.pi/agent/` (or `$PI_CODING_AGENT_DIR`), including the credential store, and it ran before policy was consulted, so no `external_directory` rule could stop it.
|
|
5
|
+
|
|
6
|
+
This is a **breaking change** in three ways, each taking effect on upgrade with no config edit.
|
|
7
|
+
|
|
8
|
+
## What changes
|
|
9
|
+
|
|
10
|
+
### Only Pi's harness entries stay auto-allowed
|
|
11
|
+
|
|
12
|
+
The bypass now covers only these entries under `~/.pi/agent/`:
|
|
13
|
+
|
|
14
|
+
- the directories `agents/`, `extensions/`, `git/`, `npm/`, `prompts/`, `skills/`, and `themes/`;
|
|
15
|
+
- the files `settings.json`, `SYSTEM.md`, `APPEND_SYSTEM.md`, and `AGENTS.md`.
|
|
16
|
+
|
|
17
|
+
Everything else there now goes through the `external_directory_read` gate like any other outside path.
|
|
18
|
+
That includes `auth.json`, `models.json`, `mcp.json`, `mcp-oauth/`, `trust.json`, `sessions/`, `subagent-sessions/`, `bin/`, and crash logs.
|
|
19
|
+
|
|
20
|
+
The global `node_modules` root, Pi's own install directory, project-local `.pi/npm/` and `.pi/git/`, and `piInfrastructureReadPaths` entries are unchanged.
|
|
21
|
+
|
|
22
|
+
### This package's logs are never auto-allowed
|
|
23
|
+
|
|
24
|
+
`~/.pi/agent/extensions/pi-permission-system/logs/` holds the review and debug logs, which record tool input.
|
|
25
|
+
It sits inside `extensions/`, but the bypass now excludes it, even when a `piInfrastructureReadPaths` entry covers it.
|
|
26
|
+
|
|
27
|
+
### A deny naming an infrastructure path now blocks
|
|
28
|
+
|
|
29
|
+
Policy is resolved before the bypass.
|
|
30
|
+
A deny from an `external_directory` or `external_directory_read` rule whose pattern names the path now blocks the read.
|
|
31
|
+
A catch-all `"*"` deny, the universal `permission["*"]` fallback, and any `ask` still leave the bypass in place, so a deny-by-default policy keeps its skill and package reads.
|
|
32
|
+
|
|
33
|
+
## Before and after
|
|
34
|
+
|
|
35
|
+
Each row is a `read` of the named path under `~/.pi/agent/`.
|
|
36
|
+
|
|
37
|
+
| Policy | Path | Before | After |
|
|
38
|
+
| ------------------------------------------------------------------------- | -------------------------- | ------- | ----------- |
|
|
39
|
+
| `"external_directory_read": {"*": "deny"}` | `auth.json` | allowed | **denied** |
|
|
40
|
+
| `"external_directory_read": {"*": "deny"}` | `sessions/<id>.jsonl` | allowed | **denied** |
|
|
41
|
+
| `"external_directory_read": {"*": "ask"}` | `sessions/<id>.jsonl` | allowed | **asked** |
|
|
42
|
+
| `"external_directory_read": {"*": "deny"}` | `extensions/…/logs/…` | allowed | **denied** |
|
|
43
|
+
| `"external_directory_read": {"*": "deny"}` | `skills/<name>/SKILL.md` | allowed | allowed |
|
|
44
|
+
| `"external_directory_read": {"*": "deny"}` | `settings.json` | allowed | allowed |
|
|
45
|
+
| `"external_directory_read": {"~/.pi/agent/git/x/SKILL.md": "deny"}` | `git/x/SKILL.md` | allowed | **denied** |
|
|
46
|
+
| `"external_directory": {"*": "deny"}` | `git/x/SKILL.md` | allowed | allowed |
|
|
47
|
+
|
|
48
|
+
## Restoring a read
|
|
49
|
+
|
|
50
|
+
Grant the path on `external_directory_read`, where the grant is visible in your config:
|
|
51
|
+
|
|
52
|
+
```jsonc
|
|
53
|
+
"permission": {
|
|
54
|
+
"external_directory_read": { "~/.pi/agent/sessions/*": "allow" }
|
|
55
|
+
}
|
|
56
|
+
```
|
|
57
|
+
|
|
58
|
+
A `piInfrastructureReadPaths` entry also restores the bypass for any path except this package's logs directory.
|
|
59
|
+
For the logs, only an `external_directory_read` rule re-allows the read.
|
|
60
|
+
|
|
61
|
+
## Writing a targeted deny
|
|
62
|
+
|
|
63
|
+
Rules are last-match-wins, so put a catch-all first and the targeted deny after it:
|
|
64
|
+
|
|
65
|
+
```jsonc
|
|
66
|
+
"permission": {
|
|
67
|
+
"external_directory_read": {
|
|
68
|
+
"*": "ask",
|
|
69
|
+
"~/.pi/agent/skills/untrusted/*": "deny"
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
```
|
|
73
|
+
|
|
74
|
+
Written the other way round, the catch-all is the matching rule, and a catch-all does not override the bypass.
|
package/package.json
CHANGED
|
@@ -128,7 +128,7 @@
|
|
|
128
128
|
},
|
|
129
129
|
"piInfrastructureReadPaths": {
|
|
130
130
|
"description": "Additional directories to auto-allow for reads as Pi infrastructure, bypassing the external_directory gate. Supports ~ expansion and wildcard patterns (* and ?).",
|
|
131
|
-
"markdownDescription": "Additional directories to auto-allow for reads as Pi infrastructure, bypassing the `external_directory` gate.\n\nThe extension auto-discovers the global node_modules root (walks up from the extension's install path; falls back to `npm root -g` from a dev checkout), Pi's own install directory (via the coding-agent `getPackageDir()` API), `agentDir
|
|
131
|
+
"markdownDescription": "Additional directories to auto-allow for reads as Pi infrastructure, bypassing the `external_directory` gate.\n\nThe extension auto-discovers the global node_modules root (walks up from the extension's install path; falls back to `npm root -g` from a dev checkout), Pi's own install directory (via the coding-agent `getPackageDir()` API), Pi's harness entries under `agentDir` (`agents/`, `extensions/`, `git/`, `npm/`, `prompts/`, `skills/`, `themes/`, `settings.json`, `SYSTEM.md`, `APPEND_SYSTEM.md`, `AGENTS.md`), and project-local `.pi/npm/` and `.pi/git/`. The package's own logs directory is never auto-allowed, even when an entry here covers it. Add entries here for edge cases where auto-discovery is insufficient (e.g. custom `npmCommand` pointing to pnpm).\n\nSupports `~`/`$HOME` expansion. Entries may be plain directory prefixes or wildcard patterns using `*` (matches any characters, including `/`) and `?` (matches exactly one character). `**` and `*` are equivalent — both cross directory boundaries.\n\nOn Windows, matching is case-insensitive and tolerant of either path separator.",
|
|
132
132
|
"default": [],
|
|
133
133
|
"type": "array",
|
|
134
134
|
"items": {
|
|
@@ -1,7 +1,10 @@
|
|
|
1
|
+
import { canonicalizePath } from "#src/path/canonicalize-path";
|
|
2
|
+
import type { NativeToolTarget } from "#src/path/native-tool-target";
|
|
1
3
|
import type { PathFlavor } from "#src/path/path-flavor";
|
|
2
4
|
|
|
3
5
|
import {
|
|
4
6
|
canonicalNormalizePathForComparison,
|
|
7
|
+
cwdRelativePolicyValues,
|
|
5
8
|
getPathPolicyValues,
|
|
6
9
|
normalizePathForComparison,
|
|
7
10
|
} from "./path-normalization";
|
|
@@ -25,7 +28,8 @@ import {
|
|
|
25
28
|
* only when it names a location distinct from the lexical form, for
|
|
26
29
|
* disclosing a symlink target in a prompt or denial message.
|
|
27
30
|
*
|
|
28
|
-
* Construct via {@link forPath} (resolved, with optional cd-folded base)
|
|
31
|
+
* Construct via {@link forPath} (resolved, with optional cd-folded base),
|
|
32
|
+
* {@link forNativeTarget} (the file a built-in Pi tool opens), or
|
|
29
33
|
* {@link forLiteral} (literal-only, for an unknown base); the constructor is
|
|
30
34
|
* private.
|
|
31
35
|
*/
|
|
@@ -34,6 +38,7 @@ export class AccessPath {
|
|
|
34
38
|
private readonly lexical: string,
|
|
35
39
|
private readonly matchAliases: readonly string[],
|
|
36
40
|
private readonly canonical: string,
|
|
41
|
+
private readonly rewritten = false,
|
|
37
42
|
) {}
|
|
38
43
|
|
|
39
44
|
/**
|
|
@@ -72,16 +77,17 @@ export class AccessPath {
|
|
|
72
77
|
}
|
|
73
78
|
|
|
74
79
|
/**
|
|
75
|
-
* The
|
|
76
|
-
*
|
|
77
|
-
*
|
|
78
|
-
*
|
|
80
|
+
* The location the access reaches when it differs from what was typed — for
|
|
81
|
+
* disclosing it in a prompt or denial message: the canonical
|
|
82
|
+
* (symlink-resolved) form when it is distinct from the lexical form, else the
|
|
83
|
+
* lexical form itself when a built-in tool's resolver rewrote the typed
|
|
84
|
+
* spelling ({@link forNativeTarget}). `undefined` otherwise.
|
|
79
85
|
*/
|
|
80
86
|
resolvedAlias(): string | undefined {
|
|
81
|
-
if (
|
|
82
|
-
return
|
|
87
|
+
if (this.canonical && this.canonical !== this.lexical) {
|
|
88
|
+
return this.canonical;
|
|
83
89
|
}
|
|
84
|
-
return this.
|
|
90
|
+
return this.rewritten ? this.lexical : undefined;
|
|
85
91
|
}
|
|
86
92
|
|
|
87
93
|
/**
|
|
@@ -115,6 +121,38 @@ export class AccessPath {
|
|
|
115
121
|
);
|
|
116
122
|
}
|
|
117
123
|
|
|
124
|
+
/**
|
|
125
|
+
* Build an `AccessPath` for the file a built-in Pi file tool opens, from its
|
|
126
|
+
* already-resolved {@link NativeToolTarget}.
|
|
127
|
+
*
|
|
128
|
+
* The target is taken as the tool will open it: no literal cleanup (trim,
|
|
129
|
+
* quote strip, `$HOME`), since Pi's resolver applies none. `matchValues()`
|
|
130
|
+
* is the target, its cwd-relative alias, and the tool's relative spelling,
|
|
131
|
+
* plus the canonical form — the same array {@link forPath} builds for a
|
|
132
|
+
* relative or already-normalized spelling the tool does not rewrite. An
|
|
133
|
+
* absolute spelling with `..` or doubled separators keeps no as-typed alias:
|
|
134
|
+
* `/tmp/../etc/passwd` matches as `/etc/passwd`, the file opened, so a rule
|
|
135
|
+
* on `/tmp/*` no longer covers it.
|
|
136
|
+
*/
|
|
137
|
+
static forNativeTarget(
|
|
138
|
+
native: NativeToolTarget,
|
|
139
|
+
options: { cwd: string; flavor: PathFlavor },
|
|
140
|
+
): AccessPath {
|
|
141
|
+
const { cwd, flavor } = options;
|
|
142
|
+
const lexical = flavor.comparable(native.target, cwd);
|
|
143
|
+
const aliases = [
|
|
144
|
+
lexical,
|
|
145
|
+
...cwdRelativePolicyValues(lexical, cwd, flavor),
|
|
146
|
+
...(native.relativeSpelling ? [native.relativeSpelling] : []),
|
|
147
|
+
];
|
|
148
|
+
return new AccessPath(
|
|
149
|
+
lexical,
|
|
150
|
+
[...new Set(aliases)],
|
|
151
|
+
flavor.fold(canonicalizePath(lexical, flavor)),
|
|
152
|
+
native.rewritten,
|
|
153
|
+
);
|
|
154
|
+
}
|
|
155
|
+
|
|
118
156
|
/**
|
|
119
157
|
* Build a literal-only `AccessPath` for a path whose effective base is
|
|
120
158
|
* unknown (a relative bash token after a non-literal `cd`).
|
|
@@ -21,7 +21,8 @@ import { classifyToolKind } from "./tool-kind";
|
|
|
21
21
|
* For a path-shaped surface (`path`, `external_directory`, or a path-bearing
|
|
22
22
|
* tool) carrying a non-empty value, it builds an `AccessPath` and emits an
|
|
23
23
|
* `access-path` intent, so the resolver matches the lexical aliases ∪ canonical
|
|
24
|
-
* (symlink-resolved) set — at parity with the gates (#486, #502)
|
|
24
|
+
* (symlink-resolved) set — at parity with the gates (#486, #502); a built-in
|
|
25
|
+
* tool surface resolves the value to the file that tool would open. An `mcp`
|
|
25
26
|
* query carrying a value evaluates that value as-is: an MCP target is already
|
|
26
27
|
* a candidate name, and rebuilding proxy input from it would derive only the
|
|
27
28
|
* status probe `mcp_status`. Every other surface, and any value-less
|
|
@@ -39,7 +40,7 @@ export function buildAccessIntentForSurface(
|
|
|
39
40
|
return {
|
|
40
41
|
kind: "access-path",
|
|
41
42
|
surface,
|
|
42
|
-
path: normalizer.
|
|
43
|
+
path: normalizer.forToolPath(surface, pathValue),
|
|
43
44
|
agentName,
|
|
44
45
|
};
|
|
45
46
|
}
|
|
@@ -83,17 +83,22 @@ function getAbsolutePathPolicyValues(
|
|
|
83
83
|
|
|
84
84
|
return [
|
|
85
85
|
absolute,
|
|
86
|
-
...
|
|
86
|
+
...(options.cwd
|
|
87
|
+
? cwdRelativePolicyValues(absolute, options.cwd, flavor)
|
|
88
|
+
: []),
|
|
87
89
|
];
|
|
88
90
|
}
|
|
89
91
|
|
|
90
|
-
|
|
92
|
+
/**
|
|
93
|
+
* The project-relative alias of an already-absolute comparison value: `[rel]`
|
|
94
|
+
* when `absolute` lies strictly inside `cwd`, otherwise `[]`. Lets a relative
|
|
95
|
+
* rule (`src/*`) match a path however it was spelled.
|
|
96
|
+
*/
|
|
97
|
+
export function cwdRelativePolicyValues(
|
|
91
98
|
absolute: string,
|
|
92
|
-
cwd: string
|
|
99
|
+
cwd: string,
|
|
93
100
|
flavor: PathFlavor,
|
|
94
101
|
): string[] {
|
|
95
|
-
if (!cwd) return [];
|
|
96
|
-
|
|
97
102
|
const normalizedCwd = normalizePathForComparison(cwd, cwd, flavor);
|
|
98
103
|
if (!normalizedCwd) return [];
|
|
99
104
|
if (absolute !== normalizedCwd && !flavor.isWithin(absolute, normalizedCwd)) {
|
|
@@ -427,7 +427,7 @@ export const unifiedConfigSchema = z
|
|
|
427
427
|
description:
|
|
428
428
|
"Additional directories to auto-allow for reads as Pi infrastructure, bypassing the external_directory gate. Supports ~ expansion and wildcard patterns (* and ?).",
|
|
429
429
|
markdownDescription:
|
|
430
|
-
"Additional directories to auto-allow for reads as Pi infrastructure, bypassing the `external_directory` gate.\n\nThe extension auto-discovers the global node_modules root (walks up from the extension's install path; falls back to `npm root -g` from a dev checkout), Pi's own install directory (via the coding-agent `getPackageDir()` API), `agentDir
|
|
430
|
+
"Additional directories to auto-allow for reads as Pi infrastructure, bypassing the `external_directory` gate.\n\nThe extension auto-discovers the global node_modules root (walks up from the extension's install path; falls back to `npm root -g` from a dev checkout), Pi's own install directory (via the coding-agent `getPackageDir()` API), Pi's harness entries under `agentDir` (`agents/`, `extensions/`, `git/`, `npm/`, `prompts/`, `skills/`, `themes/`, `settings.json`, `SYSTEM.md`, `APPEND_SYSTEM.md`, `AGENTS.md`), and project-local `.pi/npm/` and `.pi/git/`. The package's own logs directory is never auto-allowed, even when an entry here covers it. Add entries here for edge cases where auto-discovery is insufficient (e.g. custom `npmCommand` pointing to pnpm).\n\nSupports `~`/`$HOME` expansion. Entries may be plain directory prefixes or wildcard patterns using `*` (matches any characters, including `/`) and `?` (matches exactly one character). `**` and `*` are equivalent — both cross directory boundaries.\n\nOn Windows, matching is case-insensitive and tolerant of either path separator.",
|
|
431
431
|
default: [],
|
|
432
432
|
}),
|
|
433
433
|
authorizerChain: z.array(z.string().min(1)).optional().meta({
|
|
@@ -16,16 +16,44 @@ export interface ExtensionPaths {
|
|
|
16
16
|
readonly forwardingDir: string;
|
|
17
17
|
readonly globalLogsDir: string;
|
|
18
18
|
/**
|
|
19
|
-
* Static Pi infrastructure
|
|
20
|
-
*
|
|
19
|
+
* Static Pi infrastructure roots used for external-directory read
|
|
20
|
+
* auto-allow; an entry may be a directory or a single file. Computed once
|
|
21
|
+
* from `agentDir` (only Pi's harness entries, never `agentDir` itself),
|
|
21
22
|
* `discoverGlobalNodeModulesRoot()`, and (when provided) Pi's own
|
|
22
23
|
* install directory (`getPackageDir()`). Config-based extras
|
|
23
24
|
* (`piInfrastructureReadPaths`) are read from `runtime.config` at
|
|
24
25
|
* call time in the handler so they pick up config reloads.
|
|
25
26
|
*/
|
|
26
27
|
readonly piInfrastructureDirs: readonly string[];
|
|
28
|
+
/**
|
|
29
|
+
* Directories never auto-allowed as infrastructure reads, even inside one of
|
|
30
|
+
* `piInfrastructureDirs` or a configured `piInfrastructureReadPaths` entry:
|
|
31
|
+
* this package's own logs directory, whose entries hold tool input.
|
|
32
|
+
*/
|
|
33
|
+
readonly piInfrastructureExcludedDirs: readonly string[];
|
|
27
34
|
}
|
|
28
35
|
|
|
36
|
+
/**
|
|
37
|
+
* The entries under `agentDir` that Pi's harness reads: its resource roots
|
|
38
|
+
* (`skills`, `prompts`, `themes`, `extensions`, the system-prompt files, the
|
|
39
|
+
* global `AGENTS.md`), its package install roots (`npm`, `git`), settings, and
|
|
40
|
+
* subagent definitions. Everything else there (`auth.json`, `sessions/`,
|
|
41
|
+
* `mcp-oauth/`, and the like) stays behind the `external_directory` gate.
|
|
42
|
+
*/
|
|
43
|
+
const AGENT_DIR_INFRASTRUCTURE_ENTRIES = [
|
|
44
|
+
"agents",
|
|
45
|
+
"extensions",
|
|
46
|
+
"git",
|
|
47
|
+
"npm",
|
|
48
|
+
"prompts",
|
|
49
|
+
"skills",
|
|
50
|
+
"themes",
|
|
51
|
+
"settings.json",
|
|
52
|
+
"SYSTEM.md",
|
|
53
|
+
"APPEND_SYSTEM.md",
|
|
54
|
+
"AGENTS.md",
|
|
55
|
+
] as const;
|
|
56
|
+
|
|
29
57
|
/**
|
|
30
58
|
* Compute all immutable path constants from `agentDir`.
|
|
31
59
|
*
|
|
@@ -49,8 +77,7 @@ export function computeExtensionPaths(
|
|
|
49
77
|
|
|
50
78
|
const globalNodeModulesRoot = discoverGlobalNodeModulesRoot();
|
|
51
79
|
const piInfrastructureDirs: string[] = [
|
|
52
|
-
agentDir,
|
|
53
|
-
join(agentDir, "git"),
|
|
80
|
+
...AGENT_DIR_INFRASTRUCTURE_ENTRIES.map((entry) => join(agentDir, entry)),
|
|
54
81
|
...(globalNodeModulesRoot ? [globalNodeModulesRoot] : []),
|
|
55
82
|
...(piPackageDir ? [piPackageDir] : []),
|
|
56
83
|
];
|
|
@@ -62,5 +89,6 @@ export function computeExtensionPaths(
|
|
|
62
89
|
forwardingDir,
|
|
63
90
|
globalLogsDir,
|
|
64
91
|
piInfrastructureDirs,
|
|
92
|
+
piInfrastructureExcludedDirs: [globalLogsDir],
|
|
65
93
|
};
|
|
66
94
|
}
|
|
@@ -1,10 +1,12 @@
|
|
|
1
1
|
import { capabilitySurfaceForTool } from "#src/access-intent/path-surfaces";
|
|
2
2
|
import { getToolInputPath } from "#src/access-intent/tool-input-path";
|
|
3
3
|
import type { PathNormalizer } from "#src/path/path-normalizer";
|
|
4
|
+
import type { InfrastructureReadScope } from "#src/path/pi-infrastructure-read";
|
|
4
5
|
import type { ScopedPermissionResolver } from "#src/policy/permission-resolver";
|
|
5
6
|
import { buildExternalDirectoryAskPayload } from "#src/presentation/path-ask-payload";
|
|
6
7
|
import { SessionApproval } from "#src/session/session-approval";
|
|
7
8
|
import type { ToolAccessExtractorLookup } from "#src/tool-input/tool-access-extractor-registry";
|
|
9
|
+
import type { PermissionCheckResult } from "#src/types";
|
|
8
10
|
import type { GateResult } from "./descriptor";
|
|
9
11
|
import { resolveExternalDirectoryPolicy } from "./external-directory-policy";
|
|
10
12
|
import {
|
|
@@ -19,12 +21,13 @@ import type { ToolCallContext } from "./types";
|
|
|
19
21
|
*
|
|
20
22
|
* Returns `null` when the gate does not apply (no CWD, tool is not
|
|
21
23
|
* path-bearing, or path is inside the working directory).
|
|
22
|
-
* Returns a `GateBypass` for Pi infrastructure reads
|
|
24
|
+
* Returns a `GateBypass` for Pi infrastructure reads, unless a deny rule
|
|
25
|
+
* naming the path outranks it.
|
|
23
26
|
* Returns a `GateDescriptor` for external paths needing a permission check.
|
|
24
27
|
*/
|
|
25
28
|
export function describeExternalDirectoryGate(
|
|
26
29
|
tcc: ToolCallContext,
|
|
27
|
-
|
|
30
|
+
infraScope: InfrastructureReadScope,
|
|
28
31
|
resolver: ScopedPermissionResolver,
|
|
29
32
|
normalizer: PathNormalizer,
|
|
30
33
|
extractors?: ToolAccessExtractorLookup,
|
|
@@ -36,17 +39,38 @@ export function describeExternalDirectoryGate(
|
|
|
36
39
|
);
|
|
37
40
|
if (!externalDirectoryPath) return null;
|
|
38
41
|
|
|
39
|
-
|
|
42
|
+
// The boundary decision and the infrastructure-read containment check use
|
|
43
|
+
// the canonical, symlink-resolved path; pattern matching uses the typed and
|
|
44
|
+
// resolved aliases (#418). A built-in tool's path is the file Pi's resolver
|
|
45
|
+
// opens, so a `file://` spelling of an outside file is judged outside.
|
|
46
|
+
const accessPath = normalizer.forToolPath(
|
|
47
|
+
tcc.toolName,
|
|
48
|
+
externalDirectoryPath,
|
|
49
|
+
);
|
|
50
|
+
if (
|
|
51
|
+
!normalizer.isBoundaryOutsideWorkingDirectory(accessPath.boundaryValue())
|
|
52
|
+
) {
|
|
40
53
|
return null;
|
|
41
54
|
}
|
|
42
55
|
|
|
43
|
-
// The
|
|
44
|
-
//
|
|
45
|
-
|
|
46
|
-
|
|
56
|
+
// The narrowest `external_directory`-family surface this tool's identity
|
|
57
|
+
// proves; the bare family name folds both directions (ADR 0013 §10).
|
|
58
|
+
const surface = capabilitySurfaceForTool("external_directory", tcc.toolName);
|
|
59
|
+
|
|
60
|
+
// Resolved before the infrastructure bypass so a rule can be weighed against
|
|
61
|
+
// it; the runner consumes this preCheck and skips its own resolve.
|
|
62
|
+
const preCheck = resolveExternalDirectoryPolicy(
|
|
63
|
+
accessPath,
|
|
64
|
+
resolver,
|
|
65
|
+
surface,
|
|
66
|
+
tcc.agentName ?? undefined,
|
|
67
|
+
);
|
|
47
68
|
|
|
48
69
|
// ── Pi infrastructure read bypass ──────────────────────────────────────
|
|
49
|
-
if (
|
|
70
|
+
if (
|
|
71
|
+
normalizer.isInfrastructureRead(tcc.toolName, accessPath, infraScope) &&
|
|
72
|
+
!isTargetedDeny(preCheck)
|
|
73
|
+
) {
|
|
50
74
|
return {
|
|
51
75
|
action: "allow",
|
|
52
76
|
// Containment allowed this, not a rule the operator wrote.
|
|
@@ -73,18 +97,6 @@ export function describeExternalDirectoryGate(
|
|
|
73
97
|
|
|
74
98
|
// ── Build descriptor for permission check ───────────────────────────────
|
|
75
99
|
const resolvedAlias = accessPath.resolvedAlias();
|
|
76
|
-
|
|
77
|
-
// The narrowest `external_directory`-family surface this tool's identity
|
|
78
|
-
// proves; the bare family name folds both directions (ADR 0013 §10).
|
|
79
|
-
const surface = capabilitySurfaceForTool("external_directory", tcc.toolName);
|
|
80
|
-
|
|
81
|
-
// The runner consumes this preCheck and skips its own resolve.
|
|
82
|
-
const preCheck = resolveExternalDirectoryPolicy(
|
|
83
|
-
accessPath,
|
|
84
|
-
resolver,
|
|
85
|
-
surface,
|
|
86
|
-
tcc.agentName ?? undefined,
|
|
87
|
-
);
|
|
88
100
|
const pattern = normalizer.approvalPatternFor(accessPath);
|
|
89
101
|
|
|
90
102
|
const payload = buildExternalDirectoryAskPayload({
|
|
@@ -115,3 +127,21 @@ export function describeExternalDirectoryGate(
|
|
|
115
127
|
},
|
|
116
128
|
};
|
|
117
129
|
}
|
|
130
|
+
|
|
131
|
+
/**
|
|
132
|
+
* True for a deny from a rule naming this path: not the family's catch-all
|
|
133
|
+
* (`"*"`, or `"**"`, which compiles identically), and not the universal
|
|
134
|
+
* fallback (no matched pattern). Only such a deny
|
|
135
|
+
* outranks the Pi infrastructure read bypass, so a deny-by-default policy keeps
|
|
136
|
+
* its skill and package reads.
|
|
137
|
+
*/
|
|
138
|
+
function isTargetedDeny(check: PermissionCheckResult): boolean {
|
|
139
|
+
return (
|
|
140
|
+
check.state === "deny" &&
|
|
141
|
+
check.matchedPattern !== undefined &&
|
|
142
|
+
!CATCH_ALL_PATTERN.test(check.matchedPattern)
|
|
143
|
+
);
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
/** A pattern of wildcards alone, which matches every path. */
|
|
147
|
+
const CATCH_ALL_PATTERN = /^\*+$/;
|
|
@@ -41,8 +41,9 @@ export function describePathGate(
|
|
|
41
41
|
|
|
42
42
|
// Emit an access-path intent so the resolver matches the lexical aliases
|
|
43
43
|
// *and* the canonical (symlink-resolved) form, the same set
|
|
44
|
-
// `external_directory` matches (#418, #486).
|
|
45
|
-
|
|
44
|
+
// `external_directory` matches (#418, #486). A built-in tool's path is the
|
|
45
|
+
// file Pi's resolver opens, not the spelling the model typed.
|
|
46
|
+
const accessPath = normalizer.forToolPath(tcc.toolName, filePath);
|
|
46
47
|
const check = resolver.resolve({
|
|
47
48
|
kind: "access-path",
|
|
48
49
|
surface,
|
|
@@ -64,6 +65,7 @@ export function describePathGate(
|
|
|
64
65
|
const payload = buildPathAskPayload({
|
|
65
66
|
toolName: tcc.toolName,
|
|
66
67
|
pathValue: filePath,
|
|
68
|
+
resolvedPath: accessPath.resolvedAlias(),
|
|
67
69
|
agentName: tcc.agentName,
|
|
68
70
|
matchedPattern: check.matchedPattern,
|
|
69
71
|
surface,
|
|
@@ -7,6 +7,7 @@ import {
|
|
|
7
7
|
import type { ShellToolsConfig } from "#src/config/config-schema";
|
|
8
8
|
import type { SkillPromptEntry } from "#src/exposure/skill-prompt-sanitizer";
|
|
9
9
|
import type { PathNormalizer } from "#src/path/path-normalizer";
|
|
10
|
+
import type { InfrastructureReadScope } from "#src/path/pi-infrastructure-read";
|
|
10
11
|
import type { ScopedPermissionResolver } from "#src/policy/permission-resolver";
|
|
11
12
|
import type { ToolAccessExtractorLookup } from "#src/tool-input/tool-access-extractor-registry";
|
|
12
13
|
import type { ToolInputFormatterLookup } from "#src/tool-input/tool-input-formatter-registry";
|
|
@@ -39,8 +40,8 @@ import type { GateOutcome, ToolCallContext } from "./types";
|
|
|
39
40
|
export interface ToolCallGateInputs {
|
|
40
41
|
/** Active skill prompt entries for the skill-read gate. */
|
|
41
42
|
getActiveSkillEntries(): SkillPromptEntry[];
|
|
42
|
-
/**
|
|
43
|
-
|
|
43
|
+
/** Where infrastructure reads are auto-allowed (static + config-derived). */
|
|
44
|
+
getInfrastructureReadScope(): InfrastructureReadScope;
|
|
44
45
|
/** Resolved tool-preview formatter options from the current config. */
|
|
45
46
|
getToolPreviewLimits(): ToolPreviewFormatterOptions;
|
|
46
47
|
/** The session's path normalizer (platform + cwd baked in). */
|
|
@@ -60,7 +61,7 @@ export interface ToolCallGateInputs {
|
|
|
60
61
|
* `PermissionGateHandler`. `evaluate(tcc, runner)` encapsulates:
|
|
61
62
|
* - bash-command extraction and single `BashProgram.parse` (#308)
|
|
62
63
|
* - `ToolPreviewFormatter` construction from `getToolPreviewLimits()`
|
|
63
|
-
* - infrastructure-
|
|
64
|
+
* - infrastructure-read scope from `getInfrastructureReadScope()`
|
|
64
65
|
* - all six gate producers in their prescribed order
|
|
65
66
|
* - the run loop, which runs an unconditionally denying gate ahead of the
|
|
66
67
|
* rest and returns the first block outcome, or allow
|
|
@@ -98,7 +99,7 @@ export class ToolCallGatePipeline {
|
|
|
98
99
|
this.customFormatters,
|
|
99
100
|
);
|
|
100
101
|
|
|
101
|
-
const
|
|
102
|
+
const infraScope = this.inputs.getInfrastructureReadScope();
|
|
102
103
|
|
|
103
104
|
const gateProducers: Array<() => GateResult | Promise<GateResult>> = [
|
|
104
105
|
() =>
|
|
@@ -110,7 +111,7 @@ export class ToolCallGatePipeline {
|
|
|
110
111
|
() =>
|
|
111
112
|
describeExternalDirectoryGate(
|
|
112
113
|
tcc,
|
|
113
|
-
|
|
114
|
+
infraScope,
|
|
114
115
|
this.resolver,
|
|
115
116
|
normalizer,
|
|
116
117
|
this.customExtractors,
|
|
@@ -205,7 +206,7 @@ export class ToolCallGatePipeline {
|
|
|
205
206
|
|
|
206
207
|
const filePath = getPathBearingToolPath(tcc.toolName, tcc.input);
|
|
207
208
|
if (filePath !== null) {
|
|
208
|
-
const accessPath = normalizer.
|
|
209
|
+
const accessPath = normalizer.forToolPath(tcc.toolName, filePath);
|
|
209
210
|
return {
|
|
210
211
|
pathAccess: {
|
|
211
212
|
path: accessPath,
|
|
@@ -0,0 +1,137 @@
|
|
|
1
|
+
import { homedir } from "node:os";
|
|
2
|
+
|
|
3
|
+
import type { PathFlavor } from "./path-flavor";
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* The file one of Pi's built-in file tools (`read`, `write`, `edit`, `ls`,
|
|
7
|
+
* `find`, `grep`) opens for a `path` argument.
|
|
8
|
+
*
|
|
9
|
+
* Pi resolves that argument through its own resolver before touching the disk
|
|
10
|
+
* (`core/tools/path-utils.ts` `resolveToCwd`, plus `resolveReadPath` for
|
|
11
|
+
* `read`), and the resolver rewrites some spellings. A gate that matched the
|
|
12
|
+
* typed spelling would check one file while the tool opened another, so the
|
|
13
|
+
* path gates evaluate this target instead.
|
|
14
|
+
*/
|
|
15
|
+
export interface NativeToolTarget {
|
|
16
|
+
/** Absolute path the built-in tool opens. */
|
|
17
|
+
readonly target: string;
|
|
18
|
+
/**
|
|
19
|
+
* True when Pi's resolver changed the spelling in a way the typed path does
|
|
20
|
+
* not show: a Unicode space, a win32 drive mount, a `file://` URL, or a
|
|
21
|
+
* `read` fallback. `@`, `~`, and cwd resolution are not rewrites.
|
|
22
|
+
*/
|
|
23
|
+
readonly rewritten: boolean;
|
|
24
|
+
/**
|
|
25
|
+
* The normalized spelling before cwd resolution, when it is relative and no
|
|
26
|
+
* read fallback fired, so a relative rule keeps matching it.
|
|
27
|
+
*/
|
|
28
|
+
readonly relativeSpelling?: string;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/** What {@link resolveNativeToolTarget} needs besides the typed path. */
|
|
32
|
+
export interface NativeToolTargetOptions {
|
|
33
|
+
cwd: string;
|
|
34
|
+
flavor: PathFlavor;
|
|
35
|
+
/** Whether the tool also tries `read`'s variant spellings. */
|
|
36
|
+
readFallbacks: boolean;
|
|
37
|
+
/** Existence probe, following symlinks like Pi's `access(F_OK)`. */
|
|
38
|
+
exists: (absolutePath: string) => boolean;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* Mirror Pi's built-in tool path resolution, in Pi's order: Unicode spaces,
|
|
43
|
+
* one leading `@`, the win32 drive mount, `~`, a `file://` URL, cwd
|
|
44
|
+
* resolution, then (for `read`) the variant spellings Pi tries when the
|
|
45
|
+
* resolved path does not exist.
|
|
46
|
+
*/
|
|
47
|
+
export function resolveNativeToolTarget(
|
|
48
|
+
rawPath: string,
|
|
49
|
+
options: NativeToolTargetOptions,
|
|
50
|
+
): NativeToolTarget {
|
|
51
|
+
const { cwd, flavor } = options;
|
|
52
|
+
const spelling = normalizeToolPathSpelling(rawPath, flavor);
|
|
53
|
+
const resolved = flavor.impl.resolve(cwd, spelling.value);
|
|
54
|
+
const fallback = options.readFallbacks
|
|
55
|
+
? findReadVariant(resolved, options.exists)
|
|
56
|
+
: undefined;
|
|
57
|
+
if (fallback !== undefined) {
|
|
58
|
+
return { target: fallback, rewritten: true };
|
|
59
|
+
}
|
|
60
|
+
return {
|
|
61
|
+
target: resolved,
|
|
62
|
+
rewritten: spelling.rewritten,
|
|
63
|
+
...(flavor.impl.isAbsolute(spelling.value)
|
|
64
|
+
? {}
|
|
65
|
+
: { relativeSpelling: spelling.value }),
|
|
66
|
+
};
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/** Pi's `normalizeUnicodeSpaces` class. */
|
|
70
|
+
const UNICODE_SPACES = /[\u00A0\u2000-\u200A\u202F\u205F\u3000]/g;
|
|
71
|
+
|
|
72
|
+
const NARROW_NO_BREAK_SPACE = "\u202F";
|
|
73
|
+
|
|
74
|
+
interface NormalizedSpelling {
|
|
75
|
+
value: string;
|
|
76
|
+
rewritten: boolean;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/** Pi's `normalizePath` with `normalizeUnicodeSpaces` and `stripAtPrefix`. */
|
|
80
|
+
function normalizeToolPathSpelling(
|
|
81
|
+
rawPath: string,
|
|
82
|
+
flavor: PathFlavor,
|
|
83
|
+
): NormalizedSpelling {
|
|
84
|
+
const spaced = rawPath.replace(UNICODE_SPACES, " ");
|
|
85
|
+
const unprefixed = spaced.startsWith("@") ? spaced.slice(1) : spaced;
|
|
86
|
+
const driveMapped = flavor.toolShellPath(unprefixed);
|
|
87
|
+
const rewritten = spaced !== rawPath || driveMapped !== unprefixed;
|
|
88
|
+
|
|
89
|
+
const home = expandToolHome(driveMapped, flavor);
|
|
90
|
+
if (home !== undefined) return { value: home, rewritten };
|
|
91
|
+
|
|
92
|
+
if (driveMapped.startsWith("file://")) {
|
|
93
|
+
try {
|
|
94
|
+
return { value: flavor.fileUrlToPath(driveMapped), rewritten: true };
|
|
95
|
+
} catch {
|
|
96
|
+
// Pi's tool throws here and opens nothing; keep the spelling as typed.
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
return { value: driveMapped, rewritten };
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/**
|
|
103
|
+
* Pi's tilde expansion: `~` alone, `~/`, and on win32 `~\`. Unlike
|
|
104
|
+
* `expandHomePath`, it leaves `$HOME` and `${HOME}` literal.
|
|
105
|
+
*/
|
|
106
|
+
function expandToolHome(value: string, flavor: PathFlavor): string | undefined {
|
|
107
|
+
if (value === "~") return homedir();
|
|
108
|
+
const separators = flavor.hasPathSeparator("\\") ? ["~/", "~\\"] : ["~/"];
|
|
109
|
+
if (separators.some((prefix) => value.startsWith(prefix))) {
|
|
110
|
+
return flavor.impl.join(homedir(), value.slice(2));
|
|
111
|
+
}
|
|
112
|
+
return undefined;
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
/**
|
|
116
|
+
* Pi's `resolveReadPath` fallbacks: the first existing of the macOS AM/PM,
|
|
117
|
+
* NFD, curly-quote, and NFD + curly-quote variants, tried only when the
|
|
118
|
+
* resolved path itself does not exist. `undefined` when none applies.
|
|
119
|
+
*/
|
|
120
|
+
function findReadVariant(
|
|
121
|
+
resolved: string,
|
|
122
|
+
exists: (absolutePath: string) => boolean,
|
|
123
|
+
): string | undefined {
|
|
124
|
+
if (exists(resolved)) return undefined;
|
|
125
|
+
const nfd = resolved.normalize("NFD");
|
|
126
|
+
const variants = [
|
|
127
|
+
resolved.replace(/ (AM|PM)\./gi, `${NARROW_NO_BREAK_SPACE}$1.`),
|
|
128
|
+
nfd,
|
|
129
|
+
toCurlyQuotes(resolved),
|
|
130
|
+
toCurlyQuotes(nfd),
|
|
131
|
+
];
|
|
132
|
+
return variants.find((variant) => variant !== resolved && exists(variant));
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
function toCurlyQuotes(value: string): string {
|
|
136
|
+
return value.replace(/'/g, "\u2019");
|
|
137
|
+
}
|
|
@@ -5,7 +5,7 @@ import { isSafeSystemPath } from "./safe-system-paths";
|
|
|
5
5
|
* Pure geometry: is `canonicalPath` outside `canonicalCwd`?
|
|
6
6
|
*
|
|
7
7
|
* Both operands must already be canonical (symlink-resolved, win32-lowercased)
|
|
8
|
-
* — the caller prepares them (see {@link PathNormalizer.
|
|
8
|
+
* — the caller prepares them (see {@link PathNormalizer.isBoundaryOutsideWorkingDirectory}).
|
|
9
9
|
* This predicate touches no filesystem and does no derivation; the containment
|
|
10
10
|
* geometry lives on {@link PathFlavor.isWithin}.
|
|
11
11
|
*/
|
package/src/path/path-flavor.ts
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import type { PlatformPath } from "node:path";
|
|
2
2
|
import { posix as posixPath, win32 as winPath } from "node:path";
|
|
3
|
+
import { fileURLToPath } from "node:url";
|
|
3
4
|
|
|
4
5
|
import {
|
|
5
6
|
type BashTokenShape,
|
|
@@ -66,6 +67,20 @@ export interface PathFlavor {
|
|
|
66
67
|
* every token is an ordinary path, so the shape is always `{ kind: "plain" }`.
|
|
67
68
|
*/
|
|
68
69
|
bashTokenShape(token: string): BashTokenShape;
|
|
70
|
+
/**
|
|
71
|
+
* The drive translation Pi's built-in file tools apply to their `path`
|
|
72
|
+
* argument: on win32, a Git Bash / MSYS / Cygwin / WSL drive path (`/c/x`,
|
|
73
|
+
* `/mnt/c/x`, `/cygdrive/c/x`) becomes `C:\x`; on POSIX the value is
|
|
74
|
+
* unchanged. Mirrors Pi's `normalizeWindowsShellPath`, which recognizes more
|
|
75
|
+
* mount spellings than a bash token's {@link bashTokenShape}.
|
|
76
|
+
*/
|
|
77
|
+
toolShellPath(value: string): string;
|
|
78
|
+
/**
|
|
79
|
+
* Decode a `file://` URL into this platform's path, as Pi's built-in file
|
|
80
|
+
* tools do. Throws like Node's `fileURLToPath` for a URL the platform
|
|
81
|
+
* cannot express (a POSIX URL naming a host).
|
|
82
|
+
*/
|
|
83
|
+
fileUrlToPath(url: string): string;
|
|
69
84
|
}
|
|
70
85
|
|
|
71
86
|
class PlatformPathFlavor implements PathFlavor {
|
|
@@ -87,6 +102,14 @@ class PlatformPathFlavor implements PathFlavor {
|
|
|
87
102
|
return this.windows ? value.toLowerCase() : value;
|
|
88
103
|
}
|
|
89
104
|
|
|
105
|
+
toolShellPath(value: string): string {
|
|
106
|
+
return this.windows ? toWindowsDrivePath(value) : value;
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
fileUrlToPath(url: string): string {
|
|
110
|
+
return fileURLToPath(url, { windows: this.windows });
|
|
111
|
+
}
|
|
112
|
+
|
|
90
113
|
comparable(pathValue: string, base: string): string {
|
|
91
114
|
return this.fold(this.impl.normalize(this.impl.resolve(base, pathValue)));
|
|
92
115
|
}
|
|
@@ -132,3 +155,26 @@ export const win32PathFlavor: PathFlavor = new PlatformPathFlavor(
|
|
|
132
155
|
export function pathFlavorForPlatform(platform: NodeJS.Platform): PathFlavor {
|
|
133
156
|
return platform === "win32" ? win32PathFlavor : posixPathFlavor;
|
|
134
157
|
}
|
|
158
|
+
|
|
159
|
+
/** A drive path as Pi's file tools spell it: optional WSL/Cygwin mount, a letter, an optional tail. */
|
|
160
|
+
const TOOL_DRIVE_PATH_PATTERN = /^\/(?:mnt\/|cygdrive\/)?([a-z])(?:\/(.*))?$/i;
|
|
161
|
+
|
|
162
|
+
/**
|
|
163
|
+
* Pi's `normalizeWindowsShellPath`: a UNC (`//…`) or backslashed value is
|
|
164
|
+
* already native and passes through, as does anything not shaped like a drive.
|
|
165
|
+
*/
|
|
166
|
+
function toWindowsDrivePath(value: string): string {
|
|
167
|
+
if (
|
|
168
|
+
!value.startsWith("/") ||
|
|
169
|
+
value.startsWith("//") ||
|
|
170
|
+
value.includes("\\")
|
|
171
|
+
) {
|
|
172
|
+
return value;
|
|
173
|
+
}
|
|
174
|
+
const match = TOOL_DRIVE_PATH_PATTERN.exec(value);
|
|
175
|
+
if (!match) return value;
|
|
176
|
+
const [, letter] = match;
|
|
177
|
+
// The tail group is optional, so it is absent for a bare drive (`/c`).
|
|
178
|
+
const tail = match.at(2) ?? "";
|
|
179
|
+
return `${letter.toUpperCase()}:\\${tail.replaceAll("/", "\\")}`;
|
|
180
|
+
}
|
|
@@ -1,14 +1,19 @@
|
|
|
1
|
-
import { lstatSync } from "node:fs";
|
|
1
|
+
import { existsSync, lstatSync } from "node:fs";
|
|
2
2
|
import { AccessPath } from "#src/access-intent/access-path";
|
|
3
3
|
import {
|
|
4
4
|
canonicalNormalizePathForComparison,
|
|
5
5
|
normalizePathForComparison,
|
|
6
6
|
normalizePathPolicyLiteral,
|
|
7
7
|
} from "#src/access-intent/path-normalization";
|
|
8
|
+
import { classifyToolKind } from "#src/access-intent/tool-kind";
|
|
8
9
|
import { deriveApprovalPattern } from "./approval-pattern";
|
|
10
|
+
import { resolveNativeToolTarget } from "./native-tool-target";
|
|
9
11
|
import { isPathOutsideWorkingDirectory } from "./path-containment";
|
|
10
12
|
import type { PathFlavor } from "./path-flavor";
|
|
11
|
-
import {
|
|
13
|
+
import {
|
|
14
|
+
type InfrastructureReadScope,
|
|
15
|
+
isPiInfrastructureRead,
|
|
16
|
+
} from "./pi-infrastructure-read";
|
|
12
17
|
|
|
13
18
|
/**
|
|
14
19
|
* The interpreted effect of a literal `cd` target on the effective base, under
|
|
@@ -60,6 +65,33 @@ export class PathNormalizer {
|
|
|
60
65
|
});
|
|
61
66
|
}
|
|
62
67
|
|
|
68
|
+
/**
|
|
69
|
+
* Build an AccessPath for a tool call's path argument.
|
|
70
|
+
*
|
|
71
|
+
* A built-in file tool (`read`/`write`/`edit`/`ls`/`find`/`grep`) resolves
|
|
72
|
+
* the argument through Pi's own resolver, which rewrites some spellings and,
|
|
73
|
+
* for `read`, tries variant spellings that exist; the AccessPath is the file
|
|
74
|
+
* the tool opens ({@link resolveNativeToolTarget}). Every other tool's path
|
|
75
|
+
* is its own to interpret, so it keeps {@link forPath}.
|
|
76
|
+
*
|
|
77
|
+
* The variant probe is `existsSync` (`access(F_OK)`, following symlinks),
|
|
78
|
+
* matching Pi's; unlike {@link entryExists}'s `lstat`, a dangling symlink
|
|
79
|
+
* at the typed spelling does not count, so Pi tries the variants and so do we.
|
|
80
|
+
*/
|
|
81
|
+
forToolPath(toolName: string, rawPath: string): AccessPath {
|
|
82
|
+
if (classifyToolKind(toolName) !== "path") return this.forPath(rawPath);
|
|
83
|
+
const native = resolveNativeToolTarget(rawPath, {
|
|
84
|
+
cwd: this.cwd,
|
|
85
|
+
flavor: this.flavor,
|
|
86
|
+
readFallbacks: toolName.trim() === "read",
|
|
87
|
+
exists: (absolutePath) => existsSync(absolutePath),
|
|
88
|
+
});
|
|
89
|
+
return AccessPath.forNativeTarget(native, {
|
|
90
|
+
cwd: this.cwd,
|
|
91
|
+
flavor: this.flavor,
|
|
92
|
+
});
|
|
93
|
+
}
|
|
94
|
+
|
|
63
95
|
/** Build a literal-only AccessPath (unknown base after a non-literal `cd`). */
|
|
64
96
|
forLiteral(literal: string): AccessPath {
|
|
65
97
|
return AccessPath.forLiteral(literal);
|
|
@@ -157,28 +189,13 @@ export class PathNormalizer {
|
|
|
157
189
|
return this.flavor.isWithin(pathValue, directory);
|
|
158
190
|
}
|
|
159
191
|
|
|
160
|
-
/** Canonical (symlink-resolved) outside-cwd test against the baked cwd. */
|
|
161
|
-
isOutsideWorkingDirectory(pathValue: string): boolean {
|
|
162
|
-
const canonicalPath = canonicalNormalizePathForComparison(
|
|
163
|
-
pathValue,
|
|
164
|
-
this.cwd,
|
|
165
|
-
this.flavor,
|
|
166
|
-
);
|
|
167
|
-
return isPathOutsideWorkingDirectory(
|
|
168
|
-
canonicalPath,
|
|
169
|
-
this.canonicalCwd,
|
|
170
|
-
this.flavor,
|
|
171
|
-
);
|
|
172
|
-
}
|
|
173
|
-
|
|
174
192
|
/**
|
|
175
193
|
* Outside-cwd test for an already-canonical boundary value (from
|
|
176
194
|
* {@link AccessPath.boundaryValue}), against the baked cwd.
|
|
177
195
|
*
|
|
178
|
-
*
|
|
179
|
-
*
|
|
180
|
-
*
|
|
181
|
-
* `isSafeSystemPath` exclusion intact.
|
|
196
|
+
* It does not re-derive the canonical form — the caller passes a value the
|
|
197
|
+
* {@link AccessPath} already canonicalized, so a device's preserved
|
|
198
|
+
* `/dev/null` reaches the pure check's `isSafeSystemPath` exclusion intact.
|
|
182
199
|
*/
|
|
183
200
|
isBoundaryOutsideWorkingDirectory(canonicalPath: string): boolean {
|
|
184
201
|
return isPathOutsideWorkingDirectory(
|
|
@@ -205,14 +222,15 @@ export class PathNormalizer {
|
|
|
205
222
|
isInfrastructureRead(
|
|
206
223
|
toolName: string,
|
|
207
224
|
accessPath: AccessPath,
|
|
208
|
-
|
|
225
|
+
scope: InfrastructureReadScope,
|
|
209
226
|
): boolean {
|
|
210
227
|
return isPiInfrastructureRead(
|
|
211
228
|
toolName,
|
|
212
229
|
accessPath.boundaryValue(),
|
|
213
|
-
|
|
230
|
+
scope.dirs,
|
|
214
231
|
this.cwd,
|
|
215
232
|
this.flavor,
|
|
233
|
+
scope.excludedDirs,
|
|
216
234
|
);
|
|
217
235
|
}
|
|
218
236
|
|
|
@@ -4,6 +4,18 @@ import { wildcardMatch } from "#src/policy/wildcard-matcher";
|
|
|
4
4
|
import { expandHomePath } from "./expand-home";
|
|
5
5
|
import type { PathFlavor } from "./path-flavor";
|
|
6
6
|
|
|
7
|
+
/**
|
|
8
|
+
* Where Pi infrastructure reads are auto-allowed: the roots a read-only tool
|
|
9
|
+
* may read without the `external_directory` gate, and the directories never
|
|
10
|
+
* auto-allowed even inside a root.
|
|
11
|
+
*/
|
|
12
|
+
export interface InfrastructureReadScope {
|
|
13
|
+
/** Roots (directories, files, or glob patterns) whose reads are auto-allowed. */
|
|
14
|
+
readonly dirs: readonly string[];
|
|
15
|
+
/** Directories never auto-allowed, even inside a root; wins over `dirs`. */
|
|
16
|
+
readonly excludedDirs: readonly string[];
|
|
17
|
+
}
|
|
18
|
+
|
|
7
19
|
function containsGlobChars(value: string): boolean {
|
|
8
20
|
return value.includes("*") || value.includes("?");
|
|
9
21
|
}
|
|
@@ -14,12 +26,15 @@ function containsGlobChars(value: string): boolean {
|
|
|
14
26
|
*
|
|
15
27
|
* A path qualifies when:
|
|
16
28
|
* 1. The tool is read-only (in READ_ONLY_PATH_BEARING_TOOLS).
|
|
17
|
-
* 2. The normalized path is within
|
|
29
|
+
* 2. The normalized path is not within any of `excludedDirs`.
|
|
30
|
+
* 3. The normalized path is within one of the provided `infrastructureDirs`
|
|
18
31
|
* OR within the project-local Pi package directories
|
|
19
32
|
* (`<cwd>/.pi/npm/` or `<cwd>/.pi/git/`).
|
|
20
33
|
*
|
|
21
|
-
* `infrastructureDirs` entries may be absolute paths or
|
|
22
|
-
*
|
|
34
|
+
* `infrastructureDirs` entries may be absolute paths (a directory, or a file,
|
|
35
|
+
* which admits only itself) or patterns containing `~`/`$HOME` (expanded at
|
|
36
|
+
* call time) or glob characters (`*`, `?`). `excludedDirs` entries are
|
|
37
|
+
* directory prefixes with the same `~`/`$HOME` expansion.
|
|
23
38
|
* Project-local paths are computed fresh from `cwd` on each call so they
|
|
24
39
|
* follow working-directory changes without a runtime rebuild.
|
|
25
40
|
*/
|
|
@@ -29,11 +44,16 @@ export function isPiInfrastructureRead(
|
|
|
29
44
|
infrastructureDirs: readonly string[],
|
|
30
45
|
cwd: string,
|
|
31
46
|
flavor: PathFlavor,
|
|
47
|
+
excludedDirs: readonly string[] = [],
|
|
32
48
|
): boolean {
|
|
33
49
|
if (!READ_ONLY_PATH_BEARING_TOOLS.has(toolName)) {
|
|
34
50
|
return false;
|
|
35
51
|
}
|
|
36
52
|
|
|
53
|
+
for (const dir of excludedDirs) {
|
|
54
|
+
if (flavor.isWithin(normalizedPath, expandHomePath(dir))) return false;
|
|
55
|
+
}
|
|
56
|
+
|
|
37
57
|
// On Windows the path value is canonicalized + lowercased; the flavor's match
|
|
38
58
|
// options fold case (and separators) so mixed-case infra dirs and glob
|
|
39
59
|
// patterns still match.
|
|
@@ -14,6 +14,12 @@ interface PathAskFacts {
|
|
|
14
14
|
toolName: string;
|
|
15
15
|
/** The path as the caller typed it — what the user recognizes. */
|
|
16
16
|
pathValue: string;
|
|
17
|
+
/**
|
|
18
|
+
* Where the typed path lands when that is somewhere else: its symlink
|
|
19
|
+
* target, or the file a built-in tool's resolver opens for a rewritten
|
|
20
|
+
* spelling.
|
|
21
|
+
*/
|
|
22
|
+
resolvedPath?: string;
|
|
17
23
|
agentName: string | null;
|
|
18
24
|
matchedPattern?: string;
|
|
19
25
|
/**
|
|
@@ -26,13 +32,11 @@ interface PathAskFacts {
|
|
|
26
32
|
|
|
27
33
|
/** A tool ask gated by an explicit `path`-family rule. */
|
|
28
34
|
export function buildPathAskPayload(facts: PathAskFacts): PromptPayload {
|
|
29
|
-
return pathPayload("path", facts,
|
|
35
|
+
return pathPayload("path", facts, resolvedAliasEvidence(facts.resolvedPath));
|
|
30
36
|
}
|
|
31
37
|
|
|
32
|
-
/** The facts the external-directory gate adds: the boundary
|
|
38
|
+
/** The facts the external-directory gate adds: the boundary. */
|
|
33
39
|
interface ExternalDirectoryAskFacts extends PathAskFacts {
|
|
34
|
-
/** The canonical location, when it names somewhere other than the typed path. */
|
|
35
|
-
resolvedPath?: string;
|
|
36
40
|
/** The working directory the path escapes. */
|
|
37
41
|
cwd: string;
|
|
38
42
|
}
|
|
@@ -15,6 +15,7 @@ import {
|
|
|
15
15
|
import type { ToolCallGateInputs } from "#src/handlers/gates/tool-call-gate-pipeline";
|
|
16
16
|
import type { PathFlavor } from "#src/path/path-flavor";
|
|
17
17
|
import { PathNormalizer } from "#src/path/path-normalizer";
|
|
18
|
+
import type { InfrastructureReadScope } from "#src/path/pi-infrastructure-read";
|
|
18
19
|
import type { ScopedPermissionManager } from "#src/policy/permission-manager";
|
|
19
20
|
import {
|
|
20
21
|
resolveToolPreviewLimits,
|
|
@@ -237,14 +238,17 @@ export class PermissionSession implements ToolCallGateInputs {
|
|
|
237
238
|
// ── Infrastructure paths ───────────────────────────────────────────────
|
|
238
239
|
|
|
239
240
|
/**
|
|
240
|
-
*
|
|
241
|
-
* `ExtensionPaths` plus config-derived
|
|
241
|
+
* Where infrastructure reads are auto-allowed: the static roots from
|
|
242
|
+
* `ExtensionPaths` plus the config-derived `piInfrastructureReadPaths`.
|
|
242
243
|
*/
|
|
243
|
-
|
|
244
|
-
return
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
244
|
+
getInfrastructureReadScope(): InfrastructureReadScope {
|
|
245
|
+
return {
|
|
246
|
+
dirs: [
|
|
247
|
+
...this.paths.piInfrastructureDirs,
|
|
248
|
+
...(this.config.piInfrastructureReadPaths ?? []),
|
|
249
|
+
],
|
|
250
|
+
excludedDirs: this.paths.piInfrastructureExcludedDirs,
|
|
251
|
+
};
|
|
248
252
|
}
|
|
249
253
|
|
|
250
254
|
/**
|