@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.
- package/CHANGELOG.md +16 -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/path-normalizer.ts +105 -2
- 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,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
|
|
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.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
|
|
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/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;
|
|
@@ -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
|
-
});
|