@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 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
 
@@ -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 directories are automatically allowed without triggering the gate, even when `external_directory` is `ask` or `deny`.
854
- Infrastructure directories include:
855
-
856
- 1. The agent config directory (`~/.pi/agent/` or `$PI_CODING_AGENT_DIR`)
857
- 2. Git-cloned global packages (`<agentDir>/git/`)
858
- 3. The global `node_modules` root (auto-discovered from the extension's own install path; falls back to `npm root -g` when running from a local development checkout)
859
- 4. Pi's own install directory (auto-discovered via the coding-agent `getPackageDir()` API, so Pi's bundled docs and examples are readable regardless of install layout)
860
- 5. Project-local Pi packages (`<cwd>/.pi/npm/` and `<cwd>/.pi/git/`)
861
- 6. Any paths listed in `piInfrastructureReadPaths`
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@gotgenes/pi-permission-system",
3
- "version": "38.0.2",
3
+ "version": "39.0.1",
4
4
  "description": "Permission enforcement extension for the Pi coding agent.",
5
5
  "type": "module",
6
6
  "exports": {
@@ -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`, `agentDir/git`, and project-local `.pi/npm/` and `.pi/git/`. 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.",
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 (a gate-supplied one, or one
130
- * derived from the grants themselves for a path ask). An ask whose grants all
131
- * prove the same direction additionally offers the both-directions width
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 = widths?.sessionLabel ?? details.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`, `agentDir/git`, and project-local `.pi/npm/` and `.pi/git/`. 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.",
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 directories used for external-directory
20
- * read auto-allow. Computed once from `agentDir`,
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.map((entry) => ({
113
- surface: entry.surface,
114
- pattern: normalizer.approvalPatternFor(entry.path),
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 pattern from the lexical absolute form (the cd-aware resolved
134
- // path), so it matches the values a later call produces. For an unknown base
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 pattern = normalizer.approvalPatternFor(worstEntry.path);
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.single(surface, pattern),
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
- infraDirs: string[],
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 (normalizer.isInfrastructureRead(tcc.toolName, accessPath, infraDirs)) {
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.single(surface, pattern),
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 pattern from the lexical absolute form so it matches
61
+ // Derive the approval patterns from the lexical absolute form so they match
62
62
  // the policy values a later call produces.
63
- const pattern = normalizer.approvalPatternFor(accessPath);
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.single(surface, pattern),
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
- /** Combined infrastructure read directories (static + config-derived). */
43
- getInfrastructureReadDirs(): string[];
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-dir list from `getInfrastructureReadDirs()`
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 infraDirs = this.inputs.getInfrastructureReadDirs();
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
- infraDirs,
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
- approvalPattern: normalizer.approvalPatternFor(accessPath),
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 approvalPattern: string;
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} pattern.
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
- // Compute session approval suggestion for the "for this session" option.
122
- const suggestion = pathAccess
123
- ? suggestPathSessionPattern(gateSurface, pathAccess.approvalPattern)
124
- : suggestValueSessionPattern(tcc.toolName, gateSurface, check);
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: SessionApproval.single(
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: suggestion.label,
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 glob to record when a user approves an accessed path for
5
- * the session: the path's directory scope, with `*` appended.
4
+ * Derive the wildcard globs to record when a user approves an accessed path
5
+ * for the session.
6
6
  *
7
- * The scope is the value up to and including its last path separator, so the
8
- * pattern is spelled with the separator the value itself carries. That matters
9
- * on a win32 host, where Git Bash tokens are POSIX-shaped while Node's own
10
- * `sep` is a backslash: deriving `/tmp/logs\*` from `/tmp/logs/` widens the
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 deriveApprovalPattern(
26
+ export function deriveApprovalPatterns(
21
27
  pathValue: string,
22
28
  flavor: PathFlavor,
23
- ): string {
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 { deriveApprovalPattern } from "./approval-pattern";
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 { isPiInfrastructureRead } from "./pi-infrastructure-read";
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 glob for an accessed path: its directory scope plus
132
- * `*`, derived through the baked flavor.
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 pattern must be derived from 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
- approvalPatternFor(accessPath: AccessPath): string {
141
- return deriveApprovalPattern(accessPath.value(), this.flavor);
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
- infraDirs: readonly string[],
249
+ scope: InfrastructureReadScope,
223
250
  ): boolean {
224
251
  return isPiInfrastructureRead(
225
252
  toolName,
226
253
  accessPath.boundaryValue(),
227
- infraDirs,
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 one of the provided `infrastructureDirs`
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 patterns containing
22
- * `~`/`$HOME` (expanded at call time) or glob characters (`*`, `?`).
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 { ApprovalGrant } from "#src/session/approval-grant";
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 grant names its pattern; several name their count, because only the
84
- * external-directory gate aggregates an ask over many paths and there is no
85
- * pattern that describes them all. Requires at least one grant — an approval
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
- return grants.length === 1
90
- ? `"${grants[0].pattern}"`
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 a pattern the caller already
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
- approvalPattern: string,
235
- ): SessionApprovalSuggestion {
236
- return suggestExactSessionPattern(surface, approvalPattern);
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
- * Combined infrastructure read directories: static paths from
241
- * `ExtensionPaths` plus config-derived paths.
241
+ * Where infrastructure reads are auto-allowed: the static roots from
242
+ * `ExtensionPaths` plus the config-derived `piInfrastructureReadPaths`.
242
243
  */
243
- getInfrastructureReadDirs(): string[] {
244
- return [
245
- ...this.paths.piInfrastructureDirs,
246
- ...(this.config.piInfrastructureReadPaths ?? []),
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