@gotgenes/pi-permission-system 4.8.0 → 5.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,49 @@ 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
+ ## [5.0.0](https://github.com/gotgenes/pi-permission-system/compare/v4.9.0...v5.0.0) (2026-05-05)
9
+
10
+
11
+ ### ⚠ BREAKING CHANGES
12
+
13
+ * Rule.origin and PermissionCheckResult.origin are now required fields. Code that constructs Rule or PermissionCheckResult literals must include an origin value.
14
+
15
+ ### Features
16
+
17
+ * add RuleOrigin type and origin field to Rule ([b4452d1](https://github.com/gotgenes/pi-permission-system/commit/b4452d1cc9e87a8315edcd6f5f2b1425310bd0b6))
18
+ * display rule origins in /permission-system show output ([af34c8e](https://github.com/gotgenes/pi-permission-system/commit/af34c8e808c7fa67bbe68635f776ec0fd8717bfa))
19
+ * include rule origin in permission review log entries ([b19fdf6](https://github.com/gotgenes/pi-permission-system/commit/b19fdf69b48248430410643ee20bee58535b99d9))
20
+ * make Rule.origin and PermissionCheckResult.origin required ([937a9f5](https://github.com/gotgenes/pi-permission-system/commit/937a9f5c4a9442611606fa3b27962555ed8c25a9))
21
+ * propagate origin to synthesized default rule ([04f9130](https://github.com/gotgenes/pi-permission-system/commit/04f91304ec5ba975ac512989c90757528a30ef7b))
22
+ * track and propagate rule origin through checkPermission ([327bc60](https://github.com/gotgenes/pi-permission-system/commit/327bc60e7f79aafd19995337f62244fd8b0c191f))
23
+
24
+
25
+ ### Documentation
26
+
27
+ * plan rule origin provenance tracking ([#88](https://github.com/gotgenes/pi-permission-system/issues/88)) ([d8f8840](https://github.com/gotgenes/pi-permission-system/commit/d8f884028f03682a896dd0d6e8e5a335d8e669f5))
28
+ * **retro:** add retro notes for issue [#48](https://github.com/gotgenes/pi-permission-system/issues/48) ([2187a53](https://github.com/gotgenes/pi-permission-system/commit/2187a53d2af30d6a68f664b1ce4af0dc30b39061))
29
+ * update target architecture for required Rule.origin ([edf0620](https://github.com/gotgenes/pi-permission-system/commit/edf06209ce148b70131a5abf361070571db51e7b))
30
+ * update target architecture for rule origin provenance ([c82435b](https://github.com/gotgenes/pi-permission-system/commit/c82435bb75dd7b22331986c6a23bfe5cf1849ca7))
31
+
32
+ ## [4.9.0](https://github.com/gotgenes/pi-permission-system/compare/v4.8.0...v4.9.0) (2026-05-05)
33
+
34
+
35
+ ### Features
36
+
37
+ * bypass external_directory gate for Pi infrastructure reads ([229a352](https://github.com/gotgenes/pi-permission-system/commit/229a35222dd47f1d0c079f0bcd34760569e912f3))
38
+
39
+
40
+ ### Bug Fixes
41
+
42
+ * skip regex patterns in bash external-directory path extraction ([9fe4ba6](https://github.com/gotgenes/pi-permission-system/commit/9fe4ba6d259c25aa0a9e3a5508884d26a303cac3))
43
+
44
+
45
+ ### Documentation
46
+
47
+ * document piInfrastructureReadPaths config and infrastructure auto-allow ([65e0ac8](https://github.com/gotgenes/pi-permission-system/commit/65e0ac8ef8a4973c261628e026c3772faa0849ab))
48
+ * 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))
49
+ * **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))
50
+
8
51
  ## [4.8.0](https://github.com/gotgenes/pi-permission-system/compare/v4.7.0...v4.8.0) (2026-05-05)
9
52
 
10
53
 
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": "5.0.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.",
@@ -9,6 +9,7 @@ import {
9
9
  DEFAULT_EXTENSION_CONFIG,
10
10
  type PermissionSystemExtensionConfig,
11
11
  } from "./extension-config";
12
+ import type { Ruleset } from "./rule";
12
13
 
13
14
  interface PermissionSystemConfigController {
14
15
  getConfig(): PermissionSystemExtensionConfig;
@@ -17,6 +18,8 @@ interface PermissionSystemConfigController {
17
18
  ctx: ExtensionCommandContext,
18
19
  ): void;
19
20
  getConfigPath(): string;
21
+ /** Optional: returns the composed config-layer ruleset for origin display. */
22
+ getComposedRules?(): Ruleset;
20
23
  }
21
24
 
22
25
  const ON_OFF = ["on", "off"];
@@ -57,12 +60,30 @@ function toOnOff(value: boolean): string {
57
60
  return value ? "on" : "off";
58
61
  }
59
62
 
60
- function summarizeConfig(config: PermissionSystemExtensionConfig): string {
61
- return [
63
+ function formatRulesSummary(rules: Ruleset): string {
64
+ const configRules = rules.filter((r) => r.layer === "config" && r.origin);
65
+ if (configRules.length === 0) return "";
66
+ const formatted = configRules
67
+ .map((r) => {
68
+ const key =
69
+ r.pattern === "*" ? r.surface : `${r.surface}["${r.pattern}"]`;
70
+ return `${key}=${r.action} (${r.origin})`;
71
+ })
72
+ .join(", ");
73
+ return `\n rules: ${formatted}`;
74
+ }
75
+
76
+ function summarizeConfig(
77
+ config: PermissionSystemExtensionConfig,
78
+ rules?: Ruleset,
79
+ ): string {
80
+ const knobs = [
62
81
  `yoloMode=${toOnOff(config.yoloMode)}`,
63
82
  `permissionReviewLog=${toOnOff(config.permissionReviewLog)}`,
64
83
  `debugLog=${toOnOff(config.debugLog)}`,
65
84
  ].join(", ");
85
+ const rulesSuffix = rules ? formatRulesSummary(rules) : "";
86
+ return `${knobs}${rulesSuffix}`;
66
87
  }
67
88
 
68
89
  function buildSettingItems(
@@ -183,8 +204,9 @@ function handleArgs(
183
204
  }
184
205
 
185
206
  if (normalized === "show") {
207
+ const rules = controller.getComposedRules?.();
186
208
  ctx.ui.notify(
187
- `permission-system: ${summarizeConfig(controller.getConfig())}`,
209
+ `permission-system: ${summarizeConfig(controller.getConfig(), rules)}`,
188
210
  "info",
189
211
  );
190
212
  return true;
@@ -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/index.ts CHANGED
@@ -58,6 +58,10 @@ export default function piPermissionSystemExtension(pi: ExtensionAPI): void {
58
58
  getConfig: () => runtime.config,
59
59
  setConfig: (next, ctx) => saveExtensionConfig(runtime, next, ctx),
60
60
  getConfigPath: () => getGlobalConfigPath(runtime.agentDir),
61
+ getComposedRules: () =>
62
+ runtime.permissionManager.getComposedConfigRules(
63
+ runtime.lastKnownActiveAgentName ?? undefined,
64
+ ),
61
65
  });
62
66
 
63
67
  const createPermissionRequestId = (prefix: string): string =>
package/src/normalize.ts CHANGED
@@ -18,12 +18,12 @@ export function normalizeFlatConfig(permission: FlatPermissionConfig): Ruleset {
18
18
  for (const [surface, value] of Object.entries(permission)) {
19
19
  if (typeof value === "string") {
20
20
  if (isPermissionState(value)) {
21
- rules.push({ surface, pattern: "*", action: value });
21
+ rules.push({ surface, pattern: "*", action: value, origin: "builtin" });
22
22
  }
23
23
  } else if (typeof value === "object" && value !== null) {
24
24
  for (const [pattern, action] of Object.entries(value)) {
25
25
  if (isPermissionState(action)) {
26
- rules.push({ surface, pattern, action });
26
+ rules.push({ surface, pattern, action, origin: "builtin" });
27
27
  }
28
28
  }
29
29
  }