@gotgenes/pi-permission-system 18.1.1 → 18.1.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (122) hide show
  1. package/CHANGELOG.md +16 -0
  2. package/docs/configuration.md +11 -0
  3. package/package.json +1 -2
  4. package/src/access-intent/access-path.ts +29 -4
  5. package/src/access-intent/bash/bash-path-resolver.ts +39 -18
  6. package/src/access-intent/bash/msys-bash-tokens.ts +64 -0
  7. package/src/path-normalizer.ts +105 -2
  8. package/test/access-intent/access-path.test.ts +0 -277
  9. package/test/access-intent/bash/node-text.test.ts +0 -148
  10. package/test/access-intent/bash/parser.test.ts +0 -19
  11. package/test/access-intent/bash/program.test.ts +0 -673
  12. package/test/access-intent/bash/token-classification.test.ts +0 -363
  13. package/test/access-intent/bash/token-collection.test.ts +0 -300
  14. package/test/active-agent.test.ts +0 -155
  15. package/test/async-cache.test.ts +0 -48
  16. package/test/bash-arity.test.ts +0 -144
  17. package/test/bash-external-directory.test.ts +0 -1022
  18. package/test/builtin-tool-input-formatters.test.ts +0 -109
  19. package/test/canonicalize-path.test.ts +0 -119
  20. package/test/composition-root.test.ts +0 -698
  21. package/test/config-loader.test.ts +0 -740
  22. package/test/config-modal.test.ts +0 -320
  23. package/test/config-paths.test.ts +0 -83
  24. package/test/config-pipeline.test.ts +0 -90
  25. package/test/config-reporter.test.ts +0 -147
  26. package/test/config-store.test.ts +0 -466
  27. package/test/decision-audit.test.ts +0 -72
  28. package/test/decision-reporter.test.ts +0 -112
  29. package/test/denial-messages.test.ts +0 -714
  30. package/test/detect-permissive-bash-fallback.test.ts +0 -56
  31. package/test/expand-home.test.ts +0 -93
  32. package/test/extension-config.test.ts +0 -129
  33. package/test/extension-paths.test.ts +0 -108
  34. package/test/forwarded-permissions/io.test.ts +0 -251
  35. package/test/forwarding-manager.test.ts +0 -200
  36. package/test/handlers/before-agent-start.test.ts +0 -314
  37. package/test/handlers/external-directory-integration.test.ts +0 -515
  38. package/test/handlers/external-directory-session-dedup.test.ts +0 -175
  39. package/test/handlers/external-directory-symlink-acceptance.test.ts +0 -167
  40. package/test/handlers/gates/bash-command-metamorphic.test.ts +0 -88
  41. package/test/handlers/gates/bash-command.test.ts +0 -257
  42. package/test/handlers/gates/bash-external-directory.test.ts +0 -268
  43. package/test/handlers/gates/bash-path.test.ts +0 -346
  44. package/test/handlers/gates/candidate-check.test.ts +0 -52
  45. package/test/handlers/gates/external-directory-messages.test.ts +0 -85
  46. package/test/handlers/gates/external-directory-policy.test.ts +0 -134
  47. package/test/handlers/gates/external-directory.test.ts +0 -267
  48. package/test/handlers/gates/helpers.test.ts +0 -165
  49. package/test/handlers/gates/path.test.ts +0 -369
  50. package/test/handlers/gates/runner.test.ts +0 -408
  51. package/test/handlers/gates/skill-input-gate-pipeline.test.ts +0 -176
  52. package/test/handlers/gates/skill-input.test.ts +0 -128
  53. package/test/handlers/gates/skill-read.test.ts +0 -161
  54. package/test/handlers/gates/tool-call-gate-pipeline.test.ts +0 -356
  55. package/test/handlers/gates/tool.test.ts +0 -244
  56. package/test/handlers/input-events.test.ts +0 -168
  57. package/test/handlers/input.test.ts +0 -199
  58. package/test/handlers/lifecycle.test.ts +0 -221
  59. package/test/handlers/tool-call-boundary.test.ts +0 -145
  60. package/test/handlers/tool-call-events.test.ts +0 -277
  61. package/test/handlers/tool-call.test.ts +0 -395
  62. package/test/handlers/validate-requested-tool.test.ts +0 -92
  63. package/test/helpers/external-directory-fixtures.ts +0 -269
  64. package/test/helpers/gate-fixtures.ts +0 -316
  65. package/test/helpers/handler-fixtures.ts +0 -335
  66. package/test/helpers/make-fake-pi.ts +0 -100
  67. package/test/helpers/manager-harness.ts +0 -112
  68. package/test/helpers/session-fixtures.ts +0 -199
  69. package/test/input-normalizer.test.ts +0 -325
  70. package/test/logging.test.ts +0 -51
  71. package/test/mcp-targets.test.ts +0 -233
  72. package/test/node-modules-discovery.test.ts +0 -97
  73. package/test/normalize.test.ts +0 -247
  74. package/test/path-containment.test.ts +0 -161
  75. package/test/path-normalization.test.ts +0 -233
  76. package/test/path-normalizer.test.ts +0 -196
  77. package/test/path-surfaces.test.ts +0 -55
  78. package/test/pattern-suggest.test.ts +0 -248
  79. package/test/permission-dialog.test.ts +0 -205
  80. package/test/permission-event-rpc.test.ts +0 -560
  81. package/test/permission-events.test.ts +0 -400
  82. package/test/permission-forwarder.test.ts +0 -370
  83. package/test/permission-forwarding.test.ts +0 -315
  84. package/test/permission-gate.test.ts +0 -269
  85. package/test/permission-manager-unified.test.ts +0 -3714
  86. package/test/permission-merge.test.ts +0 -61
  87. package/test/permission-prompter.test.ts +0 -518
  88. package/test/permission-prompts.test.ts +0 -363
  89. package/test/permission-resolver.test.ts +0 -277
  90. package/test/permission-session.test.ts +0 -404
  91. package/test/permission-ui-prompt.test.ts +0 -146
  92. package/test/permissions-service.test.ts +0 -192
  93. package/test/pi-infrastructure-read.test.ts +0 -432
  94. package/test/policy-loader.test.ts +0 -561
  95. package/test/prompting-gateway.test.ts +0 -231
  96. package/test/rule.test.ts +0 -650
  97. package/test/safe-system-paths.test.ts +0 -46
  98. package/test/scope-merge.test.ts +0 -116
  99. package/test/service-lifecycle.test.ts +0 -163
  100. package/test/service.test.ts +0 -261
  101. package/test/session-approval.test.ts +0 -75
  102. package/test/session-logger.test.ts +0 -200
  103. package/test/session-rules.test.ts +0 -321
  104. package/test/session-start.test.ts +0 -112
  105. package/test/skill-prompt-sanitizer.test.ts +0 -418
  106. package/test/status.test.ts +0 -10
  107. package/test/subagent-context.test.ts +0 -372
  108. package/test/subagent-lifecycle-events.test.ts +0 -132
  109. package/test/subagent-registry.test.ts +0 -145
  110. package/test/synthesize.test.ts +0 -302
  111. package/test/system-prompt-sanitizer.test.ts +0 -382
  112. package/test/tool-access-extractor-registry.test.ts +0 -77
  113. package/test/tool-input-formatter-registry.test.ts +0 -75
  114. package/test/tool-input-path.test.ts +0 -84
  115. package/test/tool-input-preview.test.ts +0 -129
  116. package/test/tool-input-prompt-formatters.test.ts +0 -115
  117. package/test/tool-preview-formatter.test.ts +0 -458
  118. package/test/tool-registry.test.ts +0 -197
  119. package/test/value-guards.test.ts +0 -193
  120. package/test/wildcard-matcher.test.ts +0 -424
  121. package/test/yaml-frontmatter.test.ts +0 -91
  122. package/test/yolo-mode.test.ts +0 -188
package/CHANGELOG.md CHANGED
@@ -5,6 +5,22 @@ 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
+ ## [18.1.2](https://github.com/gotgenes/pi-packages/compare/pi-permission-system-v18.1.1...pi-permission-system-v18.1.2) (2026-07-05)
9
+
10
+
11
+ ### Bug Fixes
12
+
13
+ * **pi-permission-system:** allow-list Git Bash POSIX paths via external_directory on win32 ([#533](https://github.com/gotgenes/pi-packages/issues/533)) ([5532a43](https://github.com/gotgenes/pi-packages/commit/5532a436d825625316c67f29b90f0ceb427d4b29))
14
+ * **pi-permission-system:** fold Git Bash cd targets with MSYS semantics on win32 ([#533](https://github.com/gotgenes/pi-packages/issues/533)) ([5cb20b4](https://github.com/gotgenes/pi-packages/commit/5cb20b41db7c22c3344ab7f8a4fa39e89adb1e1a))
15
+ * **pi-permission-system:** match Git Bash POSIX-absolute bash tokens as typed on win32 ([#533](https://github.com/gotgenes/pi-packages/issues/533)) ([095fa5e](https://github.com/gotgenes/pi-packages/commit/095fa5eaf774567cfae8f82d11b0224ace7bc5eb))
16
+ * **pi-permission-system:** recognize POSIX device paths in bash commands on win32 ([#533](https://github.com/gotgenes/pi-packages/issues/533)) ([11ca70f](https://github.com/gotgenes/pi-packages/commit/11ca70fe845469d478fb47b12e8ff572930a1be4))
17
+ * **pi-permission-system:** translate MSYS drive-mount bash tokens on win32 ([#533](https://github.com/gotgenes/pi-packages/issues/533)) ([2bf4e53](https://github.com/gotgenes/pi-packages/commit/2bf4e53674c48ac9591ffb21831eb82bc3cb77b8))
18
+
19
+
20
+ ### Documentation
21
+
22
+ * **pi-permission-system:** document Git Bash path semantics on Windows ([#533](https://github.com/gotgenes/pi-packages/issues/533)) ([09afcb2](https://github.com/gotgenes/pi-packages/commit/09afcb241ea05938d88ffce8c3d6d34cbb821619))
23
+
8
24
  ## [18.1.1](https://github.com/gotgenes/pi-packages/compare/pi-permission-system-v18.1.0...pi-permission-system-v18.1.1) (2026-07-03)
9
25
 
10
26
 
@@ -505,6 +505,17 @@ On Windows, path matching for `external_directory`, `path`, and the path-bearing
505
505
  A mixed-case allow override such as `~/AppData/Roaming/npm/node_modules/@earendil-works/pi-coding-agent/*` therefore matches a lowercased, backslash-normalized path value.
506
506
  POSIX matching remains case-sensitive.
507
507
 
508
+ #### Git Bash / MSYS paths on Windows
509
+
510
+ On Windows, Pi executes bash commands through Git Bash, so a bash token that looks like a POSIX absolute path carries MSYS mount semantics rather than native `node:path.win32` semantics.
511
+ The `external_directory` and `path` gates interpret bash tokens accordingly (tool-input paths for `read`/`write`/`edit` keep native Windows semantics, since those tools resolve them through Node's filesystem):
512
+
513
+ - The safe device paths (`/dev/null`, `/dev/stdin`, `/dev/stdout`, `/dev/stderr`) are recognized as MSYS devices and never trigger the gate — the same exclusion that holds on POSIX, so `echo hi > /dev/null` does not prompt.
514
+ - MSYS drive mounts (`/c/…`, `/d/…`) are translated to their Windows equivalent (`C:\…`), so a project file referenced through a mount is matched against its real Windows path and an in-CWD mount is not flagged.
515
+ - Every other POSIX-absolute token (`/tmp/foo`, `/usr/bin`) has an install-dependent target this extension cannot resolve deterministically (Git Bash mounts `/tmp` to `%TEMP%`, MSYS2 to its own root), so it is treated as an external path matched and displayed exactly as typed, never rewritten to `C:\tmp\foo`.
516
+
517
+ To allow-list such a path, write the rule using the path as typed — for example `external_directory: { "/tmp/*": "allow" }` — and the Windows separator folding above makes the forward-slash rule match the Git Bash token.
518
+
508
519
  ### Home Directory Expansion in Patterns
509
520
 
510
521
  Pattern keys in any permission surface can start with `~/` or `$HOME/` (or be exactly `~` / `$HOME`).
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@gotgenes/pi-permission-system",
3
- "version": "18.1.1",
3
+ "version": "18.1.2",
4
4
  "description": "Permission enforcement extension for the Pi coding agent.",
5
5
  "type": "module",
6
6
  "exports": {
@@ -12,7 +12,6 @@
12
12
  },
13
13
  "files": [
14
14
  "src",
15
- "test",
16
15
  "config/config.example.json",
17
16
  "schemas/permissions.schema.json",
18
17
  "docs/*.md",
@@ -118,10 +118,35 @@ export class AccessPath {
118
118
  * unknown (a relative bash token after a non-literal `cd`).
119
119
  *
120
120
  * Carries no canonical alias and no absolute resolution — `matchValues()` is
121
- * `[literal]` (or `[]` when empty) and `boundaryValue()` is `""` — so no
122
- * spurious absolute or symlink-resolved rule can match (#393).
121
+ * `[literal, ...matchAliases]` (or `[]` when empty) and `boundaryValue()` is
122
+ * `""` — so no spurious absolute or symlink-resolved rule can match (#393).
123
+ *
124
+ * `matchAliases` supplies extra match-only forms that do not change the
125
+ * display value: a win32 Git Bash POSIX absolute carries a backslash-separated
126
+ * alias so the separator-folding path matcher can match a `/tmp/*` rule (#533).
127
+ */
128
+ static forLiteral(
129
+ literal: string,
130
+ matchAliases: readonly string[] = [],
131
+ ): AccessPath {
132
+ if (!literal) return new AccessPath("", [], "");
133
+ const aliases = [...new Set([literal, ...matchAliases.filter(Boolean)])];
134
+ return new AccessPath(literal, aliases, "");
135
+ }
136
+
137
+ /**
138
+ * Build an `AccessPath` for a Git Bash/MSYS device path (`/dev/null`,
139
+ * `/dev/std{in,out,err}`) seen in a bash command on a win32 host.
140
+ *
141
+ * The token names an MSYS runtime device, not a filesystem path, so it is
142
+ * preserved verbatim across all three representations — `value()`,
143
+ * `boundaryValue()`, and `matchValues()` are the device path itself, never
144
+ * `win32.resolve`-mangled into `c:\dev\null`. The identical lexical and
145
+ * canonical forms let the boundary check reach `isSafeSystemPath` (so the
146
+ * device never triggers `external_directory`) while a config rule still
147
+ * matches the path as typed.
123
148
  */
124
- static forLiteral(literal: string): AccessPath {
125
- return new AccessPath(literal, literal ? [literal] : [], "");
149
+ static forDevice(devicePath: string): AccessPath {
150
+ return new AccessPath(devicePath, [devicePath], devicePath);
126
151
  }
127
152
  }
@@ -315,23 +315,34 @@ export class BashPathResolver {
315
315
  * Returns `base` unchanged unless the command is `cd`:
316
316
  *
317
317
  * - `cd /abs` (absolute literal) → a fresh known base, recovering from an
318
- * earlier unknown base.
318
+ * earlier unknown base. On win32 a drive-mount target (`cd /c/x`) folds to
319
+ * its translated Windows base, while a non-mount POSIX absolute
320
+ * (`cd /tmp`) is not deterministically resolvable and yields unknown (#533).
319
321
  * - `cd rel` (relative literal) → fold into a known base, or stay unknown if
320
322
  * the base was already unknown.
321
323
  * - `cd "$DIR"` / `cd $(…)` / `cd -` / bare `cd` / `cd ~…` (non-literal) →
322
324
  * unknown.
325
+ *
326
+ * The target's platform/MSYS interpretation is delegated to the
327
+ * {@link PathNormalizer}; this method owns only the base-folding state.
323
328
  */
324
329
  private foldCd(commandNode: TSNode, base: EffectiveBase): EffectiveBase {
325
330
  if (extractCommandName(commandNode) !== "cd") return base;
326
331
  const target = cdLiteralTarget(commandNode);
327
332
  if (target === null) return UNKNOWN_BASE;
328
- if (this.normalizer.isAbsolute(target))
329
- return { kind: "known", offset: target };
330
- if (base.kind === "unknown") return UNKNOWN_BASE;
331
- return {
332
- kind: "known",
333
- offset: this.normalizer.joinBase(base.offset, target),
334
- };
333
+ const interpreted = this.normalizer.interpretBashCdTarget(target);
334
+ switch (interpreted.kind) {
335
+ case "absolute":
336
+ return { kind: "known", offset: interpreted.value };
337
+ case "unknown":
338
+ return UNKNOWN_BASE;
339
+ case "relative":
340
+ if (base.kind === "unknown") return UNKNOWN_BASE;
341
+ return {
342
+ kind: "known",
343
+ offset: this.normalizer.joinBase(base.offset, target),
344
+ };
345
+ }
335
346
  }
336
347
 
337
348
  // ── Projection ─────────────────────────────────────────────────────────
@@ -376,19 +387,29 @@ export class BashPathResolver {
376
387
  base.kind === "known"
377
388
  ? this.normalizer.resolveBase(base.offset)
378
389
  : undefined;
379
- const accessPath = this.normalizer.forPath(candidate, { resolveBase });
390
+ const accessPath = this.normalizer.forBashToken(candidate, {
391
+ resolveBase,
392
+ });
380
393
  const lexical = accessPath.value();
381
394
  if (!lexical) continue;
382
395
  // The boundary decision and dedup identity use the canonical
383
- // (symlink-resolved) form, but the returned value is the lexical form so
384
- // config patterns match the path as the user typed it (#418).
396
+ // (symlink-resolved) form the AccessPath already derived, but the returned
397
+ // value is the lexical form so config patterns match the path as the user
398
+ // typed it (#418). A win32 device path preserves `/dev/null` as its
399
+ // boundary value, so `isBoundaryOutsideWorkingDirectory` reaches the
400
+ // safe-path exclusion (#533).
385
401
  const canonical = accessPath.boundaryValue();
386
-
387
- if (
388
- this.normalizer.isOutsideWorkingDirectory(lexical) &&
389
- !seen.has(canonical)
390
- ) {
391
- seen.add(canonical);
402
+ // A literal-only bash token (a win32 non-mount POSIX absolute like `/tmp`)
403
+ // has no canonical form; it is foreign to the win32 cwd, so it is always
404
+ // external. Its lexical value is the dedup identity so two distinct
405
+ // literal-only paths do not collapse (#533).
406
+ const isExternal = canonical
407
+ ? this.normalizer.isBoundaryOutsideWorkingDirectory(canonical)
408
+ : true;
409
+ const dedupKey = canonical || lexical;
410
+
411
+ if (isExternal && !seen.has(dedupKey)) {
412
+ seen.add(dedupKey);
392
413
  externalPaths.push(accessPath);
393
414
  }
394
415
  }
@@ -450,7 +471,7 @@ export class BashPathResolver {
450
471
  base.kind === "known"
451
472
  ? this.normalizer.resolveBase(base.offset)
452
473
  : undefined;
453
- return this.normalizer.forPath(candidate, { resolveBase });
474
+ return this.normalizer.forBashToken(candidate, { resolveBase });
454
475
  }
455
476
 
456
477
  /**
@@ -0,0 +1,64 @@
1
+ /**
2
+ * Pure shape classifier for a bash-command token on a win32 host, where Pi core
3
+ * executes commands through Git Bash and POSIX-shaped absolute tokens carry
4
+ * MSYS mount semantics rather than `node:path.win32` semantics.
5
+ *
6
+ * Consumed only by {@link PathNormalizer.forBashToken}; kept as a standalone
7
+ * module so the shape knowledge is unit-testable in isolation (no filesystem,
8
+ * no platform read).
9
+ */
10
+ import { isSafeSystemPath } from "#src/safe-system-paths";
11
+
12
+ /**
13
+ * The MSYS interpretation of a win32 bash token:
14
+ *
15
+ * - `device` — a safe MSYS runtime device (`/dev/null`, `/dev/std{in,out,err}`);
16
+ * never a filesystem path.
17
+ * - `drive-mount` — an MSYS drive mount (`/c/…`, `/d/…`); `windowsPath` is its
18
+ * deterministic Windows equivalent (`C:\…`).
19
+ * - `posix-absolute` — any other absolute POSIX path (`/tmp/foo`, `/usr/bin`);
20
+ * its Windows target is install-dependent and not deterministically knowable,
21
+ * so it is treated literally.
22
+ * - `plain` — everything else (relative tokens, `~/…`, native Windows drive
23
+ * paths); handled by ordinary win32 resolution.
24
+ */
25
+ export type Win32BashTokenKind =
26
+ | { kind: "device" }
27
+ | { kind: "drive-mount"; windowsPath: string }
28
+ | { kind: "posix-absolute" }
29
+ | { kind: "plain" };
30
+
31
+ /**
32
+ * A single-letter first path segment identifies an MSYS drive mount: `/c`,
33
+ * `/c/`, or `/c/rest`. A multi-letter first segment (`/dev`, `/tmp`) is not a
34
+ * mount. The device set is checked before this pattern, so `/dev/*` never
35
+ * reaches it.
36
+ */
37
+ const MSYS_DRIVE_MOUNT_PATTERN = /^\/([a-zA-Z])(\/.*)?$/;
38
+
39
+ export function classifyWin32BashToken(token: string): Win32BashTokenKind {
40
+ if (isSafeSystemPath(token)) return { kind: "device" };
41
+
42
+ const driveMatch = MSYS_DRIVE_MOUNT_PATTERN.exec(token);
43
+ if (driveMatch) {
44
+ return {
45
+ kind: "drive-mount",
46
+ windowsPath: toWindowsDrivePath(driveMatch[1], driveMatch[2]),
47
+ };
48
+ }
49
+
50
+ if (token.startsWith("/")) return { kind: "posix-absolute" };
51
+
52
+ return { kind: "plain" };
53
+ }
54
+
55
+ /**
56
+ * Build the Windows equivalent of an MSYS drive mount: uppercase drive letter,
57
+ * `:\`, and the remainder with `/` separators rewritten to `\`. A bare or
58
+ * trailing-slash mount (`/c`, `/c/`) maps to the drive root (`C:\`).
59
+ */
60
+ function toWindowsDrivePath(letter: string, rest: string | undefined): string {
61
+ const drive = `${letter.toUpperCase()}:`;
62
+ const tail = (rest ?? "").replace(/^\//, "").replaceAll("/", "\\");
63
+ return tail ? `${drive}\\${tail}` : `${drive}\\`;
64
+ }
@@ -1,9 +1,11 @@
1
1
  import { posix as posixPath, win32 as winPath } from "node:path";
2
2
 
3
3
  import { AccessPath } from "./access-intent/access-path";
4
+ import { classifyWin32BashToken } from "./access-intent/bash/msys-bash-tokens";
4
5
  import {
5
6
  canonicalNormalizePathForComparison,
6
7
  normalizePathForComparison,
8
+ normalizePathPolicyLiteral,
7
9
  } from "./access-intent/path-normalization";
8
10
  import {
9
11
  isPathOutsideWorkingDirectory,
@@ -11,6 +13,22 @@ import {
11
13
  } from "./path-containment";
12
14
  import { isPiInfrastructureRead } from "./pi-infrastructure-read";
13
15
 
16
+ /**
17
+ * The interpreted effect of a literal `cd` target on the effective base, under
18
+ * the host platform's (and, on win32, Git Bash's) semantics.
19
+ *
20
+ * - `absolute` — the target names a resolvable absolute base (`value`); an
21
+ * earlier unknown base is recovered.
22
+ * - `relative` — the target folds into the current base.
23
+ * - `unknown` — the target is not deterministically resolvable (a win32
24
+ * non-mount POSIX absolute like `cd /tmp`, or a device), so the base becomes
25
+ * conservatively unknown.
26
+ */
27
+ export type BashCdTarget =
28
+ | { readonly kind: "absolute"; readonly value: string }
29
+ | { readonly kind: "relative" }
30
+ | { readonly kind: "unknown" };
31
+
14
32
  /**
15
33
  * Path-interpretation collaborator, constructed once at the session edge with
16
34
  * the two ambient inputs — the host `platform` and the session `cwd` — baked
@@ -47,8 +65,46 @@ export class PathNormalizer {
47
65
  }
48
66
 
49
67
  /** Build a literal-only AccessPath (unknown base after a non-literal `cd`). */
50
- forLiteral(literal: string): AccessPath {
51
- return AccessPath.forLiteral(literal);
68
+ forLiteral(literal: string, matchAliases?: readonly string[]): AccessPath {
69
+ return AccessPath.forLiteral(literal, matchAliases);
70
+ }
71
+
72
+ /**
73
+ * Build an AccessPath for a bash-command token, applying Git Bash/MSYS
74
+ * semantics on a win32 host.
75
+ *
76
+ * Pi core always executes bash through Git Bash on Windows, so a POSIX-shaped
77
+ * absolute token carries MSYS semantics, not `node:path.win32` semantics. On
78
+ * win32 the recognized safe device paths (`/dev/null`, `/dev/std{in,out,err}`)
79
+ * are preserved verbatim as devices instead of being resolved into
80
+ * `c:\dev\null`, and MSYS drive mounts (`/c/…`) are translated to their
81
+ * Windows equivalent (`C:\…`) before resolution; every other token delegates
82
+ * to {@link forPath}. On POSIX this is a straight delegation to
83
+ * {@link forPath}.
84
+ */
85
+ forBashToken(token: string, options?: { resolveBase?: string }): AccessPath {
86
+ if (this.platform !== "win32") return this.forPath(token, options);
87
+
88
+ const shape = classifyWin32BashToken(token);
89
+ switch (shape.kind) {
90
+ case "device":
91
+ return AccessPath.forDevice(token);
92
+ case "drive-mount":
93
+ return this.forPath(shape.windowsPath, options);
94
+ case "posix-absolute": {
95
+ // A non-mount POSIX absolute (`/tmp`, `/usr`) has an install-dependent
96
+ // Windows target this package cannot know, so it is kept literal: always
97
+ // external, matched and displayed as typed, never fabricated into
98
+ // `c:\tmp` (#533). The win32 path matcher folds a rule's separators
99
+ // (`/` -> `\`), so a forward-slash value is unmatchable; carry a
100
+ // backslash match alias so a natural `/tmp/*` external_directory rule
101
+ // still resolves, while `value()` stays as typed for display.
102
+ const literal = normalizePathPolicyLiteral(token);
103
+ return this.forLiteral(literal, [literal.replaceAll("/", "\\")]);
104
+ }
105
+ case "plain":
106
+ return this.forPath(token, options);
107
+ }
52
108
  }
53
109
 
54
110
  /** Platform-aware absoluteness (`win32` vs `posix` rules). */
@@ -56,6 +112,36 @@ export class PathNormalizer {
56
112
  return this.impl.isAbsolute(pathValue);
57
113
  }
58
114
 
115
+ /**
116
+ * Interpret a literal `cd` target's effect on the effective base.
117
+ *
118
+ * On win32 the target carries Git Bash/MSYS semantics: a drive mount
119
+ * (`cd /c/x`) resolves to a translated Windows base (`C:\x`), a non-mount
120
+ * POSIX absolute (`cd /tmp`) is not deterministically resolvable and yields an
121
+ * `unknown` base, and a native/relative target is handled as usual. On POSIX
122
+ * an absolute target is absolute and everything else is relative.
123
+ */
124
+ interpretBashCdTarget(target: string): BashCdTarget {
125
+ if (this.platform !== "win32") {
126
+ return this.impl.isAbsolute(target)
127
+ ? { kind: "absolute", value: target }
128
+ : { kind: "relative" };
129
+ }
130
+
131
+ const shape = classifyWin32BashToken(target);
132
+ switch (shape.kind) {
133
+ case "drive-mount":
134
+ return { kind: "absolute", value: shape.windowsPath };
135
+ case "device":
136
+ case "posix-absolute":
137
+ return { kind: "unknown" };
138
+ case "plain":
139
+ return this.impl.isAbsolute(target)
140
+ ? { kind: "absolute", value: target }
141
+ : { kind: "relative" };
142
+ }
143
+ }
144
+
59
145
  /** Resolve a `cd`-folded offset against the baked cwd (platform-aware). */
60
146
  resolveBase(offset: string): string {
61
147
  return this.impl.resolve(this.cwd, offset);
@@ -85,6 +171,23 @@ export class PathNormalizer {
85
171
  );
86
172
  }
87
173
 
174
+ /**
175
+ * Outside-cwd test for an already-canonical boundary value (from
176
+ * {@link AccessPath.boundaryValue}), against the baked cwd.
177
+ *
178
+ * Unlike {@link isOutsideWorkingDirectory}, it does not re-derive the
179
+ * canonical form — the caller passes a value the {@link AccessPath} already
180
+ * canonicalized, so a device's preserved `/dev/null` reaches the pure check's
181
+ * `isSafeSystemPath` exclusion intact.
182
+ */
183
+ isBoundaryOutsideWorkingDirectory(canonicalPath: string): boolean {
184
+ return isPathOutsideWorkingDirectory(
185
+ canonicalPath,
186
+ this.canonicalCwd,
187
+ this.platform,
188
+ );
189
+ }
190
+
88
191
  /**
89
192
  * Lexical (not symlink-resolved) comparison value, resolved against the baked
90
193
  * cwd. Mirrors the as-typed absolute form used for skill-prompt matching;
@@ -1,277 +0,0 @@
1
- import { beforeEach, describe, expect, test, vi } from "vitest";
2
-
3
- // Mock node:os so tilde-expansion is deterministic across platforms.
4
- vi.mock("node:os", () => {
5
- const homedir = vi.fn(() => "/mock/home");
6
- return {
7
- homedir,
8
- default: { homedir },
9
- };
10
- });
11
-
12
- // Mock node:fs so realpathSync (used by canonicalizePath) is controllable.
13
- // Default implementation is identity — lexical tests are unaffected.
14
- const realpathSync = vi.hoisted(() =>
15
- vi.fn<(path: string) => string>((p) => p),
16
- );
17
- vi.mock("node:fs", () => ({
18
- realpathSync,
19
- default: { realpathSync },
20
- }));
21
-
22
- import { AccessPath } from "#src/access-intent/access-path";
23
-
24
- describe("AccessPath.forPath", () => {
25
- const cwd = "/projects/my-app";
26
-
27
- beforeEach(() => {
28
- realpathSync.mockReset();
29
- realpathSync.mockImplementation((p: string) => p);
30
- });
31
-
32
- describe("matchValues()", () => {
33
- test("adds the symlink-resolved alias alongside the typed path", () => {
34
- // /tmp -> /private/tmp (the macOS symlink from the bug report, #418).
35
- realpathSync.mockImplementation((p: string) =>
36
- p.startsWith("/tmp") ? `/private${p}` : p,
37
- );
38
- expect(
39
- AccessPath.forPath("/tmp/x", { cwd, platform: "linux" }).matchValues(),
40
- ).toEqual(["/tmp/x", "/private/tmp/x"]);
41
- });
42
-
43
- test("deduplicates when the canonical form equals the lexical form", () => {
44
- expect(
45
- AccessPath.forPath("/etc/hosts", {
46
- cwd,
47
- platform: "linux",
48
- }).matchValues(),
49
- ).toEqual(["/etc/hosts"]);
50
- });
51
-
52
- test("keeps the relative aliases for an in-cwd token without duplicating", () => {
53
- expect(
54
- AccessPath.forPath("src/foo.ts", {
55
- cwd,
56
- platform: "linux",
57
- }).matchValues(),
58
- ).toEqual(["/projects/my-app/src/foo.ts", "src/foo.ts"]);
59
- });
60
-
61
- test("includes only the lexical aliases when canonical is empty", () => {
62
- // Force canonicalizePath to return the original (no-op symlink resolution
63
- // effectively means canonical === lexical, handled by dedup).
64
- expect(
65
- AccessPath.forPath("/etc/hosts", {
66
- cwd,
67
- platform: "linux",
68
- }).matchValues(),
69
- ).not.toHaveLength(0);
70
- });
71
-
72
- test("resolves a relative token against an explicit resolveBase", () => {
73
- // The cd-folded effective base differs from cwd (the bash-path case).
74
- expect(
75
- AccessPath.forPath("foo.ts", {
76
- cwd,
77
- resolveBase: "/projects/my-app/sub",
78
- platform: "linux",
79
- }).matchValues(),
80
- ).toEqual(["/projects/my-app/sub/foo.ts", "sub/foo.ts", "foo.ts"]);
81
- });
82
-
83
- test("adds the canonical alias resolved against resolveBase", () => {
84
- realpathSync.mockImplementation((p: string) =>
85
- p === "/projects/my-app/sub/foo.ts" ? "/real/foo.ts" : p,
86
- );
87
- expect(
88
- AccessPath.forPath("foo.ts", {
89
- cwd,
90
- resolveBase: "/projects/my-app/sub",
91
- platform: "linux",
92
- }).matchValues(),
93
- ).toEqual([
94
- "/projects/my-app/sub/foo.ts",
95
- "sub/foo.ts",
96
- "foo.ts",
97
- "/real/foo.ts",
98
- ]);
99
- });
100
- });
101
-
102
- describe("platform option", () => {
103
- test("win32: builds lexical/match/boundary values with win32 rules", () => {
104
- const ap = AccessPath.forPath("src\\foo.ts", {
105
- cwd: "C:\\Projects\\App",
106
- platform: "win32",
107
- });
108
- expect(ap.value()).toBe("c:\\projects\\app\\src\\foo.ts");
109
- expect(ap.boundaryValue()).toBe("c:\\projects\\app\\src\\foo.ts");
110
- expect(ap.matchValues()).toEqual([
111
- "c:\\projects\\app\\src\\foo.ts",
112
- "src\\foo.ts",
113
- ]);
114
- });
115
-
116
- test("win32: lowercases the symlink-resolved boundary value", () => {
117
- realpathSync.mockImplementation((p: string) =>
118
- p === "c:\\projects\\app\\link" ? "C:\\Real\\App" : p,
119
- );
120
- expect(
121
- AccessPath.forPath("link", {
122
- cwd: "C:\\Projects\\App",
123
- platform: "win32",
124
- }).boundaryValue(),
125
- ).toBe("c:\\real\\app");
126
- });
127
- });
128
-
129
- describe("boundaryValue()", () => {
130
- test("returns the canonical (symlink-resolved) form", () => {
131
- realpathSync.mockImplementation((p: string) =>
132
- p.startsWith("/tmp") ? `/private${p}` : p,
133
- );
134
- expect(
135
- AccessPath.forPath("/tmp/x", {
136
- cwd,
137
- platform: "linux",
138
- }).boundaryValue(),
139
- ).toBe("/private/tmp/x");
140
- });
141
-
142
- test("returns the lexical form when path has no symlinks", () => {
143
- expect(
144
- AccessPath.forPath("/etc/hosts", {
145
- cwd,
146
- platform: "linux",
147
- }).boundaryValue(),
148
- ).toBe("/etc/hosts");
149
- });
150
-
151
- test("returns empty string for empty input", () => {
152
- expect(
153
- AccessPath.forPath("", { cwd, platform: "linux" }).boundaryValue(),
154
- ).toBe("");
155
- });
156
- });
157
-
158
- describe("value()", () => {
159
- test("returns the lexical (as-typed, normalized) form", () => {
160
- realpathSync.mockImplementation((p: string) =>
161
- p.startsWith("/tmp") ? `/private${p}` : p,
162
- );
163
- // Even when the path resolves to a different canonical, value() stays lexical.
164
- expect(
165
- AccessPath.forPath("/tmp/x", { cwd, platform: "linux" }).value(),
166
- ).toBe("/tmp/x");
167
- });
168
-
169
- test("normalizes the path against cwd", () => {
170
- // A relative path becomes an absolute lexical value.
171
- expect(
172
- AccessPath.forPath("src/foo.ts", { cwd, platform: "linux" }).value(),
173
- ).toBe("/projects/my-app/src/foo.ts");
174
- });
175
-
176
- test("normalizes a relative path against an explicit resolveBase", () => {
177
- expect(
178
- AccessPath.forPath("foo.ts", {
179
- cwd,
180
- resolveBase: "/projects/my-app/sub",
181
- platform: "linux",
182
- }).value(),
183
- ).toBe("/projects/my-app/sub/foo.ts");
184
- });
185
-
186
- test("returns empty string for empty input", () => {
187
- expect(AccessPath.forPath("", { cwd, platform: "linux" }).value()).toBe(
188
- "",
189
- );
190
- });
191
- });
192
- });
193
-
194
- describe("resolvedAlias()", () => {
195
- const cwd = "/projects/my-app";
196
-
197
- beforeEach(() => {
198
- realpathSync.mockReset();
199
- realpathSync.mockImplementation((p: string) => p);
200
- });
201
-
202
- test("returns the canonical form when a symlink resolves elsewhere", () => {
203
- realpathSync.mockImplementation((p: string) =>
204
- p === "/projects/my-app/demo-symlink-passwd" ? "/etc/passwd" : p,
205
- );
206
- expect(
207
- AccessPath.forPath("demo-symlink-passwd", {
208
- cwd,
209
- platform: "linux",
210
- }).resolvedAlias(),
211
- ).toBe("/etc/passwd");
212
- });
213
-
214
- test("returns undefined when the path has no symlinks (canonical equals lexical)", () => {
215
- expect(
216
- AccessPath.forPath("/etc/hosts", {
217
- cwd,
218
- platform: "linux",
219
- }).resolvedAlias(),
220
- ).toBeUndefined();
221
- });
222
-
223
- test("returns undefined for a literal-only path (no canonical)", () => {
224
- expect(AccessPath.forLiteral("foo.ts").resolvedAlias()).toBeUndefined();
225
- });
226
-
227
- test("returns undefined for empty input", () => {
228
- expect(
229
- AccessPath.forPath("", { cwd, platform: "linux" }).resolvedAlias(),
230
- ).toBeUndefined();
231
- });
232
-
233
- test("win32: returns the lowercased canonical form for a real symlink target", () => {
234
- realpathSync.mockImplementation((p: string) =>
235
- p === "c:\\projects\\app\\link" ? "C:\\Real\\App" : p,
236
- );
237
- expect(
238
- AccessPath.forPath("link", {
239
- cwd: "C:\\Projects\\App",
240
- platform: "win32",
241
- }).resolvedAlias(),
242
- ).toBe("c:\\real\\app");
243
- });
244
-
245
- test("win32: returns undefined for a case-only difference (both forms lowercased)", () => {
246
- expect(
247
- AccessPath.forPath("src\\foo.ts", {
248
- cwd: "C:\\Projects\\App",
249
- platform: "win32",
250
- }).resolvedAlias(),
251
- ).toBeUndefined();
252
- });
253
- });
254
-
255
- describe("AccessPath.forLiteral", () => {
256
- beforeEach(() => {
257
- realpathSync.mockReset();
258
- realpathSync.mockImplementation((p: string) => p);
259
- });
260
-
261
- test("matchValues() carries only the literal — no canonical, no absolute", () => {
262
- expect(AccessPath.forLiteral("foo.ts").matchValues()).toEqual(["foo.ts"]);
263
- });
264
-
265
- test("boundaryValue() is empty (no outside-cwd notion for an unknown base)", () => {
266
- expect(AccessPath.forLiteral("foo.ts").boundaryValue()).toBe("");
267
- });
268
-
269
- test("value() returns the literal", () => {
270
- expect(AccessPath.forLiteral("foo.ts").value()).toBe("foo.ts");
271
- });
272
-
273
- test("an empty literal yields no match values", () => {
274
- expect(AccessPath.forLiteral("").matchValues()).toEqual([]);
275
- expect(AccessPath.forLiteral("").value()).toBe("");
276
- });
277
- });