@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.
- package/CHANGELOG.md +25 -0
- package/docs/configuration.md +11 -0
- package/package.json +1 -2
- package/src/access-intent/access-path.ts +29 -4
- package/src/access-intent/bash/bash-path-resolver.ts +39 -18
- package/src/access-intent/bash/msys-bash-tokens.ts +64 -0
- package/src/extension-config.ts +7 -0
- package/src/forwarded-permissions/permission-forwarder.ts +6 -3
- package/src/handlers/gates/helpers.ts +1 -1
- package/src/handlers/gates/runner.ts +22 -0
- package/src/index.ts +10 -6
- package/src/path-normalizer.ts +105 -2
- package/src/permission-manager.ts +21 -1
- package/src/permission-prompter.ts +10 -16
- package/src/prompting-gateway.ts +13 -17
- package/src/rule.ts +21 -1
- package/src/status.ts +1 -1
- package/src/yolo-mode.ts +0 -30
- package/test/access-intent/access-path.test.ts +0 -277
- package/test/access-intent/bash/node-text.test.ts +0 -148
- package/test/access-intent/bash/parser.test.ts +0 -19
- package/test/access-intent/bash/program.test.ts +0 -673
- package/test/access-intent/bash/token-classification.test.ts +0 -363
- package/test/access-intent/bash/token-collection.test.ts +0 -300
- package/test/active-agent.test.ts +0 -155
- package/test/async-cache.test.ts +0 -48
- package/test/bash-arity.test.ts +0 -144
- package/test/bash-external-directory.test.ts +0 -1022
- package/test/builtin-tool-input-formatters.test.ts +0 -109
- package/test/canonicalize-path.test.ts +0 -119
- package/test/composition-root.test.ts +0 -698
- package/test/config-loader.test.ts +0 -740
- package/test/config-modal.test.ts +0 -320
- package/test/config-paths.test.ts +0 -83
- package/test/config-pipeline.test.ts +0 -90
- package/test/config-reporter.test.ts +0 -147
- package/test/config-store.test.ts +0 -466
- package/test/decision-audit.test.ts +0 -72
- package/test/decision-reporter.test.ts +0 -112
- package/test/denial-messages.test.ts +0 -714
- package/test/detect-permissive-bash-fallback.test.ts +0 -56
- package/test/expand-home.test.ts +0 -93
- package/test/extension-config.test.ts +0 -129
- package/test/extension-paths.test.ts +0 -108
- package/test/forwarded-permissions/io.test.ts +0 -251
- package/test/forwarding-manager.test.ts +0 -200
- package/test/handlers/before-agent-start.test.ts +0 -314
- package/test/handlers/external-directory-integration.test.ts +0 -515
- package/test/handlers/external-directory-session-dedup.test.ts +0 -175
- package/test/handlers/external-directory-symlink-acceptance.test.ts +0 -167
- package/test/handlers/gates/bash-command-metamorphic.test.ts +0 -88
- package/test/handlers/gates/bash-command.test.ts +0 -257
- package/test/handlers/gates/bash-external-directory.test.ts +0 -268
- package/test/handlers/gates/bash-path.test.ts +0 -346
- package/test/handlers/gates/candidate-check.test.ts +0 -52
- package/test/handlers/gates/external-directory-messages.test.ts +0 -85
- package/test/handlers/gates/external-directory-policy.test.ts +0 -134
- package/test/handlers/gates/external-directory.test.ts +0 -267
- package/test/handlers/gates/helpers.test.ts +0 -165
- package/test/handlers/gates/path.test.ts +0 -369
- package/test/handlers/gates/runner.test.ts +0 -408
- package/test/handlers/gates/skill-input-gate-pipeline.test.ts +0 -176
- package/test/handlers/gates/skill-input.test.ts +0 -128
- package/test/handlers/gates/skill-read.test.ts +0 -161
- package/test/handlers/gates/tool-call-gate-pipeline.test.ts +0 -356
- package/test/handlers/gates/tool.test.ts +0 -244
- package/test/handlers/input-events.test.ts +0 -168
- package/test/handlers/input.test.ts +0 -199
- package/test/handlers/lifecycle.test.ts +0 -221
- package/test/handlers/tool-call-boundary.test.ts +0 -145
- package/test/handlers/tool-call-events.test.ts +0 -277
- package/test/handlers/tool-call.test.ts +0 -395
- package/test/handlers/validate-requested-tool.test.ts +0 -92
- package/test/helpers/external-directory-fixtures.ts +0 -269
- package/test/helpers/gate-fixtures.ts +0 -316
- package/test/helpers/handler-fixtures.ts +0 -335
- package/test/helpers/make-fake-pi.ts +0 -100
- package/test/helpers/manager-harness.ts +0 -112
- package/test/helpers/session-fixtures.ts +0 -199
- package/test/input-normalizer.test.ts +0 -325
- package/test/logging.test.ts +0 -51
- package/test/mcp-targets.test.ts +0 -233
- package/test/node-modules-discovery.test.ts +0 -97
- package/test/normalize.test.ts +0 -247
- package/test/path-containment.test.ts +0 -161
- package/test/path-normalization.test.ts +0 -233
- package/test/path-normalizer.test.ts +0 -196
- package/test/path-surfaces.test.ts +0 -55
- package/test/pattern-suggest.test.ts +0 -248
- package/test/permission-dialog.test.ts +0 -205
- package/test/permission-event-rpc.test.ts +0 -560
- package/test/permission-events.test.ts +0 -400
- package/test/permission-forwarder.test.ts +0 -370
- package/test/permission-forwarding.test.ts +0 -315
- package/test/permission-gate.test.ts +0 -269
- package/test/permission-manager-unified.test.ts +0 -3714
- package/test/permission-merge.test.ts +0 -61
- package/test/permission-prompter.test.ts +0 -518
- package/test/permission-prompts.test.ts +0 -363
- package/test/permission-resolver.test.ts +0 -277
- package/test/permission-session.test.ts +0 -404
- package/test/permission-ui-prompt.test.ts +0 -146
- package/test/permissions-service.test.ts +0 -192
- package/test/pi-infrastructure-read.test.ts +0 -432
- package/test/policy-loader.test.ts +0 -561
- package/test/prompting-gateway.test.ts +0 -231
- package/test/rule.test.ts +0 -650
- package/test/safe-system-paths.test.ts +0 -46
- package/test/scope-merge.test.ts +0 -116
- package/test/service-lifecycle.test.ts +0 -163
- package/test/service.test.ts +0 -261
- package/test/session-approval.test.ts +0 -75
- package/test/session-logger.test.ts +0 -200
- package/test/session-rules.test.ts +0 -321
- package/test/session-start.test.ts +0 -112
- package/test/skill-prompt-sanitizer.test.ts +0 -418
- package/test/status.test.ts +0 -10
- package/test/subagent-context.test.ts +0 -372
- package/test/subagent-lifecycle-events.test.ts +0 -132
- package/test/subagent-registry.test.ts +0 -145
- package/test/synthesize.test.ts +0 -302
- package/test/system-prompt-sanitizer.test.ts +0 -382
- package/test/tool-access-extractor-registry.test.ts +0 -77
- package/test/tool-input-formatter-registry.test.ts +0 -75
- package/test/tool-input-path.test.ts +0 -84
- package/test/tool-input-preview.test.ts +0 -129
- package/test/tool-input-prompt-formatters.test.ts +0 -115
- package/test/tool-preview-formatter.test.ts +0 -458
- package/test/tool-registry.test.ts +0 -197
- package/test/value-guards.test.ts +0 -193
- package/test/wildcard-matcher.test.ts +0 -424
- package/test/yaml-frontmatter.test.ts +0 -91
- 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
|
|
package/docs/configuration.md
CHANGED
|
@@ -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.
|
|
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
|
|
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
|
|
125
|
-
return new AccessPath(
|
|
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
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
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.
|
|
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
|
|
384
|
-
// config patterns match the path as the user
|
|
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
|
-
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
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.
|
|
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
|
+
}
|
package/src/extension-config.ts
CHANGED
|
@@ -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
|
|
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
|
-
|
|
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,
|
package/src/path-normalizer.ts
CHANGED
|
@@ -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
|
|
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 =
|