@gotgenes/pi-permission-system 38.0.2 → 39.0.1
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 +26 -0
- package/README.md +18 -17
- package/docs/configuration.md +16 -9
- package/docs/migration/0955-pi-infrastructure-read-narrowed.md +74 -0
- package/docs/session-approvals.md +8 -0
- package/package.json +1 -1
- package/schemas/permissions.schema.json +1 -1
- package/src/authority/local-user-authorizer.ts +10 -4
- package/src/config/config-schema.ts +1 -1
- package/src/config/extension-paths.ts +32 -4
- package/src/handlers/gates/bash-external-directory.ts +5 -4
- package/src/handlers/gates/bash-path.ts +4 -4
- package/src/handlers/gates/external-directory.ts +42 -17
- package/src/handlers/gates/path.ts +3 -3
- package/src/handlers/gates/tool-call-gate-pipeline.ts +7 -6
- package/src/handlers/gates/tool.ts +42 -11
- package/src/path/approval-pattern.ts +33 -8
- package/src/path/path-normalizer.ts +38 -10
- package/src/path/pi-infrastructure-read.ts +23 -3
- package/src/presentation/pattern-suggest.ts +51 -12
- package/src/session/approval-grant.ts +33 -0
- package/src/session/permission-session.ts +11 -7
- package/src/session/session-approval.ts +13 -0
package/CHANGELOG.md
CHANGED
|
@@ -5,6 +5,32 @@ 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.1](https://github.com/gotgenes/pi-packages/compare/pi-permission-system-v39.0.0...pi-permission-system-v39.0.1) (2026-10-03)
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
### Bug Fixes
|
|
12
|
+
|
|
13
|
+
* **pi-permission-system:** label two paths that share one session glob by that glob ([3ebe54f](https://github.com/gotgenes/pi-packages/commit/3ebe54fc5dd1d7900dfe4927f923e0f223dcb65c)), closes [#989](https://github.com/gotgenes/pi-packages/issues/989)
|
|
14
|
+
* **pi-permission-system:** scope a directory's session approval to that directory, not its parent ([a0bcf97](https://github.com/gotgenes/pi-packages/commit/a0bcf979e1e4bdb8b0c1cf9575432cc6e3ce4525)), closes [#989](https://github.com/gotgenes/pi-packages/issues/989)
|
|
15
|
+
* **pi-permission-system:** name the session-approval scope in a path ask that proves no direction ([483a1ef](https://github.com/gotgenes/pi-packages/commit/483a1ef951dc8c4677a9e02e94c5ce7487535cdf)), closes [#989](https://github.com/gotgenes/pi-packages/issues/989)
|
|
16
|
+
|
|
17
|
+
### Documentation
|
|
18
|
+
|
|
19
|
+
* **pi-permission-system:** document directory-scoped session approvals ([402cdf7](https://github.com/gotgenes/pi-packages/commit/402cdf721eb628a4eea926e3310acf1f1e07c8df)), closes [#989](https://github.com/gotgenes/pi-packages/issues/989)
|
|
20
|
+
|
|
21
|
+
## [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)
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
### Bug Fixes
|
|
25
|
+
|
|
26
|
+
* **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)
|
|
27
|
+
* **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)
|
|
28
|
+
* **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)
|
|
29
|
+
|
|
30
|
+
### Documentation
|
|
31
|
+
|
|
32
|
+
* **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)
|
|
33
|
+
|
|
8
34
|
## [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)
|
|
9
35
|
|
|
10
36
|
|
package/README.md
CHANGED
|
@@ -222,23 +222,24 @@ Approve-and-steer, edit diffs, and risk explanations → a downstream package ov
|
|
|
222
222
|
|
|
223
223
|
## Documentation
|
|
224
224
|
|
|
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`
|
|
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 |
|
|
242
243
|
|
|
243
244
|
## Development
|
|
244
245
|
|
package/docs/configuration.md
CHANGED
|
@@ -850,15 +850,22 @@ For `external_directory`, the decision of whether a path is outside the working
|
|
|
850
850
|
|
|
851
851
|
#### Pi Infrastructure Read Auto-Allow
|
|
852
852
|
|
|
853
|
-
Read-only tools (`read`, `find`, `grep`, `ls`) targeting Pi infrastructure
|
|
854
|
-
Infrastructure
|
|
855
|
-
|
|
856
|
-
1.
|
|
857
|
-
2.
|
|
858
|
-
3.
|
|
859
|
-
4.
|
|
860
|
-
5.
|
|
861
|
-
|
|
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.
|
|
864
|
+
|
|
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.
|
|
862
869
|
|
|
863
870
|
Write tools (`write`, `edit`) to infrastructure paths are **not** auto-allowed and still go through the gate.
|
|
864
871
|
|
|
@@ -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.
|
|
@@ -59,6 +59,14 @@ The suggested pattern is surface-specific:
|
|
|
59
59
|
| tool with path (read, write, …) | `read` for `src/foo.ts` | `src/*` |
|
|
60
60
|
| tool catch-all | `read` (no extractable path) | `*` |
|
|
61
61
|
| external_directory | `/other/project/src/foo.ts` | `/other/project/src/*` |
|
|
62
|
+
| external_directory (directory) | `ls /other/project` | `/other/project/*` |
|
|
63
|
+
|
|
64
|
+
A path that names an existing directory (`ls`, `find`, `grep`, or an extension tool's directory argument) is scoped to that directory, not its parent.
|
|
65
|
+
The approval records two rules — the directory itself and `<dir>/*` — so a repeated `ls` of the directory and every file beneath it are covered, while a sibling directory such as `/other/project-b` still asks.
|
|
66
|
+
The dialog names the pair by its contents glob, `"/other/project/*"`.
|
|
67
|
+
A path that does not exist yet, or a bash token whose base directory is unknown, keeps the parent-directory scope.
|
|
68
|
+
|
|
69
|
+
When a path ask proves no read/write direction (an extension tool, or `edit`), the session option names its scope as `Yes, allow access to "<pattern>" for this session`.
|
|
62
70
|
|
|
63
71
|
## Bash Arity Table
|
|
64
72
|
|
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": {
|
|
@@ -2,6 +2,7 @@ import type { ExtensionContext } from "@earendil-works/pi-coding-agent";
|
|
|
2
2
|
import {
|
|
3
3
|
buildDirectionalSessionLabels,
|
|
4
4
|
buildForwardedScopeLabels,
|
|
5
|
+
buildPathAccessSessionLabel,
|
|
5
6
|
describeGrantTarget,
|
|
6
7
|
} from "#src/presentation/pattern-suggest";
|
|
7
8
|
import {
|
|
@@ -126,9 +127,10 @@ function unansweredDecision(reason: string): PermissionPromptDecision {
|
|
|
126
127
|
/**
|
|
127
128
|
* The dialog options this ask offers, composed from three independent groups.
|
|
128
129
|
*
|
|
129
|
-
* The label names what the session grant covers
|
|
130
|
-
*
|
|
131
|
-
*
|
|
130
|
+
* The label names what the session grant covers: the proven direction and
|
|
131
|
+
* target for a directional path ask, else a gate-supplied label, else the
|
|
132
|
+
* target alone for a path ask that proves no direction. An ask whose grants
|
|
133
|
+
* all prove the same direction additionally offers the both-directions width
|
|
132
134
|
* (#813). A forwarded ask additionally offers the scope choice (subagent vs
|
|
133
135
|
* whole session).
|
|
134
136
|
*
|
|
@@ -144,7 +146,11 @@ function buildRequestOptions(
|
|
|
144
146
|
const widths = direction
|
|
145
147
|
? buildDirectionalSessionLabels(direction, describeGrantTarget(grants))
|
|
146
148
|
: null;
|
|
147
|
-
const sessionLabel =
|
|
149
|
+
const sessionLabel =
|
|
150
|
+
widths?.sessionLabel ??
|
|
151
|
+
details.sessionLabel ??
|
|
152
|
+
buildPathAccessSessionLabel(grants) ??
|
|
153
|
+
undefined;
|
|
148
154
|
|
|
149
155
|
const options: RequestPermissionOptions = {
|
|
150
156
|
...(sessionLabel ? { sessionLabel } : {}),
|
|
@@ -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
|
}
|
|
@@ -109,10 +109,11 @@ export function describeBashExternalDirectoryGate(
|
|
|
109
109
|
input: {},
|
|
110
110
|
payload,
|
|
111
111
|
sessionApproval: SessionApproval.forGrants(
|
|
112
|
-
uncoveredEntries.
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
112
|
+
uncoveredEntries.flatMap((entry) =>
|
|
113
|
+
normalizer
|
|
114
|
+
.approvalPatternsFor(entry.path)
|
|
115
|
+
.map((pattern) => ({ surface: entry.surface, pattern })),
|
|
116
|
+
),
|
|
116
117
|
),
|
|
117
118
|
promptDetails: {
|
|
118
119
|
source: "tool_call",
|
|
@@ -130,10 +130,10 @@ export function describeBashPathGate(
|
|
|
130
130
|
// All tokens evaluate to allow — no restriction.
|
|
131
131
|
if (!worstCheck || !worstToken || !worstEntry) return null;
|
|
132
132
|
|
|
133
|
-
// Derive the
|
|
134
|
-
// path), so
|
|
133
|
+
// Derive the patterns from the lexical absolute form (the cd-aware resolved
|
|
134
|
+
// path), so they match the values a later call produces. For an unknown base
|
|
135
135
|
// (`forLiteral`) `value()` is the raw token.
|
|
136
|
-
const
|
|
136
|
+
const patterns = normalizer.approvalPatternsFor(worstEntry.path);
|
|
137
137
|
const surface = worstEntry.surface;
|
|
138
138
|
const payload = buildPathAskPayload({
|
|
139
139
|
toolName: tcc.toolName,
|
|
@@ -147,7 +147,7 @@ export function describeBashPathGate(
|
|
|
147
147
|
surface,
|
|
148
148
|
input: { path: worstToken },
|
|
149
149
|
payload,
|
|
150
|
-
sessionApproval: SessionApproval.
|
|
150
|
+
sessionApproval: SessionApproval.forPatterns(surface, patterns),
|
|
151
151
|
promptDetails: {
|
|
152
152
|
source: "tool_call",
|
|
153
153
|
agentName: tcc.agentName,
|
|
@@ -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,
|
|
@@ -50,8 +53,24 @@ export function describeExternalDirectoryGate(
|
|
|
50
53
|
return null;
|
|
51
54
|
}
|
|
52
55
|
|
|
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
|
+
);
|
|
68
|
+
|
|
53
69
|
// ── Pi infrastructure read bypass ──────────────────────────────────────
|
|
54
|
-
if (
|
|
70
|
+
if (
|
|
71
|
+
normalizer.isInfrastructureRead(tcc.toolName, accessPath, infraScope) &&
|
|
72
|
+
!isTargetedDeny(preCheck)
|
|
73
|
+
) {
|
|
55
74
|
return {
|
|
56
75
|
action: "allow",
|
|
57
76
|
// Containment allowed this, not a rule the operator wrote.
|
|
@@ -78,19 +97,7 @@ export function describeExternalDirectoryGate(
|
|
|
78
97
|
|
|
79
98
|
// ── Build descriptor for permission check ───────────────────────────────
|
|
80
99
|
const resolvedAlias = accessPath.resolvedAlias();
|
|
81
|
-
|
|
82
|
-
// The narrowest `external_directory`-family surface this tool's identity
|
|
83
|
-
// proves; the bare family name folds both directions (ADR 0013 §10).
|
|
84
|
-
const surface = capabilitySurfaceForTool("external_directory", tcc.toolName);
|
|
85
|
-
|
|
86
|
-
// The runner consumes this preCheck and skips its own resolve.
|
|
87
|
-
const preCheck = resolveExternalDirectoryPolicy(
|
|
88
|
-
accessPath,
|
|
89
|
-
resolver,
|
|
90
|
-
surface,
|
|
91
|
-
tcc.agentName ?? undefined,
|
|
92
|
-
);
|
|
93
|
-
const pattern = normalizer.approvalPatternFor(accessPath);
|
|
100
|
+
const patterns = normalizer.approvalPatternsFor(accessPath);
|
|
94
101
|
|
|
95
102
|
const payload = buildExternalDirectoryAskPayload({
|
|
96
103
|
toolName: tcc.toolName,
|
|
@@ -107,7 +114,7 @@ export function describeExternalDirectoryGate(
|
|
|
107
114
|
input: {},
|
|
108
115
|
preCheck,
|
|
109
116
|
payload,
|
|
110
|
-
sessionApproval: SessionApproval.
|
|
117
|
+
sessionApproval: SessionApproval.forPatterns(surface, patterns),
|
|
111
118
|
promptDetails: buildPathGatePromptDetails(
|
|
112
119
|
tcc,
|
|
113
120
|
externalDirectoryPath,
|
|
@@ -120,3 +127,21 @@ export function describeExternalDirectoryGate(
|
|
|
120
127
|
},
|
|
121
128
|
};
|
|
122
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 = /^\*+$/;
|
|
@@ -58,9 +58,9 @@ export function describePathGate(
|
|
|
58
58
|
// "path" key should not trigger path-level prompts (#58).
|
|
59
59
|
if (check.matchedPattern === undefined) return null;
|
|
60
60
|
|
|
61
|
-
// Derive the approval
|
|
61
|
+
// Derive the approval patterns from the lexical absolute form so they match
|
|
62
62
|
// the policy values a later call produces.
|
|
63
|
-
const
|
|
63
|
+
const patterns = normalizer.approvalPatternsFor(accessPath);
|
|
64
64
|
|
|
65
65
|
const payload = buildPathAskPayload({
|
|
66
66
|
toolName: tcc.toolName,
|
|
@@ -75,7 +75,7 @@ export function describePathGate(
|
|
|
75
75
|
surface,
|
|
76
76
|
input: { path: filePath },
|
|
77
77
|
payload,
|
|
78
|
-
sessionApproval: SessionApproval.
|
|
78
|
+
sessionApproval: SessionApproval.forPatterns(surface, patterns),
|
|
79
79
|
promptDetails: buildPathGatePromptDetails(
|
|
80
80
|
tcc,
|
|
81
81
|
filePath,
|
|
@@ -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,
|
|
@@ -209,7 +210,7 @@ export class ToolCallGatePipeline {
|
|
|
209
210
|
return {
|
|
210
211
|
pathAccess: {
|
|
211
212
|
path: accessPath,
|
|
212
|
-
|
|
213
|
+
approvalPatterns: normalizer.approvalPatternsFor(accessPath),
|
|
213
214
|
},
|
|
214
215
|
toolCheck: this.resolver.resolve({
|
|
215
216
|
kind: "access-path",
|
|
@@ -34,7 +34,7 @@ import type { ToolCallContext } from "./types";
|
|
|
34
34
|
*/
|
|
35
35
|
export interface ToolPathAccess {
|
|
36
36
|
readonly path: AccessPath;
|
|
37
|
-
readonly
|
|
37
|
+
readonly approvalPatterns: readonly string[];
|
|
38
38
|
}
|
|
39
39
|
|
|
40
40
|
/**
|
|
@@ -42,7 +42,7 @@ export interface ToolPathAccess {
|
|
|
42
42
|
*
|
|
43
43
|
* Bash → command string; MCP → qualified target; everything else → catch-all
|
|
44
44
|
* wildcard. A path-bearing tool that resolved a path never reaches here — its
|
|
45
|
-
* suggestion comes from the already-derived {@link ToolPathAccess}
|
|
45
|
+
* suggestion comes from the already-derived {@link ToolPathAccess} patterns.
|
|
46
46
|
*/
|
|
47
47
|
function deriveSuggestionValue(
|
|
48
48
|
toolName: string,
|
|
@@ -76,6 +76,40 @@ function gateSurfaceOf(
|
|
|
76
76
|
return toolName;
|
|
77
77
|
}
|
|
78
78
|
|
|
79
|
+
/** A session option: the approval to record and the label offering it. */
|
|
80
|
+
interface SessionOption {
|
|
81
|
+
readonly approval: SessionApproval;
|
|
82
|
+
readonly label: string;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/** The session option for a path-bearing tool, from its derived patterns. */
|
|
86
|
+
function pathSessionOption(
|
|
87
|
+
gateSurface: string,
|
|
88
|
+
approvalPatterns: readonly string[],
|
|
89
|
+
): SessionOption {
|
|
90
|
+
const suggestion = suggestPathSessionPattern(gateSurface, approvalPatterns);
|
|
91
|
+
return {
|
|
92
|
+
approval: SessionApproval.forPatterns(
|
|
93
|
+
suggestion.surface,
|
|
94
|
+
suggestion.patterns,
|
|
95
|
+
),
|
|
96
|
+
label: suggestion.label,
|
|
97
|
+
};
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/** The session option for a gate whose value is not a path. */
|
|
101
|
+
function valueSessionOption(
|
|
102
|
+
toolName: string,
|
|
103
|
+
gateSurface: string,
|
|
104
|
+
check: PermissionCheckResult,
|
|
105
|
+
): SessionOption {
|
|
106
|
+
const suggestion = suggestValueSessionPattern(toolName, gateSurface, check);
|
|
107
|
+
return {
|
|
108
|
+
approval: SessionApproval.single(suggestion.surface, suggestion.pattern),
|
|
109
|
+
label: suggestion.label,
|
|
110
|
+
};
|
|
111
|
+
}
|
|
112
|
+
|
|
79
113
|
/**
|
|
80
114
|
* The session approval for a gate whose value is not a path.
|
|
81
115
|
*
|
|
@@ -118,10 +152,10 @@ export function describeToolGate(
|
|
|
118
152
|
PATH_BEARING_TOOLS,
|
|
119
153
|
);
|
|
120
154
|
|
|
121
|
-
//
|
|
122
|
-
const
|
|
123
|
-
?
|
|
124
|
-
:
|
|
155
|
+
// The session approval and label for the "for this session" option.
|
|
156
|
+
const sessionOption = pathAccess
|
|
157
|
+
? pathSessionOption(gateSurface, pathAccess.approvalPatterns)
|
|
158
|
+
: valueSessionOption(tcc.toolName, gateSurface, check);
|
|
125
159
|
|
|
126
160
|
const payload = buildToolAskPayload({
|
|
127
161
|
check,
|
|
@@ -148,16 +182,13 @@ export function describeToolGate(
|
|
|
148
182
|
surface: gateSurface,
|
|
149
183
|
input: tcc.input,
|
|
150
184
|
payload,
|
|
151
|
-
sessionApproval:
|
|
152
|
-
suggestion.surface,
|
|
153
|
-
suggestion.pattern,
|
|
154
|
-
),
|
|
185
|
+
sessionApproval: sessionOption.approval,
|
|
155
186
|
promptDetails: {
|
|
156
187
|
source: "tool_call",
|
|
157
188
|
agentName: tcc.agentName,
|
|
158
189
|
toolCallId: tcc.toolCallId,
|
|
159
190
|
toolName: tcc.toolName,
|
|
160
|
-
sessionLabel:
|
|
191
|
+
sessionLabel: sessionOption.label,
|
|
161
192
|
accessIntent,
|
|
162
193
|
...permissionLogContext,
|
|
163
194
|
},
|
|
@@ -1,13 +1,19 @@
|
|
|
1
1
|
import type { PathFlavor } from "./path-flavor";
|
|
2
2
|
|
|
3
3
|
/**
|
|
4
|
-
* Derive the wildcard
|
|
5
|
-
* the session
|
|
4
|
+
* Derive the wildcard globs to record when a user approves an accessed path
|
|
5
|
+
* for the session.
|
|
6
6
|
*
|
|
7
|
-
*
|
|
8
|
-
*
|
|
9
|
-
*
|
|
10
|
-
* `
|
|
7
|
+
* A non-directory (or a path the caller could not confirm is a directory) is
|
|
8
|
+
* scoped to its enclosing directory: the value up to and including its last
|
|
9
|
+
* path separator, with `*` appended. A directory is scoped to itself and its
|
|
10
|
+
* contents — `D` and `D<sep>*` — rather than to its parent, which would grant
|
|
11
|
+
* every sibling directory (#989). Two patterns are needed because `D<sep>*`
|
|
12
|
+
* does not match `D` itself, and `D*` would also match a sibling `D-evil`.
|
|
13
|
+
*
|
|
14
|
+
* Each pattern is spelled with the separator the value itself carries. That
|
|
15
|
+
* matters on a win32 host, where Git Bash tokens are POSIX-shaped while Node's
|
|
16
|
+
* own `sep` is a backslash: deriving `/tmp/logs\*` from `/tmp/logs/` widens the
|
|
11
17
|
* grant to the parent directory once the `windowsSeparators` fold (#653)
|
|
12
18
|
* normalizes both operands. A value carrying no separator falls back to the
|
|
13
19
|
* current directory, which is what callers see only if they skipped resolving
|
|
@@ -17,11 +23,30 @@ import type { PathFlavor } from "./path-flavor";
|
|
|
17
23
|
* never an ambient `node:path` read, so win32 derivation is decidable — and
|
|
18
24
|
* testable — on a POSIX host (#655).
|
|
19
25
|
*/
|
|
20
|
-
export function
|
|
26
|
+
export function deriveApprovalPatterns(
|
|
21
27
|
pathValue: string,
|
|
22
28
|
flavor: PathFlavor,
|
|
23
|
-
|
|
29
|
+
isDirectory: boolean,
|
|
30
|
+
): readonly string[] {
|
|
31
|
+
if (!isDirectory) return [parentScopePattern(pathValue, flavor)];
|
|
32
|
+
return [pathValue, directoryContentsPattern(pathValue, flavor)];
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/** The value's enclosing directory scope (up to its last separator) plus `*`. */
|
|
36
|
+
function parentScopePattern(pathValue: string, flavor: PathFlavor): string {
|
|
24
37
|
const lastSeparator = flavor.lastSeparatorIndex(pathValue);
|
|
25
38
|
if (lastSeparator < 0) return `.${flavor.impl.sep}*`;
|
|
26
39
|
return `${pathValue.slice(0, lastSeparator + 1)}*`;
|
|
27
40
|
}
|
|
41
|
+
|
|
42
|
+
/** Everything beneath a directory value: the value, a separator, then `*`. */
|
|
43
|
+
function directoryContentsPattern(
|
|
44
|
+
pathValue: string,
|
|
45
|
+
flavor: PathFlavor,
|
|
46
|
+
): string {
|
|
47
|
+
const lastSeparator = flavor.lastSeparatorIndex(pathValue);
|
|
48
|
+
if (lastSeparator === pathValue.length - 1) return `${pathValue}*`;
|
|
49
|
+
const separator =
|
|
50
|
+
lastSeparator < 0 ? flavor.impl.sep : pathValue[lastSeparator];
|
|
51
|
+
return `${pathValue}${separator}*`;
|
|
52
|
+
}
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { existsSync, lstatSync } from "node:fs";
|
|
1
|
+
import { existsSync, lstatSync, statSync } from "node:fs";
|
|
2
2
|
import { AccessPath } from "#src/access-intent/access-path";
|
|
3
3
|
import {
|
|
4
4
|
canonicalNormalizePathForComparison,
|
|
@@ -6,11 +6,14 @@ import {
|
|
|
6
6
|
normalizePathPolicyLiteral,
|
|
7
7
|
} from "#src/access-intent/path-normalization";
|
|
8
8
|
import { classifyToolKind } from "#src/access-intent/tool-kind";
|
|
9
|
-
import {
|
|
9
|
+
import { deriveApprovalPatterns } from "./approval-pattern";
|
|
10
10
|
import { resolveNativeToolTarget } from "./native-tool-target";
|
|
11
11
|
import { isPathOutsideWorkingDirectory } from "./path-containment";
|
|
12
12
|
import type { PathFlavor } from "./path-flavor";
|
|
13
|
-
import {
|
|
13
|
+
import {
|
|
14
|
+
type InfrastructureReadScope,
|
|
15
|
+
isPiInfrastructureRead,
|
|
16
|
+
} from "./pi-infrastructure-read";
|
|
14
17
|
|
|
15
18
|
/**
|
|
16
19
|
* The interpreted effect of a literal `cd` target on the effective base, under
|
|
@@ -128,17 +131,41 @@ export class PathNormalizer {
|
|
|
128
131
|
}
|
|
129
132
|
|
|
130
133
|
/**
|
|
131
|
-
* The session-approval
|
|
132
|
-
*
|
|
134
|
+
* The session-approval globs for an accessed path, derived through the baked
|
|
135
|
+
* flavor: an existing directory's own scope (itself and its contents), else
|
|
136
|
+
* the enclosing directory's scope plus `*`.
|
|
133
137
|
*
|
|
134
138
|
* Takes the already-built {@link AccessPath} — the lexical form is what a
|
|
135
|
-
* later tool call is matched on, so the
|
|
139
|
+
* later tool call is matched on, so the patterns must be derived from the
|
|
136
140
|
* same representation the decision displayed (#438). Deriving it here rather
|
|
137
141
|
* than at each gate keeps the platform's separator alphabet with the object
|
|
138
142
|
* that owns the flavor, instead of an ambient `node:path` read (#655).
|
|
139
143
|
*/
|
|
140
|
-
|
|
141
|
-
return
|
|
144
|
+
approvalPatternsFor(accessPath: AccessPath): readonly string[] {
|
|
145
|
+
return deriveApprovalPatterns(
|
|
146
|
+
accessPath.value(),
|
|
147
|
+
this.flavor,
|
|
148
|
+
this.namesDirectory(accessPath),
|
|
149
|
+
);
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
/**
|
|
153
|
+
* Whether an accessed path names an existing directory, so its session grant
|
|
154
|
+
* covers that directory rather than its parent.
|
|
155
|
+
*
|
|
156
|
+
* Uses `stat`, following symlinks: a link to a directory is what `ls` lists.
|
|
157
|
+
* A literal-only path (no canonical form — an unknown base, or a win32
|
|
158
|
+
* non-mount POSIX absolute) is never probed, since `stat` would resolve it
|
|
159
|
+
* against the process cwd or a fabricated drive. Any error answers `false`,
|
|
160
|
+
* which keeps the parent-directory grant callers had before this probe.
|
|
161
|
+
*/
|
|
162
|
+
private namesDirectory(accessPath: AccessPath): boolean {
|
|
163
|
+
if (!accessPath.boundaryValue()) return false;
|
|
164
|
+
try {
|
|
165
|
+
return statSync(accessPath.value()).isDirectory();
|
|
166
|
+
} catch {
|
|
167
|
+
return false;
|
|
168
|
+
}
|
|
142
169
|
}
|
|
143
170
|
|
|
144
171
|
/** Platform-aware absoluteness (`win32` vs `posix` rules). */
|
|
@@ -219,14 +246,15 @@ export class PathNormalizer {
|
|
|
219
246
|
isInfrastructureRead(
|
|
220
247
|
toolName: string,
|
|
221
248
|
accessPath: AccessPath,
|
|
222
|
-
|
|
249
|
+
scope: InfrastructureReadScope,
|
|
223
250
|
): boolean {
|
|
224
251
|
return isPiInfrastructureRead(
|
|
225
252
|
toolName,
|
|
226
253
|
accessPath.boundaryValue(),
|
|
227
|
-
|
|
254
|
+
scope.dirs,
|
|
228
255
|
this.cwd,
|
|
229
256
|
this.flavor,
|
|
257
|
+
scope.excludedDirs,
|
|
230
258
|
);
|
|
231
259
|
}
|
|
232
260
|
|
|
@@ -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.
|
|
@@ -5,9 +5,10 @@ import {
|
|
|
5
5
|
import {
|
|
6
6
|
type CapabilityDirection,
|
|
7
7
|
PATH_BEARING_TOOLS,
|
|
8
|
+
surfaceFamilyMembers,
|
|
8
9
|
surfaceFamilyOf,
|
|
9
10
|
} from "#src/access-intent/path-surfaces";
|
|
10
|
-
import type
|
|
11
|
+
import { type ApprovalGrant, grantTargets } from "#src/session/approval-grant";
|
|
11
12
|
|
|
12
13
|
/** The suggestion returned for a "Yes, for this session" dialog option. */
|
|
13
14
|
export interface SessionApprovalSuggestion {
|
|
@@ -80,15 +81,16 @@ export interface ForwardedScopeLabels {
|
|
|
80
81
|
/**
|
|
81
82
|
* What an approval's grants cover, as one phrase.
|
|
82
83
|
*
|
|
83
|
-
* A single
|
|
84
|
-
* external-directory gate aggregates an ask over many paths and there is
|
|
85
|
-
* pattern that describes them all.
|
|
84
|
+
* A single target names its pattern; several name their count, because only
|
|
85
|
+
* the external-directory gate aggregates an ask over many paths and there is
|
|
86
|
+
* no pattern that describes them all. Targets, not grants: a directory's
|
|
87
|
+
* two-grant approval and two paths sharing one glob each count once
|
|
88
|
+
* ({@link grantTargets}). Requires at least one grant — an approval
|
|
86
89
|
* with none is never offered as a session option.
|
|
87
90
|
*/
|
|
88
91
|
export function describeGrantTarget(grants: readonly ApprovalGrant[]): string {
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
: `${grants.length} paths`;
|
|
92
|
+
const targets = grantTargets(grants);
|
|
93
|
+
return targets.length === 1 ? `"${targets[0]}"` : `${targets.length} paths`;
|
|
92
94
|
}
|
|
93
95
|
|
|
94
96
|
/** The two session-option labels for an ask whose grants prove one direction. */
|
|
@@ -120,6 +122,25 @@ export function buildDirectionalSessionLabels(
|
|
|
120
122
|
};
|
|
121
123
|
}
|
|
122
124
|
|
|
125
|
+
/**
|
|
126
|
+
* Label a session grant on the `path` / `external_directory` families that
|
|
127
|
+
* proves no direction (an extension tool, `edit`), so the option still names
|
|
128
|
+
* its scope instead of the dialog's bare default. Returns `null` when any
|
|
129
|
+
* grant lies outside those families — their gates supply their own label.
|
|
130
|
+
*/
|
|
131
|
+
export function buildPathAccessSessionLabel(
|
|
132
|
+
grants: readonly ApprovalGrant[],
|
|
133
|
+
): string | null {
|
|
134
|
+
const allPathFamily =
|
|
135
|
+
grants.length > 0 &&
|
|
136
|
+
grants.every(
|
|
137
|
+
(grant) => surfaceFamilyMembers(surfaceFamilyOf(grant.surface)) !== null,
|
|
138
|
+
);
|
|
139
|
+
return allPathFamily
|
|
140
|
+
? `Yes, allow access to ${describeGrantTarget(grants)} for this session`
|
|
141
|
+
: null;
|
|
142
|
+
}
|
|
143
|
+
|
|
123
144
|
/**
|
|
124
145
|
* Build the two scope labels shown when a human grants a forwarded request
|
|
125
146
|
* "for this session."
|
|
@@ -221,19 +242,37 @@ export function suggestSessionPattern(
|
|
|
221
242
|
return { surface, pattern, label: buildLabel(pattern, surface) };
|
|
222
243
|
}
|
|
223
244
|
|
|
245
|
+
/** The suggestion for a path surface: every pattern to record, one label. */
|
|
246
|
+
export interface PathSessionSuggestion {
|
|
247
|
+
/** The permission surface this approval applies to. */
|
|
248
|
+
surface: string;
|
|
249
|
+
/** The wildcard patterns to store as session rules. */
|
|
250
|
+
patterns: readonly string[];
|
|
251
|
+
/** Human-readable label for the "for session" dialog option. */
|
|
252
|
+
label: string;
|
|
253
|
+
}
|
|
254
|
+
|
|
224
255
|
/**
|
|
225
|
-
* Build the suggestion for a path surface from
|
|
256
|
+
* Build the suggestion for a path surface from patterns the caller already
|
|
226
257
|
* derived through its `PathNormalizer` (#655).
|
|
227
258
|
*
|
|
228
259
|
* The derivation belongs to the normalizer, which owns the session's
|
|
229
260
|
* `PathFlavor`; this module labels the result and must not re-interpret the
|
|
230
|
-
* separators it is handed.
|
|
261
|
+
* separators it is handed. The label names the approval's target
|
|
262
|
+
* ({@link grantTargets}), so a directory's pair reads as its contents glob.
|
|
231
263
|
*/
|
|
232
264
|
export function suggestPathSessionPattern(
|
|
233
265
|
surface: string,
|
|
234
|
-
|
|
235
|
-
):
|
|
236
|
-
|
|
266
|
+
approvalPatterns: readonly string[],
|
|
267
|
+
): PathSessionSuggestion {
|
|
268
|
+
const targets = grantTargets(
|
|
269
|
+
approvalPatterns.map((pattern) => ({ surface, pattern })),
|
|
270
|
+
);
|
|
271
|
+
return {
|
|
272
|
+
surface,
|
|
273
|
+
patterns: approvalPatterns,
|
|
274
|
+
label: buildLabel(targets.join('", "'), surface),
|
|
275
|
+
};
|
|
237
276
|
}
|
|
238
277
|
|
|
239
278
|
/**
|
|
@@ -75,3 +75,36 @@ export function provenDirectionOf(
|
|
|
75
75
|
? direction
|
|
76
76
|
: null;
|
|
77
77
|
}
|
|
78
|
+
|
|
79
|
+
/**
|
|
80
|
+
* The distinct targets an approval covers, for naming it in a prompt.
|
|
81
|
+
*
|
|
82
|
+
* A directory approval is recorded as two grants on one surface — the
|
|
83
|
+
* directory itself (`D`) and its contents (`D/*`) — because no single wildcard
|
|
84
|
+
* matches both without also matching a sibling like `D-evil`. Here the exact
|
|
85
|
+
* grant folds into its contents grant, so the pair reads as the one target
|
|
86
|
+
* `D/*`. Identical patterns (two paths in one directory) collapse to one.
|
|
87
|
+
*/
|
|
88
|
+
export function grantTargets(grants: readonly ApprovalGrant[]): string[] {
|
|
89
|
+
const targets = grants
|
|
90
|
+
.filter((grant) => !hasContentsSibling(grant, grants))
|
|
91
|
+
.map((grant) => grant.pattern);
|
|
92
|
+
return [...new Set(targets)];
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/** Whether `grants` also holds `grant`'s contents pattern on the same surface. */
|
|
96
|
+
function hasContentsSibling(
|
|
97
|
+
grant: ApprovalGrant,
|
|
98
|
+
grants: readonly ApprovalGrant[],
|
|
99
|
+
): boolean {
|
|
100
|
+
return grants.some(
|
|
101
|
+
(other) =>
|
|
102
|
+
other.surface === grant.surface &&
|
|
103
|
+
CONTENTS_SUFFIXES.some(
|
|
104
|
+
(suffix) => other.pattern === `${grant.pattern}${suffix}`,
|
|
105
|
+
),
|
|
106
|
+
);
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
/** A separator (either alphabet) followed by the wildcard. */
|
|
110
|
+
const CONTENTS_SUFFIXES = ["/*", "\\*"] as const;
|
|
@@ -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
|
/**
|
|
@@ -23,6 +23,19 @@ export class SessionApproval {
|
|
|
23
23
|
return new SessionApproval([{ surface, pattern }]);
|
|
24
24
|
}
|
|
25
25
|
|
|
26
|
+
/**
|
|
27
|
+
* Create an approval whose patterns were all proven on one surface — a
|
|
28
|
+
* directory approval records the directory and its contents as two patterns.
|
|
29
|
+
*/
|
|
30
|
+
static forPatterns(
|
|
31
|
+
surface: string,
|
|
32
|
+
patterns: readonly string[],
|
|
33
|
+
): SessionApproval {
|
|
34
|
+
return new SessionApproval(
|
|
35
|
+
patterns.map((pattern) => ({ surface, pattern })),
|
|
36
|
+
);
|
|
37
|
+
}
|
|
38
|
+
|
|
26
39
|
/**
|
|
27
40
|
* Create an approval from grants that may name different surfaces (e.g. a
|
|
28
41
|
* bash external-directory ask whose uncovered paths proved different
|