@gotgenes/pi-permission-system 4.8.0 → 4.9.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,25 @@ 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
+ ## [4.9.0](https://github.com/gotgenes/pi-permission-system/compare/v4.8.0...v4.9.0) (2026-05-05)
9
+
10
+
11
+ ### Features
12
+
13
+ * bypass external_directory gate for Pi infrastructure reads ([229a352](https://github.com/gotgenes/pi-permission-system/commit/229a35222dd47f1d0c079f0bcd34760569e912f3))
14
+
15
+
16
+ ### Bug Fixes
17
+
18
+ * skip regex patterns in bash external-directory path extraction ([9fe4ba6](https://github.com/gotgenes/pi-permission-system/commit/9fe4ba6d259c25aa0a9e3a5508884d26a303cac3))
19
+
20
+
21
+ ### Documentation
22
+
23
+ * document piInfrastructureReadPaths config and infrastructure auto-allow ([65e0ac8](https://github.com/gotgenes/pi-permission-system/commit/65e0ac8ef8a4973c261628e026c3772faa0849ab))
24
+ * plan auto-allow reads from Pi infrastructure directories ([#48](https://github.com/gotgenes/pi-permission-system/issues/48)) ([06b8d44](https://github.com/gotgenes/pi-permission-system/commit/06b8d441d569b1c2893f5c434357eb8b2fc9180f))
25
+ * **retro:** add retro notes for issue [#53](https://github.com/gotgenes/pi-permission-system/issues/53) ([1988d7a](https://github.com/gotgenes/pi-permission-system/commit/1988d7ab09432df09825c560ba377233e0d3ab33))
26
+
8
27
  ## [4.8.0](https://github.com/gotgenes/pi-permission-system/compare/v4.7.0...v4.8.0) (2026-05-05)
9
28
 
10
29
 
package/README.md CHANGED
@@ -118,6 +118,7 @@ The config file combines runtime knobs and permission policy in one object:
118
118
  "debugLog": false,
119
119
  "permissionReviewLog": true,
120
120
  "yoloMode": false,
121
+ "piInfrastructureReadPaths": [], // extra dirs to auto-allow for reads
121
122
 
122
123
  // Flat permission policy
123
124
  "permission": {
@@ -134,11 +135,12 @@ The config file combines runtime knobs and permission policy in one object:
134
135
 
135
136
  #### Runtime knobs
136
137
 
137
- | Key | Default | Description |
138
- | --------------------- | ------- | ------------------------------------------------------------------------------------------------------- |
139
- | `debugLog` | `false` | Enables verbose diagnostic logging to `logs/pi-permission-system-debug.jsonl` |
140
- | `permissionReviewLog` | `true` | Enables the permission request/denial review log at `logs/pi-permission-system-permission-review.jsonl` |
141
- | `yoloMode` | `false` | Auto-approves `ask` results instead of prompting when yolo mode is enabled |
138
+ | Key | Default | Description |
139
+ | ---------------------------- | ------- | ------------------------------------------------------------------------------------------------------- |
140
+ | `debugLog` | `false` | Enables verbose diagnostic logging to `logs/pi-permission-system-debug.jsonl` |
141
+ | `permissionReviewLog` | `true` | Enables the permission request/denial review log at `logs/pi-permission-system-permission-review.jsonl` |
142
+ | `yoloMode` | `false` | Auto-approves `ask` results instead of prompting when yolo mode is enabled |
143
+ | `piInfrastructureReadPaths` | `[]` | Extra directories to auto-allow for reads, bypassing the `external_directory` gate (supports `~`) |
142
144
 
143
145
  Both logs write to `~/.pi/agent/extensions/pi-permission-system/logs/`.
144
146
  No debug output is printed to the terminal.
@@ -372,6 +374,17 @@ Quoted strings are stripped first to reduce false positives.
372
374
  This is a best-effort heuristic — variable expansion, subshells, and escaped quotes are not parsed.
373
375
  OS device paths (`/dev/null`, `/dev/stdin`, `/dev/stdout`, `/dev/stderr`) are always excluded.
374
376
 
377
+ **Pi infrastructure read auto-allow** — Read-only tools (`read`, `find`, `grep`, `ls`) targeting Pi infrastructure directories are automatically allowed without triggering the gate, even when `external_directory` is `ask` or `deny`.
378
+ Infrastructure directories include:
379
+
380
+ 1. The agent config directory (`~/.pi/agent/` or `$PI_CODING_AGENT_DIR`)
381
+ 2. Git-cloned global packages (`<agentDir>/git/`)
382
+ 3. The global `node_modules` root (auto-discovered from the extension's own install path — works for npm, pnpm, bun, Homebrew)
383
+ 4. Project-local Pi packages (`<cwd>/.pi/npm/` and `<cwd>/.pi/git/`)
384
+ 5. Any paths listed in `piInfrastructureReadPaths`
385
+
386
+ Write tools (`write`, `edit`) to infrastructure paths are **not** auto-allowed and still go through the gate.
387
+
375
388
  ---
376
389
 
377
390
  ## Common Recipes
@@ -5,6 +5,8 @@
5
5
  "permissionReviewLog": true,
6
6
  "yoloMode": false,
7
7
 
8
+ "piInfrastructureReadPaths": [],
9
+
8
10
  "permission": {
9
11
  "*": "ask",
10
12
  "read": "allow",
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@gotgenes/pi-permission-system",
3
- "version": "4.8.0",
3
+ "version": "4.9.0",
4
4
  "description": "Permission enforcement extension for the Pi coding agent.",
5
5
  "type": "module",
6
6
  "files": [
@@ -29,6 +29,16 @@
29
29
  "type": "boolean",
30
30
  "default": false
31
31
  },
32
+ "piInfrastructureReadPaths": {
33
+ "description": "Additional directories to auto-allow for reads as Pi infrastructure, bypassing the external_directory gate. Supports ~ expansion. Directory prefixes only (no globs).",
34
+ "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, `agentDir`, `agentDir/git`, and project-local `.pi/npm/` and `.pi/git/`. Add entries here for edge cases where auto-discovery is insufficient.\n\nSupports `~` expansion. Directory prefixes only — no glob patterns.",
35
+ "type": "array",
36
+ "items": {
37
+ "type": "string",
38
+ "minLength": 1
39
+ },
40
+ "default": []
41
+ },
32
42
  "permission": {
33
43
  "description": "Flat permission policy. Each key is a surface name; values are a PermissionState string (catch-all) or a pattern→action map.",
34
44
  "markdownDescription": "Flat permission policy.\n\nEach top-level key is a surface name:\n- `\"*\"` — universal fallback (replaces `defaultPolicy.tools` from the legacy format)\n- Tool names (`read`, `write`, `bash`, `mcp`, `skill`, `external_directory`, etc.)\n\nA **string** value is shorthand for `{ \"*\": action }` (surface-level catch-all).\nAn **object** value maps wildcard patterns to actions — last matching pattern wins.\n\n**Merge order (lowest → highest precedence):** global → project → per-agent frontmatter.",
@@ -17,6 +17,8 @@ export interface PermissionSystemExtensionConfig {
17
17
  debugLog: boolean;
18
18
  permissionReviewLog: boolean;
19
19
  yoloMode: boolean;
20
+ /** Additional directories to auto-allow for reads as Pi infrastructure. */
21
+ piInfrastructureReadPaths?: string[];
20
22
  }
21
23
 
22
24
  export interface PermissionSystemConfigLoadResult {
@@ -81,11 +83,21 @@ export function normalizePermissionSystemConfig(
81
83
  raw: unknown,
82
84
  ): PermissionSystemExtensionConfig {
83
85
  const record = toRecord(raw);
84
- return {
86
+ const rawPaths = record.piInfrastructureReadPaths;
87
+ const piInfrastructureReadPaths: string[] | undefined =
88
+ Array.isArray(rawPaths) &&
89
+ rawPaths.every((p): p is string => typeof p === "string")
90
+ ? rawPaths
91
+ : undefined;
92
+ const result: PermissionSystemExtensionConfig = {
85
93
  debugLog: record.debugLog === true,
86
94
  permissionReviewLog: record.permissionReviewLog !== false,
87
95
  yoloMode: record.yoloMode === true,
88
96
  };
97
+ if (piInfrastructureReadPaths !== undefined) {
98
+ result.piInfrastructureReadPaths = piInfrastructureReadPaths;
99
+ }
100
+ return result;
89
101
  }
90
102
 
91
103
  function ensureConfigDirectory(configPath: string): void {
@@ -1,9 +1,38 @@
1
1
  import { createRequire } from "node:module";
2
2
  import { homedir } from "node:os";
3
- import { join, normalize, resolve, sep } from "node:path";
3
+ import { basename, dirname, join, normalize, resolve, sep } from "node:path";
4
+ import { fileURLToPath } from "node:url";
4
5
 
5
6
  import { getNonEmptyString, toRecord } from "./common";
6
7
 
8
+ /**
9
+ * Discover the global node_modules root by walking up from the given file URL
10
+ * (defaults to this module's own `import.meta.url`).
11
+ *
12
+ * Works regardless of package manager (npm, pnpm, bun, Homebrew) because the
13
+ * extension itself is installed inside the directory we want to find.
14
+ * Returns `null` when the file is not inside any node_modules tree, or when
15
+ * the URL cannot be parsed — callers must degrade gracefully.
16
+ */
17
+ export function discoverGlobalNodeModulesRoot(
18
+ fromUrl = import.meta.url,
19
+ ): string | null {
20
+ try {
21
+ const thisFile = fileURLToPath(fromUrl);
22
+ let dir = dirname(thisFile);
23
+ // Walk up until we find a directory named "node_modules" or hit the root.
24
+ while (dir !== dirname(dir)) {
25
+ if (basename(dir) === "node_modules") {
26
+ return dir;
27
+ }
28
+ dir = dirname(dir);
29
+ }
30
+ return null;
31
+ } catch {
32
+ return null;
33
+ }
34
+ }
35
+
7
36
  /**
8
37
  * Paths that are universally safe and should never trigger external-directory checks.
9
38
  * These are OS device files: read returns EOF or process streams, write discards or goes to process streams.
@@ -23,6 +52,60 @@ export function isSafeSystemPath(normalizedPath: string): boolean {
23
52
  return SAFE_SYSTEM_PATHS.has(normalizedPath);
24
53
  }
25
54
 
55
+ /**
56
+ * Returns true if the given tool + normalized path combination qualifies for
57
+ * automatic allow as a Pi infrastructure read.
58
+ *
59
+ * A path qualifies when:
60
+ * 1. The tool is read-only (in READ_ONLY_PATH_BEARING_TOOLS).
61
+ * 2. The normalized path is within one of the provided `infrastructureDirs`
62
+ * OR within the project-local Pi package directories
63
+ * (`<cwd>/.pi/npm/` or `<cwd>/.pi/git/`).
64
+ *
65
+ * `infrastructureDirs` should contain pre-expanded absolute paths (no `~`).
66
+ * Project-local paths are computed fresh from `cwd` on each call so they
67
+ * follow working-directory changes without a runtime rebuild.
68
+ */
69
+ export function isPiInfrastructureRead(
70
+ toolName: string,
71
+ normalizedPath: string,
72
+ infrastructureDirs: readonly string[],
73
+ cwd: string,
74
+ ): boolean {
75
+ if (!READ_ONLY_PATH_BEARING_TOOLS.has(toolName)) {
76
+ return false;
77
+ }
78
+
79
+ for (const dir of infrastructureDirs) {
80
+ if (isPathWithinDirectory(normalizedPath, dir)) {
81
+ return true;
82
+ }
83
+ }
84
+
85
+ // Project-local Pi packages — checked fresh every call so CWD changes work.
86
+ const projectNpmDir = join(cwd, ".pi", "npm");
87
+ const projectGitDir = join(cwd, ".pi", "git");
88
+ if (isPathWithinDirectory(normalizedPath, projectNpmDir)) {
89
+ return true;
90
+ }
91
+ if (isPathWithinDirectory(normalizedPath, projectGitDir)) {
92
+ return true;
93
+ }
94
+
95
+ return false;
96
+ }
97
+
98
+ /**
99
+ * File tools that only read — never write — the filesystem.
100
+ * Only these tools are eligible for the Pi infrastructure auto-allow.
101
+ */
102
+ export const READ_ONLY_PATH_BEARING_TOOLS: ReadonlySet<string> = new Set([
103
+ "read",
104
+ "find",
105
+ "grep",
106
+ "ls",
107
+ ]);
108
+
26
109
  export const PATH_BEARING_TOOLS = new Set([
27
110
  "read",
28
111
  "write",
@@ -352,6 +435,13 @@ function collectPathCandidateTokens(node: TSNode, tokens: string[]): void {
352
435
  */
353
436
  const URL_PATTERN = /^[a-z][a-z0-9+.-]*:\/\//i;
354
437
 
438
+ /**
439
+ * Regex metacharacter sequences that are never found in real filesystem paths.
440
+ * If a token contains any of these, it is almost certainly a regex pattern
441
+ * (e.g. a grep argument) rather than a path.
442
+ */
443
+ const REGEX_METACHAR_PATTERN = /\.\*|\.\+|\\\||\\\(|\\\)|\[.*?\]|\^\//;
444
+
355
445
  /**
356
446
  * Determines whether a token looks like a path candidate worth resolving.
357
447
  * Returns the raw token string if it's a candidate, or null to skip.
@@ -380,6 +470,11 @@ function classifyTokenAsPathCandidate(token: string): string | null {
380
470
  // and are never meaningful path arguments in practice.
381
471
  if (/^\/+$/.test(token)) return null;
382
472
 
473
+ // Skip tokens that contain regex metacharacter sequences — these are almost
474
+ // certainly grep/sed/awk patterns, not filesystem paths.
475
+ // Matches: .*, .+, \|, \(, \), [...], or ^/ (anchored regex starting with /)
476
+ if (REGEX_METACHAR_PATTERN.test(token)) return null;
477
+
383
478
  // Must look like a path: starts with /, ~/, or contains ..
384
479
  if (token.startsWith("/")) return token;
385
480
  if (token.startsWith("~/")) return token;
@@ -15,6 +15,7 @@ import {
15
15
  formatExternalDirectoryUserDeniedReason,
16
16
  getPathBearingToolPath,
17
17
  isPathOutsideWorkingDirectory,
18
+ isPiInfrastructureRead,
18
19
  normalizePathForComparison,
19
20
  PATH_BEARING_TOOLS,
20
21
  } from "../external-directory";
@@ -170,82 +171,107 @@ export async function handleToolCall(
170
171
  externalDirectoryPath,
171
172
  ctx.cwd,
172
173
  );
173
- const extCheck = deps.runtime.permissionManager.checkPermission(
174
- "external_directory",
175
- { path: normalizedExtPath },
176
- agentName ?? undefined,
177
- deps.runtime.sessionRules.getRuleset(),
178
- );
179
174
 
180
- if (extCheck.source === "session") {
181
- deps.runtime.writeReviewLog("permission_request.session_approved", {
182
- source: "tool_call",
183
- toolCallId: (event as { toolCallId: string }).toolCallId,
184
- toolName,
185
- agentName,
186
- path: externalDirectoryPath,
187
- resolution: "session_approved",
188
- sessionApprovalPattern: extCheck.matchedPattern,
189
- });
190
- // Fall through to normal permission check
175
+ // ── Pi infrastructure read bypass ──────────────────────────────────
176
+ // Auto-allow read-only tools targeting Pi infrastructure directories
177
+ // (agent dir, global node_modules, project-local .pi/npm|git, and
178
+ // any user-configured extras). Writes are never bypassed.
179
+ const allInfraDirs = [
180
+ ...deps.runtime.piInfrastructureDirs,
181
+ ...(deps.runtime.config.piInfrastructureReadPaths ?? []),
182
+ ];
183
+ if (
184
+ isPiInfrastructureRead(toolName, normalizedExtPath, allInfraDirs, ctx.cwd)
185
+ ) {
186
+ deps.runtime.writeReviewLog(
187
+ "permission_request.infrastructure_auto_allowed",
188
+ {
189
+ source: "tool_call",
190
+ toolCallId: (event as { toolCallId: string }).toolCallId,
191
+ toolName,
192
+ agentName,
193
+ path: externalDirectoryPath,
194
+ },
195
+ );
196
+ // Fall through to normal tool-permission check.
191
197
  } else {
192
- let extDirDecision: PermissionPromptDecision | null = null;
193
- const extDirMessage = formatExternalDirectoryAskPrompt(
194
- toolName,
195
- externalDirectoryPath,
196
- ctx.cwd,
198
+ const extCheck = deps.runtime.permissionManager.checkPermission(
199
+ "external_directory",
200
+ { path: normalizedExtPath },
197
201
  agentName ?? undefined,
202
+ deps.runtime.sessionRules.getRuleset(),
198
203
  );
199
- const extDirGate = await applyPermissionGate({
200
- state: extCheck.state,
201
- canConfirm: deps.canRequestPermissionConfirmation(ctx),
202
- promptForApproval: async () => {
203
- const decision = await deps.promptPermission(ctx, {
204
- requestId: (event as { toolCallId: string }).toolCallId,
205
- source: "tool_call",
206
- agentName,
207
- message: extDirMessage,
208
- toolCallId: (event as { toolCallId: string }).toolCallId,
209
- toolName,
210
- path: externalDirectoryPath,
211
- });
212
- extDirDecision = decision;
213
- return decision;
214
- },
215
- writeLog: deps.runtime.writeReviewLog,
216
- logContext: {
204
+
205
+ if (extCheck.source === "session") {
206
+ deps.runtime.writeReviewLog("permission_request.session_approved", {
217
207
  source: "tool_call",
218
208
  toolCallId: (event as { toolCallId: string }).toolCallId,
219
209
  toolName,
220
210
  agentName,
221
211
  path: externalDirectoryPath,
222
- message: extDirMessage,
223
- },
224
- messages: {
225
- denyReason: formatExternalDirectoryDenyReason(
212
+ resolution: "session_approved",
213
+ sessionApprovalPattern: extCheck.matchedPattern,
214
+ });
215
+ // Fall through to normal permission check
216
+ } else {
217
+ let extDirDecision: PermissionPromptDecision | null = null;
218
+ const extDirMessage = formatExternalDirectoryAskPrompt(
219
+ toolName,
220
+ externalDirectoryPath,
221
+ ctx.cwd,
222
+ agentName ?? undefined,
223
+ );
224
+ const extDirGate = await applyPermissionGate({
225
+ state: extCheck.state,
226
+ canConfirm: deps.canRequestPermissionConfirmation(ctx),
227
+ promptForApproval: async () => {
228
+ const decision = await deps.promptPermission(ctx, {
229
+ requestId: (event as { toolCallId: string }).toolCallId,
230
+ source: "tool_call",
231
+ agentName,
232
+ message: extDirMessage,
233
+ toolCallId: (event as { toolCallId: string }).toolCallId,
234
+ toolName,
235
+ path: externalDirectoryPath,
236
+ });
237
+ extDirDecision = decision;
238
+ return decision;
239
+ },
240
+ writeLog: deps.runtime.writeReviewLog,
241
+ logContext: {
242
+ source: "tool_call",
243
+ toolCallId: (event as { toolCallId: string }).toolCallId,
226
244
  toolName,
227
- externalDirectoryPath,
228
- ctx.cwd,
229
- agentName ?? undefined,
230
- ),
231
- unavailableReason: `Accessing '${externalDirectoryPath}' outside the working directory requires approval, but no interactive UI is available.`,
232
- userDeniedReason: (decision) =>
233
- formatExternalDirectoryUserDeniedReason(
245
+ agentName,
246
+ path: externalDirectoryPath,
247
+ message: extDirMessage,
248
+ },
249
+ messages: {
250
+ denyReason: formatExternalDirectoryDenyReason(
234
251
  toolName,
235
252
  externalDirectoryPath,
236
- decision.denialReason,
253
+ ctx.cwd,
254
+ agentName ?? undefined,
237
255
  ),
238
- },
239
- });
240
- if (extDirGate.action === "block") {
241
- return { block: true, reason: extDirGate.reason };
242
- }
256
+ unavailableReason: `Accessing '${externalDirectoryPath}' outside the working directory requires approval, but no interactive UI is available.`,
257
+ userDeniedReason: (decision) =>
258
+ formatExternalDirectoryUserDeniedReason(
259
+ toolName,
260
+ externalDirectoryPath,
261
+ decision.denialReason,
262
+ ),
263
+ },
264
+ });
265
+ if (extDirGate.action === "block") {
266
+ return { block: true, reason: extDirGate.reason };
267
+ }
243
268
 
244
- if (extDirDecision?.state === "approved_for_session") {
245
- const pattern = deriveApprovalPattern(normalizedExtPath);
246
- deps.runtime.sessionRules.approve("external_directory", pattern);
269
+ if (extDirDecision?.state === "approved_for_session") {
270
+ const pattern = deriveApprovalPattern(normalizedExtPath);
271
+ deps.runtime.sessionRules.approve("external_directory", pattern);
272
+ }
247
273
  }
248
- }
274
+ } // end else (not Pi infrastructure read)
249
275
  // Fall through to normal permission check
250
276
  }
251
277
 
package/src/runtime.ts CHANGED
@@ -34,6 +34,7 @@ import {
34
34
  normalizePermissionSystemConfig,
35
35
  type PermissionSystemExtensionConfig,
36
36
  } from "./extension-config";
37
+ import { discoverGlobalNodeModulesRoot } from "./external-directory";
37
38
  import {
38
39
  type PermissionForwardingDeps,
39
40
  processForwardedPermissionRequests,
@@ -64,6 +65,14 @@ export interface ExtensionRuntime {
64
65
  readonly subagentSessionsDir: string;
65
66
  readonly forwardingDir: string;
66
67
  readonly globalLogsDir: string;
68
+ /**
69
+ * Static Pi infrastructure directories used for external-directory
70
+ * read auto-allow. Computed once at construction from `agentDir` and
71
+ * `discoverGlobalNodeModulesRoot()`. Config-based extras
72
+ * (`piInfrastructureReadPaths`) are read from `runtime.config` at
73
+ * call time in the handler so they pick up config reloads.
74
+ */
75
+ readonly piInfrastructureDirs: string[];
67
76
 
68
77
  // ── Mutable state ──────────────────────────────────────────────────────
69
78
  config: PermissionSystemExtensionConfig;
@@ -341,6 +350,13 @@ export function createExtensionRuntime(options?: {
341
350
  const forwardingDir = join(sessionsDir, "permission-forwarding");
342
351
  const globalLogsDir = getGlobalLogsDir(agentDir);
343
352
 
353
+ const globalNodeModulesRoot = discoverGlobalNodeModulesRoot();
354
+ const piInfrastructureDirs: string[] = [
355
+ agentDir,
356
+ join(agentDir, "git"),
357
+ ...(globalNodeModulesRoot ? [globalNodeModulesRoot] : []),
358
+ ];
359
+
344
360
  // Build a plain-object runtime first so the logger's `getConfig` closure
345
361
  // can reference `runtime.config` directly (always reads current value).
346
362
  const runtime: ExtensionRuntime = {
@@ -349,6 +365,7 @@ export function createExtensionRuntime(options?: {
349
365
  subagentSessionsDir,
350
366
  forwardingDir,
351
367
  globalLogsDir,
368
+ piInfrastructureDirs,
352
369
  config: { ...DEFAULT_EXTENSION_CONFIG },
353
370
  runtimeContext: null,
354
371
  permissionManager: createPermissionManagerForCwd(agentDir, undefined),
@@ -544,6 +544,56 @@ describe("extractExternalPathsFromBashCommand", () => {
544
544
  expect(etcHostsCount).toBe(1);
545
545
  });
546
546
  });
547
+
548
+ describe("regex patterns are not mistaken for paths", () => {
549
+ test("grep -v with //.*pattern is not flagged", async () => {
550
+ const result = await extractExternalPathsFromBashCommand(
551
+ 'grep -n "glob" src/foo.ts 2>/dev/null | grep -v "//.*glob\\|globalConfig" | head -30',
552
+ cwd,
553
+ );
554
+ expect(result).toHaveLength(0);
555
+ });
556
+
557
+ test("grep -v with //.*pattern without backslash-pipe is not flagged", async () => {
558
+ const result = await extractExternalPathsFromBashCommand(
559
+ 'grep -v "//.*foo" file.txt',
560
+ cwd,
561
+ );
562
+ expect(result).toHaveLength(0);
563
+ });
564
+
565
+ test("grep with backslash-pipe alternation is not flagged", async () => {
566
+ const result = await extractExternalPathsFromBashCommand(
567
+ 'grep "foo\\|bar\\|baz" src/file.ts',
568
+ cwd,
569
+ );
570
+ expect(result).toHaveLength(0);
571
+ });
572
+
573
+ test("grep -E with ^/ anchored regex is not flagged", async () => {
574
+ const result = await extractExternalPathsFromBashCommand(
575
+ 'grep -E "^/usr/bin" file.txt',
576
+ cwd,
577
+ );
578
+ expect(result).toHaveLength(0);
579
+ });
580
+
581
+ test("sed with regex containing slashes is not flagged", async () => {
582
+ const result = await extractExternalPathsFromBashCommand(
583
+ 'sed "s/foo.*/bar/g" file.txt',
584
+ cwd,
585
+ );
586
+ expect(result).toHaveLength(0);
587
+ });
588
+
589
+ test("real external paths are still detected alongside regex args", async () => {
590
+ const result = await extractExternalPathsFromBashCommand(
591
+ 'grep -v "//.*pattern" /etc/hosts',
592
+ cwd,
593
+ );
594
+ expect(result).toContain("/etc/hosts");
595
+ });
596
+ });
547
597
  });
548
598
 
549
599
  describe("formatBashExternalDirectoryAskPrompt", () => {
@@ -64,6 +64,7 @@ function makeRuntime(
64
64
  subagentSessionsDir: "/test/agent/subagent-sessions",
65
65
  forwardingDir: "/test/agent/sessions/permission-forwarding",
66
66
  globalLogsDir: "/test/agent/extensions/pi-permission-system/logs",
67
+ piInfrastructureDirs: ["/test/agent", "/test/agent/git"],
67
68
  config: { debugLog: false, permissionReviewLog: true, yoloMode: false },
68
69
  runtimeContext: null,
69
70
  permissionManager: {
@@ -395,6 +396,152 @@ describe("handleToolCall — external-directory gate", () => {
395
396
  });
396
397
  });
397
398
 
399
+ // ── Pi infrastructure read bypass ───────────────────────────────────────────
400
+
401
+ describe("handleToolCall — Pi infrastructure read bypass", () => {
402
+ const infraPath = "/test/agent/git/some-package/SKILL.md";
403
+
404
+ it("skips external-directory gate for read tool targeting an infra dir", async () => {
405
+ const deps = makeDeps({
406
+ runtime: makeRuntime({
407
+ permissionManager: {
408
+ checkPermission: vi
409
+ .fn()
410
+ .mockReturnValue(makePermissionResult("allow")),
411
+ } as unknown as ExtensionRuntime["permissionManager"],
412
+ }),
413
+ getAllTools: vi.fn().mockReturnValue([{ name: "read" }]),
414
+ });
415
+ const event = {
416
+ type: "tool_call",
417
+ toolCallId: "tc-infra-read",
418
+ name: "read",
419
+ input: { path: infraPath },
420
+ };
421
+ const result = await handleToolCall(deps, event, makeCtx());
422
+ expect(result).toEqual({});
423
+ // external_directory permission check must NOT have been called.
424
+ const checkPermission = deps.runtime.permissionManager
425
+ .checkPermission as ReturnType<typeof vi.fn>;
426
+ const calls = checkPermission.mock.calls as Array<[string, ...unknown[]]>;
427
+ const extDirCalls = calls.filter(
428
+ ([surface]) => surface === "external_directory",
429
+ );
430
+ expect(extDirCalls).toHaveLength(0);
431
+ });
432
+
433
+ it("does NOT skip gate for write tool targeting an infra dir", async () => {
434
+ const deps = makeDeps({
435
+ runtime: makeRuntime({
436
+ permissionManager: {
437
+ checkPermission: vi
438
+ .fn()
439
+ .mockReturnValue(makePermissionResult("deny")),
440
+ } as unknown as ExtensionRuntime["permissionManager"],
441
+ }),
442
+ getAllTools: vi.fn().mockReturnValue([{ name: "write" }]),
443
+ });
444
+ const event = {
445
+ type: "tool_call",
446
+ toolCallId: "tc-infra-write",
447
+ name: "write",
448
+ input: { path: infraPath },
449
+ };
450
+ const result = await handleToolCall(deps, event, makeCtx());
451
+ expect(result).toMatchObject({ block: true });
452
+ const checkPermission = deps.runtime.permissionManager
453
+ .checkPermission as ReturnType<typeof vi.fn>;
454
+ const calls = checkPermission.mock.calls as Array<[string, ...unknown[]]>;
455
+ const extDirCalls = calls.filter(
456
+ ([surface]) => surface === "external_directory",
457
+ );
458
+ expect(extDirCalls.length).toBeGreaterThan(0);
459
+ });
460
+
461
+ it("does NOT skip gate for read tool targeting a non-infra external path", async () => {
462
+ const deps = makeDeps({
463
+ runtime: makeRuntime({
464
+ permissionManager: {
465
+ checkPermission: vi
466
+ .fn()
467
+ .mockReturnValue(makePermissionResult("deny")),
468
+ } as unknown as ExtensionRuntime["permissionManager"],
469
+ }),
470
+ getAllTools: vi.fn().mockReturnValue([{ name: "read" }]),
471
+ });
472
+ const event = {
473
+ type: "tool_call",
474
+ toolCallId: "tc-non-infra",
475
+ name: "read",
476
+ input: { path: "/etc/passwd" },
477
+ };
478
+ const result = await handleToolCall(deps, event, makeCtx());
479
+ expect(result).toMatchObject({ block: true });
480
+ const checkPermission = deps.runtime.permissionManager
481
+ .checkPermission as ReturnType<typeof vi.fn>;
482
+ const calls = checkPermission.mock.calls as Array<[string, ...unknown[]]>;
483
+ const extDirCalls = calls.filter(
484
+ ([surface]) => surface === "external_directory",
485
+ );
486
+ expect(extDirCalls.length).toBeGreaterThan(0);
487
+ });
488
+
489
+ it("writes a review log entry when bypassing the gate", async () => {
490
+ const writeReviewLog = vi.fn();
491
+ const deps = makeDeps({
492
+ runtime: makeRuntime({ writeReviewLog }),
493
+ getAllTools: vi.fn().mockReturnValue([{ name: "read" }]),
494
+ });
495
+ const event = {
496
+ type: "tool_call",
497
+ toolCallId: "tc-infra-log",
498
+ name: "read",
499
+ input: { path: infraPath },
500
+ };
501
+ await handleToolCall(deps, event, makeCtx());
502
+ expect(writeReviewLog).toHaveBeenCalledWith(
503
+ "permission_request.infrastructure_auto_allowed",
504
+ expect.objectContaining({ toolName: "read", path: infraPath }),
505
+ );
506
+ });
507
+
508
+ it("respects config piInfrastructureReadPaths for bypass", async () => {
509
+ const customInfraPath = "/custom/infra/packages/SKILL.md";
510
+ const deps = makeDeps({
511
+ runtime: makeRuntime({
512
+ piInfrastructureDirs: [],
513
+ config: {
514
+ debugLog: false,
515
+ permissionReviewLog: true,
516
+ yoloMode: false,
517
+ piInfrastructureReadPaths: ["/custom/infra/packages"],
518
+ },
519
+ permissionManager: {
520
+ checkPermission: vi
521
+ .fn()
522
+ .mockReturnValue(makePermissionResult("allow")),
523
+ } as unknown as ExtensionRuntime["permissionManager"],
524
+ }),
525
+ getAllTools: vi.fn().mockReturnValue([{ name: "read" }]),
526
+ });
527
+ const event = {
528
+ type: "tool_call",
529
+ toolCallId: "tc-config-infra",
530
+ name: "read",
531
+ input: { path: customInfraPath },
532
+ };
533
+ const result = await handleToolCall(deps, event, makeCtx());
534
+ expect(result).toEqual({});
535
+ const checkPermission = deps.runtime.permissionManager
536
+ .checkPermission as ReturnType<typeof vi.fn>;
537
+ const calls = checkPermission.mock.calls as Array<[string, ...unknown[]]>;
538
+ const extDirCalls = calls.filter(
539
+ ([surface]) => surface === "external_directory",
540
+ );
541
+ expect(extDirCalls).toHaveLength(0);
542
+ });
543
+ });
544
+
398
545
  // ── bash external-directory gate ──────────────────────────────────────────
399
546
 
400
547
  describe("handleToolCall — bash external-directory gate", () => {
@@ -0,0 +1,245 @@
1
+ import { join } from "node:path";
2
+ import { describe, expect, test } from "vitest";
3
+
4
+ import {
5
+ discoverGlobalNodeModulesRoot,
6
+ isPiInfrastructureRead,
7
+ } from "../src/external-directory";
8
+
9
+ // ── discoverGlobalNodeModulesRoot ──────────────────────────────────────────
10
+
11
+ describe("discoverGlobalNodeModulesRoot", () => {
12
+ test("returns the node_modules dir when the file is inside one", () => {
13
+ const url =
14
+ "file:///opt/homebrew/lib/node_modules/pi-permission-system/dist/external-directory.js";
15
+ expect(discoverGlobalNodeModulesRoot(url)).toBe(
16
+ "/opt/homebrew/lib/node_modules",
17
+ );
18
+ });
19
+
20
+ test("returns node_modules for a deeply nested file", () => {
21
+ const url =
22
+ "file:///home/user/.nvm/versions/node/v20/lib/node_modules/pi-permission-system/src/external-directory.js";
23
+ expect(discoverGlobalNodeModulesRoot(url)).toBe(
24
+ "/home/user/.nvm/versions/node/v20/lib/node_modules",
25
+ );
26
+ });
27
+
28
+ test("returns node_modules for a bun global install path", () => {
29
+ const url =
30
+ "file:///home/user/.bun/install/global/node_modules/pi-permission-system/dist/external-directory.js";
31
+ expect(discoverGlobalNodeModulesRoot(url)).toBe(
32
+ "/home/user/.bun/install/global/node_modules",
33
+ );
34
+ });
35
+
36
+ test("returns the innermost (closest-to-file) node_modules ancestor", () => {
37
+ // The walk-up algorithm stops at the first node_modules dir it encounters,
38
+ // which is the innermost one when the file is inside a nested install.
39
+ // In practice this never happens for a real global install — the extension
40
+ // is always directly at <global_root>/node_modules/pi-permission-system/…
41
+ const url =
42
+ "file:///opt/lib/node_modules/some-pkg/node_modules/pi-permission-system/dist/index.js";
43
+ expect(discoverGlobalNodeModulesRoot(url)).toBe(
44
+ "/opt/lib/node_modules/some-pkg/node_modules",
45
+ );
46
+ });
47
+
48
+ test("returns null when the file is not inside any node_modules directory", () => {
49
+ const url =
50
+ "file:///home/user/development/pi-permission-system/dist/external-directory.js";
51
+ expect(discoverGlobalNodeModulesRoot(url)).toBeNull();
52
+ });
53
+
54
+ test("returns null for a root-level file", () => {
55
+ const url = "file:///external-directory.js";
56
+ expect(discoverGlobalNodeModulesRoot(url)).toBeNull();
57
+ });
58
+
59
+ test("returns null for an invalid URL", () => {
60
+ expect(discoverGlobalNodeModulesRoot("not-a-url")).toBeNull();
61
+ });
62
+
63
+ test("works with the real import.meta.url of this extension (smoke test)", () => {
64
+ // The extension IS installed inside a node_modules tree when running in CI
65
+ // or global install. In a local dev checkout the result may be null — that's
66
+ // the documented graceful-degradation path.
67
+ const result = discoverGlobalNodeModulesRoot();
68
+ expect(result === null || result.endsWith("node_modules")).toBe(true);
69
+ });
70
+
71
+ test("the discovered path includes the pi-permission-system package directory", () => {
72
+ const url =
73
+ "file:///opt/homebrew/lib/node_modules/pi-permission-system/dist/external-directory.js";
74
+ const root = discoverGlobalNodeModulesRoot(url);
75
+ expect(root).not.toBeNull();
76
+ expect(join(root!, "pi-permission-system")).toBe(
77
+ "/opt/homebrew/lib/node_modules/pi-permission-system",
78
+ );
79
+ });
80
+ });
81
+
82
+ // ── isPiInfrastructureRead ─────────────────────────────────────────────────
83
+
84
+ const INFRA_DIRS = [
85
+ "/home/user/.pi/agent",
86
+ "/home/user/.pi/agent/git",
87
+ "/opt/homebrew/lib/node_modules",
88
+ ];
89
+ const CWD = "/home/user/project";
90
+
91
+ describe("isPiInfrastructureRead", () => {
92
+ // ── read tools allowed for infra paths ──────────────────────────────────
93
+
94
+ test("allows 'read' tool for a file inside agentDir", () => {
95
+ expect(
96
+ isPiInfrastructureRead(
97
+ "read",
98
+ "/home/user/.pi/agent/extensions/pi-permission-system/config.json",
99
+ INFRA_DIRS,
100
+ CWD,
101
+ ),
102
+ ).toBe(true);
103
+ });
104
+
105
+ test("allows 'find' tool for a path inside node_modules infra dir", () => {
106
+ expect(
107
+ isPiInfrastructureRead(
108
+ "find",
109
+ "/opt/homebrew/lib/node_modules/pi-ask-user/skills",
110
+ INFRA_DIRS,
111
+ CWD,
112
+ ),
113
+ ).toBe(true);
114
+ });
115
+
116
+ test("allows 'grep' tool for a path inside agentDir/git", () => {
117
+ expect(
118
+ isPiInfrastructureRead(
119
+ "grep",
120
+ "/home/user/.pi/agent/git/some-package/README.md",
121
+ INFRA_DIRS,
122
+ CWD,
123
+ ),
124
+ ).toBe(true);
125
+ });
126
+
127
+ test("allows 'ls' tool for a path inside node_modules infra dir", () => {
128
+ expect(
129
+ isPiInfrastructureRead(
130
+ "ls",
131
+ "/opt/homebrew/lib/node_modules/pi-permission-system",
132
+ INFRA_DIRS,
133
+ CWD,
134
+ ),
135
+ ).toBe(true);
136
+ });
137
+
138
+ // ── write tools never allowed even for infra paths ───────────────────────
139
+
140
+ test("blocks 'write' tool for a file inside agentDir", () => {
141
+ expect(
142
+ isPiInfrastructureRead(
143
+ "write",
144
+ "/home/user/.pi/agent/extensions/pi-permission-system/config.json",
145
+ INFRA_DIRS,
146
+ CWD,
147
+ ),
148
+ ).toBe(false);
149
+ });
150
+
151
+ test("blocks 'edit' tool for a file inside node_modules", () => {
152
+ expect(
153
+ isPiInfrastructureRead(
154
+ "edit",
155
+ "/opt/homebrew/lib/node_modules/pi-ask-user/skills/ask-user/SKILL.md",
156
+ INFRA_DIRS,
157
+ CWD,
158
+ ),
159
+ ).toBe(false);
160
+ });
161
+
162
+ test("blocks 'bash' tool regardless of path", () => {
163
+ expect(
164
+ isPiInfrastructureRead(
165
+ "bash",
166
+ "/opt/homebrew/lib/node_modules/pi-ask-user/SKILL.md",
167
+ INFRA_DIRS,
168
+ CWD,
169
+ ),
170
+ ).toBe(false);
171
+ });
172
+
173
+ // ── non-infra paths not allowed ──────────────────────────────────────────
174
+
175
+ test("does not allow 'read' for a path outside all infra dirs", () => {
176
+ expect(isPiInfrastructureRead("read", "/etc/passwd", INFRA_DIRS, CWD)).toBe(
177
+ false,
178
+ );
179
+ });
180
+
181
+ test("does not allow 'read' for a path only partially matching an infra dir prefix", () => {
182
+ // /home/user/.pi/agent-other should not match /home/user/.pi/agent
183
+ expect(
184
+ isPiInfrastructureRead(
185
+ "read",
186
+ "/home/user/.pi/agent-other/config.json",
187
+ INFRA_DIRS,
188
+ CWD,
189
+ ),
190
+ ).toBe(false);
191
+ });
192
+
193
+ // ── project-local Pi packages (.pi/npm, .pi/git) ─────────────────────────
194
+
195
+ test("allows 'read' for a path inside project-local .pi/npm/", () => {
196
+ expect(
197
+ isPiInfrastructureRead(
198
+ "read",
199
+ `${CWD}/.pi/npm/node_modules/some-skill/SKILL.md`,
200
+ INFRA_DIRS,
201
+ CWD,
202
+ ),
203
+ ).toBe(true);
204
+ });
205
+
206
+ test("allows 'read' for a path inside project-local .pi/git/", () => {
207
+ expect(
208
+ isPiInfrastructureRead(
209
+ "read",
210
+ `${CWD}/.pi/git/github.com/org/skill-repo/SKILL.md`,
211
+ INFRA_DIRS,
212
+ CWD,
213
+ ),
214
+ ).toBe(true);
215
+ });
216
+
217
+ test("blocks 'write' for a path inside project-local .pi/npm/", () => {
218
+ expect(
219
+ isPiInfrastructureRead(
220
+ "write",
221
+ `${CWD}/.pi/npm/node_modules/some-skill/SKILL.md`,
222
+ INFRA_DIRS,
223
+ CWD,
224
+ ),
225
+ ).toBe(false);
226
+ });
227
+
228
+ // ── empty / edge cases ───────────────────────────────────────────────────
229
+
230
+ test("returns false when infrastructureDirs is empty and path is not project-local", () => {
231
+ expect(isPiInfrastructureRead("read", "/etc/passwd", [], CWD)).toBe(false);
232
+ });
233
+
234
+ test("returns false when infrastructureDirs is empty but path IS project-local .pi/npm", () => {
235
+ // Project-local paths are checked separately from the dirs array.
236
+ expect(
237
+ isPiInfrastructureRead(
238
+ "read",
239
+ `${CWD}/.pi/npm/node_modules/x/SKILL.md`,
240
+ [],
241
+ CWD,
242
+ ),
243
+ ).toBe(true);
244
+ });
245
+ });
@@ -11,6 +11,7 @@ const {
11
11
  mockGetActiveAgentName,
12
12
  mockGetActiveAgentNameFromSystemPrompt,
13
13
  mockBuildResolvedConfigLogEntry,
14
+ mockDiscoverGlobalNodeModulesRoot,
14
15
  } = vi.hoisted(() => ({
15
16
  mockLoggerDebug:
16
17
  vi.fn<
@@ -27,6 +28,7 @@ const {
27
28
  mockGetActiveAgentNameFromSystemPrompt:
28
29
  vi.fn<(prompt?: string) => string | null>(),
29
30
  mockBuildResolvedConfigLogEntry: vi.fn(),
31
+ mockDiscoverGlobalNodeModulesRoot: vi.fn<() => string | null>(),
30
32
  }));
31
33
 
32
34
  vi.mock("../src/logging", () => ({
@@ -65,6 +67,10 @@ vi.mock("../src/subagent-context", () => ({
65
67
  isSubagentExecutionContext: vi.fn().mockReturnValue(false),
66
68
  }));
67
69
 
70
+ vi.mock("../src/external-directory", () => ({
71
+ discoverGlobalNodeModulesRoot: mockDiscoverGlobalNodeModulesRoot,
72
+ }));
73
+
68
74
  vi.mock("../src/session-rules", () => ({
69
75
  SessionRules: vi.fn(),
70
76
  deriveApprovalPattern: vi.fn(),
@@ -99,6 +105,10 @@ describe("createExtensionRuntime", () => {
99
105
  debug: mockLoggerDebug,
100
106
  review: mockLoggerReview,
101
107
  });
108
+ mockDiscoverGlobalNodeModulesRoot.mockReset();
109
+ mockDiscoverGlobalNodeModulesRoot.mockReturnValue(
110
+ "/mock/global/node_modules",
111
+ );
102
112
  });
103
113
 
104
114
  // ── Path derivation ──────────────────────────────────────────────────────
@@ -130,6 +140,41 @@ describe("createExtensionRuntime", () => {
130
140
  expect(runtime.globalLogsDir).toBe(getGlobalLogsDir("/test/agent"));
131
141
  });
132
142
 
143
+ // ── piInfrastructureDirs ─────────────────────────────────────────────────
144
+
145
+ it("includes agentDir in piInfrastructureDirs", () => {
146
+ const runtime = createExtensionRuntime({ agentDir: "/test/agent" });
147
+ expect(runtime.piInfrastructureDirs).toContain("/test/agent");
148
+ });
149
+
150
+ it("includes agentDir/git in piInfrastructureDirs", () => {
151
+ const runtime = createExtensionRuntime({ agentDir: "/test/agent" });
152
+ expect(runtime.piInfrastructureDirs).toContain("/test/agent/git");
153
+ });
154
+
155
+ it("includes discovered global node_modules root in piInfrastructureDirs", () => {
156
+ const runtime = createExtensionRuntime({ agentDir: "/test/agent" });
157
+ expect(runtime.piInfrastructureDirs).toContain("/mock/global/node_modules");
158
+ });
159
+
160
+ it("excludes null when discoverGlobalNodeModulesRoot returns null", () => {
161
+ mockDiscoverGlobalNodeModulesRoot.mockReturnValue(null);
162
+ const runtime = createExtensionRuntime({ agentDir: "/test/agent" });
163
+ for (const dir of runtime.piInfrastructureDirs) {
164
+ expect(dir).not.toBeNull();
165
+ expect(typeof dir).toBe("string");
166
+ }
167
+ });
168
+
169
+ it("omits global node_modules from piInfrastructureDirs when discovery returns null", () => {
170
+ mockDiscoverGlobalNodeModulesRoot.mockReturnValue(null);
171
+ const runtime = createExtensionRuntime({ agentDir: "/test/agent" });
172
+ // Only agentDir and agentDir/git should be present.
173
+ expect(runtime.piInfrastructureDirs).toHaveLength(2);
174
+ expect(runtime.piInfrastructureDirs).toContain("/test/agent");
175
+ expect(runtime.piInfrastructureDirs).toContain("/test/agent/git");
176
+ });
177
+
133
178
  // ── Default mutable state ────────────────────────────────────────────────
134
179
 
135
180
  it("initializes config to DEFAULT_EXTENSION_CONFIG", () => {