@gotgenes/pi-permission-system 18.1.1 → 18.2.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 (133) hide show
  1. package/CHANGELOG.md +25 -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/extension-config.ts +7 -0
  8. package/src/forwarded-permissions/permission-forwarder.ts +6 -3
  9. package/src/handlers/gates/helpers.ts +1 -1
  10. package/src/handlers/gates/runner.ts +22 -0
  11. package/src/index.ts +10 -6
  12. package/src/path-normalizer.ts +105 -2
  13. package/src/permission-manager.ts +21 -1
  14. package/src/permission-prompter.ts +10 -16
  15. package/src/prompting-gateway.ts +13 -17
  16. package/src/rule.ts +21 -1
  17. package/src/status.ts +1 -1
  18. package/src/yolo-mode.ts +0 -30
  19. package/test/access-intent/access-path.test.ts +0 -277
  20. package/test/access-intent/bash/node-text.test.ts +0 -148
  21. package/test/access-intent/bash/parser.test.ts +0 -19
  22. package/test/access-intent/bash/program.test.ts +0 -673
  23. package/test/access-intent/bash/token-classification.test.ts +0 -363
  24. package/test/access-intent/bash/token-collection.test.ts +0 -300
  25. package/test/active-agent.test.ts +0 -155
  26. package/test/async-cache.test.ts +0 -48
  27. package/test/bash-arity.test.ts +0 -144
  28. package/test/bash-external-directory.test.ts +0 -1022
  29. package/test/builtin-tool-input-formatters.test.ts +0 -109
  30. package/test/canonicalize-path.test.ts +0 -119
  31. package/test/composition-root.test.ts +0 -698
  32. package/test/config-loader.test.ts +0 -740
  33. package/test/config-modal.test.ts +0 -320
  34. package/test/config-paths.test.ts +0 -83
  35. package/test/config-pipeline.test.ts +0 -90
  36. package/test/config-reporter.test.ts +0 -147
  37. package/test/config-store.test.ts +0 -466
  38. package/test/decision-audit.test.ts +0 -72
  39. package/test/decision-reporter.test.ts +0 -112
  40. package/test/denial-messages.test.ts +0 -714
  41. package/test/detect-permissive-bash-fallback.test.ts +0 -56
  42. package/test/expand-home.test.ts +0 -93
  43. package/test/extension-config.test.ts +0 -129
  44. package/test/extension-paths.test.ts +0 -108
  45. package/test/forwarded-permissions/io.test.ts +0 -251
  46. package/test/forwarding-manager.test.ts +0 -200
  47. package/test/handlers/before-agent-start.test.ts +0 -314
  48. package/test/handlers/external-directory-integration.test.ts +0 -515
  49. package/test/handlers/external-directory-session-dedup.test.ts +0 -175
  50. package/test/handlers/external-directory-symlink-acceptance.test.ts +0 -167
  51. package/test/handlers/gates/bash-command-metamorphic.test.ts +0 -88
  52. package/test/handlers/gates/bash-command.test.ts +0 -257
  53. package/test/handlers/gates/bash-external-directory.test.ts +0 -268
  54. package/test/handlers/gates/bash-path.test.ts +0 -346
  55. package/test/handlers/gates/candidate-check.test.ts +0 -52
  56. package/test/handlers/gates/external-directory-messages.test.ts +0 -85
  57. package/test/handlers/gates/external-directory-policy.test.ts +0 -134
  58. package/test/handlers/gates/external-directory.test.ts +0 -267
  59. package/test/handlers/gates/helpers.test.ts +0 -165
  60. package/test/handlers/gates/path.test.ts +0 -369
  61. package/test/handlers/gates/runner.test.ts +0 -408
  62. package/test/handlers/gates/skill-input-gate-pipeline.test.ts +0 -176
  63. package/test/handlers/gates/skill-input.test.ts +0 -128
  64. package/test/handlers/gates/skill-read.test.ts +0 -161
  65. package/test/handlers/gates/tool-call-gate-pipeline.test.ts +0 -356
  66. package/test/handlers/gates/tool.test.ts +0 -244
  67. package/test/handlers/input-events.test.ts +0 -168
  68. package/test/handlers/input.test.ts +0 -199
  69. package/test/handlers/lifecycle.test.ts +0 -221
  70. package/test/handlers/tool-call-boundary.test.ts +0 -145
  71. package/test/handlers/tool-call-events.test.ts +0 -277
  72. package/test/handlers/tool-call.test.ts +0 -395
  73. package/test/handlers/validate-requested-tool.test.ts +0 -92
  74. package/test/helpers/external-directory-fixtures.ts +0 -269
  75. package/test/helpers/gate-fixtures.ts +0 -316
  76. package/test/helpers/handler-fixtures.ts +0 -335
  77. package/test/helpers/make-fake-pi.ts +0 -100
  78. package/test/helpers/manager-harness.ts +0 -112
  79. package/test/helpers/session-fixtures.ts +0 -199
  80. package/test/input-normalizer.test.ts +0 -325
  81. package/test/logging.test.ts +0 -51
  82. package/test/mcp-targets.test.ts +0 -233
  83. package/test/node-modules-discovery.test.ts +0 -97
  84. package/test/normalize.test.ts +0 -247
  85. package/test/path-containment.test.ts +0 -161
  86. package/test/path-normalization.test.ts +0 -233
  87. package/test/path-normalizer.test.ts +0 -196
  88. package/test/path-surfaces.test.ts +0 -55
  89. package/test/pattern-suggest.test.ts +0 -248
  90. package/test/permission-dialog.test.ts +0 -205
  91. package/test/permission-event-rpc.test.ts +0 -560
  92. package/test/permission-events.test.ts +0 -400
  93. package/test/permission-forwarder.test.ts +0 -370
  94. package/test/permission-forwarding.test.ts +0 -315
  95. package/test/permission-gate.test.ts +0 -269
  96. package/test/permission-manager-unified.test.ts +0 -3714
  97. package/test/permission-merge.test.ts +0 -61
  98. package/test/permission-prompter.test.ts +0 -518
  99. package/test/permission-prompts.test.ts +0 -363
  100. package/test/permission-resolver.test.ts +0 -277
  101. package/test/permission-session.test.ts +0 -404
  102. package/test/permission-ui-prompt.test.ts +0 -146
  103. package/test/permissions-service.test.ts +0 -192
  104. package/test/pi-infrastructure-read.test.ts +0 -432
  105. package/test/policy-loader.test.ts +0 -561
  106. package/test/prompting-gateway.test.ts +0 -231
  107. package/test/rule.test.ts +0 -650
  108. package/test/safe-system-paths.test.ts +0 -46
  109. package/test/scope-merge.test.ts +0 -116
  110. package/test/service-lifecycle.test.ts +0 -163
  111. package/test/service.test.ts +0 -261
  112. package/test/session-approval.test.ts +0 -75
  113. package/test/session-logger.test.ts +0 -200
  114. package/test/session-rules.test.ts +0 -321
  115. package/test/session-start.test.ts +0 -112
  116. package/test/skill-prompt-sanitizer.test.ts +0 -418
  117. package/test/status.test.ts +0 -10
  118. package/test/subagent-context.test.ts +0 -372
  119. package/test/subagent-lifecycle-events.test.ts +0 -132
  120. package/test/subagent-registry.test.ts +0 -145
  121. package/test/synthesize.test.ts +0 -302
  122. package/test/system-prompt-sanitizer.test.ts +0 -382
  123. package/test/tool-access-extractor-registry.test.ts +0 -77
  124. package/test/tool-input-formatter-registry.test.ts +0 -75
  125. package/test/tool-input-path.test.ts +0 -84
  126. package/test/tool-input-preview.test.ts +0 -129
  127. package/test/tool-input-prompt-formatters.test.ts +0 -115
  128. package/test/tool-preview-formatter.test.ts +0 -458
  129. package/test/tool-registry.test.ts +0 -197
  130. package/test/value-guards.test.ts +0 -193
  131. package/test/wildcard-matcher.test.ts +0 -424
  132. package/test/yaml-frontmatter.test.ts +0 -91
  133. package/test/yolo-mode.test.ts +0 -188
package/CHANGELOG.md CHANGED
@@ -5,6 +5,31 @@ 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.2.0](https://github.com/gotgenes/pi-packages/compare/pi-permission-system-v18.1.2...pi-permission-system-v18.2.0) (2026-07-06)
9
+
10
+
11
+ ### Features
12
+
13
+ * **pi-permission-system:** add yolo rule origin and ask→allow rewrite helper ([a4bbcc0](https://github.com/gotgenes/pi-packages/commit/a4bbcc0f25d57e31c7658e48ec5bc4465a4c1908))
14
+ * **pi-permission-system:** auto-approve yolo-origin allow in the gate runner ([caf8419](https://github.com/gotgenes/pi-packages/commit/caf8419f835b37693678fccf77373a828f850386))
15
+ * **pi-permission-system:** rewrite ask rules to yolo-origin allow at check time ([cd4e509](https://github.com/gotgenes/pi-packages/commit/cd4e509290aed701928890a6df585b383abbfc2b))
16
+
17
+ ## [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)
18
+
19
+
20
+ ### Bug Fixes
21
+
22
+ * **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))
23
+ * **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))
24
+ * **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))
25
+ * **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))
26
+ * **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))
27
+
28
+
29
+ ### Documentation
30
+
31
+ * **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))
32
+
8
33
  ## [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
34
 
10
35
 
@@ -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.2.0",
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
+ }
@@ -66,6 +66,13 @@ export function normalizePermissionSystemConfig(
66
66
  return result;
67
67
  }
68
68
 
69
+ export function isYoloModeEnabled(
70
+ config: PermissionSystemExtensionConfig,
71
+ ): boolean {
72
+ // eslint-disable-next-line @typescript-eslint/no-unnecessary-type-conversion -- typed as boolean but may be undefined at runtime (untyped callers); Boolean() guards against that
73
+ return Boolean(config.yoloMode);
74
+ }
75
+
69
76
  export function ensurePermissionSystemLogsDirectory(
70
77
  logsDir: string,
71
78
  ): string | undefined {
@@ -6,6 +6,7 @@ import {
6
6
  type SessionEntryView,
7
7
  } from "#src/active-agent";
8
8
  import type { ConfigReader } from "#src/config-store";
9
+ import { isYoloModeEnabled } from "#src/extension-config";
9
10
  import type {
10
11
  PermissionDecisionUi,
11
12
  PermissionPromptDecision,
@@ -31,7 +32,6 @@ import type { DebugReviewLogger } from "#src/session-logger";
31
32
  import { isSubagentExecutionContext } from "#src/subagent-context";
32
33
  import type { SubagentSessionRegistry } from "#src/subagent-registry";
33
34
  import { toRecord } from "#src/value-guards";
34
- import { shouldAutoApprovePermissionState } from "#src/yolo-mode";
35
35
 
36
36
  import {
37
37
  cleanupPermissionForwardingLocationIfEmpty,
@@ -90,7 +90,7 @@ export interface PermissionForwarderDeps {
90
90
  message: string,
91
91
  options?: RequestPermissionOptions,
92
92
  ) => Promise<PermissionPromptDecision>;
93
- /** Read current config for yolo-mode auto-approve check (called at prompt time). */
93
+ /** Read current config for the retained forwarded-inbox yolo auto-approve check. */
94
94
  config: ConfigReader;
95
95
  }
96
96
 
@@ -506,7 +506,10 @@ export class PermissionForwarder implements ApprovalRequester, InboxProcessor {
506
506
  approved: false,
507
507
  state: "denied",
508
508
  };
509
- if (shouldAutoApprovePermissionState("ask", this.config.current())) {
509
+ // Last yolo check outside the composed ruleset: dissolves when
510
+ // processInbox is refactored onto evaluate() + Authorizer selection in
511
+ // the Phase 9 spine work.
512
+ if (isYoloModeEnabled(this.config.current())) {
510
513
  this.logger.review(
511
514
  "forwarded_permission.auto_approved",
512
515
  forwardedPermissionLogDetails,
@@ -62,7 +62,7 @@ export function deriveResolution(
62
62
  canConfirm: boolean,
63
63
  autoApproved = false,
64
64
  ): PermissionDecisionResolution {
65
- if (state === "allow") return "policy_allow";
65
+ if (state === "allow") return autoApproved ? "auto_approved" : "policy_allow";
66
66
  if (state === "deny") return "policy_deny";
67
67
  // state === "ask"
68
68
  if (action === "allow") {
@@ -105,6 +105,28 @@ export class GateRunner {
105
105
  return { action: "allow" };
106
106
  }
107
107
 
108
+ // 2b. Yolo fast-path — a composition-stage ask→allow rewrite records
109
+ // origin "yolo" on the matched rule. Auto-approve without prompting,
110
+ // preserving today's single auto_approved review entry + decision event
111
+ // so review-log parity holds (#526).
112
+ if (check.state === "allow" && check.origin === "yolo") {
113
+ this.reporter.writeReviewLog("permission_request.auto_approved", {
114
+ ...descriptor.logContext,
115
+ agentName,
116
+ resolution: "auto_approved",
117
+ });
118
+ this.reporter.emitDecision(
119
+ buildDecisionEvent(
120
+ descriptor.decision,
121
+ check,
122
+ agentName,
123
+ "allow",
124
+ deriveResolution(check.state, "allow", false, false, true),
125
+ ),
126
+ );
127
+ return { action: "allow" };
128
+ }
129
+
108
130
  // 3. Apply the deny/ask/allow gate
109
131
  const canConfirm = this.prompter.canConfirm();
110
132
 
package/src/index.ts CHANGED
@@ -6,6 +6,7 @@ import { getGlobalConfigPath } from "./config-paths";
6
6
  import { ConfigStore } from "./config-store";
7
7
  import { DecisionAudit } from "./decision-audit";
8
8
  import { GateDecisionReporter } from "./decision-reporter";
9
+ import { isYoloModeEnabled } from "./extension-config";
9
10
  import { computeExtensionPaths } from "./extension-paths";
10
11
  import {
11
12
  PermissionForwarder,
@@ -46,10 +47,6 @@ export default function piPermissionSystemExtension(pi: ExtensionAPI): void {
46
47
  // session (PathNormalizer) and, later, rule evaluation. Interior modules must
47
48
  // not read process.platform (enforced by the eslint guard scoped to src/).
48
49
  const hostPlatform = process.platform;
49
- const permissionManager = new PermissionManager({
50
- agentDir,
51
- platform: hostPlatform,
52
- });
53
50
  const sessionRules = new SessionRules();
54
51
  const subagentRegistry = getSubagentSessionRegistry();
55
52
  const formatterRegistry = new ToolInputFormatterRegistry();
@@ -65,6 +62,15 @@ export default function piPermissionSystemExtension(pi: ExtensionAPI): void {
65
62
  // eslint-disable-next-line prefer-const -- forward-declared let; `const` requires an initializer
66
63
  let session: PermissionSession;
67
64
 
65
+ // Constructed after the `configStore` forward declaration so the yolo reader
66
+ // can close over it; the closure runs per check(), after configStore is
67
+ // assigned below. yolo becomes a composition-stage ask→allow rewrite (#526).
68
+ const permissionManager = new PermissionManager({
69
+ agentDir,
70
+ platform: hostPlatform,
71
+ isYoloEnabled: () => isYoloModeEnabled(configStore.current()),
72
+ });
73
+
68
74
  const logger = new PermissionSessionLogger({
69
75
  globalLogsDir: paths.globalLogsDir,
70
76
  getConfig: () => configStore.current(),
@@ -90,14 +96,12 @@ export default function piPermissionSystemExtension(pi: ExtensionAPI): void {
90
96
  const forwarder = new PermissionForwarder(forwardingDeps);
91
97
 
92
98
  const prompter = new PermissionPrompter({
93
- config: configStore,
94
99
  logger,
95
100
  events: pi.events,
96
101
  forwarder,
97
102
  });
98
103
 
99
104
  const gateway = new PromptingGateway({
100
- config: configStore,
101
105
  subagentSessionsDir: paths.subagentSessionsDir,
102
106
  platform: hostPlatform,
103
107
  registry: subagentRegistry,
@@ -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;
@@ -20,6 +20,7 @@ import {
20
20
  evaluateAnyValue,
21
21
  evaluateFirst,
22
22
  pathMatchOptions,
23
+ rewriteAsksToYolo,
23
24
  } from "./rule";
24
25
  import { mergeScopesWithOrigins } from "./scope-merge";
25
26
  import {
@@ -53,6 +54,9 @@ const DEFAULT_UNIVERSAL_FALLBACK: PermissionState = "ask";
53
54
  /** Promotion predicate matching no token — the no-`path`-rules default (#509). */
54
55
  const NO_PROMOTION: PathRuleTokenMatcher = () => false;
55
56
 
57
+ /** Default yolo reader — yolo disabled unless the composition root injects one. */
58
+ const YOLO_DISABLED = (): boolean => false;
59
+
56
60
  type FileCacheEntry<TValue> = {
57
61
  stamp: string;
58
62
  value: TValue;
@@ -110,11 +114,20 @@ export interface PermissionManagerOptions extends PolicyLoaderOptions {
110
114
  * Defaults to a POSIX flavor; production always supplies the real platform.
111
115
  */
112
116
  platform?: NodeJS.Platform;
117
+ /**
118
+ * yolo-mode reader, injected from the composition root. When it reports
119
+ * true, {@link PermissionManager.check} rewrites every matched `ask` to a
120
+ * standing `allow` tagged `origin: "yolo"` (recorded authority, #526).
121
+ * Read per check so a mid-session config change takes effect; defaults to
122
+ * yolo disabled.
123
+ */
124
+ isYoloEnabled?: () => boolean;
113
125
  }
114
126
 
115
127
  export class PermissionManager implements ScopedPermissionManager {
116
128
  private readonly agentDir: string | undefined;
117
129
  private readonly platform: NodeJS.Platform;
130
+ private readonly isYoloEnabled: () => boolean;
118
131
  private loader: PolicyLoader;
119
132
  private readonly resolvedPermissionsCache = new Map<
120
133
  string,
@@ -124,6 +137,7 @@ export class PermissionManager implements ScopedPermissionManager {
124
137
  constructor(options: PermissionManagerOptions = {}) {
125
138
  this.agentDir = options.agentDir;
126
139
  this.platform = options.platform ?? "linux";
140
+ this.isYoloEnabled = options.isYoloEnabled ?? YOLO_DISABLED;
127
141
  this.loader =
128
142
  options.policyLoader ??
129
143
  new FilePolicyLoader(
@@ -311,9 +325,15 @@ export class PermissionManager implements ScopedPermissionManager {
311
325
  sessionRules?: Ruleset,
312
326
  ): PermissionCheckResult {
313
327
  const { composedRules } = this.resolvePermissions(intent.agentName);
314
- const fullRules: Ruleset = sessionRules?.length
328
+ const composedWithSession: Ruleset = sessionRules?.length
315
329
  ? [...composedRules, ...sessionRules]
316
330
  : composedRules;
331
+ // Apply the yolo rewrite post-cache so the resolved-permissions cache and
332
+ // the display surfaces (getComposedConfigRules / getToolPermission) stay
333
+ // yolo-free — only the resolution path sees the ask→allow rewrite (#526).
334
+ const fullRules: Ruleset = this.isYoloEnabled()
335
+ ? rewriteAsksToYolo(composedWithSession)
336
+ : composedWithSession;
317
337
 
318
338
  if (intent.kind === "path-values") {
319
339
  const lookupValues =