@gotgenes/pi-permission-system 5.18.1 → 5.18.2

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,30 @@ 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.18.2](https://github.com/gotgenes/pi-packages/compare/pi-permission-system-v5.18.1...pi-permission-system-v5.18.2) (2026-05-17)
9
+
10
+
11
+ ### Bug Fixes
12
+
13
+ * bash path gate skips tokens matching only universal default ([#58](https://github.com/gotgenes/pi-packages/issues/58)) ([33fd169](https://github.com/gotgenes/pi-packages/commit/33fd1693b0c409b16c6a05072f52df78427713a1))
14
+ * restore per-package lint:md and lint scripts ([0e42617](https://github.com/gotgenes/pi-packages/commit/0e42617c443a7f8695f33855fa17058fc1712f27))
15
+ * skip path gate when no explicit path rules configured ([#58](https://github.com/gotgenes/pi-packages/issues/58)) ([a6d55e1](https://github.com/gotgenes/pi-packages/commit/a6d55e17bea4fbb8d4b46259da38ac4c8b919455))
16
+ * use root markdownlint config from all packages ([30192f8](https://github.com/gotgenes/pi-packages/commit/30192f8ccfc5c3c420f9f9b602df174baf263e92))
17
+
18
+
19
+ ### Documentation
20
+
21
+ * add redirect AGENTS.md to each package subdirectory ([cbdcd29](https://github.com/gotgenes/pi-packages/commit/cbdcd297194c814f545ae93eaa7418e9337450d3))
22
+ * plan fix path gate firing for universal default ([#58](https://github.com/gotgenes/pi-packages/issues/58)) ([ae9bbab](https://github.com/gotgenes/pi-packages/commit/ae9bbab1b2f3062bfad754a1a8b9f1a2c3ea29f7))
23
+
24
+
25
+ ### Miscellaneous Chores
26
+
27
+ * consolidate configs into monorepo root ([8583eaf](https://github.com/gotgenes/pi-packages/commit/8583eaf0764ac98def1987f20fafcc25e912b134))
28
+ * remove per-package pi-autoformat configs ([b2d405a](https://github.com/gotgenes/pi-packages/commit/b2d405a0a278341e4f6ff1c8b607533eaa4f021a))
29
+ * replace markdownlint-cli2 with rumdl ([d8dc789](https://github.com/gotgenes/pi-packages/commit/d8dc7897d854bf11396b85bc8c365e8e2ed7e66c))
30
+ * update package.json URLs to monorepo ([b92dbfa](https://github.com/gotgenes/pi-packages/commit/b92dbfaeaeb6cf2823272cb6fb6f206fb99a5009))
31
+
8
32
  ## [5.18.1](https://github.com/gotgenes/pi-permission-system/compare/v5.18.0...v5.18.1) (2026-05-15)
9
33
 
10
34
 
package/README.md CHANGED
@@ -54,11 +54,11 @@ pi install npm:@gotgenes/pi-permission-system
54
54
 
55
55
  All permissions use one of three states:
56
56
 
57
- |State|Behavior|
58
- |---|---|
59
- |`allow`|Permits the action silently|
60
- |`deny`|Blocks the action with an error message|
61
- |`ask`|Prompts the user for confirmation via UI|
57
+ | State | Behavior |
58
+ | ------- | ---------------------------------------- |
59
+ | `allow` | Permits the action silently |
60
+ | `deny` | Blocks the action with an error message |
61
+ | `ask` | Prompts the user for confirmation via UI |
62
62
 
63
63
  When the dialog prompts, you can approve once or approve a pattern for the rest of the session.
64
64
  See [docs/session-approvals.md](docs/session-approvals.md) for details on session-scoped rules and pattern suggestions.
@@ -75,10 +75,10 @@ Four layers compose with most-restrictive-wins: `path` (cross-cutting) → `exte
75
75
 
76
76
  Config lives in one JSON file per scope:
77
77
 
78
- |Scope|Path|
79
- |---|---|
80
- |Global|`~/.pi/agent/extensions/pi-permission-system/config.json`|
81
- |Project|`<cwd>/.pi/extensions/pi-permission-system/config.json`|
78
+ | Scope | Path |
79
+ | ------- | --------------------------------------------------------- |
80
+ | Global | `~/.pi/agent/extensions/pi-permission-system/config.json` |
81
+ | Project | `<cwd>/.pi/extensions/pi-permission-system/config.json` |
82
82
 
83
83
  Project overrides global; per-agent YAML frontmatter overrides both.
84
84
 
@@ -88,16 +88,16 @@ For the full reference — all surfaces, runtime knobs, per-agent overrides, mer
88
88
 
89
89
  ## Documentation
90
90
 
91
- |Document|Contents|
92
- |---|---|
93
- |[docs/configuration.md](docs/configuration.md)|Full policy reference, runtime knobs, per-agent overrides, recipes|
94
- |[docs/session-approvals.md](docs/session-approvals.md)|Session-scoped rules, pattern suggestions, bash arity table|
95
- |[docs/cross-extension-api.md](docs/cross-extension-api.md)|Cross-extension service accessor, event bus integration, decision broadcasts|
96
- |[docs/subagent-integration.md](docs/subagent-integration.md)|Permission forwarding, coexistence with subagent extensions|
97
- |[docs/guides/permission-frontmatter-for-subagent-extensions.md](docs/guides/permission-frontmatter-for-subagent-extensions.md)|Convention guide for subagent extension authors|
98
- |[docs/opencode-compatibility.md](docs/opencode-compatibility.md)|OpenCode compatibility — shared concepts, divergences, porting guide|
99
- |[docs/troubleshooting.md](docs/troubleshooting.md)|Common issues, diagnostic logging, threat model|
100
- |[docs/migration/legacy-to-flat.md](docs/migration/legacy-to-flat.md)|Migration from pre-v2 config layout|
91
+ | Document | Contents |
92
+ | ------------------------------------------------------------------------------------------------------------------------------ | ---------------------------------------------------------------------------- |
93
+ | [docs/configuration.md](docs/configuration.md) | Full policy reference, runtime knobs, per-agent overrides, recipes |
94
+ | [docs/session-approvals.md](docs/session-approvals.md) | Session-scoped rules, pattern suggestions, bash arity table |
95
+ | [docs/cross-extension-api.md](docs/cross-extension-api.md) | Cross-extension service accessor, event bus integration, decision broadcasts |
96
+ | [docs/subagent-integration.md](docs/subagent-integration.md) | Permission forwarding, coexistence with subagent extensions |
97
+ | [docs/guides/permission-frontmatter-for-subagent-extensions.md](docs/guides/permission-frontmatter-for-subagent-extensions.md) | Convention guide for subagent extension authors |
98
+ | [docs/opencode-compatibility.md](docs/opencode-compatibility.md) | OpenCode compatibility — shared concepts, divergences, porting guide |
99
+ | [docs/troubleshooting.md](docs/troubleshooting.md) | Common issues, diagnostic logging, threat model |
100
+ | [docs/migration/legacy-to-flat.md](docs/migration/legacy-to-flat.md) | Migration from pre-v2 config layout |
101
101
 
102
102
  ## Development
103
103
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@gotgenes/pi-permission-system",
3
- "version": "5.18.1",
3
+ "version": "5.18.2",
4
4
  "description": "Permission enforcement extension for the Pi coding agent.",
5
5
  "type": "module",
6
6
  "exports": {
@@ -33,11 +33,12 @@
33
33
  "license": "MIT",
34
34
  "repository": {
35
35
  "type": "git",
36
- "url": "git+https://github.com/gotgenes/pi-permission-system.git"
36
+ "url": "git+https://github.com/gotgenes/pi-packages.git",
37
+ "directory": "packages/pi-permission-system"
37
38
  },
38
- "homepage": "https://github.com/gotgenes/pi-permission-system#readme",
39
+ "homepage": "https://github.com/gotgenes/pi-packages/tree/main/packages/pi-permission-system#readme",
39
40
  "bugs": {
40
- "url": "https://github.com/gotgenes/pi-permission-system/issues"
41
+ "url": "https://github.com/gotgenes/pi-packages/issues"
41
42
  },
42
43
  "engines": {
43
44
  "node": ">=20"
@@ -59,25 +60,19 @@
59
60
  "@earendil-works/pi-coding-agent": "^0.74.0",
60
61
  "@earendil-works/pi-tui": "^0.74.0",
61
62
  "@types/node": "^25.6.2",
62
- "markdownlint-cli2": "^0.22.1",
63
- "typescript": "6.0.3",
64
- "vitest": "^4.1.5"
63
+ "typescript": "^6.0.3",
64
+ "vitest": "^4.1.5",
65
+ "rumdl": "^0.1.93"
65
66
  },
66
67
  "dependencies": {
67
68
  "tree-sitter-bash": "^0.25.1",
68
69
  "web-tree-sitter": "^0.26.8"
69
70
  },
70
71
  "scripts": {
71
- "build": "tsc -p tsconfig.json",
72
- "lint": "biome check .",
73
- "lint:fix": "biome check --write .",
74
- "lint:md": "markdownlint-cli2 '*.md' 'docs/**/*.md' '.pi/prompts/**/*.md'",
75
- "lint:md:fix": "markdownlint-cli2 --fix '*.md' 'docs/**/*.md' '.pi/prompts/**/*.md'",
76
- "lint:imports": "! grep -rn --include='*.ts' 'from \"\\.[./][^\"]*\\.js\"' src/ tests/",
77
- "lint:all": "pnpm run lint && pnpm run lint:md && pnpm run lint:imports",
78
- "format": "biome format --write .",
72
+ "check": "tsc --noEmit",
79
73
  "test": "vitest run",
80
74
  "test:watch": "vitest",
81
- "check": "pnpm run build && pnpm run lint:all && pnpm run test"
75
+ "lint:md": "rumdl check '*.md' 'docs/**/*.md' '.pi/prompts/**/*.md'",
76
+ "lint": "biome check . && pnpm run lint:md"
82
77
  }
83
78
  }
@@ -56,6 +56,14 @@ export async function describeBashPathGate(
56
56
  sessionRules,
57
57
  );
58
58
 
59
+ // No explicit path rule matched — only the universal default fired.
60
+ // Treat this token as unrestricted to preserve backward compatibility
61
+ // for configs without a "path" key (#58).
62
+ if (check.matchedPattern === undefined && check.source !== "session") {
63
+ allSessionCovered = false;
64
+ continue;
65
+ }
66
+
59
67
  if (check.source !== "session") {
60
68
  allSessionCovered = false;
61
69
  }
@@ -1,4 +1,5 @@
1
1
  import { getPathBearingToolPath } from "../../path-utils";
2
+ import type { Rule } from "../../rule";
2
3
  import { deriveApprovalPattern } from "../../session-rules";
3
4
  import type { PermissionCheckResult } from "../../types";
4
5
  import type { GateDescriptor, GateResult } from "./descriptor";
@@ -9,30 +10,40 @@ type CheckPermissionFn = (
9
10
  surface: string,
10
11
  input: unknown,
11
12
  agentName?: string,
13
+ sessionRules?: Rule[],
12
14
  ) => PermissionCheckResult;
13
15
 
14
16
  /**
15
17
  * Build a pure descriptor for the cross-cutting path permission gate (tools).
16
18
  *
17
19
  * Returns `null` when the gate does not apply (tool is not path-bearing,
18
- * no extractable path, or the `path` surface evaluates to `allow`).
20
+ * no extractable path, the `path` surface evaluates to `allow`, or no
21
+ * explicit `path` rule matched — i.e. only the universal default fired).
19
22
  * Returns a `GateDescriptor` when the path matches a `deny` or `ask` rule.
20
23
  */
21
24
  export function describePathGate(
22
25
  tcc: ToolCallContext,
23
26
  checkPermission: CheckPermissionFn,
27
+ getSessionRuleset: () => Rule[],
24
28
  ): GateResult {
25
29
  const filePath = getPathBearingToolPath(tcc.toolName, tcc.input);
26
30
  if (!filePath) return null;
27
31
 
32
+ const sessionRules = getSessionRuleset();
28
33
  const check = checkPermission(
29
34
  "path",
30
35
  { path: filePath },
31
36
  tcc.agentName ?? undefined,
37
+ sessionRules,
32
38
  );
33
39
 
34
40
  if (check.state === "allow") return null;
35
41
 
42
+ // No explicit path rule matched — only the universal default fired.
43
+ // Skip the gate to preserve backward compatibility: configs without a
44
+ // "path" key should not trigger path-level prompts (#58).
45
+ if (check.matchedPattern === undefined) return null;
46
+
36
47
  const pattern = deriveApprovalPattern(filePath);
37
48
 
38
49
  const descriptor: GateDescriptor = {
@@ -32,6 +32,10 @@ export function describeSkillReadGate(
32
32
  return null;
33
33
  }
34
34
 
35
+ if (tcc.cwd === undefined) {
36
+ return null;
37
+ }
38
+
35
39
  const normalizedReadPath = normalizePathForComparison(path, tcc.cwd);
36
40
  const matchedSkill = findSkillPathMatch(
37
41
  normalizedReadPath,
@@ -36,7 +36,7 @@ export class SessionLifecycleHandler {
36
36
  session.logResolvedConfigPaths();
37
37
 
38
38
  const agentName = session.resolveAgentName(ctx);
39
- const policyIssues = session.getConfigIssues(agentName);
39
+ const policyIssues = session.getConfigIssues(agentName ?? undefined);
40
40
  for (const issue of policyIssues) {
41
41
  session.logger.warn(issue);
42
42
  }
@@ -143,7 +143,7 @@ export class PermissionGateHandler {
143
143
  }
144
144
 
145
145
  // ── Path gate for tools (descriptor + runner) ────────────────────────────
146
- const pathDesc = describePathGate(tcc, checkPermission);
146
+ const pathDesc = describePathGate(tcc, checkPermission, getSessionRuleset);
147
147
  if (pathDesc) {
148
148
  if (isGateBypass(pathDesc)) {
149
149
  if (pathDesc.log) {
@@ -1,8 +1,7 @@
1
- import assert from "node:assert/strict";
2
1
  import { mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs";
3
2
  import { tmpdir } from "node:os";
4
3
  import { join } from "node:path";
5
- import { test, vi } from "vitest";
4
+ import { expect, test, vi } from "vitest";
6
5
  import { registerPermissionSystemCommand } from "../src/config-modal";
7
6
  import {
8
7
  DEFAULT_EXTENSION_CONFIG,
@@ -105,21 +104,17 @@ test("permission-system command completions expose top-level config actions", ()
105
104
  controller as never,
106
105
  );
107
106
 
108
- assert.ok(definition !== null);
109
- assert.ok(typeof definition?.getArgumentCompletions === "function");
107
+ expect(definition!.getArgumentCompletions).toBeTypeOf("function");
110
108
 
111
- const topLevel = definition?.getArgumentCompletions?.("");
112
- assert.ok(Array.isArray(topLevel));
113
- assert.ok(topLevel?.some((item) => item.value === "show"));
114
- assert.ok(topLevel?.some((item) => item.value === "reset"));
109
+ const topLevel = definition!.getArgumentCompletions?.("");
110
+ expect(Array.isArray(topLevel)).toBeTruthy();
111
+ expect(topLevel?.some((item) => item.value === "show")).toBeTruthy();
112
+ expect(topLevel?.some((item) => item.value === "reset")).toBeTruthy();
115
113
 
116
- const filtered = definition?.getArgumentCompletions?.("pa");
117
- assert.deepEqual(
118
- filtered?.map((item) => item.value),
119
- ["path"],
120
- );
121
- assert.equal(definition?.getArgumentCompletions?.("path extra"), null);
122
- assert.equal(definition?.getArgumentCompletions?.("zzz"), null);
114
+ const filtered = definition!.getArgumentCompletions?.("pa");
115
+ expect(filtered?.map((item) => item.value)).toEqual(["path"]);
116
+ expect(definition!.getArgumentCompletions?.("path extra")).toBe(null);
117
+ expect(definition!.getArgumentCompletions?.("zzz")).toBe(null);
123
118
  } finally {
124
119
  rmSync(baseDir, { recursive: true, force: true });
125
120
  }
@@ -156,7 +151,7 @@ test("permission-system command handlers manage config summary, persistence, and
156
151
  config = normalizePermissionSystemConfig(
157
152
  JSON.parse(readFileSync(configPath, "utf-8")) as unknown,
158
153
  );
159
- assert.notDeepEqual(config, currentConfig);
154
+ expect(config).not.toEqual(currentConfig);
160
155
  },
161
156
  getConfigPath: () => configPath,
162
157
  };
@@ -180,40 +175,33 @@ test("permission-system command handlers manage config summary, persistence, and
180
175
  controller as never,
181
176
  );
182
177
 
183
- assert.equal(registeredName, "permission-system");
184
- assert.ok(definition !== null);
185
- assert.ok(
186
- (definition?.description ?? "").includes(
187
- "Configure pi-permission-system",
188
- ),
178
+ expect(registeredName).toBe("permission-system");
179
+ expect(definition!.description ?? "").toContain(
180
+ "Configure pi-permission-system",
189
181
  );
190
182
 
191
183
  const infoCtx = createCommandContext(true);
192
- await definition?.handler("show", infoCtx.ctx);
193
- assert.ok(
194
- lastNotification(infoCtx.notifications).message.includes("yoloMode=on"),
184
+ await definition!.handler("show", infoCtx.ctx);
185
+ expect(lastNotification(infoCtx.notifications).message).toContain(
186
+ "yoloMode=on",
195
187
  );
196
- assert.ok(
197
- lastNotification(infoCtx.notifications).message.includes("debugLog=on"),
188
+ expect(lastNotification(infoCtx.notifications).message).toContain(
189
+ "debugLog=on",
198
190
  );
199
191
 
200
- await definition?.handler("path", infoCtx.ctx);
201
- assert.equal(
202
- lastNotification(infoCtx.notifications).message,
192
+ await definition!.handler("path", infoCtx.ctx);
193
+ expect(lastNotification(infoCtx.notifications).message).toBe(
203
194
  `permission-system config: ${configPath}`,
204
195
  );
205
196
 
206
- await definition?.handler("help", infoCtx.ctx);
207
- assert.ok(
208
- lastNotification(infoCtx.notifications).message.includes(
209
- "Usage: /permission-system",
210
- ),
197
+ await definition!.handler("help", infoCtx.ctx);
198
+ expect(lastNotification(infoCtx.notifications).message).toContain(
199
+ "Usage: /permission-system",
211
200
  );
212
201
 
213
- await definition?.handler("reset", infoCtx.ctx);
214
- assert.deepEqual(config, DEFAULT_EXTENSION_CONFIG);
215
- assert.equal(
216
- lastNotification(infoCtx.notifications).message,
202
+ await definition!.handler("reset", infoCtx.ctx);
203
+ expect(config).toEqual(DEFAULT_EXTENSION_CONFIG);
204
+ expect(lastNotification(infoCtx.notifications).message).toBe(
217
205
  "Permission system settings reset to defaults.",
218
206
  );
219
207
 
@@ -221,26 +209,23 @@ test("permission-system command handlers manage config summary, persistence, and
221
209
  string,
222
210
  unknown
223
211
  >;
224
- assert.deepEqual(persisted, DEFAULT_EXTENSION_CONFIG);
225
-
226
- await definition?.handler("unknown", infoCtx.ctx);
227
- assert.equal(lastNotification(infoCtx.notifications).level, "warning");
228
- assert.ok(
229
- lastNotification(infoCtx.notifications).message.includes(
230
- "Usage: /permission-system",
231
- ),
212
+ expect(persisted).toEqual(DEFAULT_EXTENSION_CONFIG);
213
+
214
+ await definition!.handler("unknown", infoCtx.ctx);
215
+ expect(lastNotification(infoCtx.notifications).level).toBe("warning");
216
+ expect(lastNotification(infoCtx.notifications).message).toContain(
217
+ "Usage: /permission-system",
232
218
  );
233
219
 
234
220
  const headlessCtx = createCommandContext(false);
235
- await definition?.handler("", headlessCtx.ctx);
236
- assert.equal(
237
- lastNotification(headlessCtx.notifications).message,
221
+ await definition!.handler("", headlessCtx.ctx);
222
+ expect(lastNotification(headlessCtx.notifications).message).toBe(
238
223
  "/permission-system requires interactive TUI mode.",
239
224
  );
240
225
 
241
226
  const modalCtx = createCommandContext(true);
242
- await definition?.handler("", modalCtx.ctx);
243
- assert.equal(modalCtx.getCustomCalls(), 1);
227
+ await definition!.handler("", modalCtx.ctx);
228
+ expect(modalCtx.getCustomCalls()).toBe(1);
244
229
  } finally {
245
230
  rmSync(baseDir, { recursive: true, force: true });
246
231
  }
@@ -289,10 +274,10 @@ test("show output includes rule origins when getComposedRules is provided", asyn
289
274
  await definition!.handler("show", ctx.ctx);
290
275
  const msg = lastNotification(ctx.notifications).message;
291
276
 
292
- assert.ok(msg.includes("global"), `expected 'global' in: ${msg}`);
293
- assert.ok(msg.includes("project"), `expected 'project' in: ${msg}`);
294
- assert.ok(msg.includes("read"), `expected 'read' in: ${msg}`);
295
- assert.ok(msg.includes("bash"), `expected 'bash' in: ${msg}`);
277
+ expect(msg).toContain("global");
278
+ expect(msg).toContain("project");
279
+ expect(msg).toContain("read");
280
+ expect(msg).toContain("bash");
296
281
  });
297
282
 
298
283
  test("show output omits rule summary when getComposedRules is not provided", async () => {
@@ -323,7 +308,7 @@ test("show output omits rule summary when getComposedRules is not provided", asy
323
308
  const msg = lastNotification(ctx.notifications).message;
324
309
 
325
310
  // Config knobs still present.
326
- assert.ok(msg.includes("yoloMode=on"), `expected yoloMode=on in: ${msg}`);
311
+ expect(msg).toContain("yoloMode=on");
327
312
  // No rule annotation lines.
328
- assert.ok(!msg.includes("(global)"), `unexpected '(global)' in: ${msg}`);
313
+ expect(msg).not.toContain("(global)");
329
314
  });
@@ -1,4 +1,3 @@
1
- import assert from "node:assert/strict";
2
1
  import {
3
2
  mkdirSync,
4
3
  mkdtempSync,
@@ -8,7 +7,7 @@ import {
8
7
  } from "node:fs";
9
8
  import { tmpdir } from "node:os";
10
9
  import { join } from "node:path";
11
- import { test } from "vitest";
10
+ import { expect, test } from "vitest";
12
11
  import { buildResolvedConfigLogEntry } from "../src/config-reporter";
13
12
  import { createPermissionSystemLogger } from "../src/logging";
14
13
  import type { ResolvedPolicyPaths } from "../src/permission-manager";
@@ -30,23 +29,21 @@ test("buildResolvedConfigLogEntry includes policy paths and legacy detection fla
30
29
 
31
30
  const result = buildResolvedConfigLogEntry({ policyPaths });
32
31
 
33
- assert.equal(
34
- result.globalConfigPath,
32
+ expect(result.globalConfigPath).toBe(
35
33
  "/home/user/.pi/agent/extensions/pi-permission-system/config.json",
36
34
  );
37
- assert.equal(result.globalConfigExists, true);
38
- assert.equal(
39
- result.projectConfigPath,
35
+ expect(result.globalConfigExists).toBe(true);
36
+ expect(result.projectConfigPath).toBe(
40
37
  "/projects/my-app/.pi/extensions/pi-permission-system/config.json",
41
38
  );
42
- assert.equal(result.projectConfigExists, false);
43
- assert.equal(result.agentsDir, "/home/user/.pi/agent/agents");
44
- assert.equal(result.agentsDirExists, true);
45
- assert.equal(result.projectAgentsDir, "/projects/my-app/.pi/agent/agents");
46
- assert.equal(result.projectAgentsDirExists, false);
47
- assert.equal(result.legacyGlobalPolicyDetected, false);
48
- assert.equal(result.legacyProjectPolicyDetected, false);
49
- assert.equal(result.legacyExtensionConfigDetected, false);
39
+ expect(result.projectConfigExists).toBe(false);
40
+ expect(result.agentsDir).toBe("/home/user/.pi/agent/agents");
41
+ expect(result.agentsDirExists).toBe(true);
42
+ expect(result.projectAgentsDir).toBe("/projects/my-app/.pi/agent/agents");
43
+ expect(result.projectAgentsDirExists).toBe(false);
44
+ expect(result.legacyGlobalPolicyDetected).toBe(false);
45
+ expect(result.legacyProjectPolicyDetected).toBe(false);
46
+ expect(result.legacyExtensionConfigDetected).toBe(false);
50
47
  });
51
48
 
52
49
  test("buildResolvedConfigLogEntry handles null project paths", () => {
@@ -64,10 +61,10 @@ test("buildResolvedConfigLogEntry handles null project paths", () => {
64
61
 
65
62
  const result = buildResolvedConfigLogEntry({ policyPaths });
66
63
 
67
- assert.equal(result.projectConfigPath, null);
68
- assert.equal(result.projectConfigExists, false);
69
- assert.equal(result.projectAgentsDir, null);
70
- assert.equal(result.projectAgentsDirExists, false);
64
+ expect(result.projectConfigPath).toBe(null);
65
+ expect(result.projectConfigExists).toBe(false);
66
+ expect(result.projectAgentsDir).toBe(null);
67
+ expect(result.projectAgentsDirExists).toBe(false);
71
68
  });
72
69
 
73
70
  test("buildResolvedConfigLogEntry surfaces legacy detection flags", () => {
@@ -89,9 +86,9 @@ test("buildResolvedConfigLogEntry surfaces legacy detection flags", () => {
89
86
  legacyExtensionConfigDetected: true,
90
87
  });
91
88
 
92
- assert.equal(result.legacyGlobalPolicyDetected, true);
93
- assert.equal(result.legacyProjectPolicyDetected, false);
94
- assert.equal(result.legacyExtensionConfigDetected, true);
89
+ expect(result.legacyGlobalPolicyDetected).toBe(true);
90
+ expect(result.legacyProjectPolicyDetected).toBe(false);
91
+ expect(result.legacyExtensionConfigDetected).toBe(true);
95
92
  });
96
93
 
97
94
  test("config.resolved entry appears in review log via logger", () => {
@@ -132,18 +129,18 @@ test("config.resolved entry appears in review log via logger", () => {
132
129
  const logContent = readFileSync(reviewLogPath, "utf-8").trim();
133
130
  const parsed = JSON.parse(logContent) as Record<string, unknown>;
134
131
 
135
- assert.equal(parsed.event, "config.resolved");
136
- assert.equal(parsed.globalConfigPath, globalConfigPath);
137
- assert.equal(parsed.globalConfigExists, true);
138
- assert.equal(parsed.agentsDir, agentsDir);
139
- assert.equal(parsed.agentsDirExists, false);
140
- assert.equal(parsed.projectConfigPath, null);
141
- assert.equal(parsed.projectConfigExists, false);
142
- assert.equal(parsed.projectAgentsDir, null);
143
- assert.equal(parsed.projectAgentsDirExists, false);
144
- assert.equal(parsed.legacyGlobalPolicyDetected, false);
145
- assert.equal(parsed.legacyProjectPolicyDetected, false);
146
- assert.equal(parsed.legacyExtensionConfigDetected, false);
132
+ expect(parsed.event).toBe("config.resolved");
133
+ expect(parsed.globalConfigPath).toBe(globalConfigPath);
134
+ expect(parsed.globalConfigExists).toBe(true);
135
+ expect(parsed.agentsDir).toBe(agentsDir);
136
+ expect(parsed.agentsDirExists).toBe(false);
137
+ expect(parsed.projectConfigPath).toBe(null);
138
+ expect(parsed.projectConfigExists).toBe(false);
139
+ expect(parsed.projectAgentsDir).toBe(null);
140
+ expect(parsed.projectAgentsDirExists).toBe(false);
141
+ expect(parsed.legacyGlobalPolicyDetected).toBe(false);
142
+ expect(parsed.legacyProjectPolicyDetected).toBe(false);
143
+ expect(parsed.legacyExtensionConfigDetected).toBe(false);
147
144
  } finally {
148
145
  rmSync(tempDir, { recursive: true, force: true });
149
146
  }
@@ -308,7 +308,7 @@ describe("external_directory policy state — allow", () => {
308
308
  const reviewCalls = (session.logger.review as ReturnType<typeof vi.fn>).mock
309
309
  .calls;
310
310
  const blockEntries = reviewCalls.filter(
311
- ([eventName]: [string]) => eventName === "permission_request.blocked",
311
+ ([eventName]: string[]) => eventName === "permission_request.blocked",
312
312
  );
313
313
  expect(blockEntries).toHaveLength(0);
314
314
  });
@@ -404,7 +404,7 @@ describe("external_directory policy state — deny", () => {
404
404
  const reviewCalls = (session.logger.review as ReturnType<typeof vi.fn>).mock
405
405
  .calls;
406
406
  const blockEntries = reviewCalls.filter(
407
- ([eventName]: [string]) => eventName === "permission_request.blocked",
407
+ ([eventName]: string[]) => eventName === "permission_request.blocked",
408
408
  );
409
409
  expect(blockEntries.length).toBeGreaterThanOrEqual(1);
410
410
  expect(blockEntries[0][1]).toMatchObject({
@@ -546,7 +546,7 @@ describe("external_directory policy state — ask", () => {
546
546
  const reviewCalls = (session.logger.review as ReturnType<typeof vi.fn>).mock
547
547
  .calls;
548
548
  const blockEntries = reviewCalls.filter(
549
- ([eventName]: [string]) => eventName === "permission_request.blocked",
549
+ ([eventName]: string[]) => eventName === "permission_request.blocked",
550
550
  );
551
551
  expect(blockEntries.length).toBeGreaterThanOrEqual(1);
552
552
  expect(blockEntries[0][1]).toMatchObject({
@@ -119,7 +119,7 @@ describe("describeBashPathGate", () => {
119
119
  it("returns GateDescriptor when a token evaluates to ask", async () => {
120
120
  const checkPermission = vi
121
121
  .fn<CheckPermissionFn>()
122
- .mockReturnValue(makeCheckResult({ state: "ask" }));
122
+ .mockReturnValue(makeCheckResult({ state: "ask", matchedPattern: "*" }));
123
123
  const getSessionRuleset = vi.fn<() => Rule[]>().mockReturnValue([]);
124
124
  const result = await describeBashPathGate(
125
125
  makeTcc({ input: { command: "cat .env" } }),
@@ -135,7 +135,9 @@ describe("describeBashPathGate", () => {
135
135
  it("descriptor includes triggering token in prompt message", async () => {
136
136
  const checkPermission = vi
137
137
  .fn<CheckPermissionFn>()
138
- .mockReturnValue(makeCheckResult({ state: "deny" }));
138
+ .mockReturnValue(
139
+ makeCheckResult({ state: "deny", matchedPattern: "*.env" }),
140
+ );
139
141
  const getSessionRuleset = vi.fn<() => Rule[]>().mockReturnValue([]);
140
142
  const result = (await describeBashPathGate(
141
143
  makeTcc({ input: { command: "cat .env" } }),
@@ -149,7 +151,9 @@ describe("describeBashPathGate", () => {
149
151
  it("descriptor decision uses surface 'path'", async () => {
150
152
  const checkPermission = vi
151
153
  .fn<CheckPermissionFn>()
152
- .mockReturnValue(makeCheckResult({ state: "deny" }));
154
+ .mockReturnValue(
155
+ makeCheckResult({ state: "deny", matchedPattern: "*.env" }),
156
+ );
153
157
  const getSessionRuleset = vi.fn<() => Rule[]>().mockReturnValue([]);
154
158
  const result = (await describeBashPathGate(
155
159
  makeTcc({ input: { command: "cat .env" } }),
@@ -257,4 +261,54 @@ describe("describeBashPathGate", () => {
257
261
  expect(isGateDescriptor(result)).toBe(true);
258
262
  expect((result as GateDescriptor).preCheck?.state).toBe("deny");
259
263
  });
264
+
265
+ it("returns null when all tokens match only the universal default", async () => {
266
+ const checkPermission = vi.fn<CheckPermissionFn>().mockReturnValue(
267
+ makeCheckResult({
268
+ state: "ask",
269
+ matchedPattern: undefined,
270
+ source: "special",
271
+ origin: "builtin",
272
+ }),
273
+ );
274
+ const getSessionRuleset = vi.fn<() => Rule[]>().mockReturnValue([]);
275
+ const result = await describeBashPathGate(
276
+ makeTcc({ input: { command: "cat .env" } }),
277
+ checkPermission,
278
+ getSessionRuleset,
279
+ );
280
+ expect(result).toBeNull();
281
+ });
282
+
283
+ it("ignores tokens matching universal default but fires for explicit rule matches", async () => {
284
+ const checkPermission = vi
285
+ .fn<CheckPermissionFn>()
286
+ .mockImplementation((_surface, input) => {
287
+ const record = input as Record<string, unknown>;
288
+ if (record.path === ".env") {
289
+ return makeCheckResult({
290
+ state: "deny",
291
+ matchedPattern: "*.env",
292
+ });
293
+ }
294
+ // Other tokens match only the universal default
295
+ return makeCheckResult({
296
+ state: "ask",
297
+ matchedPattern: undefined,
298
+ source: "special",
299
+ origin: "builtin",
300
+ });
301
+ });
302
+ const getSessionRuleset = vi.fn<() => Rule[]>().mockReturnValue([]);
303
+ const result = await describeBashPathGate(
304
+ makeTcc({ input: { command: "cat src/foo.ts .env" } }),
305
+ checkPermission,
306
+ getSessionRuleset,
307
+ );
308
+ expect(result).not.toBeNull();
309
+ expect(isGateDescriptor(result)).toBe(true);
310
+ const desc = result as GateDescriptor;
311
+ expect(desc.preCheck?.state).toBe("deny");
312
+ expect(desc.decision.value).toBe(".env");
313
+ });
260
314
  });