@gotgenes/pi-permission-system 38.0.2 → 39.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -5,6 +5,19 @@ All notable changes to this project will be documented in this file.
5
5
  The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
6
6
  and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
7
7
 
8
+ ## [39.0.0](https://github.com/gotgenes/pi-packages/compare/pi-permission-system-v38.0.2...pi-permission-system-v39.0.0) (2026-10-03)
9
+
10
+
11
+ ### Bug Fixes
12
+
13
+ * **pi-permission-system:** **breaking:** a targeted external_directory deny now blocks a Pi infrastructure read ([5c9dc88](https://github.com/gotgenes/pi-packages/commit/5c9dc88f57a68fe87ade2da50ad613b9b9fdcb82)), closes [#955](https://github.com/gotgenes/pi-packages/issues/955)
14
+ * **pi-permission-system:** **breaking:** the Pi infrastructure read bypass no longer covers the permission logs ([0ad3434](https://github.com/gotgenes/pi-packages/commit/0ad34344fb035211adc29317201d6f00cd8c7a76)), closes [#955](https://github.com/gotgenes/pi-packages/issues/955)
15
+ * **pi-permission-system:** **breaking:** the Pi infrastructure read bypass covers only Pi's harness entries, not all of ~/.pi/agent ([7826794](https://github.com/gotgenes/pi-packages/commit/7826794820b0a93257597b4cd14cd36de4193e84)), closes [#955](https://github.com/gotgenes/pi-packages/issues/955)
16
+
17
+ ### Documentation
18
+
19
+ * **pi-permission-system:** document the narrowed Pi infrastructure read bypass ([7cdfb8f](https://github.com/gotgenes/pi-packages/commit/7cdfb8fb9222bc8917aaa057cc0106d5c6806030)), closes [#955](https://github.com/gotgenes/pi-packages/issues/955)
20
+
8
21
  ## [38.0.2](https://github.com/gotgenes/pi-packages/compare/pi-permission-system-v38.0.1...pi-permission-system-v38.0.2) (2026-10-02)
9
22
 
10
23
 
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.
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.0",
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": {
@@ -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
  }
@@ -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,18 +97,6 @@ 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
100
  const pattern = normalizer.approvalPatternFor(accessPath);
94
101
 
95
102
  const payload = buildExternalDirectoryAskPayload({
@@ -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 = /^\*+$/;
@@ -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,
@@ -10,7 +10,10 @@ import { deriveApprovalPattern } 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
@@ -219,14 +222,15 @@ export class PathNormalizer {
219
222
  isInfrastructureRead(
220
223
  toolName: string,
221
224
  accessPath: AccessPath,
222
- infraDirs: readonly string[],
225
+ scope: InfrastructureReadScope,
223
226
  ): boolean {
224
227
  return isPiInfrastructureRead(
225
228
  toolName,
226
229
  accessPath.boundaryValue(),
227
- infraDirs,
230
+ scope.dirs,
228
231
  this.cwd,
229
232
  this.flavor,
233
+ scope.excludedDirs,
230
234
  );
231
235
  }
232
236
 
@@ -4,6 +4,18 @@ import { wildcardMatch } from "#src/policy/wildcard-matcher";
4
4
  import { expandHomePath } from "./expand-home";
5
5
  import type { PathFlavor } from "./path-flavor";
6
6
 
7
+ /**
8
+ * Where Pi infrastructure reads are auto-allowed: the roots a read-only tool
9
+ * may read without the `external_directory` gate, and the directories never
10
+ * auto-allowed even inside a root.
11
+ */
12
+ export interface InfrastructureReadScope {
13
+ /** Roots (directories, files, or glob patterns) whose reads are auto-allowed. */
14
+ readonly dirs: readonly string[];
15
+ /** Directories never auto-allowed, even inside a root; wins over `dirs`. */
16
+ readonly excludedDirs: readonly string[];
17
+ }
18
+
7
19
  function containsGlobChars(value: string): boolean {
8
20
  return value.includes("*") || value.includes("?");
9
21
  }
@@ -14,12 +26,15 @@ function containsGlobChars(value: string): boolean {
14
26
  *
15
27
  * A path qualifies when:
16
28
  * 1. The tool is read-only (in READ_ONLY_PATH_BEARING_TOOLS).
17
- * 2. The normalized path is within 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.
@@ -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
  /**