@gotgenes/pi-permission-system 16.2.1 → 17.1.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.
Files changed (61) hide show
  1. package/CHANGELOG.md +26 -0
  2. package/README.md +2 -1
  3. package/package.json +1 -1
  4. package/src/access-intent/access-intent.ts +8 -10
  5. package/src/access-intent/access-path.ts +39 -16
  6. package/src/access-intent/bash/bash-path-resolver.ts +531 -0
  7. package/src/access-intent/bash/program.ts +21 -14
  8. package/src/access-intent/bash/token-classification.ts +1 -1
  9. package/src/canonicalize-path.ts +11 -5
  10. package/src/forwarded-permissions/permission-forwarder.ts +11 -1
  11. package/src/forwarding-manager.ts +7 -1
  12. package/src/handlers/before-agent-start.ts +1 -0
  13. package/src/handlers/gates/bash-path-extractor.ts +7 -5
  14. package/src/handlers/gates/bash-path.ts +13 -13
  15. package/src/handlers/gates/external-directory.ts +14 -16
  16. package/src/handlers/gates/path.ts +12 -7
  17. package/src/handlers/gates/skill-read.ts +7 -1
  18. package/src/handlers/gates/tool-call-gate-pipeline.ts +21 -4
  19. package/src/handlers/gates/tool.ts +4 -2
  20. package/src/index.ts +12 -1
  21. package/src/input-normalizer.ts +9 -4
  22. package/src/path-normalizer.ts +70 -0
  23. package/src/path-utils.ts +39 -28
  24. package/src/permission-manager.ts +21 -7
  25. package/src/permission-session.ts +35 -2
  26. package/src/prompting-gateway.ts +3 -0
  27. package/src/rule.ts +8 -6
  28. package/src/skill-prompt-sanitizer.ts +15 -4
  29. package/src/subagent-context.ts +17 -9
  30. package/test/access-intent/access-path.test.ts +124 -15
  31. package/test/access-intent/bash/program.test.ts +178 -80
  32. package/test/bash-external-directory.test.ts +15 -1
  33. package/test/canonicalize-path.test.ts +34 -8
  34. package/test/forwarding-manager.test.ts +7 -1
  35. package/test/handlers/external-directory-symlink-acceptance.test.ts +26 -4
  36. package/test/handlers/gates/bash-command-metamorphic.test.ts +5 -1
  37. package/test/handlers/gates/bash-external-directory.test.ts +5 -2
  38. package/test/handlers/gates/bash-path.test.ts +14 -9
  39. package/test/handlers/gates/external-directory-policy.test.ts +28 -18
  40. package/test/handlers/gates/external-directory.test.ts +9 -1
  41. package/test/handlers/gates/path.test.ts +90 -20
  42. package/test/handlers/gates/skill-read.test.ts +22 -13
  43. package/test/handlers/gates/tool-call-gate-pipeline.test.ts +2 -1
  44. package/test/handlers/gates/tool.test.ts +13 -1
  45. package/test/helpers/gate-fixtures.ts +11 -2
  46. package/test/helpers/session-fixtures.ts +3 -0
  47. package/test/input-normalizer.test.ts +104 -39
  48. package/test/path-normalizer.test.ts +123 -0
  49. package/test/path-utils.test.ts +130 -51
  50. package/test/permission-forwarder.test.ts +1 -0
  51. package/test/permission-manager-unified.test.ts +40 -0
  52. package/test/permission-resolver.test.ts +14 -32
  53. package/test/permission-session.test.ts +41 -0
  54. package/test/pi-infrastructure-read.test.ts +27 -4
  55. package/test/prompting-gateway.test.ts +1 -0
  56. package/test/rule.test.ts +88 -42
  57. package/test/session-rules.test.ts +21 -4
  58. package/test/skill-prompt-sanitizer.test.ts +69 -14
  59. package/test/subagent-context.test.ts +77 -31
  60. package/test/synthesize.test.ts +13 -11
  61. package/src/access-intent/bash/cwd-projection.ts +0 -498
package/src/path-utils.ts CHANGED
@@ -1,11 +1,4 @@
1
- import {
2
- join,
3
- normalize,
4
- posix as posixPath,
5
- relative,
6
- resolve,
7
- win32 as winPath,
8
- } from "node:path";
1
+ import { join, posix as posixPath, win32 as winPath } from "node:path";
9
2
 
10
3
  import { canonicalizePath } from "./canonicalize-path";
11
4
  import { expandHomePath } from "./expand-home";
@@ -16,6 +9,7 @@ import { wildcardMatch } from "./wildcard-matcher";
16
9
  export function normalizePathForComparison(
17
10
  pathValue: string,
18
11
  cwd: string,
12
+ platform: NodeJS.Platform,
19
13
  ): string {
20
14
  const trimmed = pathValue.trim().replace(/^['"]|['"]$/g, "");
21
15
  if (!trimmed) {
@@ -25,9 +19,10 @@ export function normalizePathForComparison(
25
19
  let normalizedPath = trimmed.startsWith("@") ? trimmed.slice(1) : trimmed;
26
20
  normalizedPath = expandHomePath(normalizedPath);
27
21
 
28
- const absolutePath = resolve(cwd, normalizedPath);
29
- const normalizedAbsolutePath = normalize(absolutePath);
30
- return process.platform === "win32"
22
+ const impl = platform === "win32" ? winPath : posixPath;
23
+ const absolutePath = impl.resolve(cwd, normalizedPath);
24
+ const normalizedAbsolutePath = impl.normalize(absolutePath);
25
+ return platform === "win32"
31
26
  ? normalizedAbsolutePath.toLowerCase()
32
27
  : normalizedAbsolutePath;
33
28
  }
@@ -38,13 +33,13 @@ export function normalizePathForComparison(
38
33
  * Containment is decided with Node's platform-native `path.relative` rather
39
34
  * than a hand-rolled prefix check: on `win32` the comparison folds case (and
40
35
  * tolerates either separator), matching the case-insensitive filesystem.
41
- * `platform` defaults to `process.platform` and is injectable so Windows
42
- * behavior is testable on a POSIX CI.
36
+ * `platform` is injected from the composition root so Windows behavior is
37
+ * testable on a POSIX CI.
43
38
  */
44
39
  export function isPathWithinDirectory(
45
40
  pathValue: string,
46
41
  directory: string,
47
- platform: NodeJS.Platform = process.platform,
42
+ platform: NodeJS.Platform,
48
43
  ): boolean {
49
44
  if (!pathValue || !directory) {
50
45
  return false;
@@ -101,46 +96,56 @@ export function normalizePathPolicyLiteral(pathValue: string): string {
101
96
  */
102
97
  export function getPathPolicyValues(
103
98
  pathValue: string,
104
- options: PathPolicyValueOptions = {},
99
+ options: PathPolicyValueOptions,
100
+ platform: NodeJS.Platform,
105
101
  ): string[] {
106
102
  const literal = normalizePathPolicyLiteral(pathValue);
107
103
  if (!literal) return [];
108
104
  if (literal === "*") return ["*"];
109
105
 
110
106
  return [
111
- ...new Set([...getAbsolutePathPolicyValues(pathValue, options), literal]),
107
+ ...new Set([
108
+ ...getAbsolutePathPolicyValues(pathValue, options, platform),
109
+ literal,
110
+ ]),
112
111
  ];
113
112
  }
114
113
 
115
114
  function getAbsolutePathPolicyValues(
116
115
  pathValue: string,
117
116
  options: PathPolicyValueOptions,
117
+ platform: NodeJS.Platform,
118
118
  ): string[] {
119
119
  const resolveBase = options.resolveBase ?? options.cwd;
120
120
  if (!resolveBase) return [];
121
121
 
122
- const absolute = normalizePathForComparison(pathValue, resolveBase);
122
+ const absolute = normalizePathForComparison(pathValue, resolveBase, platform);
123
123
  if (!absolute) return [];
124
124
 
125
- return [absolute, ...getCwdRelativePathPolicyValues(absolute, options.cwd)];
125
+ return [
126
+ absolute,
127
+ ...getCwdRelativePathPolicyValues(absolute, options.cwd, platform),
128
+ ];
126
129
  }
127
130
 
128
131
  function getCwdRelativePathPolicyValues(
129
132
  absolute: string,
130
133
  cwd: string | undefined,
134
+ platform: NodeJS.Platform,
131
135
  ): string[] {
132
136
  if (!cwd) return [];
133
137
 
134
- const normalizedCwd = normalizePathForComparison(cwd, cwd);
138
+ const normalizedCwd = normalizePathForComparison(cwd, cwd, platform);
135
139
  if (!normalizedCwd) return [];
136
140
  if (
137
141
  absolute !== normalizedCwd &&
138
- !isPathWithinDirectory(absolute, normalizedCwd)
142
+ !isPathWithinDirectory(absolute, normalizedCwd, platform)
139
143
  ) {
140
144
  return [];
141
145
  }
142
146
 
143
- const relativeValue = relative(normalizedCwd, absolute);
147
+ const impl = platform === "win32" ? winPath : posixPath;
148
+ const relativeValue = impl.relative(normalizedCwd, absolute);
144
149
  return relativeValue ? [relativeValue] : [];
145
150
  }
146
151
 
@@ -253,26 +258,32 @@ export function getToolInputPath(
253
258
  export function canonicalNormalizePathForComparison(
254
259
  pathValue: string,
255
260
  cwd: string,
261
+ platform: NodeJS.Platform,
256
262
  ): string {
257
- const lexical = normalizePathForComparison(pathValue, cwd);
263
+ const lexical = normalizePathForComparison(pathValue, cwd, platform);
258
264
  if (!lexical) return "";
259
- const canonical = canonicalizePath(lexical);
260
- return process.platform === "win32" ? canonical.toLowerCase() : canonical;
265
+ const canonical = canonicalizePath(lexical, platform);
266
+ return platform === "win32" ? canonical.toLowerCase() : canonical;
261
267
  }
262
268
 
263
269
  export function isPathOutsideWorkingDirectory(
264
270
  pathValue: string,
265
271
  cwd: string,
272
+ platform: NodeJS.Platform,
266
273
  ): boolean {
267
- const normalizedCwd = canonicalNormalizePathForComparison(cwd, cwd);
268
- const normalizedPath = canonicalNormalizePathForComparison(pathValue, cwd);
274
+ const normalizedCwd = canonicalNormalizePathForComparison(cwd, cwd, platform);
275
+ const normalizedPath = canonicalNormalizePathForComparison(
276
+ pathValue,
277
+ cwd,
278
+ platform,
279
+ );
269
280
  if (!normalizedCwd || !normalizedPath) {
270
281
  return false;
271
282
  }
272
283
  if (isSafeSystemPath(normalizedPath)) {
273
284
  return false;
274
285
  }
275
- return !isPathWithinDirectory(normalizedPath, normalizedCwd);
286
+ return !isPathWithinDirectory(normalizedPath, normalizedCwd, platform);
276
287
  }
277
288
 
278
289
  function containsGlobChars(value: string): boolean {
@@ -299,7 +310,7 @@ export function isPiInfrastructureRead(
299
310
  normalizedPath: string,
300
311
  infrastructureDirs: readonly string[],
301
312
  cwd: string,
302
- platform: NodeJS.Platform = process.platform,
313
+ platform: NodeJS.Platform,
303
314
  ): boolean {
304
315
  if (!READ_ONLY_PATH_BEARING_TOOLS.has(toolName)) {
305
316
  return false;
@@ -85,10 +85,17 @@ export interface PermissionManagerOptions extends PolicyLoaderOptions {
85
85
  * from this value and supports {@link PermissionManager.configureForCwd}.
86
86
  */
87
87
  agentDir?: string;
88
+ /**
89
+ * Host platform, injected from the composition root, that decides whether
90
+ * path-surface rule matching folds case (and separators) on Windows.
91
+ * Defaults to a POSIX flavor; production always supplies the real platform.
92
+ */
93
+ platform?: NodeJS.Platform;
88
94
  }
89
95
 
90
96
  export class PermissionManager implements ScopedPermissionManager {
91
97
  private readonly agentDir: string | undefined;
98
+ private readonly platform: NodeJS.Platform;
92
99
  private currentCwd: string | undefined;
93
100
  private loader: PolicyLoader;
94
101
  private readonly resolvedPermissionsCache = new Map<
@@ -98,6 +105,7 @@ export class PermissionManager implements ScopedPermissionManager {
98
105
 
99
106
  constructor(options: PermissionManagerOptions = {}) {
100
107
  this.agentDir = options.agentDir;
108
+ this.platform = options.platform ?? "linux";
101
109
  this.loader =
102
110
  options.policyLoader ??
103
111
  new FilePolicyLoader(
@@ -216,23 +224,25 @@ export class PermissionManager implements ScopedPermissionManager {
216
224
 
217
225
  // Special surfaces (external_directory): evaluate directly by surface name.
218
226
  if (SPECIAL_PERMISSION_KEYS.has(normalizedToolName)) {
219
- return evaluate(normalizedToolName, "*", composedRules).action;
227
+ return evaluate(normalizedToolName, "*", composedRules, this.platform)
228
+ .action;
220
229
  }
221
230
 
222
231
  // Bash, MCP, skill: evaluate with "*" value — the per-surface catch-all
223
232
  // (or universal default) handles this correctly.
224
233
  if (normalizedToolName === "bash") {
225
- return evaluate("bash", "*", composedRules).action;
234
+ return evaluate("bash", "*", composedRules, this.platform).action;
226
235
  }
227
236
  if (normalizedToolName === "mcp") {
228
- return evaluate("mcp", "*", composedRules).action;
237
+ return evaluate("mcp", "*", composedRules, this.platform).action;
229
238
  }
230
239
  if (normalizedToolName === "skill") {
231
- return evaluate("skill", "*", composedRules).action;
240
+ return evaluate("skill", "*", composedRules, this.platform).action;
232
241
  }
233
242
 
234
243
  // Tool-name surfaces (read, write, etc. and extension tools).
235
- return evaluate(normalizedToolName, "*", composedRules).action;
244
+ return evaluate(normalizedToolName, "*", composedRules, this.platform)
245
+ .action;
236
246
  }
237
247
 
238
248
  /**
@@ -260,6 +270,7 @@ export class PermissionManager implements ScopedPermissionManager {
260
270
  intent.surface,
261
271
  intent.surface,
262
272
  fullRules,
273
+ this.platform,
263
274
  );
264
275
  }
265
276
 
@@ -269,6 +280,7 @@ export class PermissionManager implements ScopedPermissionManager {
269
280
  toolName,
270
281
  intent.input,
271
282
  this.loader.getConfiguredMcpServerNames(),
283
+ this.platform,
272
284
  this.currentCwd,
273
285
  );
274
286
  return buildCheckResult(
@@ -278,6 +290,7 @@ export class PermissionManager implements ScopedPermissionManager {
278
290
  toolName,
279
291
  intent.surface,
280
292
  fullRules,
293
+ this.platform,
281
294
  );
282
295
  }
283
296
  }
@@ -296,10 +309,11 @@ function buildCheckResult(
296
309
  normalizedToolName: string,
297
310
  toolName: string,
298
311
  fullRules: Ruleset,
312
+ platform: NodeJS.Platform,
299
313
  ): PermissionCheckResult {
300
314
  const { rule, value } = PATH_SURFACES.has(surface)
301
- ? evaluateAnyValue(surface, values, fullRules)
302
- : evaluateFirst(surface, values, fullRules);
315
+ ? evaluateAnyValue(surface, values, fullRules, platform)
316
+ : evaluateFirst(surface, values, fullRules, platform);
303
317
 
304
318
  // For MCP, replace the normalizer's fallback target with the actual
305
319
  // matched candidate value so PermissionCheckResult.target is accurate.
@@ -9,6 +9,7 @@ import type { PermissionSystemExtensionConfig } from "./extension-config";
9
9
  import type { ExtensionPaths } from "./extension-paths";
10
10
  import type { ForwardingController } from "./forwarding-manager";
11
11
  import type { ToolCallGateInputs } from "./handlers/gates/tool-call-gate-pipeline";
12
+ import { PathNormalizer } from "./path-normalizer";
12
13
  import type { ScopedPermissionManager } from "./permission-manager";
13
14
  import type { PromptingGatewayLifecycle } from "./prompting-gateway";
14
15
 
@@ -37,6 +38,7 @@ export class PermissionSession implements ToolCallGateInputs {
37
38
  private context: ExtensionContext | null = null;
38
39
  private skillEntries: SkillPromptEntry[] = [];
39
40
  private knownAgentName: string | null = null;
41
+ private pathNormalizer: PathNormalizer;
40
42
 
41
43
  constructor(
42
44
  private readonly paths: ExtensionPaths,
@@ -45,13 +47,28 @@ export class PermissionSession implements ToolCallGateInputs {
45
47
  private readonly sessionRules: SessionRules,
46
48
  private readonly configStore: SessionConfigStore,
47
49
  private readonly gateway: PromptingGatewayLifecycle,
48
- ) {}
50
+ private readonly platform: NodeJS.Platform,
51
+ ) {
52
+ // Placeholder until the first activate(ctx) binds the real cwd; every gate
53
+ // evaluate runs after activate (handleToolCall activates first), so this
54
+ // empty-cwd value is never read.
55
+ this.pathNormalizer = new PathNormalizer(platform, "");
56
+ }
49
57
 
50
58
  // ── Context lifecycle ──────────────────────────────────────────────────
51
59
 
52
- /** Store the current extension context, start forwarding, and activate the gateway. */
60
+ /**
61
+ * Store the current extension context, rebuild the path normalizer for its
62
+ * cwd, start forwarding, and activate the gateway.
63
+ *
64
+ * The normalizer is (re)built here rather than only at `resetForNewSession`
65
+ * so it always tracks the active context's cwd — `ctx.cwd` is stable within a
66
+ * session, so this is a no-op rebuild in production, but it closes the
67
+ * fail-open gap if a tool call ever arrives before `session_start`.
68
+ */
53
69
  activate(ctx: ExtensionContext): void {
54
70
  this.context = ctx;
71
+ this.pathNormalizer = new PathNormalizer(this.platform, ctx.cwd);
55
72
  this.forwarding.start(ctx);
56
73
  this.gateway.activate(ctx);
57
74
  }
@@ -68,6 +85,11 @@ export class PermissionSession implements ToolCallGateInputs {
68
85
  return this.context;
69
86
  }
70
87
 
88
+ /** The host platform injected at the composition root. */
89
+ getPlatform(): NodeJS.Platform {
90
+ return this.platform;
91
+ }
92
+
71
93
  // ── UI notifications ────────────────────────────────────────────────────
72
94
 
73
95
  /** Surface a warning message to the user via the active UI context, if any. */
@@ -186,4 +208,15 @@ export class PermissionSession implements ToolCallGateInputs {
186
208
  getToolPreviewLimits(): ToolPreviewFormatterOptions {
187
209
  return resolveToolPreviewLimits(this.config);
188
210
  }
211
+
212
+ // ── Path normalization ────────────────────────────────────────────────
213
+
214
+ /**
215
+ * The session's {@link PathNormalizer}, carrying the host platform and the
216
+ * session cwd. Rebuilt on every `resetForNewSession` so a session switch
217
+ * rebinds the cwd.
218
+ */
219
+ getPathNormalizer(): PathNormalizer {
220
+ return this.pathNormalizer;
221
+ }
189
222
  }
@@ -23,6 +23,8 @@ export interface PromptingGatewayDeps {
23
23
  config: ConfigReader;
24
24
  /** Static path used to detect a forwarding subagent context. */
25
25
  subagentSessionsDir: string;
26
+ /** Host platform, injected from the composition root, for subagent-context path detection. */
27
+ platform: NodeJS.Platform;
26
28
  /** Process-global registry used to detect a registered child session. */
27
29
  registry?: SubagentSessionRegistry;
28
30
  /** Resolves the permission decision: direct UI dialog or forwarded to parent. */
@@ -82,6 +84,7 @@ export class PromptingGateway
82
84
  isSubagent: isSubagentExecutionContext(
83
85
  this.context,
84
86
  this.deps.subagentSessionsDir,
87
+ this.deps.platform,
85
88
  this.deps.registry,
86
89
  ),
87
90
  });
package/src/rule.ts CHANGED
@@ -54,8 +54,8 @@ export function evaluate(
54
54
  surface: string,
55
55
  pattern: string,
56
56
  rules: Ruleset,
57
+ platform: NodeJS.Platform,
57
58
  defaultAction?: PermissionState,
58
- platform: NodeJS.Platform = process.platform,
59
59
  ): Rule {
60
60
  const rule = rules.findLast((r) =>
61
61
  ruleMatches(r, surface, pattern, platform),
@@ -124,10 +124,11 @@ export function evaluateMostRestrictive(
124
124
  surface: string,
125
125
  values: string[],
126
126
  rules: Ruleset,
127
+ platform: NodeJS.Platform,
127
128
  ): { rule: Rule; value: string } | null {
128
129
  let worst: { rule: Rule; value: string } | null = null;
129
130
  for (const value of values) {
130
- const rule = evaluate(surface, value, rules);
131
+ const rule = evaluate(surface, value, rules, platform);
131
132
  if (rule.action === "deny") return { rule, value };
132
133
  if (rule.action === "ask" && worst?.rule.action !== "ask") {
133
134
  worst = { rule, value };
@@ -140,9 +141,10 @@ export function evaluateFirst(
140
141
  surface: string,
141
142
  values: string[],
142
143
  rules: Ruleset,
144
+ platform: NodeJS.Platform,
143
145
  ): { rule: Rule; value: string } {
144
146
  for (const value of values) {
145
- const rule = evaluate(surface, value, rules);
147
+ const rule = evaluate(surface, value, rules, platform);
146
148
  if (rule.layer !== "default") {
147
149
  return { rule, value };
148
150
  }
@@ -150,7 +152,7 @@ export function evaluateFirst(
150
152
  // All candidates matched only the synthesized default — use the first.
151
153
  const fallbackValue = values[0] ?? "*";
152
154
  return {
153
- rule: evaluate(surface, fallbackValue, rules),
155
+ rule: evaluate(surface, fallbackValue, rules, platform),
154
156
  value: fallbackValue,
155
157
  };
156
158
  }
@@ -167,7 +169,7 @@ export function evaluateAnyValue(
167
169
  surface: string,
168
170
  values: string[],
169
171
  rules: Ruleset,
170
- platform: NodeJS.Platform = process.platform,
172
+ platform: NodeJS.Platform,
171
173
  ): { rule: Rule; value: string } {
172
174
  const fallbackValue = values[0] ?? "*";
173
175
  const rule = rules.findLast((r) =>
@@ -182,7 +184,7 @@ export function evaluateAnyValue(
182
184
  };
183
185
  }
184
186
  return {
185
- rule: evaluate(surface, fallbackValue, rules),
187
+ rule: evaluate(surface, fallbackValue, rules, platform),
186
188
  value: fallbackValue,
187
189
  };
188
190
  }
@@ -152,14 +152,23 @@ function createResolvedSkillEntry(
152
152
  entry: ParsedSkillPromptEntry,
153
153
  state: PermissionState,
154
154
  cwd: string,
155
+ platform: NodeJS.Platform,
155
156
  ): SkillPromptEntry {
156
157
  return {
157
158
  name: entry.name,
158
159
  description: entry.description,
159
160
  location: entry.location,
160
161
  state,
161
- normalizedLocation: normalizePathForComparison(entry.location, cwd),
162
- normalizedBaseDir: normalizePathForComparison(dirname(entry.location), cwd),
162
+ normalizedLocation: normalizePathForComparison(
163
+ entry.location,
164
+ cwd,
165
+ platform,
166
+ ),
167
+ normalizedBaseDir: normalizePathForComparison(
168
+ dirname(entry.location),
169
+ cwd,
170
+ platform,
171
+ ),
163
172
  };
164
173
  }
165
174
 
@@ -190,6 +199,7 @@ export function resolveSkillPromptEntries(
190
199
  permissionManager: SkillPermissionChecker,
191
200
  agentName: string | null,
192
201
  cwd: string,
202
+ platform: NodeJS.Platform,
193
203
  ): { prompt: string; entries: SkillPromptEntry[] } {
194
204
  const sections = parseAllSkillPromptSections(prompt);
195
205
  if (sections.length === 0) {
@@ -209,7 +219,7 @@ export function resolveSkillPromptEntries(
209
219
  agentName,
210
220
  permissionCache,
211
221
  );
212
- return createResolvedSkillEntry(entry, state, cwd);
222
+ return createResolvedSkillEntry(entry, state, cwd, platform);
213
223
  });
214
224
 
215
225
  const visibleSectionEntries = resolvedEntries.filter(
@@ -257,6 +267,7 @@ export function resolveSkillPromptEntries(
257
267
  export function findSkillPathMatch(
258
268
  normalizedPath: string,
259
269
  entries: readonly SkillPromptEntry[],
270
+ platform: NodeJS.Platform,
260
271
  ): SkillPromptEntry | null {
261
272
  if (!normalizedPath || entries.length === 0) {
262
273
  return null;
@@ -275,7 +286,7 @@ export function findSkillPathMatch(
275
286
  for (const entry of entries) {
276
287
  if (
277
288
  !entry.normalizedBaseDir ||
278
- !isPathWithinDirectory(normalizedPath, entry.normalizedBaseDir)
289
+ !isPathWithinDirectory(normalizedPath, entry.normalizedBaseDir, platform)
279
290
  ) {
280
291
  continue;
281
292
  }
@@ -1,4 +1,4 @@
1
- import { normalize } from "node:path";
1
+ import { posix as posixPath, win32 as winPath } from "node:path";
2
2
 
3
3
  import { SUBAGENT_ENV_HINT_KEYS } from "./permission-forwarding";
4
4
  import type { SubagentSessionRegistry } from "./subagent-registry";
@@ -15,16 +15,19 @@ export interface SubagentDetectionContext {
15
15
  };
16
16
  }
17
17
 
18
- export function normalizeFilesystemPath(pathValue: string): string {
19
- const normalizedPath = normalize(pathValue);
20
- return process.platform === "win32"
21
- ? normalizedPath.toLowerCase()
22
- : normalizedPath;
18
+ export function normalizeFilesystemPath(
19
+ pathValue: string,
20
+ platform: NodeJS.Platform,
21
+ ): string {
22
+ const impl = platform === "win32" ? winPath : posixPath;
23
+ const normalizedPath = impl.normalize(pathValue);
24
+ return platform === "win32" ? normalizedPath.toLowerCase() : normalizedPath;
23
25
  }
24
26
 
25
27
  function isPathWithinDirectoryForSubagent(
26
28
  pathValue: string,
27
29
  directory: string,
30
+ platform: NodeJS.Platform,
28
31
  ): boolean {
29
32
  if (!pathValue || !directory) {
30
33
  return false;
@@ -34,7 +37,7 @@ function isPathWithinDirectoryForSubagent(
34
37
  return true;
35
38
  }
36
39
 
37
- const sep = process.platform === "win32" ? "\\" : "/";
40
+ const sep = platform === "win32" ? "\\" : "/";
38
41
  const prefix = directory.endsWith(sep) ? directory : `${directory}${sep}`;
39
42
  return pathValue.startsWith(prefix);
40
43
  }
@@ -68,6 +71,7 @@ export function isRegisteredSubagentChild(
68
71
  export function isSubagentExecutionContext(
69
72
  ctx: SubagentDetectionContext,
70
73
  subagentSessionsDir: string,
74
+ platform: NodeJS.Platform,
71
75
  registry?: SubagentSessionRegistry,
72
76
  ): boolean {
73
77
  // 1. Explicit registry — in-process subagent extensions register by child
@@ -95,10 +99,14 @@ export function isSubagentExecutionContext(
95
99
  return false;
96
100
  }
97
101
 
98
- const normalizedSessionDir = normalizeFilesystemPath(sessionDir);
99
- const normalizedSubagentRoot = normalizeFilesystemPath(subagentSessionsDir);
102
+ const normalizedSessionDir = normalizeFilesystemPath(sessionDir, platform);
103
+ const normalizedSubagentRoot = normalizeFilesystemPath(
104
+ subagentSessionsDir,
105
+ platform,
106
+ );
100
107
  return isPathWithinDirectoryForSubagent(
101
108
  normalizedSessionDir,
102
109
  normalizedSubagentRoot,
110
+ platform,
103
111
  );
104
112
  }