@gotgenes/pi-permission-system 17.1.0 → 17.1.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,13 @@ 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
+ ## [17.1.1](https://github.com/gotgenes/pi-packages/compare/pi-permission-system-v17.1.0...pi-permission-system-v17.1.1) (2026-06-29)
9
+
10
+
11
+ ### Bug Fixes
12
+
13
+ * **pi-permission-system:** gate Windows drive-letter paths in bash external_directory ([#508](https://github.com/gotgenes/pi-packages/issues/508)) ([2d33183](https://github.com/gotgenes/pi-packages/commit/2d331834254f58bcb0782b3da353132821536296))
14
+
8
15
  ## [17.1.0](https://github.com/gotgenes/pi-packages/compare/pi-permission-system-v17.0.0...pi-permission-system-v17.1.0) (2026-06-28)
9
16
 
10
17
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@gotgenes/pi-permission-system",
3
- "version": "17.1.0",
3
+ "version": "17.1.1",
4
4
  "description": "Permission enforcement extension for the Pi coding agent.",
5
5
  "type": "module",
6
6
  "exports": {
@@ -347,7 +347,7 @@ export class BashPathResolver {
347
347
  // anywhere, so flag it conservatively (resolved against the baked cwd
348
348
  // only for a display path). Absolute / `~` candidates are base-independent
349
349
  // below.
350
- if (base.kind === "unknown" && isRelativeCandidate(candidate)) {
350
+ if (base.kind === "unknown" && this.isRelativeCandidate(candidate)) {
351
351
  const accessPath = this.normalizer.forPath(candidate);
352
352
  const canonical = accessPath.boundaryValue();
353
353
  if (canonical && !isSafeSystemPath(canonical) && !seen.has(canonical)) {
@@ -421,7 +421,7 @@ export class BashPathResolver {
421
421
  // An unknown base + relative candidate stays literal-only: a resolved
422
422
  // absolute or canonical alias would resolve against the wrong directory and
423
423
  // could spuriously match a rule (#393).
424
- if (base.kind === "unknown" && isRelativeCandidate(candidate)) {
424
+ if (base.kind === "unknown" && this.isRelativeCandidate(candidate)) {
425
425
  return this.normalizer.forLiteral(normalizePathPolicyLiteral(candidate));
426
426
  }
427
427
 
@@ -431,6 +431,20 @@ export class BashPathResolver {
431
431
  : undefined;
432
432
  return this.normalizer.forPath(candidate, { resolveBase });
433
433
  }
434
+
435
+ /**
436
+ * True when a path candidate is relative (resolved against the effective
437
+ * directory) rather than absolute or home-relative (`~…`), which are
438
+ * base-independent.
439
+ *
440
+ * Delegates the absoluteness decision to the platform-aware `PathNormalizer`
441
+ * rather than a POSIX-only `startsWith("/")` check, so Windows drive-letter
442
+ * paths (`C:/…`, `C:\…`) are correctly treated as absolute on win32 and as
443
+ * relative on POSIX (where they denote an in-CWD path).
444
+ */
445
+ private isRelativeCandidate(candidate: string): boolean {
446
+ return !this.normalizer.isAbsolute(candidate) && !candidate.startsWith("~");
447
+ }
434
448
  }
435
449
 
436
450
  // ── Pure AST/string helpers ──────────────────────────────────────────────────
@@ -519,13 +533,3 @@ function literalTextOf(node: TSNode): string | null {
519
533
  return null;
520
534
  }
521
535
  }
522
-
523
- /**
524
- * True when a path candidate is relative (resolved against the effective
525
- * directory) rather than absolute (`/…`) or home-relative (`~…`), which are
526
- * base-independent.
527
- * Used to decide which candidates an unknown base affects.
528
- */
529
- function isRelativeCandidate(candidate: string): boolean {
530
- return !candidate.startsWith("/") && !candidate.startsWith("~");
531
- }
@@ -8,6 +8,12 @@
8
8
  * Both classifiers share the private `rejectNonPathToken` predicate that captures
9
9
  * the seven rejection cases common to both (the production clone this module was
10
10
  * extracted to eliminate).
11
+ *
12
+ * Both classifiers recognize Windows drive-letter absolute paths (`C:/…`, `C:\…`)
13
+ * unconditionally on all platforms. On POSIX the token resolves as a real in-CWD
14
+ * relative path and is gated by the `path` surface; on Windows the `PathNormalizer`
15
+ * routes it through the absolute-path branch. Shape recognition is platform-independent
16
+ * string matching; the platform-sensitive absoluteness decision belongs to `PathNormalizer`.
11
17
  */
12
18
 
13
19
  // ── Public classifiers ─────────────────────────────────────────────────────
@@ -19,6 +25,7 @@
19
25
  * - Absolute paths (starting with `/`)
20
26
  * - Home-relative paths (starting with `~/`)
21
27
  * - Parent-traversal paths (containing `..`)
28
+ * - Windows drive-letter absolute paths (`C:/…` or `C:\…`)
22
29
  *
23
30
  * Returns the raw token string if it qualifies, or `null` to skip.
24
31
  */
@@ -28,6 +35,7 @@ export function classifyTokenAsPathCandidate(token: string): string | null {
28
35
  if (token.startsWith("/")) return token;
29
36
  if (token.startsWith("~/")) return token;
30
37
  if (token.includes("..")) return token;
38
+ if (WINDOWS_DRIVE_PATH_PATTERN.test(token)) return token;
31
39
 
32
40
  return null;
33
41
  }
@@ -38,8 +46,13 @@ export function classifyTokenAsPathCandidate(token: string): string | null {
38
46
  * Accepts the same shapes as `classifyTokenAsPathCandidate`, plus:
39
47
  * - Dot-files and `./`-relative paths (starting with `.`)
40
48
  * - Any relative path containing `/` (e.g. `src/foo.ts`)
49
+ * - Windows drive-letter absolute paths (`C:/…` or `C:\…`)
41
50
  *
42
51
  * The `~/foo` case is covered by `includes("/")` — no separate `~/` branch needed.
52
+ * The forward-slash drive form (`C:/…`) is also caught by `includes("/")`, but the
53
+ * explicit `WINDOWS_DRIVE_PATH_PATTERN` branch makes both separator forms first-class
54
+ * and order-independent, and covers the backslash-only form (`D:\…`) which `includes("/")`
55
+ * cannot reach.
43
56
  *
44
57
  * Does NOT require the strict "must start with `/` or `~/` or contain `..`"
45
58
  * gate that the external-directory classifier uses.
@@ -52,12 +65,22 @@ export function classifyTokenAsRuleCandidate(token: string): string | null {
52
65
  if (token.startsWith(".")) return token;
53
66
  if (token.includes("/")) return token; // covers ~/ paths and all relative paths with /
54
67
  if (token.includes("..")) return token; // bare ".." (no slash)
68
+ if (WINDOWS_DRIVE_PATH_PATTERN.test(token)) return token; // backslash-only drive form
55
69
 
56
70
  return null;
57
71
  }
58
72
 
59
73
  // ── Private rejection predicate ────────────────────────────────────────────
60
74
 
75
+ /**
76
+ * Windows drive-letter absolute path: a single ASCII letter, a colon, then a
77
+ * separator (`/` or `\`). Matches `C:/…` and `C:\…` but not drive-relative
78
+ * `C:foo` (no separator) or multi-letter schemes (`https:`, `mailto:`).
79
+ * Single-letter schemes with `//` (e.g. `c://x`) are already rejected by
80
+ * `URL_PATTERN` before this pattern is tested.
81
+ */
82
+ const WINDOWS_DRIVE_PATH_PATTERN = /^[a-zA-Z]:[/\\]/;
83
+
61
84
  /**
62
85
  * URL pattern to skip tokens that look like URLs rather than paths.
63
86
  */
@@ -84,8 +84,7 @@ export class AgentPrepHandler {
84
84
  toolPromptResult.prompt,
85
85
  this.resolver,
86
86
  agentName,
87
- ctx.cwd,
88
- this.session.getPlatform(),
87
+ this.session.getPathNormalizer(),
89
88
  );
90
89
  this.session.setActiveSkillEntries(skillPromptResult.entries);
91
90
  return skillPromptResult.prompt !== event.systemPrompt
@@ -1,5 +1,5 @@
1
1
  import type { PathNormalizer } from "#src/path-normalizer";
2
- import { getToolInputPath, isPiInfrastructureRead } from "#src/path-utils";
2
+ import { getToolInputPath } from "#src/path-utils";
3
3
  import type { ScopedPermissionResolver } from "#src/permission-resolver";
4
4
  import { SessionApproval } from "#src/session-approval";
5
5
  import { deriveApprovalPattern } from "#src/session-rules";
@@ -22,7 +22,6 @@ export function describeExternalDirectoryGate(
22
22
  infraDirs: string[],
23
23
  resolver: ScopedPermissionResolver,
24
24
  normalizer: PathNormalizer,
25
- platform: NodeJS.Platform,
26
25
  extractors?: ToolAccessExtractorLookup,
27
26
  ): GateResult {
28
27
  const externalDirectoryPath = getToolInputPath(
@@ -40,18 +39,9 @@ export function describeExternalDirectoryGate(
40
39
  // check (below) use the canonical, symlink-resolved path; pattern matching
41
40
  // uses the typed and resolved aliases (#418).
42
41
  const accessPath = normalizer.forPath(externalDirectoryPath);
43
- const canonicalExtPath = accessPath.boundaryValue();
44
42
 
45
43
  // ── Pi infrastructure read bypass ──────────────────────────────────────
46
- if (
47
- isPiInfrastructureRead(
48
- tcc.toolName,
49
- canonicalExtPath,
50
- infraDirs,
51
- tcc.cwd,
52
- platform,
53
- )
54
- ) {
44
+ if (normalizer.isInfrastructureRead(tcc.toolName, accessPath, infraDirs)) {
55
45
  return {
56
46
  action: "allow",
57
47
  log: {
@@ -1,4 +1,4 @@
1
- import { normalizePathForComparison } from "#src/path-utils";
1
+ import type { PathNormalizer } from "#src/path-normalizer";
2
2
  import { formatSkillPathAskPrompt } from "#src/permission-prompts";
3
3
  import type { SkillPromptEntry } from "#src/skill-prompt-sanitizer";
4
4
  import { findSkillPathMatch } from "#src/skill-prompt-sanitizer";
@@ -15,7 +15,7 @@ import type { ToolCallContext } from "./types";
15
15
  */
16
16
  export function describeSkillReadGate(
17
17
  tcc: ToolCallContext,
18
- platform: NodeJS.Platform,
18
+ normalizer: PathNormalizer,
19
19
  getActiveSkillEntries: () => SkillPromptEntry[],
20
20
  ): GateDescriptor | null {
21
21
  const activeSkillEntries = getActiveSkillEntries();
@@ -30,15 +30,11 @@ export function describeSkillReadGate(
30
30
  return null;
31
31
  }
32
32
 
33
- const normalizedReadPath = normalizePathForComparison(
34
- path,
35
- tcc.cwd,
36
- platform,
37
- );
33
+ const normalizedReadPath = normalizer.comparableValue(path);
38
34
  const matchedSkill = findSkillPathMatch(
39
35
  normalizedReadPath,
40
36
  activeSkillEntries,
41
- platform,
37
+ normalizer,
42
38
  );
43
39
 
44
40
  if (!matchedSkill) {
@@ -85,7 +85,7 @@ export class ToolCallGatePipeline {
85
85
 
86
86
  const gateProducers: Array<() => GateResult | Promise<GateResult>> = [
87
87
  () =>
88
- describeSkillReadGate(tcc, platform, () =>
88
+ describeSkillReadGate(tcc, normalizer, () =>
89
89
  this.inputs.getActiveSkillEntries(),
90
90
  ),
91
91
  () =>
@@ -96,7 +96,6 @@ export class ToolCallGatePipeline {
96
96
  infraDirs,
97
97
  this.resolver,
98
98
  normalizer,
99
- platform,
100
99
  this.customExtractors,
101
100
  ),
102
101
  () => describeBashExternalDirectoryGate(tcc, bashProgram, this.resolver),
@@ -4,6 +4,8 @@ import { AccessPath } from "./access-intent/access-path";
4
4
  import {
5
5
  isPathOutsideWorkingDirectory,
6
6
  isPathWithinDirectory,
7
+ isPiInfrastructureRead,
8
+ normalizePathForComparison,
7
9
  } from "./path-utils";
8
10
 
9
11
  /**
@@ -67,4 +69,32 @@ export class PathNormalizer {
67
69
  isOutsideWorkingDirectory(pathValue: string): boolean {
68
70
  return isPathOutsideWorkingDirectory(pathValue, this.cwd, this.platform);
69
71
  }
72
+
73
+ /**
74
+ * Lexical (not symlink-resolved) comparison value, resolved against the baked
75
+ * cwd. Mirrors the as-typed absolute form used for skill-prompt matching;
76
+ * touches no filesystem, unlike {@link forPath}'s canonical alias.
77
+ */
78
+ comparableValue(pathValue: string): string {
79
+ return normalizePathForComparison(pathValue, this.cwd, this.platform);
80
+ }
81
+
82
+ /**
83
+ * Pi infrastructure-read containment for a read-only tool, decided against
84
+ * the canonical (symlink-resolved) path and the baked cwd/platform. Takes the
85
+ * already-built {@link AccessPath} so the caller does not re-resolve it.
86
+ */
87
+ isInfrastructureRead(
88
+ toolName: string,
89
+ accessPath: AccessPath,
90
+ infraDirs: readonly string[],
91
+ ): boolean {
92
+ return isPiInfrastructureRead(
93
+ toolName,
94
+ accessPath.boundaryValue(),
95
+ infraDirs,
96
+ this.cwd,
97
+ this.platform,
98
+ );
99
+ }
70
100
  }
@@ -1,9 +1,6 @@
1
1
  import { dirname } from "node:path";
2
2
 
3
- import {
4
- isPathWithinDirectory,
5
- normalizePathForComparison,
6
- } from "./path-utils";
3
+ import type { PathNormalizer } from "./path-normalizer";
7
4
  import type { PermissionCheckResult, PermissionState } from "./types";
8
5
 
9
6
  /**
@@ -151,24 +148,15 @@ function resolvePermissionState(
151
148
  function createResolvedSkillEntry(
152
149
  entry: ParsedSkillPromptEntry,
153
150
  state: PermissionState,
154
- cwd: string,
155
- platform: NodeJS.Platform,
151
+ normalizer: PathNormalizer,
156
152
  ): SkillPromptEntry {
157
153
  return {
158
154
  name: entry.name,
159
155
  description: entry.description,
160
156
  location: entry.location,
161
157
  state,
162
- normalizedLocation: normalizePathForComparison(
163
- entry.location,
164
- cwd,
165
- platform,
166
- ),
167
- normalizedBaseDir: normalizePathForComparison(
168
- dirname(entry.location),
169
- cwd,
170
- platform,
171
- ),
158
+ normalizedLocation: normalizer.comparableValue(entry.location),
159
+ normalizedBaseDir: normalizer.comparableValue(dirname(entry.location)),
172
160
  };
173
161
  }
174
162
 
@@ -198,8 +186,7 @@ export function resolveSkillPromptEntries(
198
186
  prompt: string,
199
187
  permissionManager: SkillPermissionChecker,
200
188
  agentName: string | null,
201
- cwd: string,
202
- platform: NodeJS.Platform,
189
+ normalizer: PathNormalizer,
203
190
  ): { prompt: string; entries: SkillPromptEntry[] } {
204
191
  const sections = parseAllSkillPromptSections(prompt);
205
192
  if (sections.length === 0) {
@@ -219,7 +206,7 @@ export function resolveSkillPromptEntries(
219
206
  agentName,
220
207
  permissionCache,
221
208
  );
222
- return createResolvedSkillEntry(entry, state, cwd, platform);
209
+ return createResolvedSkillEntry(entry, state, normalizer);
223
210
  });
224
211
 
225
212
  const visibleSectionEntries = resolvedEntries.filter(
@@ -267,7 +254,7 @@ export function resolveSkillPromptEntries(
267
254
  export function findSkillPathMatch(
268
255
  normalizedPath: string,
269
256
  entries: readonly SkillPromptEntry[],
270
- platform: NodeJS.Platform,
257
+ normalizer: PathNormalizer,
271
258
  ): SkillPromptEntry | null {
272
259
  if (!normalizedPath || entries.length === 0) {
273
260
  return null;
@@ -286,7 +273,7 @@ export function findSkillPathMatch(
286
273
  for (const entry of entries) {
287
274
  if (
288
275
  !entry.normalizedBaseDir ||
289
- !isPathWithinDirectory(normalizedPath, entry.normalizedBaseDir, platform)
276
+ !normalizer.isWithinDirectory(normalizedPath, entry.normalizedBaseDir)
290
277
  ) {
291
278
  continue;
292
279
  }
@@ -114,6 +114,40 @@ describe("classifyTokenAsPathCandidate", () => {
114
114
  expect(classifyTokenAsPathCandidate("./build")).toBeNull();
115
115
  });
116
116
  });
117
+
118
+ describe("Windows drive-letter acceptance gate", () => {
119
+ test("forward-slash drive path → returned as-is", () => {
120
+ expect(classifyTokenAsPathCandidate("C:/Windows/win.ini")).toBe(
121
+ "C:/Windows/win.ini",
122
+ );
123
+ expect(classifyTokenAsPathCandidate("D:/secrets/password.txt")).toBe(
124
+ "D:/secrets/password.txt",
125
+ );
126
+ });
127
+
128
+ test("backslash drive path → returned as-is", () => {
129
+ expect(classifyTokenAsPathCandidate("C:\\Windows\\win.ini")).toBe(
130
+ "C:\\Windows\\win.ini",
131
+ );
132
+ expect(classifyTokenAsPathCandidate("D:\\secrets\\password.txt")).toBe(
133
+ "D:\\secrets\\password.txt",
134
+ );
135
+ });
136
+
137
+ test("lowercase drive letter → returned as-is", () => {
138
+ expect(classifyTokenAsPathCandidate("c:/foo")).toBe("c:/foo");
139
+ });
140
+
141
+ test("single-letter scheme with double-slash (c://x) → null (URL_PATTERN fires first)", () => {
142
+ // c:// matches URL_PATTERN before the drive-letter check runs.
143
+ expect(classifyTokenAsPathCandidate("c://x")).toBeNull();
144
+ });
145
+
146
+ test("drive-relative path without separator (C:foo) → null", () => {
147
+ // No / or \ after the colon — not an absolute drive path per node:path.
148
+ expect(classifyTokenAsPathCandidate("C:foo")).toBeNull();
149
+ });
150
+ });
117
151
  });
118
152
 
119
153
  describe("classifyTokenAsRuleCandidate", () => {
@@ -204,6 +238,35 @@ describe("classifyTokenAsRuleCandidate", () => {
204
238
  });
205
239
  });
206
240
 
241
+ describe("Windows drive-letter acceptance gate", () => {
242
+ test("forward-slash drive path → returned as-is", () => {
243
+ // Forward-slash form was already accepted via token.includes("/").
244
+ // The explicit branch makes it first-class and order-independent.
245
+ expect(classifyTokenAsRuleCandidate("C:/Windows/win.ini")).toBe(
246
+ "C:/Windows/win.ini",
247
+ );
248
+ });
249
+
250
+ test("backslash drive path → returned as-is (new: no forward slash)", () => {
251
+ // Previously dropped by both classifiers; the backslash form has no /
252
+ // so the includes("/") branch could not catch it.
253
+ expect(classifyTokenAsRuleCandidate("D:\\secrets\\password.txt")).toBe(
254
+ "D:\\secrets\\password.txt",
255
+ );
256
+ expect(classifyTokenAsRuleCandidate("C:\\Windows\\win.ini")).toBe(
257
+ "C:\\Windows\\win.ini",
258
+ );
259
+ });
260
+
261
+ test("lowercase drive letter (backslash) → returned as-is", () => {
262
+ expect(classifyTokenAsRuleCandidate("c:\\foo")).toBe("c:\\foo");
263
+ });
264
+
265
+ test("drive-relative path without separator (C:foo) → null", () => {
266
+ expect(classifyTokenAsRuleCandidate("C:foo")).toBeNull();
267
+ });
268
+ });
269
+
207
270
  describe("rule-vs-path divergence", () => {
208
271
  const dotFiles = [".env", ".gitignore", ".eslintrc"];
209
272
  const relPaths = ["src/index.ts", "lib/utils.js", "config/settings.json"];
@@ -230,6 +293,18 @@ describe("classifyTokenAsRuleCandidate", () => {
230
293
  });
231
294
  }
232
295
 
296
+ const winDrivePaths = [
297
+ "C:/Windows/win.ini",
298
+ "D:\\secrets\\password.txt",
299
+ "c:/foo",
300
+ ];
301
+ for (const tok of winDrivePaths) {
302
+ test(`Windows drive path "${tok}": both classifiers accept`, () => {
303
+ expect(classifyTokenAsRuleCandidate(tok)).toBe(tok);
304
+ expect(classifyTokenAsPathCandidate(tok)).toBe(tok);
305
+ });
306
+ }
307
+
233
308
  const sharedRejected = ["hello", "--flag", "FOO=/bar", "https://x.com"];
234
309
  for (const tok of sharedRejected) {
235
310
  test(`"${tok}": both classifiers reject`, () => {
@@ -953,6 +953,44 @@ describe("formatBashExternalDirectoryAskPrompt", () => {
953
953
  });
954
954
  });
955
955
 
956
+ describe("Windows drive-letter paths (win32 semantics)", () => {
957
+ const windowsCwd = "C:/projects/app";
958
+
959
+ async function extractWin32(command: string): Promise<string[]> {
960
+ return extractWithNormalizer(
961
+ command,
962
+ new PathNormalizer("win32", windowsCwd),
963
+ );
964
+ }
965
+
966
+ test("forward-slash drive path outside CWD is flagged", async () => {
967
+ const result = await extractWin32("cat C:/Windows/win.ini");
968
+ expect(result).not.toHaveLength(0);
969
+ });
970
+
971
+ test("different drive letter outside CWD is flagged", async () => {
972
+ const result = await extractWin32("cat D:/secrets/password.txt");
973
+ expect(result).not.toHaveLength(0);
974
+ });
975
+
976
+ test("drive path inside CWD is not flagged (known base)", async () => {
977
+ const result = await extractWin32("cat C:/projects/app/inside.txt");
978
+ expect(result).toHaveLength(0);
979
+ });
980
+
981
+ test("drive path inside CWD is not flagged after non-literal cd (unknown base)", async () => {
982
+ // Before the isRelativeCandidate conversion, the hand-rolled startsWith("/")
983
+ // check treats C:/ as relative on win32, so the unknown-base conservative
984
+ // branch fires and over-flags an inside-CWD drive path.
985
+ // After the conversion (!normalizer.isAbsolute), C:/ is absolute on win32
986
+ // and routes to the resolved branch with its inside-CWD check.
987
+ const result = await extractWin32(
988
+ 'cd "$D" && cat C:/projects/app/inside.txt',
989
+ );
990
+ expect(result).toHaveLength(0);
991
+ });
992
+ });
993
+
956
994
  describe("bash external-directory denial messages (centralized)", () => {
957
995
  test("denial message includes command, paths, and extension tag", () => {
958
996
  const result = formatDenyReason({
@@ -89,7 +89,6 @@ describe("external_directory symlink acceptance (#418)", () => {
89
89
  [],
90
90
  resolver,
91
91
  new PathNormalizer(process.platform, cwd),
92
- "linux",
93
92
  );
94
93
  expect(isGateDescriptor(result)).toBe(true);
95
94
  expect((result as GateDescriptor).preCheck?.state).toBe("allow");
@@ -113,7 +112,6 @@ describe("external_directory symlink acceptance (#418)", () => {
113
112
  [],
114
113
  resolver,
115
114
  new PathNormalizer(process.platform, cwd),
116
- "linux",
117
115
  );
118
116
  expect(isGateDescriptor(result)).toBe(true);
119
117
  expect((result as GateDescriptor).preCheck?.state).toBe("allow");
@@ -132,7 +130,6 @@ describe("external_directory symlink acceptance (#418)", () => {
132
130
  [],
133
131
  resolver,
134
132
  new PathNormalizer(process.platform, cwd),
135
- "linux",
136
133
  );
137
134
  expect(isGateDescriptor(result)).toBe(true);
138
135
  expect((result as GateDescriptor).preCheck?.state).toBe("ask");
@@ -43,7 +43,6 @@ function gateUnderTest(
43
43
  infraDirs,
44
44
  resolver,
45
45
  new PathNormalizer(process.platform, tcc.cwd),
46
- "linux",
47
46
  extractors,
48
47
  );
49
48
  }
@@ -1,8 +1,12 @@
1
1
  import { describe, expect, it, vi } from "vitest";
2
2
  import { describeSkillReadGate } from "#src/handlers/gates/skill-read";
3
3
  import type { ToolCallContext } from "#src/handlers/gates/types";
4
+ import { PathNormalizer } from "#src/path-normalizer";
4
5
  import type { SkillPromptEntry } from "#src/skill-prompt-sanitizer";
5
6
 
7
+ // All test tccs use cwd "/test/project"; one normalizer serves every call.
8
+ const normalizer = new PathNormalizer("linux", "/test/project");
9
+
6
10
  // ── SDK stubs ──────────────────────────────────────────────────────────────
7
11
  vi.mock("@earendil-works/pi-coding-agent", async (importOriginal) => {
8
12
  const original =
@@ -43,21 +47,21 @@ describe("describeSkillReadGate", () => {
43
47
  it("returns null when tool is not read", () => {
44
48
  const result = describeSkillReadGate(
45
49
  makeTcc({ toolName: "write" }),
46
- "linux",
50
+ normalizer,
47
51
  () => [makeSkillEntry()],
48
52
  );
49
53
  expect(result).toBeNull();
50
54
  });
51
55
 
52
56
  it("returns null when no active skill entries", () => {
53
- const result = describeSkillReadGate(makeTcc(), "linux", () => []);
57
+ const result = describeSkillReadGate(makeTcc(), normalizer, () => []);
54
58
  expect(result).toBeNull();
55
59
  });
56
60
 
57
61
  it("returns null when read path does not match any skill", () => {
58
62
  const result = describeSkillReadGate(
59
63
  makeTcc({ input: { path: "/test/project/src/index.ts" } }),
60
- "linux",
64
+ normalizer,
61
65
  () => [makeSkillEntry()],
62
66
  );
63
67
  expect(result).toBeNull();
@@ -66,14 +70,14 @@ describe("describeSkillReadGate", () => {
66
70
  it("returns null when input has no path", () => {
67
71
  const result = describeSkillReadGate(
68
72
  makeTcc({ input: {} }),
69
- "linux",
73
+ normalizer,
70
74
  () => [makeSkillEntry()],
71
75
  );
72
76
  expect(result).toBeNull();
73
77
  });
74
78
 
75
79
  it("returns GateDescriptor with preResolved.state matching skill entry state (ask)", () => {
76
- const result = describeSkillReadGate(makeTcc(), "linux", () => [
80
+ const result = describeSkillReadGate(makeTcc(), normalizer, () => [
77
81
  makeSkillEntry({ state: "ask" }),
78
82
  ]);
79
83
  expect(result).not.toBeNull();
@@ -82,7 +86,7 @@ describe("describeSkillReadGate", () => {
82
86
  });
83
87
 
84
88
  it("returns GateDescriptor with preResolved.state matching skill entry state (allow)", () => {
85
- const result = describeSkillReadGate(makeTcc(), "linux", () => [
89
+ const result = describeSkillReadGate(makeTcc(), normalizer, () => [
86
90
  makeSkillEntry({ state: "allow" }),
87
91
  ]);
88
92
  expect(result).not.toBeNull();
@@ -91,7 +95,7 @@ describe("describeSkillReadGate", () => {
91
95
  });
92
96
 
93
97
  it("returns GateDescriptor with preResolved.state matching skill entry state (deny)", () => {
94
- const result = describeSkillReadGate(makeTcc(), "linux", () => [
98
+ const result = describeSkillReadGate(makeTcc(), normalizer, () => [
95
99
  makeSkillEntry({ state: "deny" }),
96
100
  ]);
97
101
  expect(result).not.toBeNull();
@@ -100,7 +104,7 @@ describe("describeSkillReadGate", () => {
100
104
  });
101
105
 
102
106
  it("decision surface is 'skill' and decision value is the skill name", () => {
103
- const result = describeSkillReadGate(makeTcc(), "linux", () => [
107
+ const result = describeSkillReadGate(makeTcc(), normalizer, () => [
104
108
  makeSkillEntry({ name: "my-skill" }),
105
109
  ])!;
106
110
  expect(result.decision.surface).toBe("skill");
@@ -108,7 +112,7 @@ describe("describeSkillReadGate", () => {
108
112
  });
109
113
 
110
114
  it("denialContext contains the skill name and read path", () => {
111
- const result = describeSkillReadGate(makeTcc(), "linux", () => [
115
+ const result = describeSkillReadGate(makeTcc(), normalizer, () => [
112
116
  makeSkillEntry({ name: "librarian" }),
113
117
  ])!;
114
118
  expect(result.denialContext).toEqual({
@@ -122,7 +126,7 @@ describe("describeSkillReadGate", () => {
122
126
  it("promptDetails includes skill_read source and skillName", () => {
123
127
  const result = describeSkillReadGate(
124
128
  makeTcc({ agentName: "test-agent", toolCallId: "tc-42" }),
125
- "linux",
129
+ normalizer,
126
130
  () => [makeSkillEntry({ name: "my-skill" })],
127
131
  )!;
128
132
  expect(result.promptDetails).toMatchObject({
@@ -138,7 +142,7 @@ describe("describeSkillReadGate", () => {
138
142
  it("logContext includes skill_read source and skillName", () => {
139
143
  const result = describeSkillReadGate(
140
144
  makeTcc({ agentName: "agent-1" }),
141
- "linux",
145
+ normalizer,
142
146
  () => [makeSkillEntry({ name: "librarian" })],
143
147
  )!;
144
148
  expect(result.logContext).toMatchObject({
@@ -149,7 +153,7 @@ describe("describeSkillReadGate", () => {
149
153
  });
150
154
 
151
155
  it("surface is 'skill' on the descriptor", () => {
152
- const result = describeSkillReadGate(makeTcc(), "linux", () => [
156
+ const result = describeSkillReadGate(makeTcc(), normalizer, () => [
153
157
  makeSkillEntry(),
154
158
  ])!;
155
159
  expect(result.surface).toBe("skill");
@@ -77,6 +77,13 @@ describe("PathNormalizer", () => {
77
77
  );
78
78
  expect(normalizer.isOutsideWorkingDirectory("/etc/hosts")).toBe(true);
79
79
  });
80
+
81
+ test("comparableValue returns the lexical absolute form (no FS)", () => {
82
+ expect(normalizer.comparableValue("src/foo.ts")).toBe(
83
+ "/projects/my-app/src/foo.ts",
84
+ );
85
+ expect(normalizer.comparableValue("/etc/hosts")).toBe("/etc/hosts");
86
+ });
80
87
  });
81
88
 
82
89
  describe("win32 flavor", () => {
@@ -119,5 +126,41 @@ describe("PathNormalizer", () => {
119
126
  ).toBe(false);
120
127
  expect(normalizer.isOutsideWorkingDirectory("C:\\Other\\dir")).toBe(true);
121
128
  });
129
+
130
+ test("comparableValue case-folds the lexical absolute form", () => {
131
+ expect(normalizer.comparableValue("src\\foo.ts")).toBe(
132
+ "c:\\projects\\app\\src\\foo.ts",
133
+ );
134
+ });
135
+ });
136
+
137
+ describe("isInfrastructureRead", () => {
138
+ const normalizer = new PathNormalizer("linux", "/projects/my-app");
139
+
140
+ test("allows a read-only tool targeting a configured infra dir", () => {
141
+ const ap = normalizer.forPath("/infra/git/pkg/SKILL.md");
142
+ expect(normalizer.isInfrastructureRead("read", ap, ["/infra"])).toBe(
143
+ true,
144
+ );
145
+ });
146
+
147
+ test("does not allow a write tool targeting an infra dir", () => {
148
+ const ap = normalizer.forPath("/infra/git/pkg/file.ts");
149
+ expect(normalizer.isInfrastructureRead("write", ap, ["/infra"])).toBe(
150
+ false,
151
+ );
152
+ });
153
+
154
+ test("does not allow a read-only tool outside any infra dir", () => {
155
+ const ap = normalizer.forPath("/elsewhere/file.ts");
156
+ expect(normalizer.isInfrastructureRead("read", ap, ["/infra"])).toBe(
157
+ false,
158
+ );
159
+ });
160
+
161
+ test("allows a read targeting the project-local .pi/npm dir (from baked cwd)", () => {
162
+ const ap = normalizer.forPath("/projects/my-app/.pi/npm/dep/index.js");
163
+ expect(normalizer.isInfrastructureRead("read", ap, [])).toBe(true);
164
+ });
122
165
  });
123
166
  });
@@ -1,5 +1,6 @@
1
1
  import { resolve } from "node:path";
2
2
  import { afterEach, describe, expect, test, vi } from "vitest";
3
+ import { PathNormalizer } from "#src/path-normalizer";
3
4
  import type { ScopedPermissionManager } from "#src/permission-manager";
4
5
  import {
5
6
  findSkillPathMatch,
@@ -30,6 +31,11 @@ afterEach(() => {
30
31
 
31
32
  const CWD = "/projects/my-app";
32
33
 
34
+ // `findSkillPathMatch` only uses the normalizer's platform (it compares two
35
+ // already-absolute paths via `isWithinDirectory`), so this CWD-baked instance
36
+ // serves every call regardless of the entries' cwd.
37
+ const normalizer = new PathNormalizer("linux", CWD);
38
+
33
39
  function makeManager(
34
40
  defaultState: "allow" | "deny" | "ask" = "allow",
35
41
  overrides: Record<string, "allow" | "deny" | "ask"> = {},
@@ -72,13 +78,7 @@ describe("resolveSkillPromptEntries", () => {
72
78
  test("returns unchanged prompt and empty entries when no skills section present", () => {
73
79
  const input = "You are a helpful assistant.";
74
80
  const manager = makeManager("allow");
75
- const result = resolveSkillPromptEntries(
76
- input,
77
- manager,
78
- null,
79
- CWD,
80
- "linux",
81
- );
81
+ const result = resolveSkillPromptEntries(input, manager, null, normalizer);
82
82
  expect(result.prompt).toBe(input);
83
83
  expect(result.entries).toEqual([]);
84
84
  expect(manager.checkPermission).not.toHaveBeenCalled();
@@ -87,13 +87,7 @@ describe("resolveSkillPromptEntries", () => {
87
87
  test("keeps all skills when all are allowed", () => {
88
88
  const input = availableSkillsSection("librarian", "ask-user");
89
89
  const manager = makeManager("allow");
90
- const result = resolveSkillPromptEntries(
91
- input,
92
- manager,
93
- null,
94
- CWD,
95
- "linux",
96
- );
90
+ const result = resolveSkillPromptEntries(input, manager, null, normalizer);
97
91
  expect(result.prompt).toContain("librarian");
98
92
  expect(result.prompt).toContain("ask-user");
99
93
  expect(result.entries).toHaveLength(2);
@@ -102,13 +96,7 @@ describe("resolveSkillPromptEntries", () => {
102
96
  test("removes denied skill from section", () => {
103
97
  const input = availableSkillsSection("librarian", "dangerous");
104
98
  const manager = makeManager("allow", { dangerous: "deny" });
105
- const result = resolveSkillPromptEntries(
106
- input,
107
- manager,
108
- null,
109
- CWD,
110
- "linux",
111
- );
99
+ const result = resolveSkillPromptEntries(input, manager, null, normalizer);
112
100
  expect(result.prompt).toContain("librarian");
113
101
  expect(result.prompt).not.toContain("dangerous");
114
102
  // denied skill is excluded from returned entries
@@ -118,13 +106,7 @@ describe("resolveSkillPromptEntries", () => {
118
106
  test("removes entire section when all skills are denied", () => {
119
107
  const input = `Intro\n${availableSkillsSection("dangerous")}\nOutro`;
120
108
  const manager = makeManager("deny");
121
- const result = resolveSkillPromptEntries(
122
- input,
123
- manager,
124
- null,
125
- CWD,
126
- "linux",
127
- );
109
+ const result = resolveSkillPromptEntries(input, manager, null, normalizer);
128
110
  expect(result.prompt).not.toContain("<available_skills>");
129
111
  expect(result.prompt).toContain("Intro");
130
112
  expect(result.prompt).toContain("Outro");
@@ -134,13 +116,7 @@ describe("resolveSkillPromptEntries", () => {
134
116
  test("keeps ask-state skills in section and entries", () => {
135
117
  const input = availableSkillsSection("librarian");
136
118
  const manager = makeManager("ask");
137
- const result = resolveSkillPromptEntries(
138
- input,
139
- manager,
140
- null,
141
- CWD,
142
- "linux",
143
- );
119
+ const result = resolveSkillPromptEntries(input, manager, null, normalizer);
144
120
  expect(result.prompt).toContain("librarian");
145
121
  expect(result.entries).toHaveLength(1);
146
122
  expect(result.entries[0].state).toBe("ask");
@@ -149,7 +125,7 @@ describe("resolveSkillPromptEntries", () => {
149
125
  test("delegates permission check to permissionManager for each skill", () => {
150
126
  const input = availableSkillsSection("alpha", "beta");
151
127
  const manager = makeManager("allow");
152
- resolveSkillPromptEntries(input, manager, null, CWD, "linux");
128
+ resolveSkillPromptEntries(input, manager, null, normalizer);
153
129
  expect(manager.checkPermission).toHaveBeenCalledWith(
154
130
  "skill",
155
131
  { name: "alpha" },
@@ -165,7 +141,7 @@ describe("resolveSkillPromptEntries", () => {
165
141
  test("passes agentName to permissionManager", () => {
166
142
  const input = availableSkillsSection("librarian");
167
143
  const manager = makeManager("allow");
168
- resolveSkillPromptEntries(input, manager, "my-agent", CWD, "linux");
144
+ resolveSkillPromptEntries(input, manager, "my-agent", normalizer);
169
145
  expect(manager.checkPermission).toHaveBeenCalledWith(
170
146
  "skill",
171
147
  { name: "librarian" },
@@ -180,7 +156,7 @@ describe("resolveSkillPromptEntries", () => {
180
156
  availableSkillsSection("librarian"),
181
157
  ].join("\n");
182
158
  const manager = makeManager("allow");
183
- resolveSkillPromptEntries(input, manager, null, CWD, "linux");
159
+ resolveSkillPromptEntries(input, manager, null, normalizer);
184
160
  // Should only be called once despite appearing twice.
185
161
  expect(manager.checkPermission).toHaveBeenCalledTimes(1);
186
162
  });
@@ -189,13 +165,7 @@ describe("resolveSkillPromptEntries", () => {
189
165
  const location = "/skills/librarian/SKILL.md";
190
166
  const input = availableSkillsSection("librarian");
191
167
  const manager = makeManager("allow");
192
- const result = resolveSkillPromptEntries(
193
- input,
194
- manager,
195
- null,
196
- CWD,
197
- "linux",
198
- );
168
+ const result = resolveSkillPromptEntries(input, manager, null, normalizer);
199
169
  expect(result.entries[0].normalizedLocation).toBe(location);
200
170
  expect(result.entries[0].normalizedBaseDir).toBe("/skills/librarian");
201
171
  });
@@ -205,13 +175,7 @@ describe("resolveSkillPromptEntries", () => {
205
175
  const section2 = availableSkillsSection("beta");
206
176
  const input = `${section1}\n${section2}`;
207
177
  const manager = makeManager("allow", { beta: "deny" });
208
- const result = resolveSkillPromptEntries(
209
- input,
210
- manager,
211
- null,
212
- CWD,
213
- "linux",
214
- );
178
+ const result = resolveSkillPromptEntries(input, manager, null, normalizer);
215
179
  expect(result.entries.map((e) => e.name)).toContain("alpha");
216
180
  expect(result.entries.map((e) => e.name)).not.toContain("beta");
217
181
  });
@@ -240,12 +204,12 @@ describe("findSkillPathMatch", () => {
240
204
  ];
241
205
 
242
206
  test("returns null for empty normalized path", () => {
243
- expect(findSkillPathMatch("", entries, "linux")).toBeNull();
207
+ expect(findSkillPathMatch("", entries, normalizer)).toBeNull();
244
208
  });
245
209
 
246
210
  test("returns null for empty entries array", () => {
247
211
  expect(
248
- findSkillPathMatch("/skills/librarian/SKILL.md", [], "linux"),
212
+ findSkillPathMatch("/skills/librarian/SKILL.md", [], normalizer),
249
213
  ).toBeNull();
250
214
  });
251
215
 
@@ -253,7 +217,7 @@ describe("findSkillPathMatch", () => {
253
217
  const match = findSkillPathMatch(
254
218
  "/skills/librarian/SKILL.md",
255
219
  entries,
256
- "linux",
220
+ normalizer,
257
221
  );
258
222
  expect(match?.name).toBe("librarian");
259
223
  });
@@ -262,13 +226,17 @@ describe("findSkillPathMatch", () => {
262
226
  const match = findSkillPathMatch(
263
227
  "/skills/librarian/extra/helper.md",
264
228
  entries,
265
- "linux",
229
+ normalizer,
266
230
  );
267
231
  expect(match?.name).toBe("librarian");
268
232
  });
269
233
 
270
234
  test("returns null for path not within any skill directory", () => {
271
- const match = findSkillPathMatch("/other/path/file.md", entries, "linux");
235
+ const match = findSkillPathMatch(
236
+ "/other/path/file.md",
237
+ entries,
238
+ normalizer,
239
+ );
272
240
  expect(match).toBeNull();
273
241
  });
274
242
 
@@ -277,7 +245,7 @@ describe("findSkillPathMatch", () => {
277
245
  const match = findSkillPathMatch(
278
246
  "/skills/librarian-extra/SKILL.md",
279
247
  entries,
280
- "linux",
248
+ normalizer,
281
249
  );
282
250
  expect(match).toBeNull();
283
251
  });
@@ -304,7 +272,7 @@ describe("findSkillPathMatch", () => {
304
272
  const match = findSkillPathMatch(
305
273
  "/skills/parent/child/helper.md",
306
274
  nestedEntries,
307
- "linux",
275
+ normalizer,
308
276
  );
309
277
  expect(match?.name).toBe("child");
310
278
  });
@@ -380,8 +348,7 @@ test("REGRESSION: resolveSkillPromptEntries sanitizes every available_skills blo
380
348
  prompt,
381
349
  asChecker(manager),
382
350
  null,
383
- "/cwd",
384
- "linux",
351
+ new PathNormalizer("linux", "/cwd"),
385
352
  );
386
353
 
387
354
  expect(result.prompt).not.toContain("denied-skill");
@@ -428,20 +395,19 @@ test("REGRESSION: resolveSkillPromptEntries keeps only visible skills available
428
395
  prompt,
429
396
  asChecker(manager),
430
397
  null,
431
- "/cwd",
432
- "linux",
398
+ new PathNormalizer("linux", "/cwd"),
433
399
  );
434
400
  const visiblePath = resolve("/cwd", "./skills/visible/file.ts");
435
401
  const blockedPath = resolve("/cwd", "./skills/blocked/file.ts");
436
402
  const matchedVisibleSkill = findSkillPathMatch(
437
403
  process.platform === "win32" ? visiblePath.toLowerCase() : visiblePath,
438
404
  result.entries,
439
- "linux",
405
+ normalizer,
440
406
  );
441
407
  const matchedBlockedSkill = findSkillPathMatch(
442
408
  process.platform === "win32" ? blockedPath.toLowerCase() : blockedPath,
443
409
  result.entries,
444
- "linux",
410
+ normalizer,
445
411
  );
446
412
 
447
413
  expect(matchedVisibleSkill?.name).toBe("visible-skill");