@gotgenes/pi-permission-system 10.9.0 → 10.10.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -5,6 +5,34 @@ 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
+ ## [10.10.1](https://github.com/gotgenes/pi-packages/compare/pi-permission-system-v10.10.0...pi-permission-system-v10.10.1) (2026-06-11)
9
+
10
+
11
+ ### Documentation
12
+
13
+ * fix bash rule precedence examples and wording ([#387](https://github.com/gotgenes/pi-packages/issues/387)) ([9e18d6f](https://github.com/gotgenes/pi-packages/commit/9e18d6faab6e3ceaa1a8839f5b6753d5457a2a28))
14
+
15
+ ## [10.10.0](https://github.com/gotgenes/pi-packages/compare/pi-permission-system-v10.9.0...pi-permission-system-v10.10.0) (2026-06-10)
16
+
17
+
18
+ ### Features
19
+
20
+ * **pi-permission-system:** add case-insensitive and Windows-separator options to wildcard matcher ([587b3e8](https://github.com/gotgenes/pi-packages/commit/587b3e88d0deed365ab690d38145e2f9ce8eaee6))
21
+
22
+
23
+ ### Bug Fixes
24
+
25
+ * **pi-permission-system:** auto-allow infrastructure reads case-insensitively on Windows ([a3f137a](https://github.com/gotgenes/pi-packages/commit/a3f137ad5edb8f42378144fa9ca556996954155c))
26
+ * **pi-permission-system:** auto-detect Pi's install directory for infrastructure reads ([#382](https://github.com/gotgenes/pi-packages/issues/382)) ([c3d89ba](https://github.com/gotgenes/pi-packages/commit/c3d89ba4f58805fb5012258beb2db108ef61ebbe))
27
+ * **pi-permission-system:** include an optional Pi package dir in infrastructure reads ([da667ec](https://github.com/gotgenes/pi-packages/commit/da667eca95f02f8de246bdc42ca90df7696fe2ca))
28
+ * **pi-permission-system:** make path containment case-insensitive on Windows via path.relative ([c10b84a](https://github.com/gotgenes/pi-packages/commit/c10b84ad4508b1cf8ead763e3fa0560a1e9ba370))
29
+ * **pi-permission-system:** match external_directory/path patterns case-insensitively on Windows ([3ed92da](https://github.com/gotgenes/pi-packages/commit/3ed92dabc1643fb5e0c52b9eac76e0940f8a8dc4))
30
+
31
+
32
+ ### Documentation
33
+
34
+ * **pi-permission-system:** document Windows case-insensitive matching and Pi-install auto-allow ([c98d33b](https://github.com/gotgenes/pi-packages/commit/c98d33b775eea9cbe83f019222841a6ab820942f))
35
+
8
36
  ## [10.9.0](https://github.com/gotgenes/pi-packages/compare/pi-permission-system-v10.8.0...pi-permission-system-v10.9.0) (2026-06-10)
9
37
 
10
38
 
@@ -23,9 +23,9 @@
23
23
  "edit": "deny",
24
24
  "bash": {
25
25
  "*": "ask",
26
+ "git *": "ask",
26
27
  "git status": "allow",
27
- "git diff": "allow",
28
- "git *": "ask"
28
+ "git diff": "allow"
29
29
  },
30
30
  "mcp": { "*": "ask", "mcp_status": "allow", "mcp_list": "allow" },
31
31
  "skill": { "*": "ask" },
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@gotgenes/pi-permission-system",
3
- "version": "10.9.0",
3
+ "version": "10.10.1",
4
4
  "description": "Permission enforcement extension for the Pi coding agent.",
5
5
  "type": "module",
6
6
  "exports": {
@@ -56,13 +56,13 @@
56
56
  ]
57
57
  },
58
58
  "peerDependencies": {
59
- "@earendil-works/pi-coding-agent": ">=0.75.0",
60
- "@earendil-works/pi-tui": ">=0.75.0"
59
+ "@earendil-works/pi-coding-agent": ">=0.79.0",
60
+ "@earendil-works/pi-tui": ">=0.79.0"
61
61
  },
62
62
  "devDependencies": {
63
63
  "@biomejs/biome": "^2.4.16",
64
- "@earendil-works/pi-coding-agent": "0.75.4",
65
- "@earendil-works/pi-tui": "0.75.4",
64
+ "@earendil-works/pi-coding-agent": "0.79.1",
65
+ "@earendil-works/pi-tui": "0.79.1",
66
66
  "@types/node": "^22.15.3",
67
67
  "rumdl": "^0.2.10",
68
68
  "typescript": "^6.0.3",
@@ -43,7 +43,7 @@
43
43
  },
44
44
  "piInfrastructureReadPaths": {
45
45
  "description": "Additional directories to auto-allow for reads as Pi infrastructure, bypassing the external_directory gate. Supports ~ expansion and wildcard patterns (* and ?).",
46
- "markdownDescription": "Additional directories to auto-allow for reads as Pi infrastructure, bypassing the `external_directory` gate.\n\nThe extension auto-discovers the global node_modules root (walks up from the extension's install path; falls back to `npm root -g` from a dev checkout), `agentDir`, `agentDir/git`, and project-local `.pi/npm/` and `.pi/git/`. Add entries here for edge cases where auto-discovery is insufficient (e.g. custom `npmCommand` pointing to pnpm).\n\nSupports `~`/`$HOME` expansion. Entries may be plain directory prefixes or wildcard patterns using `*` (matches any characters, including `/`) and `?` (matches exactly one character). `**` and `*` are equivalent — both cross directory boundaries.",
46
+ "markdownDescription": "Additional directories to auto-allow for reads as Pi infrastructure, bypassing the `external_directory` gate.\n\nThe extension auto-discovers the global node_modules root (walks up from the extension's install path; falls back to `npm root -g` from a dev checkout), Pi's own install directory (via the coding-agent `getPackageDir()` API), `agentDir`, `agentDir/git`, and project-local `.pi/npm/` and `.pi/git/`. Add entries here for edge cases where auto-discovery is insufficient (e.g. custom `npmCommand` pointing to pnpm).\n\nSupports `~`/`$HOME` expansion. Entries may be plain directory prefixes or wildcard patterns using `*` (matches any characters, including `/`) and `?` (matches exactly one character). `**` and `*` are equivalent — both cross directory boundaries.\n\nOn Windows, matching is case-insensitive and tolerant of either path separator.",
47
47
  "type": "array",
48
48
  "items": {
49
49
  "type": "string",
@@ -86,9 +86,9 @@
86
86
  "edit": "deny",
87
87
  "bash": {
88
88
  "*": "ask",
89
+ "git *": "ask",
89
90
  "git status": "allow",
90
- "git diff": "allow",
91
- "git *": "ask"
91
+ "git diff": "allow"
92
92
  },
93
93
  "mcp": { "*": "ask", "mcp_status": "allow", "exa:*": "allow" },
94
94
  "skill": { "*": "ask", "librarian": "allow" },
@@ -1,4 +1,22 @@
1
- import type { ExtensionContext } from "@earendil-works/pi-coding-agent";
1
+ /**
2
+ * Minimal session-entry view: the only fields {@link getActiveAgentName}
3
+ * reads off each entry. Narrowing to this structural slice (rather than the
4
+ * SDK `SessionEntry` discriminated union) keeps callers and test fixtures free
5
+ * of the union's nine unrelated variants.
6
+ */
7
+ export interface SessionEntryView {
8
+ type: string;
9
+ customType?: string;
10
+ data?: unknown;
11
+ }
12
+
13
+ /**
14
+ * Narrow context for {@link getActiveAgentName} — it reads only the session
15
+ * entries. A full `ExtensionContext` satisfies this structurally.
16
+ */
17
+ export interface ActiveAgentContext {
18
+ sessionManager: { getEntries(): readonly SessionEntryView[] };
19
+ }
2
20
 
3
21
  /**
4
22
  * Matches the `<active_agent name="...">` tag injected by pi-agent-router
@@ -16,14 +34,10 @@ export function normalizeAgentName(value: unknown): string | null {
16
34
  return trimmed ? trimmed : null;
17
35
  }
18
36
 
19
- export function getActiveAgentName(ctx: ExtensionContext): string | null {
37
+ export function getActiveAgentName(ctx: ActiveAgentContext): string | null {
20
38
  const entries = ctx.sessionManager.getEntries();
21
39
  for (let i = entries.length - 1; i >= 0; i--) {
22
- const entry = entries[i] as {
23
- type: string;
24
- customType?: string;
25
- data?: unknown;
26
- };
40
+ const entry = entries[i];
27
41
  if (entry.type !== "custom" || entry.customType !== "active_agent") {
28
42
  continue;
29
43
  }
@@ -17,8 +17,9 @@ export interface ExtensionPaths {
17
17
  readonly globalLogsDir: string;
18
18
  /**
19
19
  * Static Pi infrastructure directories used for external-directory
20
- * read auto-allow. Computed once from `agentDir` and
21
- * `discoverGlobalNodeModulesRoot()`. Config-based extras
20
+ * read auto-allow. Computed once from `agentDir`,
21
+ * `discoverGlobalNodeModulesRoot()`, and (when provided) Pi's own
22
+ * install directory (`getPackageDir()`). Config-based extras
22
23
  * (`piInfrastructureReadPaths`) are read from `runtime.config` at
23
24
  * call time in the handler so they pick up config reloads.
24
25
  */
@@ -30,8 +31,17 @@ export interface ExtensionPaths {
30
31
  *
31
32
  * Calls `discoverGlobalNodeModulesRoot()` internally so the result is
32
33
  * self-contained. Call this once at extension startup, not at module scope.
34
+ *
35
+ * `piPackageDir` is Pi's own install directory (from the coding-agent
36
+ * `getPackageDir()` API, resolved at the composition root). When provided it is
37
+ * auto-allowed for read-only tools so the agent can read Pi's bundled docs and
38
+ * examples regardless of install layout. It is strictly narrower than the
39
+ * discovered global `node_modules` root already included here.
33
40
  */
34
- export function computeExtensionPaths(agentDir: string): ExtensionPaths {
41
+ export function computeExtensionPaths(
42
+ agentDir: string,
43
+ piPackageDir?: string,
44
+ ): ExtensionPaths {
35
45
  const sessionsDir = join(agentDir, "sessions");
36
46
  const subagentSessionsDir = join(agentDir, "subagent-sessions");
37
47
  const forwardingDir = join(sessionsDir, "permission-forwarding");
@@ -42,6 +52,7 @@ export function computeExtensionPaths(agentDir: string): ExtensionPaths {
42
52
  agentDir,
43
53
  join(agentDir, "git"),
44
54
  ...(globalNodeModulesRoot ? [globalNodeModulesRoot] : []),
55
+ ...(piPackageDir ? [piPackageDir] : []),
45
56
  ];
46
57
 
47
58
  return {
@@ -1,14 +1,14 @@
1
1
  import { existsSync } from "node:fs";
2
2
  import { join } from "node:path";
3
- import type { ExtensionContext } from "@earendil-works/pi-coding-agent";
4
-
5
3
  import {
6
4
  getActiveAgentName,
7
5
  getActiveAgentNameFromSystemPrompt,
6
+ type SessionEntryView,
8
7
  } from "#src/active-agent";
9
8
  import { toRecord } from "#src/common";
10
9
  import type { ConfigReader } from "#src/config-store";
11
10
  import type {
11
+ PermissionDecisionUi,
12
12
  PermissionPromptDecision,
13
13
  RequestPermissionOptions,
14
14
  } from "#src/permission-dialog";
@@ -47,6 +47,25 @@ import {
47
47
  writeJsonFileAtomic,
48
48
  } from "./io";
49
49
 
50
+ /**
51
+ * Narrow context the forwarder reads: the UI gate (`hasUI`), the dialog UI
52
+ * surface, and the three session-manager readers it uses directly or via
53
+ * {@link isSubagentExecutionContext} / {@link getActiveAgentName}.
54
+ *
55
+ * `getSystemPrompt` is read reflectively (see `getContextSystemPrompt`), so it
56
+ * is intentionally not a typed member. A full `ExtensionContext` satisfies this
57
+ * structurally, so production callers pass `ctx` unchanged.
58
+ */
59
+ export interface ForwarderContext {
60
+ hasUI: boolean;
61
+ ui: PermissionDecisionUi;
62
+ sessionManager: {
63
+ getSessionId(): string;
64
+ getSessionDir(): string;
65
+ getEntries(): readonly SessionEntryView[];
66
+ };
67
+ }
68
+
50
69
  /**
51
70
  * Constructor config for `PermissionForwarder`.
52
71
  *
@@ -63,7 +82,7 @@ export interface PermissionForwarderDeps {
63
82
  events?: PermissionEventBus;
64
83
  logger: DebugReviewLogger;
65
84
  requestPermissionDecisionFromUi: (
66
- ui: ExtensionContext["ui"],
85
+ ui: PermissionDecisionUi,
67
86
  title: string,
68
87
  message: string,
69
88
  options?: RequestPermissionOptions,
@@ -74,7 +93,7 @@ export interface PermissionForwarderDeps {
74
93
 
75
94
  // ── Module-private helpers ────────────────────────────────────────────────
76
95
 
77
- function getSessionId(ctx: ExtensionContext): string {
96
+ function getSessionId(ctx: ForwarderContext): string {
78
97
  try {
79
98
  const sessionId = ctx.sessionManager.getSessionId();
80
99
  if (typeof sessionId === "string" && sessionId.trim()) {
@@ -85,7 +104,7 @@ function getSessionId(ctx: ExtensionContext): string {
85
104
  return "unknown";
86
105
  }
87
106
 
88
- function getContextSystemPrompt(ctx: ExtensionContext): string | undefined {
107
+ function getContextSystemPrompt(ctx: ForwarderContext): string | undefined {
89
108
  const getSystemPrompt = toRecord(ctx).getSystemPrompt;
90
109
  if (typeof getSystemPrompt !== "function") {
91
110
  return undefined;
@@ -132,7 +151,7 @@ function formatForwardedPermissionPrompt(
132
151
  */
133
152
  export interface ApprovalRequester {
134
153
  requestApproval(
135
- ctx: ExtensionContext,
154
+ ctx: ForwarderContext,
136
155
  message: string,
137
156
  options?: RequestPermissionOptions,
138
157
  forwarded?: ForwardedPromptDisplay,
@@ -148,7 +167,7 @@ export interface ApprovalRequester {
148
167
  * `{ processInbox: vi.fn() }` mock.
149
168
  */
150
169
  export interface InboxProcessor {
151
- processInbox(ctx: ExtensionContext): Promise<void>;
170
+ processInbox(ctx: ForwarderContext): Promise<void>;
152
171
  }
153
172
 
154
173
  // ── PermissionForwarder ───────────────────────────────────────────────────
@@ -169,7 +188,7 @@ export class PermissionForwarder implements ApprovalRequester, InboxProcessor {
169
188
  private readonly events: PermissionEventBus | undefined;
170
189
  private readonly logger: DebugReviewLogger;
171
190
  private readonly requestPermissionDecisionFromUi: (
172
- ui: ExtensionContext["ui"],
191
+ ui: PermissionDecisionUi,
173
192
  title: string,
174
193
  message: string,
175
194
  options?: RequestPermissionOptions,
@@ -193,7 +212,7 @@ export class PermissionForwarder implements ApprovalRequester, InboxProcessor {
193
212
  * when this session has UI, otherwise forward to the parent session.
194
213
  */
195
214
  requestApproval(
196
- ctx: ExtensionContext,
215
+ ctx: ForwarderContext,
197
216
  message: string,
198
217
  options?: RequestPermissionOptions,
199
218
  forwarded?: ForwardedPromptDisplay,
@@ -217,7 +236,7 @@ export class PermissionForwarder implements ApprovalRequester, InboxProcessor {
217
236
  }
218
237
 
219
238
  /** Drain and respond to this session's forwarded-permission inbox. */
220
- async processInbox(ctx: ExtensionContext): Promise<void> {
239
+ async processInbox(ctx: ForwarderContext): Promise<void> {
221
240
  if (!ctx.hasUI) {
222
241
  return;
223
242
  }
@@ -263,7 +282,7 @@ export class PermissionForwarder implements ApprovalRequester, InboxProcessor {
263
282
  // ── Private methods ────────────────────────────────────────────────────
264
283
 
265
284
  private async waitForForwardedApproval(
266
- ctx: ExtensionContext,
285
+ ctx: ForwarderContext,
267
286
  message: string,
268
287
  forwarded?: ForwardedPromptDisplay,
269
288
  ): Promise<PermissionPromptDecision> {
@@ -345,7 +364,7 @@ export class PermissionForwarder implements ApprovalRequester, InboxProcessor {
345
364
  }
346
365
 
347
366
  private buildForwardedRequest(
348
- ctx: ExtensionContext,
367
+ ctx: ForwarderContext,
349
368
  message: string,
350
369
  requesterSessionId: string,
351
370
  targetSessionId: string,
@@ -430,7 +449,7 @@ export class PermissionForwarder implements ApprovalRequester, InboxProcessor {
430
449
  }
431
450
 
432
451
  private async processSingleForwardedRequest(
433
- ctx: ExtensionContext,
452
+ ctx: ForwarderContext,
434
453
  request: ForwardedPermissionRequest,
435
454
  location: PermissionForwardingLocation,
436
455
  requestPath: string,
package/src/index.ts CHANGED
@@ -1,5 +1,5 @@
1
1
  import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
2
- import { getAgentDir } from "@earendil-works/pi-coding-agent";
2
+ import { getAgentDir, getPackageDir } from "@earendil-works/pi-coding-agent";
3
3
  import { registerBuiltinToolInputFormatters } from "./builtin-tool-input-formatters";
4
4
  import { registerPermissionSystemCommand } from "./config-modal";
5
5
  import { getGlobalConfigPath } from "./config-paths";
@@ -36,7 +36,9 @@ import { ToolInputFormatterRegistry } from "./tool-input-formatter-registry";
36
36
 
37
37
  export default function piPermissionSystemExtension(pi: ExtensionAPI): void {
38
38
  const agentDir = getAgentDir();
39
- const paths = computeExtensionPaths(agentDir);
39
+ // getPackageDir() is Pi's own install dir; auto-allow it for read-only tools
40
+ // so the agent can read Pi's bundled docs/examples regardless of layout.
41
+ const paths = computeExtensionPaths(agentDir, getPackageDir());
40
42
  const permissionManager = new PermissionManager({ agentDir });
41
43
  const sessionRules = new SessionRules();
42
44
  const subagentRegistry = getSubagentSessionRegistry();
package/src/path-utils.ts CHANGED
@@ -1,4 +1,10 @@
1
- import { join, normalize, resolve, sep } from "node:path";
1
+ import {
2
+ join,
3
+ normalize,
4
+ posix as posixPath,
5
+ resolve,
6
+ win32 as winPath,
7
+ } from "node:path";
2
8
 
3
9
  import { canonicalizePath } from "./canonicalize-path";
4
10
  import { getNonEmptyString, toRecord } from "./common";
@@ -24,9 +30,19 @@ export function normalizePathForComparison(
24
30
  : normalizedAbsolutePath;
25
31
  }
26
32
 
33
+ /**
34
+ * Returns true when `pathValue` is `directory` itself or nested inside it.
35
+ *
36
+ * Containment is decided with Node's platform-native `path.relative` rather
37
+ * than a hand-rolled prefix check: on `win32` the comparison folds case (and
38
+ * tolerates either separator), matching the case-insensitive filesystem.
39
+ * `platform` defaults to `process.platform` and is injectable so Windows
40
+ * behavior is testable on a POSIX CI.
41
+ */
27
42
  export function isPathWithinDirectory(
28
43
  pathValue: string,
29
44
  directory: string,
45
+ platform: NodeJS.Platform = process.platform,
30
46
  ): boolean {
31
47
  if (!pathValue || !directory) {
32
48
  return false;
@@ -36,8 +52,14 @@ export function isPathWithinDirectory(
36
52
  return true;
37
53
  }
38
54
 
39
- const prefix = directory.endsWith(sep) ? directory : `${directory}${sep}`;
40
- return pathValue.startsWith(prefix);
55
+ const impl = platform === "win32" ? winPath : posixPath;
56
+ const rel = impl.relative(directory, pathValue);
57
+ return (
58
+ rel !== "" &&
59
+ rel !== ".." &&
60
+ !rel.startsWith(`..${impl.sep}`) &&
61
+ !impl.isAbsolute(rel)
62
+ );
41
63
  }
42
64
 
43
65
  /**
@@ -79,6 +101,17 @@ export const PATH_BEARING_TOOLS = new Set([
79
101
  "ls",
80
102
  ]);
81
103
 
104
+ /**
105
+ * Surfaces whose patterns are matched against filesystem paths and therefore
106
+ * fold case (and separators) on Windows: the path-bearing tools plus the
107
+ * cross-cutting `path` gate and the `external_directory` boundary gate.
108
+ */
109
+ export const PATH_SURFACES: ReadonlySet<string> = new Set([
110
+ ...PATH_BEARING_TOOLS,
111
+ "external_directory",
112
+ "path",
113
+ ]);
114
+
82
115
  export function getPathBearingToolPath(
83
116
  toolName: string,
84
117
  input: unknown,
@@ -144,16 +177,24 @@ export function isPiInfrastructureRead(
144
177
  normalizedPath: string,
145
178
  infrastructureDirs: readonly string[],
146
179
  cwd: string,
180
+ platform: NodeJS.Platform = process.platform,
147
181
  ): boolean {
148
182
  if (!READ_ONLY_PATH_BEARING_TOOLS.has(toolName)) {
149
183
  return false;
150
184
  }
151
185
 
186
+ // On Windows the path value is canonicalized + lowercased; fold case (and
187
+ // separators) so mixed-case infra dirs and glob patterns still match.
188
+ const matchOptions =
189
+ platform === "win32"
190
+ ? { caseInsensitive: true, windowsSeparators: true }
191
+ : undefined;
192
+
152
193
  for (const dir of infrastructureDirs) {
153
194
  if (containsGlobChars(dir)) {
154
- if (wildcardMatch(dir, normalizedPath)) return true;
195
+ if (wildcardMatch(dir, normalizedPath, matchOptions)) return true;
155
196
  } else {
156
- if (isPathWithinDirectory(normalizedPath, expandHomePath(dir)))
197
+ if (isPathWithinDirectory(normalizedPath, expandHomePath(dir), platform))
157
198
  return true;
158
199
  }
159
200
  }
@@ -161,10 +202,10 @@ export function isPiInfrastructureRead(
161
202
  // Project-local Pi packages — checked fresh every call so CWD changes work.
162
203
  const projectNpmDir = join(cwd, ".pi", "npm");
163
204
  const projectGitDir = join(cwd, ".pi", "git");
164
- if (isPathWithinDirectory(normalizedPath, projectNpmDir)) {
205
+ if (isPathWithinDirectory(normalizedPath, projectNpmDir, platform)) {
165
206
  return true;
166
207
  }
167
- if (isPathWithinDirectory(normalizedPath, projectGitDir)) {
208
+ if (isPathWithinDirectory(normalizedPath, projectGitDir, platform)) {
168
209
  return true;
169
210
  }
170
211
 
package/src/rule.ts CHANGED
@@ -1,3 +1,4 @@
1
+ import { PATH_SURFACES } from "./path-utils";
1
2
  import type { PermissionState } from "./types";
2
3
  import { wildcardMatch } from "./wildcard-matcher";
3
4
 
@@ -52,10 +53,19 @@ export function evaluate(
52
53
  pattern: string,
53
54
  rules: Ruleset,
54
55
  defaultAction?: PermissionState,
56
+ platform: NodeJS.Platform = process.platform,
55
57
  ): Rule {
58
+ // On Windows, path-surface values are canonicalized + lowercased; fold the
59
+ // pattern→value match (case and separators) so mixed-case / forward-slash
60
+ // overrides still match. The surface→surface match stays exact.
61
+ const matchOptions =
62
+ platform === "win32" && PATH_SURFACES.has(surface)
63
+ ? { caseInsensitive: true, windowsSeparators: true }
64
+ : undefined;
56
65
  const rule = rules.findLast(
57
66
  (r) =>
58
- wildcardMatch(r.surface, surface) && wildcardMatch(r.pattern, pattern),
67
+ wildcardMatch(r.surface, surface) &&
68
+ wildcardMatch(r.pattern, pattern, matchOptions),
59
69
  );
60
70
  if (rule !== undefined) return rule;
61
71
  return {
@@ -1,9 +1,20 @@
1
1
  import { normalize } from "node:path";
2
- import type { ExtensionContext } from "@earendil-works/pi-coding-agent";
3
2
 
4
3
  import { SUBAGENT_ENV_HINT_KEYS } from "./permission-forwarding";
5
4
  import type { SubagentSessionRegistry } from "./subagent-registry";
6
5
 
6
+ /**
7
+ * Narrow context for subagent detection — the only session-manager readers
8
+ * {@link isSubagentExecutionContext} and {@link isRegisteredSubagentChild}
9
+ * consume. A full `ExtensionContext` satisfies this structurally.
10
+ */
11
+ export interface SubagentDetectionContext {
12
+ sessionManager: {
13
+ getSessionId(): string;
14
+ getSessionDir(): string;
15
+ };
16
+ }
17
+
7
18
  export function normalizeFilesystemPath(pathValue: string): string {
8
19
  const normalizedPath = normalize(pathValue);
9
20
  return process.platform === "win32"
@@ -39,7 +50,7 @@ function isPathWithinDirectoryForSubagent(
39
50
  * child must not publish over its parent.
40
51
  */
41
52
  export function isRegisteredSubagentChild(
42
- ctx: ExtensionContext,
53
+ ctx: SubagentDetectionContext,
43
54
  registry: SubagentSessionRegistry,
44
55
  ): boolean {
45
56
  try {
@@ -55,7 +66,7 @@ export function isRegisteredSubagentChild(
55
66
  }
56
67
 
57
68
  export function isSubagentExecutionContext(
58
- ctx: ExtensionContext,
69
+ ctx: SubagentDetectionContext,
59
70
  subagentSessionsDir: string,
60
71
  registry?: SubagentSessionRegistry,
61
72
  ): boolean {
@@ -12,6 +12,19 @@ export type WildcardPatternMatch<TState> = {
12
12
  matchedName: string;
13
13
  };
14
14
 
15
+ /**
16
+ * Optional folding applied when matching path-surface patterns on Windows.
17
+ *
18
+ * - `caseInsensitive` compiles the pattern with the `i` flag so a mixed-case
19
+ * pattern matches a lowercased (canonicalized) path value.
20
+ * - `windowsSeparators` rewrites `/` to `\` in the expanded pattern so a
21
+ * forward-slash pattern matches a backslash-separated path value.
22
+ */
23
+ export interface WildcardMatchOptions {
24
+ caseInsensitive?: boolean;
25
+ windowsSeparators?: boolean;
26
+ }
27
+
15
28
  function escapeRegExp(value: string): string {
16
29
  return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
17
30
  }
@@ -19,8 +32,12 @@ function escapeRegExp(value: string): string {
19
32
  export function compileWildcardPattern<TState>(
20
33
  pattern: string,
21
34
  state: TState,
35
+ options?: WildcardMatchOptions,
22
36
  ): CompiledWildcardPattern<TState> {
23
- const expanded = expandHomePath(pattern);
37
+ let expanded = expandHomePath(pattern);
38
+ if (options?.windowsSeparators) {
39
+ expanded = expanded.replaceAll("/", "\\");
40
+ }
24
41
  let escaped = expanded
25
42
  .split("*")
26
43
  .map((part) => escapeRegExp(part).replaceAll("\\?", "."))
@@ -36,7 +53,7 @@ export function compileWildcardPattern<TState>(
36
53
  return {
37
54
  pattern,
38
55
  state,
39
- regex: new RegExp(`^${escaped}$`, "s"),
56
+ regex: new RegExp(`^${escaped}$`, options?.caseInsensitive ? "si" : "s"),
40
57
  };
41
58
  }
42
59
 
@@ -73,8 +90,12 @@ export function findCompiledWildcardMatch<TState>(
73
90
  * `?` matches exactly one character.
74
91
  * Used by evaluate() for rule matching.
75
92
  */
76
- export function wildcardMatch(pattern: string, value: string): boolean {
77
- return compileWildcardPattern(pattern, null).regex.test(value);
93
+ export function wildcardMatch(
94
+ pattern: string,
95
+ value: string,
96
+ options?: WildcardMatchOptions,
97
+ ): boolean {
98
+ return compileWildcardPattern(pattern, null, options).regex.test(value);
78
99
  }
79
100
 
80
101
  export function findCompiledWildcardMatchForNames<TState>(
@@ -1,28 +1,23 @@
1
- import type { ExtensionContext } from "@earendil-works/pi-coding-agent";
2
1
  import { afterEach, describe, expect, test, vi } from "vitest";
3
2
  import {
4
3
  ACTIVE_AGENT_TAG_REGEX,
4
+ type ActiveAgentContext,
5
5
  getActiveAgentName,
6
6
  getActiveAgentNameFromSystemPrompt,
7
7
  normalizeAgentName,
8
+ type SessionEntryView,
8
9
  } from "#src/active-agent";
9
10
 
10
11
  afterEach(() => {
11
12
  vi.restoreAllMocks();
12
13
  });
13
14
 
14
- type SessionEntry = {
15
- type: string;
16
- customType?: string;
17
- data?: unknown;
18
- };
19
-
20
- function makeCtx(entries: SessionEntry[]): ExtensionContext {
15
+ function makeCtx(entries: SessionEntryView[]): ActiveAgentContext {
21
16
  return {
22
17
  sessionManager: {
23
18
  getEntries: vi.fn(() => entries),
24
19
  },
25
- } as unknown as ExtensionContext;
20
+ };
26
21
  }
27
22
 
28
23
  describe("ACTIVE_AGENT_TAG_REGEX", () => {
@@ -78,6 +78,25 @@ describe("computeExtensionPaths", () => {
78
78
  }
79
79
  });
80
80
 
81
+ it("includes piPackageDir in piInfrastructureDirs when provided", () => {
82
+ const paths = computeExtensionPaths("/test/agent", "/pi/install");
83
+ expect(paths.piInfrastructureDirs).toContain("/pi/install");
84
+ });
85
+
86
+ it("omits piPackageDir when not provided (current behavior preserved)", () => {
87
+ const paths = computeExtensionPaths("/test/agent");
88
+ expect(paths.piInfrastructureDirs).toEqual([
89
+ "/test/agent",
90
+ "/test/agent/git",
91
+ "/mock/global/node_modules",
92
+ ]);
93
+ });
94
+
95
+ it("omits piPackageDir when given an empty string", () => {
96
+ const paths = computeExtensionPaths("/test/agent", "");
97
+ expect(paths.piInfrastructureDirs).not.toContain("");
98
+ });
99
+
81
100
  it("two calls with different agentDirs produce independent results", () => {
82
101
  const a = computeExtensionPaths("/agent/a");
83
102
  const b = computeExtensionPaths("/agent/b");
@@ -117,6 +117,42 @@ describe("isPathWithinDirectory", () => {
117
117
  test("returns false for empty directory", () => {
118
118
  expect(isPathWithinDirectory("/a/b", "")).toBe(false);
119
119
  });
120
+
121
+ // ── platform-aware containment (Windows is case-insensitive) ────────────
122
+
123
+ test("win32: folds case for a case-different descendant", () => {
124
+ expect(
125
+ isPathWithinDirectory(
126
+ "c:\\users\\foo\\dir\\sub\\x.md",
127
+ "C:\\Users\\Foo\\dir",
128
+ "win32",
129
+ ),
130
+ ).toBe(true);
131
+ });
132
+
133
+ test("win32: folds case when path equals directory in different case", () => {
134
+ expect(
135
+ isPathWithinDirectory(
136
+ "c:\\users\\foo\\dir\\sub",
137
+ "C:\\USERS\\foo\\DIR",
138
+ "win32",
139
+ ),
140
+ ).toBe(true);
141
+ });
142
+
143
+ test("win32: rejects a sibling directory", () => {
144
+ expect(
145
+ isPathWithinDirectory(
146
+ "C:\\Users\\Foo\\other",
147
+ "C:\\Users\\Foo\\dir",
148
+ "win32",
149
+ ),
150
+ ).toBe(false);
151
+ });
152
+
153
+ test("posix platform stays case-sensitive", () => {
154
+ expect(isPathWithinDirectory("/a/B/c", "/a/b", "linux")).toBe(false);
155
+ });
120
156
  });
121
157
 
122
158
  describe("PATH_BEARING_TOOLS", () => {
@@ -404,4 +440,42 @@ describe("isPiInfrastructureRead", () => {
404
440
  ),
405
441
  ).toBe(true);
406
442
  });
443
+
444
+ // ── Windows: case-insensitive infra-read matching ─────────────────────
445
+
446
+ test("win32: plain infra dir matches a case-different path", () => {
447
+ expect(
448
+ isPiInfrastructureRead(
449
+ "read",
450
+ "c:\\users\\foo\\.pi\\agent\\config.json",
451
+ ["C:\\Users\\Foo\\.pi\\agent"],
452
+ "C:\\proj",
453
+ "win32",
454
+ ),
455
+ ).toBe(true);
456
+ });
457
+
458
+ test("win32: glob infra dir matches case-insensitively", () => {
459
+ expect(
460
+ isPiInfrastructureRead(
461
+ "read",
462
+ "c:\\users\\foo\\npm\\node_modules\\@earendil-works\\pi-coding-agent\\skill.md",
463
+ ["C:\\Users\\Foo\\**\\pi-coding-agent\\**"],
464
+ "C:\\proj",
465
+ "win32",
466
+ ),
467
+ ).toBe(true);
468
+ });
469
+
470
+ test("win32: rejects a path outside every infra dir", () => {
471
+ expect(
472
+ isPiInfrastructureRead(
473
+ "read",
474
+ "c:\\windows\\system32\\drivers\\etc\\hosts",
475
+ ["C:\\Users\\Foo\\.pi\\agent"],
476
+ "C:\\proj",
477
+ "win32",
478
+ ),
479
+ ).toBe(false);
480
+ });
407
481
  });
@@ -1,10 +1,10 @@
1
1
  import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs";
2
2
  import { tmpdir } from "node:os";
3
3
  import { join } from "node:path";
4
- import type { ExtensionContext } from "@earendil-works/pi-coding-agent";
5
4
  import { afterEach, describe, expect, test, vi } from "vitest";
6
5
  import { DEFAULT_EXTENSION_CONFIG } from "#src/extension-config";
7
6
  import {
7
+ type ForwarderContext,
8
8
  PermissionForwarder,
9
9
  type PermissionForwarderDeps,
10
10
  } from "#src/forwarded-permissions/permission-forwarder";
@@ -27,6 +27,25 @@ function makeDeps(
27
27
  };
28
28
  }
29
29
 
30
+ function makeCtx(
31
+ overrides: {
32
+ hasUI?: boolean;
33
+ ui?: ForwarderContext["ui"];
34
+ sessionManager?: Partial<ForwarderContext["sessionManager"]>;
35
+ } = {},
36
+ ): ForwarderContext {
37
+ return {
38
+ hasUI: overrides.hasUI ?? false,
39
+ ui: overrides.ui ?? { select: vi.fn(), input: vi.fn() },
40
+ sessionManager: {
41
+ getSessionId: vi.fn(() => ""),
42
+ getSessionDir: vi.fn(() => ""),
43
+ getEntries: vi.fn(() => []),
44
+ ...overrides.sessionManager,
45
+ },
46
+ };
47
+ }
48
+
30
49
  afterEach(() => {
31
50
  vi.unstubAllEnvs();
32
51
  });
@@ -48,10 +67,7 @@ describe("requestApproval — UI fast path", () => {
48
67
  );
49
68
 
50
69
  await forwarder.requestApproval(
51
- {
52
- hasUI: true,
53
- ui: { select: vi.fn(), input: vi.fn() },
54
- } as unknown as ExtensionContext,
70
+ makeCtx({ hasUI: true }),
55
71
  "Allow git push?",
56
72
  );
57
73
 
@@ -76,12 +92,7 @@ describe("requestApproval — non-UI, non-subagent path", () => {
76
92
  );
77
93
 
78
94
  const result = await forwarder.requestApproval(
79
- {
80
- hasUI: false,
81
- sessionManager: {
82
- getSessionDir: vi.fn().mockReturnValue(null),
83
- },
84
- } as unknown as ExtensionContext,
95
+ makeCtx({ hasUI: false }),
85
96
  "Allow git push?",
86
97
  );
87
98
 
@@ -136,13 +147,14 @@ describe("processInbox", () => {
136
147
  }),
137
148
  );
138
149
 
139
- await forwarder.processInbox({
140
- hasUI: true,
141
- ui: { select: vi.fn(), input: vi.fn() },
142
- sessionManager: {
143
- getSessionId: vi.fn().mockReturnValue("parent-session"),
144
- },
145
- } as unknown as ExtensionContext);
150
+ await forwarder.processInbox(
151
+ makeCtx({
152
+ hasUI: true,
153
+ sessionManager: {
154
+ getSessionId: vi.fn(() => "parent-session"),
155
+ },
156
+ }),
157
+ );
146
158
 
147
159
  expect(events.emit).toHaveBeenCalledWith(
148
160
  "permissions:ui_prompt",
@@ -207,13 +219,14 @@ describe("processInbox", () => {
207
219
  }),
208
220
  );
209
221
 
210
- await forwarder.processInbox({
211
- hasUI: true,
212
- ui: { select: vi.fn(), input: vi.fn() },
213
- sessionManager: {
214
- getSessionId: vi.fn().mockReturnValue("parent-session"),
215
- },
216
- } as unknown as ExtensionContext);
222
+ await forwarder.processInbox(
223
+ makeCtx({
224
+ hasUI: true,
225
+ sessionManager: {
226
+ getSessionId: vi.fn(() => "parent-session"),
227
+ },
228
+ }),
229
+ );
217
230
 
218
231
  expect(events.emit).toHaveBeenCalledWith(
219
232
  "permissions:ui_prompt",
@@ -276,13 +289,14 @@ describe("processInbox", () => {
276
289
  }),
277
290
  );
278
291
 
279
- await forwarder.processInbox({
280
- hasUI: true,
281
- ui: { select: vi.fn(), input: vi.fn() },
282
- sessionManager: {
283
- getSessionId: vi.fn().mockReturnValue("parent-session"),
284
- },
285
- } as unknown as ExtensionContext);
292
+ await forwarder.processInbox(
293
+ makeCtx({
294
+ hasUI: true,
295
+ sessionManager: {
296
+ getSessionId: vi.fn(() => "parent-session"),
297
+ },
298
+ }),
299
+ );
286
300
 
287
301
  expect(events.emit).not.toHaveBeenCalledWith(
288
302
  "permissions:ui_prompt",
package/test/rule.test.ts CHANGED
@@ -232,6 +232,81 @@ describe("evaluate", () => {
232
232
  expect(evaluate("read", "*", [rule]).origin).toBe(origin);
233
233
  }
234
234
  });
235
+
236
+ // ── Windows: path-surface patterns fold case (last-match-wins) ──────────
237
+
238
+ const denyExternalAll: Rule = {
239
+ surface: "external_directory",
240
+ pattern: "*",
241
+ action: "deny",
242
+ layer: "config",
243
+ origin: "global",
244
+ };
245
+ const allowExternalPi: Rule = {
246
+ surface: "external_directory",
247
+ pattern: "C:\\Users\\Foo\\pi\\*",
248
+ action: "allow",
249
+ layer: "config",
250
+ origin: "global",
251
+ };
252
+
253
+ test("win32: external_directory allow override matches a lowercased path over a preceding deny", () => {
254
+ const result = evaluate(
255
+ "external_directory",
256
+ "c:\\users\\foo\\pi\\docs\\readme.md",
257
+ [denyExternalAll, allowExternalPi],
258
+ undefined,
259
+ "win32",
260
+ );
261
+ expect(result.action).toBe("allow");
262
+ });
263
+
264
+ test("posix: the same mixed-case override stays case-sensitive (falls through to deny)", () => {
265
+ const result = evaluate(
266
+ "external_directory",
267
+ "c:\\users\\foo\\pi\\docs\\readme.md",
268
+ [denyExternalAll, allowExternalPi],
269
+ undefined,
270
+ "linux",
271
+ );
272
+ expect(result.action).toBe("deny");
273
+ });
274
+
275
+ test("win32: a forward-slash external_directory pattern matches a backslash value", () => {
276
+ const allowForwardSlash: Rule = {
277
+ surface: "external_directory",
278
+ pattern: "C:/Users/Foo/pi/*",
279
+ action: "allow",
280
+ layer: "config",
281
+ origin: "global",
282
+ };
283
+ const result = evaluate(
284
+ "external_directory",
285
+ "c:\\users\\foo\\pi\\docs\\readme.md",
286
+ [denyExternalAll, allowForwardSlash],
287
+ undefined,
288
+ "win32",
289
+ );
290
+ expect(result.action).toBe("allow");
291
+ });
292
+
293
+ test("win32: bash surface stays case-sensitive (not a path surface)", () => {
294
+ const result = evaluate(
295
+ "bash",
296
+ "GIT push",
297
+ [
298
+ {
299
+ surface: "bash",
300
+ pattern: "git *",
301
+ action: "allow",
302
+ origin: "global",
303
+ },
304
+ ],
305
+ undefined,
306
+ "win32",
307
+ );
308
+ expect(result.action).toBe("ask");
309
+ });
235
310
  });
236
311
 
237
312
  describe("evaluateFirst", () => {
@@ -1,10 +1,10 @@
1
- import type { ExtensionContext } from "@earendil-works/pi-coding-agent";
2
1
  import { afterEach, describe, expect, test, vi } from "vitest";
3
2
  import { SUBAGENT_ENV_HINT_KEYS } from "#src/permission-forwarding";
4
3
  import {
5
4
  isRegisteredSubagentChild,
6
5
  isSubagentExecutionContext,
7
6
  normalizeFilesystemPath,
7
+ type SubagentDetectionContext,
8
8
  } from "#src/subagent-context";
9
9
  import { SubagentSessionRegistry } from "#src/subagent-registry";
10
10
 
@@ -16,13 +16,13 @@ afterEach(() => {
16
16
  function makeCtx(
17
17
  sessionDir: string | null,
18
18
  sessionId: string = "",
19
- ): ExtensionContext {
19
+ ): SubagentDetectionContext {
20
20
  return {
21
21
  sessionManager: {
22
- getSessionDir: vi.fn(() => sessionDir),
22
+ getSessionDir: vi.fn(() => sessionDir ?? ""),
23
23
  getSessionId: vi.fn(() => sessionId),
24
24
  },
25
- } as unknown as ExtensionContext;
25
+ };
26
26
  }
27
27
 
28
28
  describe("isRegisteredSubagentChild", () => {
@@ -52,14 +52,14 @@ describe("isRegisteredSubagentChild", () => {
52
52
  test("returns false when getSessionId throws", () => {
53
53
  const registry = new SubagentSessionRegistry();
54
54
  registry.register(childSessionId, {});
55
- const ctx = {
55
+ const ctx: SubagentDetectionContext = {
56
56
  sessionManager: {
57
- getSessionDir: vi.fn(() => null),
57
+ getSessionDir: vi.fn(() => ""),
58
58
  getSessionId: vi.fn(() => {
59
59
  throw new Error("session id unavailable");
60
60
  }),
61
61
  },
62
- } as unknown as ExtensionContext;
62
+ };
63
63
  expect(isRegisteredSubagentChild(ctx, registry)).toBe(false);
64
64
  });
65
65
  });
@@ -287,6 +287,46 @@ describe("wildcardMatch", () => {
287
287
  expect(wildcardMatch("*", "")).toBe(true);
288
288
  });
289
289
  });
290
+
291
+ describe("match options (Windows path folding)", () => {
292
+ test("caseInsensitive matches a value differing only in case", () => {
293
+ expect(
294
+ wildcardMatch("C:\\Users\\Foo\\*", "c:\\users\\foo\\bar.md", {
295
+ caseInsensitive: true,
296
+ }),
297
+ ).toBe(true);
298
+ });
299
+
300
+ test("case folding is off by default", () => {
301
+ expect(wildcardMatch("C:\\Users\\Foo\\*", "c:\\users\\foo\\bar.md")).toBe(
302
+ false,
303
+ );
304
+ });
305
+
306
+ test("windowsSeparators matches a backslash value against a forward-slash pattern", () => {
307
+ expect(
308
+ wildcardMatch("C:/Users/Foo/*", "C:\\Users\\Foo\\bar.md", {
309
+ windowsSeparators: true,
310
+ }),
311
+ ).toBe(true);
312
+ });
313
+
314
+ test("separator normalization is off by default", () => {
315
+ expect(wildcardMatch("C:/Users/Foo/*", "C:\\Users\\Foo\\bar.md")).toBe(
316
+ false,
317
+ );
318
+ });
319
+
320
+ test("both options fold a mixed-case forward-slash pattern onto a lowercased backslash value", () => {
321
+ expect(
322
+ wildcardMatch(
323
+ "C:/Users/Foo/AppData/Roaming/*",
324
+ "c:\\users\\foo\\appdata\\roaming\\npm\\x.md",
325
+ { caseInsensitive: true, windowsSeparators: true },
326
+ ),
327
+ ).toBe(true);
328
+ });
329
+ });
290
330
  });
291
331
 
292
332
  describe("? single-character wildcard", () => {