@gotgenes/pi-permission-system 22.0.0 → 23.0.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +30 -0
- package/docs/configuration.md +12 -5
- package/package.json +1 -1
- package/src/access-intent/access-path.ts +4 -12
- package/src/access-intent/bash/bash-path-resolver.ts +99 -47
- package/src/access-intent/bash/program.ts +4 -8
- package/src/access-intent/bash/token-classification.ts +27 -21
- package/src/access-intent/bash/token-collection.ts +38 -1
- package/src/handlers/gates/tool-call-gate-pipeline.ts +4 -12
- package/src/path-normalizer.ts +35 -10
- package/src/permission-manager.ts +0 -46
- package/src/permission-session.ts +0 -12
- package/src/types.ts +0 -11
- package/src/wildcard-matcher.ts +39 -14
package/CHANGELOG.md
CHANGED
|
@@ -5,6 +5,36 @@ 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
|
+
## [23.0.1](https://github.com/gotgenes/pi-packages/compare/pi-permission-system-v23.0.0...pi-permission-system-v23.0.1) (2026-07-25)
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
### Bug Fixes
|
|
12
|
+
|
|
13
|
+
* **pi-permission-system:** fold separators on both sides of a win32 path match ([50e2ac0](https://github.com/gotgenes/pi-packages/commit/50e2ac0dd66b4b308a676849a09e3fff59e754be)), closes [#653](https://github.com/gotgenes/pi-packages/issues/653)
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
### Documentation
|
|
17
|
+
|
|
18
|
+
* **pi-permission-system:** record the symmetric win32 separator fold ([e2eea21](https://github.com/gotgenes/pi-packages/commit/e2eea21e0aac1212f46be9680fb59146b90c68f7)), closes [#653](https://github.com/gotgenes/pi-packages/issues/653)
|
|
19
|
+
|
|
20
|
+
## [23.0.0](https://github.com/gotgenes/pi-packages/compare/pi-permission-system-v22.0.0...pi-permission-system-v23.0.0) (2026-07-24)
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
### ⚠ BREAKING CHANGES
|
|
24
|
+
|
|
25
|
+
* **pi-permission-system:** path values embedded in `--opt=value` bash tokens are now extracted and gated by the `path` and `external_directory` surfaces. Previously they were invisible to both, so a permissive `bash` rule such as `grep *` allowed them. Add an allow pattern on `external_directory` or `path` for an intended target.
|
|
26
|
+
* **pi-permission-system:** bash commands referencing existing bare-named files or in-project symlinks are now gated by `path` rules (matched against the canonical, symlink-resolved form) and by `external_directory` when they resolve outside the working directory. Previously a permissive `bash` allow rule such as `cat *` bypassed both. A bare token naming no file is still dropped, so `git status`-style commands are unaffected. To restore prior behavior for an intended target, add an allow pattern on `external_directory` (for outside-CWD paths) or on `path`.
|
|
27
|
+
|
|
28
|
+
### Bug Fixes
|
|
29
|
+
|
|
30
|
+
* **pi-permission-system:** classify path values embedded in --opt=value tokens ([0be19fd](https://github.com/gotgenes/pi-packages/commit/0be19fd209254ea76840747c128d3bc5112a912b)), closes [#645](https://github.com/gotgenes/pi-packages/issues/645)
|
|
31
|
+
* **pi-permission-system:** gate existing bare-named files and symlinks in bash commands ([9467858](https://github.com/gotgenes/pi-packages/commit/9467858cdb8824cbdcf5a05994dd0d6b3784cbaa)), closes [#645](https://github.com/gotgenes/pi-packages/issues/645)
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
### Documentation
|
|
35
|
+
|
|
36
|
+
* **pi-permission-system:** update architecture and skill docs for probe-based path candidacy ([90c402d](https://github.com/gotgenes/pi-packages/commit/90c402d54edbc857597aca66e0cb5f01bade550f)), closes [#645](https://github.com/gotgenes/pi-packages/issues/645)
|
|
37
|
+
|
|
8
38
|
## [22.0.0](https://github.com/gotgenes/pi-packages/compare/pi-permission-system-v21.0.0...pi-permission-system-v22.0.0) (2026-07-24)
|
|
9
39
|
|
|
10
40
|
|
package/docs/configuration.md
CHANGED
|
@@ -469,8 +469,12 @@ For bash commands, the extension extracts path-candidate tokens from the command
|
|
|
469
469
|
The most restrictive result across all tokens determines the outcome.
|
|
470
470
|
When the current working directory is known, relative bash tokens are matched with cwd-normalized policy values, resolved against the effective directory after literal `cd` commands; a token after a non-literal `cd` (e.g. `cd "$DIR"`) stays conservative and matches only its literal form.
|
|
471
471
|
|
|
472
|
-
A bare filename with no path shape at all (e.g. `id_rsa` in `cat id_rsa`) is also gated
|
|
473
|
-
|
|
472
|
+
A bare filename with no path shape at all (e.g. `id_rsa` in `cat id_rsa`) is also gated, provided it names a file that actually exists — so `"id_rsa": "deny"` or `"*.pem": "deny"` blocks the file whether it is referenced by a bare name, a relative path, or the `read` tool.
|
|
473
|
+
Because the resolved path is matched, this covers a bare **symlink** whose target a rule names: with `".some.secret": "deny"`, `cat a_sym` is denied when `a_sym` points at `.some.secret`.
|
|
474
|
+
A bare token that names nothing (e.g. `status` in `git status`, `build` in `npm run build`) is left alone, so ordinary subcommands and branch names never prompt.
|
|
475
|
+
An existing file that matches no `path` rule is likewise left alone — the catch-all `"*"` entry alone does not gate it.
|
|
476
|
+
|
|
477
|
+
A path embedded in a long option (e.g. `--file=/tmp/patterns` in `grep --file=/tmp/patterns target`) is extracted and gated like any other path token; an option value that is not path-shaped (e.g. `--format=json`) is ignored.
|
|
474
478
|
|
|
475
479
|
On Windows, where a backslash is a path separator, a backslash-relative bash argument (e.g. `dir\file` in `cat dir\file`) is gated by a `path` rule the same as its forward-slash equivalent (`dir/file`) and the same as the file accessed through the `read` tool.
|
|
476
480
|
On other platforms a backslash is a legal filename character, so such a token is not treated as a path.
|
|
@@ -614,15 +618,18 @@ Infrastructure directories include:
|
|
|
614
618
|
Write tools (`write`, `edit`) to infrastructure paths are **not** auto-allowed and still go through the gate.
|
|
615
619
|
|
|
616
620
|
On Windows, path matching for `external_directory`, `path`, and the path-bearing tools is case-insensitive and tolerant of either separator (`\` or `/`), matching the case-insensitive filesystem.
|
|
617
|
-
|
|
618
|
-
|
|
621
|
+
The separator folding applies to the rule pattern **and** to the value it is matched against, so either side may be written with either separator.
|
|
622
|
+
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, and a forward-slash rule such as `"/dev/null"` matches a value that is also spelled with forward slashes.
|
|
623
|
+
POSIX matching remains case-sensitive and does not fold separators.
|
|
619
624
|
|
|
620
625
|
#### Git Bash / MSYS paths on Windows
|
|
621
626
|
|
|
622
627
|
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.
|
|
623
628
|
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):
|
|
624
629
|
|
|
625
|
-
- The safe device paths (`/dev/null`, `/dev/stdin`, `/dev/stdout`, `/dev/stderr`) are recognized as MSYS devices
|
|
630
|
+
- The safe device paths (`/dev/null`, `/dev/stdin`, `/dev/stdout`, `/dev/stderr`) are recognized as MSYS devices rather than filesystem paths, so they never trigger the `external_directory` gate — the same exclusion that holds on POSIX.
|
|
631
|
+
The cross-cutting `path` surface still governs them on both platforms: if a `path` rule matches the token, it decides.
|
|
632
|
+
A device is therefore allow-listed the way any other path is, written as typed — `path: { "/dev/null": "allow" }`.
|
|
626
633
|
- 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.
|
|
627
634
|
- 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`.
|
|
628
635
|
|
package/package.json
CHANGED
|
@@ -120,20 +120,12 @@ export class AccessPath {
|
|
|
120
120
|
* unknown (a relative bash token after a non-literal `cd`).
|
|
121
121
|
*
|
|
122
122
|
* Carries no canonical alias and no absolute resolution — `matchValues()` is
|
|
123
|
-
* `[literal
|
|
124
|
-
*
|
|
125
|
-
*
|
|
126
|
-
* `matchAliases` supplies extra match-only forms that do not change the
|
|
127
|
-
* display value: a win32 Git Bash POSIX absolute carries a backslash-separated
|
|
128
|
-
* alias so the separator-folding path matcher can match a `/tmp/*` rule (#533).
|
|
123
|
+
* `[literal]` (or `[]` when empty) and `boundaryValue()` is `""` — so no
|
|
124
|
+
* spurious absolute or symlink-resolved rule can match (#393).
|
|
129
125
|
*/
|
|
130
|
-
static forLiteral(
|
|
131
|
-
literal: string,
|
|
132
|
-
matchAliases: readonly string[] = [],
|
|
133
|
-
): AccessPath {
|
|
126
|
+
static forLiteral(literal: string): AccessPath {
|
|
134
127
|
if (!literal) return new AccessPath("", [], "");
|
|
135
|
-
|
|
136
|
-
return new AccessPath(literal, aliases, "");
|
|
128
|
+
return new AccessPath(literal, [literal], "");
|
|
137
129
|
}
|
|
138
130
|
|
|
139
131
|
/**
|
|
@@ -5,7 +5,7 @@ import {
|
|
|
5
5
|
} from "#src/access-intent/bash/node-text";
|
|
6
6
|
import type { TSNode } from "#src/access-intent/bash/parser";
|
|
7
7
|
import {
|
|
8
|
-
|
|
8
|
+
classifyBareTokenCandidate,
|
|
9
9
|
classifyTokenAsPathCandidate,
|
|
10
10
|
classifyTokenAsRuleCandidate,
|
|
11
11
|
} from "#src/access-intent/bash/token-classification";
|
|
@@ -18,10 +18,6 @@ import {
|
|
|
18
18
|
import { normalizePathPolicyLiteral } from "#src/access-intent/path-normalization";
|
|
19
19
|
import type { PathNormalizer } from "#src/path-normalizer";
|
|
20
20
|
import { isSafeSystemPath } from "#src/safe-system-paths";
|
|
21
|
-
import type { PathRuleTokenMatcher } from "#src/types";
|
|
22
|
-
|
|
23
|
-
/** Default promotion predicate: promotes nothing (#509). */
|
|
24
|
-
const NO_PROMOTION: PathRuleTokenMatcher = () => false;
|
|
25
21
|
|
|
26
22
|
// ── Internal types ───────────────────────────────────────────────────────────
|
|
27
23
|
|
|
@@ -85,22 +81,20 @@ const UNKNOWN_BASE: EffectiveBase = { kind: "unknown" };
|
|
|
85
81
|
* (`forPath`/`forLiteral`/`resolveBase`), and the outside-cwd boundary
|
|
86
82
|
* decision — so no walk step re-reads the platform or threads the cwd.
|
|
87
83
|
*
|
|
88
|
-
*
|
|
89
|
-
*
|
|
90
|
-
*
|
|
91
|
-
*
|
|
92
|
-
* built and owned by `PermissionManager.getPromotablePathTokenMatcher`.
|
|
84
|
+
* A bare token that fails both shape gates is admitted when the normalizer's
|
|
85
|
+
* existence probe says it names a real filesystem entry (ADR 0009, #645). The
|
|
86
|
+
* resolver consults no ruleset: candidacy is a filesystem question, and the
|
|
87
|
+
* policy decision belongs to the gates downstream.
|
|
93
88
|
*
|
|
94
89
|
* Tell-don't-ask: callers hand it a parsed tree and receive the resolved
|
|
95
90
|
* {@link ResolvedBashPaths} slices in one {@link resolve} call; the AST walk,
|
|
96
91
|
* the `cd`-folding state, and the intermediate path candidates stay private.
|
|
97
92
|
* One instance per parse ({@link BashProgram.parse} constructs it with the
|
|
98
|
-
* session normalizer
|
|
93
|
+
* session normalizer).
|
|
99
94
|
*/
|
|
100
95
|
export class BashPathResolver {
|
|
101
96
|
constructor(
|
|
102
97
|
private readonly normalizer: PathNormalizer,
|
|
103
|
-
private readonly isPromotablePathToken: PathRuleTokenMatcher = NO_PROMOTION,
|
|
104
98
|
private readonly workdir?: string,
|
|
105
99
|
) {}
|
|
106
100
|
|
|
@@ -426,7 +420,14 @@ export class BashPathResolver {
|
|
|
426
420
|
|
|
427
421
|
for (const { token, base } of candidates) {
|
|
428
422
|
const candidate = classifyTokenAsPathCandidate(token);
|
|
429
|
-
if (!candidate)
|
|
423
|
+
if (!candidate) {
|
|
424
|
+
// A bare token the strict shape gate rejects can still escape the tree
|
|
425
|
+
// through a symlink, so probe it and apply the ordinary boundary
|
|
426
|
+
// decision to whatever it resolves to (#645).
|
|
427
|
+
const probed = this.probeBareToken(token, base);
|
|
428
|
+
if (probed) this.collectIfExternal(probed.path, seen, externalPaths);
|
|
429
|
+
continue;
|
|
430
|
+
}
|
|
430
431
|
|
|
431
432
|
// Unknown effective directory: a relative candidate could resolve
|
|
432
433
|
// anywhere, so flag it conservatively (resolved against the baked cwd
|
|
@@ -446,45 +447,58 @@ export class BashPathResolver {
|
|
|
446
447
|
base.kind === "known"
|
|
447
448
|
? this.normalizer.resolveBase(base.offset)
|
|
448
449
|
: undefined;
|
|
449
|
-
|
|
450
|
-
resolveBase,
|
|
451
|
-
|
|
452
|
-
|
|
453
|
-
|
|
454
|
-
// The boundary decision and dedup identity use the canonical
|
|
455
|
-
// (symlink-resolved) form the AccessPath already derived, but the returned
|
|
456
|
-
// value is the lexical form so config patterns match the path as the user
|
|
457
|
-
// typed it (#418). A win32 device path preserves `/dev/null` as its
|
|
458
|
-
// boundary value, so `isBoundaryOutsideWorkingDirectory` reaches the
|
|
459
|
-
// safe-path exclusion (#533).
|
|
460
|
-
const canonical = accessPath.boundaryValue();
|
|
461
|
-
// A literal-only bash token (a win32 non-mount POSIX absolute like `/tmp`)
|
|
462
|
-
// has no canonical form; it is foreign to the win32 cwd, so it is always
|
|
463
|
-
// external. Its lexical value is the dedup identity so two distinct
|
|
464
|
-
// literal-only paths do not collapse (#533).
|
|
465
|
-
const isExternal = canonical
|
|
466
|
-
? this.normalizer.isBoundaryOutsideWorkingDirectory(canonical)
|
|
467
|
-
: true;
|
|
468
|
-
const dedupKey = canonical || lexical;
|
|
469
|
-
|
|
470
|
-
if (isExternal && !seen.has(dedupKey)) {
|
|
471
|
-
seen.add(dedupKey);
|
|
472
|
-
externalPaths.push(accessPath);
|
|
473
|
-
}
|
|
450
|
+
this.collectIfExternal(
|
|
451
|
+
this.normalizer.forBashToken(candidate, { resolveBase }),
|
|
452
|
+
seen,
|
|
453
|
+
externalPaths,
|
|
454
|
+
);
|
|
474
455
|
}
|
|
475
456
|
|
|
476
457
|
return externalPaths;
|
|
477
458
|
}
|
|
478
459
|
|
|
460
|
+
/**
|
|
461
|
+
* Record `accessPath` when it resolves outside the working directory and has
|
|
462
|
+
* not already been collected.
|
|
463
|
+
*
|
|
464
|
+
* The boundary decision and dedup identity use the canonical
|
|
465
|
+
* (symlink-resolved) form the {@link AccessPath} already derived, while the
|
|
466
|
+
* stored value keeps the lexical form so config patterns match the path as
|
|
467
|
+
* the user typed it (#418). A win32 device path preserves `/dev/null` as its
|
|
468
|
+
* boundary value, so `isBoundaryOutsideWorkingDirectory` reaches the
|
|
469
|
+
* safe-path exclusion (#533).
|
|
470
|
+
*
|
|
471
|
+
* A literal-only bash token (a win32 non-mount POSIX absolute like `/tmp`)
|
|
472
|
+
* has no canonical form; it is foreign to the win32 cwd, so it is always
|
|
473
|
+
* external. Its lexical value is the dedup identity so two distinct
|
|
474
|
+
* literal-only paths do not collapse (#533).
|
|
475
|
+
*/
|
|
476
|
+
private collectIfExternal(
|
|
477
|
+
accessPath: AccessPath,
|
|
478
|
+
seen: Set<string>,
|
|
479
|
+
out: AccessPath[],
|
|
480
|
+
): void {
|
|
481
|
+
const lexical = accessPath.value();
|
|
482
|
+
if (!lexical) return;
|
|
483
|
+
const canonical = accessPath.boundaryValue();
|
|
484
|
+
const isExternal = canonical
|
|
485
|
+
? this.normalizer.isBoundaryOutsideWorkingDirectory(canonical)
|
|
486
|
+
: true;
|
|
487
|
+
const dedupKey = canonical || lexical;
|
|
488
|
+
if (isExternal && !seen.has(dedupKey)) {
|
|
489
|
+
seen.add(dedupKey);
|
|
490
|
+
out.push(accessPath);
|
|
491
|
+
}
|
|
492
|
+
}
|
|
493
|
+
|
|
479
494
|
/**
|
|
480
495
|
* Project the collected candidates into rule candidates with their cd-aware
|
|
481
496
|
* policy lookup values.
|
|
482
497
|
*
|
|
483
498
|
* Filters candidates through the broad path classifier
|
|
484
|
-
* (`classifyTokenAsRuleCandidate`), falling back to
|
|
485
|
-
*
|
|
486
|
-
*
|
|
487
|
-
* `isPromotablePathToken` predicate matches an active, specific `path` rule.
|
|
499
|
+
* (`classifyTokenAsRuleCandidate`), falling back to {@link probeBareToken}
|
|
500
|
+
* for a bare token the broad classifier rejects for shape — admitted only
|
|
501
|
+
* when it names an existing filesystem entry (#645).
|
|
488
502
|
* On win32 the broad classifier is told to treat a backslash as a path
|
|
489
503
|
* separator, so a backslash-relative token (`dir\file`) is recognized as a
|
|
490
504
|
* rule candidate the same as its forward-slash equivalent (#520); on POSIX
|
|
@@ -501,24 +515,62 @@ export class BashPathResolver {
|
|
|
501
515
|
const result: BashPathRuleCandidate[] = [];
|
|
502
516
|
|
|
503
517
|
for (const { token, base } of candidates) {
|
|
518
|
+
const shaped = classifyTokenAsRuleCandidate(
|
|
519
|
+
token,
|
|
520
|
+
this.normalizer.flavor,
|
|
521
|
+
);
|
|
504
522
|
const candidate =
|
|
505
|
-
|
|
506
|
-
|
|
523
|
+
shaped === null
|
|
524
|
+
? this.probeBareToken(token, base)
|
|
525
|
+
: { token: shaped, path: this.buildRuleCandidatePath(shaped, base) };
|
|
507
526
|
if (!candidate) continue;
|
|
508
527
|
|
|
509
|
-
const
|
|
510
|
-
const matchValues = path.matchValues();
|
|
528
|
+
const matchValues = candidate.path.matchValues();
|
|
511
529
|
if (matchValues.length === 0) continue;
|
|
512
530
|
|
|
513
531
|
const key = matchValues.join("\0");
|
|
514
532
|
if (seen.has(key)) continue;
|
|
515
533
|
seen.add(key);
|
|
516
|
-
result.push(
|
|
534
|
+
result.push(candidate);
|
|
517
535
|
}
|
|
518
536
|
|
|
519
537
|
return result;
|
|
520
538
|
}
|
|
521
539
|
|
|
540
|
+
/**
|
|
541
|
+
* Promote a bare token the shape gates rejected, when it names an existing
|
|
542
|
+
* filesystem entry — the existence probe (ADR 0009, #645).
|
|
543
|
+
*
|
|
544
|
+
* Most bash argument tokens are not paths (`status`, `build`, `main`), so a
|
|
545
|
+
* bare token is admitted only when the filesystem confirms it names something
|
|
546
|
+
* real. Candidacy therefore comes from the filesystem and never from the
|
|
547
|
+
* ruleset, which keeps the classifiers pure and lets a symlink be matched by
|
|
548
|
+
* rules naming its *target* — the case raw-token matching could not see.
|
|
549
|
+
*
|
|
550
|
+
* Returns `null` when the token's shape rules out a path, when the effective
|
|
551
|
+
* base is unknown (no concrete directory to resolve against, so the token
|
|
552
|
+
* stays unpromoted per #393 conservatism), or when nothing exists at the
|
|
553
|
+
* resolved location.
|
|
554
|
+
*
|
|
555
|
+
* Shared by both projections so a promoted token is identical whether it is
|
|
556
|
+
* being matched against `path` rules or tested against the cwd boundary.
|
|
557
|
+
*/
|
|
558
|
+
private probeBareToken(
|
|
559
|
+
token: string,
|
|
560
|
+
base: EffectiveBase,
|
|
561
|
+
): BashPathRuleCandidate | null {
|
|
562
|
+
const bare = classifyBareTokenCandidate(token);
|
|
563
|
+
if (bare === null) return null;
|
|
564
|
+
if (base.kind !== "known") return null;
|
|
565
|
+
|
|
566
|
+
const path = this.normalizer.forBashToken(bare, {
|
|
567
|
+
resolveBase: this.normalizer.resolveBase(base.offset),
|
|
568
|
+
});
|
|
569
|
+
const lexical = path.value();
|
|
570
|
+
if (!lexical || !this.normalizer.entryExists(lexical)) return null;
|
|
571
|
+
return { token: bare, path };
|
|
572
|
+
}
|
|
573
|
+
|
|
522
574
|
private buildRuleCandidatePath(
|
|
523
575
|
candidate: string,
|
|
524
576
|
base: EffectiveBase,
|
|
@@ -9,7 +9,6 @@ import {
|
|
|
9
9
|
} from "#src/access-intent/bash/command-enumeration";
|
|
10
10
|
import { getParser } from "#src/access-intent/bash/parser";
|
|
11
11
|
import type { PathNormalizer } from "#src/path-normalizer";
|
|
12
|
-
import type { PathRuleTokenMatcher } from "#src/types";
|
|
13
12
|
|
|
14
13
|
export type { BashCommand, BashPathRuleCandidate };
|
|
15
14
|
|
|
@@ -40,11 +39,10 @@ export class BashProgram {
|
|
|
40
39
|
* Heredoc bodies, comments, and other non-argument content are skipped. An
|
|
41
40
|
* unparseable command yields an empty program.
|
|
42
41
|
*
|
|
43
|
-
* `
|
|
44
|
-
*
|
|
45
|
-
*
|
|
46
|
-
*
|
|
47
|
-
* are unaffected.
|
|
42
|
+
* A bare token (e.g. `id_rsa`, `outside-link`) enters both slices when it
|
|
43
|
+
* names an existing filesystem entry — the existence probe the resolver owns
|
|
44
|
+
* (ADR 0009, #645). No policy is consulted, so every caller gets identical
|
|
45
|
+
* slices for a given command and working directory.
|
|
48
46
|
*
|
|
49
47
|
* `options.workdir`, when supplied (an aliased shell tool's working directory,
|
|
50
48
|
* #574), seeds the initial effective base — as if the command were prefixed
|
|
@@ -54,7 +52,6 @@ export class BashProgram {
|
|
|
54
52
|
static async parse(
|
|
55
53
|
command: string,
|
|
56
54
|
normalizer: PathNormalizer,
|
|
57
|
-
isPromotablePathToken?: PathRuleTokenMatcher,
|
|
58
55
|
options?: { workdir?: string },
|
|
59
56
|
): Promise<BashProgram> {
|
|
60
57
|
const parser = await getParser();
|
|
@@ -64,7 +61,6 @@ export class BashProgram {
|
|
|
64
61
|
try {
|
|
65
62
|
const { externalPaths, ruleCandidates } = new BashPathResolver(
|
|
66
63
|
normalizer,
|
|
67
|
-
isPromotablePathToken,
|
|
68
64
|
options?.workdir,
|
|
69
65
|
).resolve(tree.rootNode);
|
|
70
66
|
return new BashProgram(
|
|
@@ -4,9 +4,16 @@
|
|
|
4
4
|
* Exports three classifiers consumed by `bash-path-resolver.ts`:
|
|
5
5
|
* - `classifyTokenAsPathCandidate` — strict gate for the external-directory guard.
|
|
6
6
|
* - `classifyTokenAsRuleCandidate` — broader gate for cross-cutting `path` rules.
|
|
7
|
-
* - `
|
|
8
|
-
*
|
|
9
|
-
*
|
|
7
|
+
* - `classifyBareTokenCandidate` — prelude-only gate for a bare token (e.g.
|
|
8
|
+
* `id_rsa`, `outside-link`) that `classifyTokenAsRuleCandidate` rejects for
|
|
9
|
+
* shape. It answers only "is this shape capable of naming a path?"; whether
|
|
10
|
+
* it *does* name one is settled by the resolver's existence probe (#645).
|
|
11
|
+
*
|
|
12
|
+
* Token classification is three-valued: definitely-path (shape), definitely-not
|
|
13
|
+
* (prelude), and unknown (a bare word). These functions own the first two; the
|
|
14
|
+
* third is resolved against the filesystem rather than against policy, so no
|
|
15
|
+
* classifier here consults the ruleset — see
|
|
16
|
+
* `docs/decisions/0009-bash-path-projection-completeness-contract.md`.
|
|
10
17
|
*
|
|
11
18
|
* All three classifiers share the private `rejectNonPathToken` predicate that
|
|
12
19
|
* captures the six rejection cases common to them (the production clone this
|
|
@@ -28,7 +35,6 @@
|
|
|
28
35
|
* never reads `process.platform` itself.
|
|
29
36
|
*/
|
|
30
37
|
import type { PathFlavor } from "#src/path/path-flavor";
|
|
31
|
-
import type { PathRuleTokenMatcher } from "#src/types";
|
|
32
38
|
|
|
33
39
|
// ── Public classifiers ─────────────────────────────────────────────────────
|
|
34
40
|
|
|
@@ -91,28 +97,28 @@ export function classifyTokenAsRuleCandidate(
|
|
|
91
97
|
}
|
|
92
98
|
|
|
93
99
|
/**
|
|
94
|
-
*
|
|
100
|
+
* Prelude-only classifier for a bare token (#645).
|
|
101
|
+
*
|
|
102
|
+
* A bare token (`id_rsa`, `outside-link`) has none of the shapes
|
|
103
|
+
* `classifyTokenAsRuleCandidate` accepts, because most bash argument tokens are
|
|
104
|
+
* not file paths (subcommands, branch names, search patterns). This classifier
|
|
105
|
+
* answers the narrower question the existence probe needs: could this token's
|
|
106
|
+
* *shape* name a path at all?
|
|
95
107
|
*
|
|
96
|
-
*
|
|
97
|
-
*
|
|
98
|
-
*
|
|
99
|
-
* patterns). This classifier promotes a bare token into the rule-candidate
|
|
100
|
-
* surface only when the caller-supplied `isPromotable` predicate says it
|
|
101
|
-
* matches an active, specific `path` deny/ask rule, closing the bypass without
|
|
102
|
-
* treating every bare argument as a path.
|
|
108
|
+
* It runs only the shared `rejectNonPathToken` prelude, so a flag,
|
|
109
|
+
* env-assignment, URL, `@scope` token, or regex-shaped token is never a
|
|
110
|
+
* candidate. Everything else is returned for the caller to probe.
|
|
103
111
|
*
|
|
104
|
-
*
|
|
105
|
-
*
|
|
106
|
-
*
|
|
112
|
+
* Deliberately consults no policy: candidacy is settled by the filesystem and
|
|
113
|
+
* the decision by the ruleset, which keeps this module a pure shape function
|
|
114
|
+
* (ADR 0009). It replaced the rule-driven promotion of #509, which matched a
|
|
115
|
+
* token's *spelling* against `path` rules and so could never see that a
|
|
116
|
+
* symlink's target is what a rule names.
|
|
107
117
|
*
|
|
108
118
|
* Returns the raw token string if it qualifies, or `null` to skip.
|
|
109
119
|
*/
|
|
110
|
-
export function
|
|
111
|
-
token:
|
|
112
|
-
isPromotable: PathRuleTokenMatcher,
|
|
113
|
-
): string | null {
|
|
114
|
-
if (rejectNonPathToken(token)) return null;
|
|
115
|
-
return isPromotable(token) ? token : null;
|
|
120
|
+
export function classifyBareTokenCandidate(token: string): string | null {
|
|
121
|
+
return rejectNonPathToken(token) ? null : token;
|
|
116
122
|
}
|
|
117
123
|
|
|
118
124
|
// ── Private rejection predicate ────────────────────────────────────────────
|
|
@@ -42,9 +42,10 @@ export function collectCommandTokens(node: TSNode): string[] {
|
|
|
42
42
|
const config = commandName
|
|
43
43
|
? PATTERN_FIRST_COMMANDS.get(commandName)
|
|
44
44
|
: undefined;
|
|
45
|
-
|
|
45
|
+
const tokens = config
|
|
46
46
|
? collectPatternCommandTokens(node, config)
|
|
47
47
|
: collectGenericCommandTokens(node);
|
|
48
|
+
return [...tokens, ...collectEmbeddedOptionValues(node)];
|
|
48
49
|
}
|
|
49
50
|
|
|
50
51
|
/**
|
|
@@ -81,6 +82,42 @@ export function extractCommandName(node: TSNode): string | undefined {
|
|
|
81
82
|
|
|
82
83
|
// ── Private helpers and config ─────────────────────────────────────────────
|
|
83
84
|
|
|
85
|
+
/**
|
|
86
|
+
* A long or short option carrying its value inline: one or two leading dashes,
|
|
87
|
+
* a name containing no `=` or whitespace, then `=` and a non-empty value.
|
|
88
|
+
* Only the first `=` separates, so `--opt=/tmp/a=b` yields `/tmp/a=b`.
|
|
89
|
+
*/
|
|
90
|
+
const OPTION_VALUE_PATTERN = /^-{1,2}[^=\s]+=(.+)$/;
|
|
91
|
+
|
|
92
|
+
/**
|
|
93
|
+
* The values embedded in this command's `--opt=value` argument tokens.
|
|
94
|
+
*
|
|
95
|
+
* Read straight from the argument nodes rather than from the collected token
|
|
96
|
+
* list, because a pattern-first command's collector classifies a flag and never
|
|
97
|
+
* emits it — so `grep --file=/tmp/patterns` would otherwise lose the path.
|
|
98
|
+
*
|
|
99
|
+
* This is token *preprocessing*, not classification: the extracted value is
|
|
100
|
+
* handed to the ordinary shape classifiers and existence probe, so
|
|
101
|
+
* `--file=/tmp/patterns` reaches the path surfaces while `--format=json`
|
|
102
|
+
* yields a bare `json` that names nothing and is dropped. Keeping the split
|
|
103
|
+
* here is what lets the projection see option-embedded paths without per-command
|
|
104
|
+
* option tables (ADR 0009, #645).
|
|
105
|
+
*/
|
|
106
|
+
function collectEmbeddedOptionValues(node: TSNode): string[] {
|
|
107
|
+
const values: string[] = [];
|
|
108
|
+
for (let i = 0; i < node.childCount; i++) {
|
|
109
|
+
const child = node.child(i);
|
|
110
|
+
if (!child) continue;
|
|
111
|
+
if (child.type === "command_name" || child.type === "variable_assignment")
|
|
112
|
+
continue;
|
|
113
|
+
if (!ARG_NODE_TYPES.has(child.type)) continue;
|
|
114
|
+
|
|
115
|
+
const value = OPTION_VALUE_PATTERN.exec(resolveNodeText(child))?.[1];
|
|
116
|
+
if (value !== undefined) values.push(value);
|
|
117
|
+
}
|
|
118
|
+
return values;
|
|
119
|
+
}
|
|
120
|
+
|
|
84
121
|
interface PatternCommandConfig {
|
|
85
122
|
/** Flags that consume the next argument as a non-path value (pattern, separator, etc.) */
|
|
86
123
|
readonly argConsumingFlags: ReadonlySet<string>;
|
|
@@ -15,7 +15,7 @@ import {
|
|
|
15
15
|
ToolPreviewFormatter,
|
|
16
16
|
type ToolPreviewFormatterOptions,
|
|
17
17
|
} from "#src/tool-preview-formatter";
|
|
18
|
-
import type {
|
|
18
|
+
import type { PermissionCheckResult } from "#src/types";
|
|
19
19
|
import { resolveBashCommandCheck } from "./bash-command";
|
|
20
20
|
import { describeBashExternalDirectoryGate } from "./bash-external-directory";
|
|
21
21
|
import { describeBashPathGate } from "./bash-path";
|
|
@@ -52,11 +52,6 @@ export interface ToolCallGateInputs {
|
|
|
52
52
|
* tool is gated through the bash stack at parity with native `bash` (#574).
|
|
53
53
|
*/
|
|
54
54
|
getShellToolAliases(): ShellToolsConfig | undefined;
|
|
55
|
-
/**
|
|
56
|
-
* Predicate deciding whether a bare bash token should be promoted into the
|
|
57
|
-
* `path` rule-candidate surface (#509), scoped to the given agent.
|
|
58
|
-
*/
|
|
59
|
-
getPromotablePathTokenMatcher(agentName?: string): PathRuleTokenMatcher;
|
|
60
55
|
}
|
|
61
56
|
|
|
62
57
|
/**
|
|
@@ -93,12 +88,9 @@ export class ToolCallGatePipeline {
|
|
|
93
88
|
);
|
|
94
89
|
const normalizer = this.inputs.getPathNormalizer();
|
|
95
90
|
const bashProgram = shell?.command
|
|
96
|
-
? await BashProgram.parse(
|
|
97
|
-
shell.
|
|
98
|
-
|
|
99
|
-
this.inputs.getPromotablePathTokenMatcher(tcc.agentName ?? undefined),
|
|
100
|
-
{ workdir: shell.workdir },
|
|
101
|
-
)
|
|
91
|
+
? await BashProgram.parse(shell.command, normalizer, {
|
|
92
|
+
workdir: shell.workdir,
|
|
93
|
+
})
|
|
102
94
|
: null;
|
|
103
95
|
|
|
104
96
|
const formatter = new ToolPreviewFormatter(
|
package/src/path-normalizer.ts
CHANGED
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+
import { lstatSync } from "node:fs";
|
|
2
|
+
|
|
1
3
|
import type { PathFlavor } from "#src/path/path-flavor";
|
|
2
4
|
|
|
3
5
|
import { AccessPath } from "./access-intent/access-path";
|
|
@@ -60,8 +62,8 @@ export class PathNormalizer {
|
|
|
60
62
|
}
|
|
61
63
|
|
|
62
64
|
/** Build a literal-only AccessPath (unknown base after a non-literal `cd`). */
|
|
63
|
-
forLiteral(literal: string
|
|
64
|
-
return AccessPath.forLiteral(literal
|
|
65
|
+
forLiteral(literal: string): AccessPath {
|
|
66
|
+
return AccessPath.forLiteral(literal);
|
|
65
67
|
}
|
|
66
68
|
|
|
67
69
|
/**
|
|
@@ -84,17 +86,14 @@ export class PathNormalizer {
|
|
|
84
86
|
return AccessPath.forDevice(token);
|
|
85
87
|
case "drive-mount":
|
|
86
88
|
return this.forPath(shape.windowsPath, options);
|
|
87
|
-
case "posix-absolute":
|
|
89
|
+
case "posix-absolute":
|
|
88
90
|
// A non-mount POSIX absolute (`/tmp`, `/usr`) has an install-dependent
|
|
89
91
|
// Windows target this package cannot know, so it is kept literal: always
|
|
90
92
|
// external, matched and displayed as typed, never fabricated into
|
|
91
|
-
// `c:\tmp` (#533). The win32 path matcher folds
|
|
92
|
-
//
|
|
93
|
-
//
|
|
94
|
-
|
|
95
|
-
const literal = normalizePathPolicyLiteral(token);
|
|
96
|
-
return this.forLiteral(literal, [literal.replaceAll("/", "\\")]);
|
|
97
|
-
}
|
|
93
|
+
// `c:\tmp` (#533). The win32 path matcher folds separators on both the
|
|
94
|
+
// rule and the value (#653), so a natural `/tmp/*` rule matches the
|
|
95
|
+
// as-typed literal directly.
|
|
96
|
+
return this.forLiteral(normalizePathPolicyLiteral(token));
|
|
98
97
|
case "plain":
|
|
99
98
|
return this.forPath(token, options);
|
|
100
99
|
}
|
|
@@ -203,4 +202,30 @@ export class PathNormalizer {
|
|
|
203
202
|
this.flavor,
|
|
204
203
|
);
|
|
205
204
|
}
|
|
205
|
+
|
|
206
|
+
/**
|
|
207
|
+
* True when `absolutePath` names an existing filesystem entry.
|
|
208
|
+
*
|
|
209
|
+
* The existence probe that resolves an *unknown* bash token: a bare word is a
|
|
210
|
+
* path candidate iff it names something real (ADR 0009, #645). Uses `lstat`,
|
|
211
|
+
* not `stat`, so a symlink counts as an entry even when its target is
|
|
212
|
+
* dangling — the link is the operand the command names, and dropping it would
|
|
213
|
+
* reopen the bypass this probe closes.
|
|
214
|
+
*
|
|
215
|
+
* Any error (ENOENT, ENOTDIR, EACCES, ELOOP) answers `false`: an entry the
|
|
216
|
+
* gate cannot confirm is not promoted, leaving the token exactly as
|
|
217
|
+
* unrestricted as it is today.
|
|
218
|
+
*
|
|
219
|
+
* Lives here beside {@link forPath}'s canonicalization so the package keeps a
|
|
220
|
+
* single filesystem edge for path interpretation.
|
|
221
|
+
*/
|
|
222
|
+
entryExists(absolutePath: string): boolean {
|
|
223
|
+
if (!absolutePath) return false;
|
|
224
|
+
try {
|
|
225
|
+
lstatSync(absolutePath);
|
|
226
|
+
return true;
|
|
227
|
+
} catch {
|
|
228
|
+
return false;
|
|
229
|
+
}
|
|
230
|
+
}
|
|
206
231
|
}
|
|
@@ -22,7 +22,6 @@ import {
|
|
|
22
22
|
evaluateAnyValue,
|
|
23
23
|
evaluateFirst,
|
|
24
24
|
floorAllowsToAsk,
|
|
25
|
-
pathMatchOptions,
|
|
26
25
|
rewriteAsksToYolo,
|
|
27
26
|
} from "./rule";
|
|
28
27
|
import { mergeScopesWithOrigins } from "./scope-merge";
|
|
@@ -33,21 +32,16 @@ import {
|
|
|
33
32
|
} from "./synthesize";
|
|
34
33
|
import type {
|
|
35
34
|
FlatPermissionConfig,
|
|
36
|
-
PathRuleTokenMatcher,
|
|
37
35
|
PermissionCheckResult,
|
|
38
36
|
PermissionState,
|
|
39
37
|
} from "./types";
|
|
40
38
|
import { isPermissionState } from "./types";
|
|
41
|
-
import { wildcardMatch } from "./wildcard-matcher";
|
|
42
39
|
|
|
43
40
|
const SPECIAL_PERMISSION_KEYS = new Set(["external_directory", "path"]);
|
|
44
41
|
|
|
45
42
|
/** Universal fallback when permission["*"] is absent from all scopes. */
|
|
46
43
|
const DEFAULT_UNIVERSAL_FALLBACK: PermissionState = "ask";
|
|
47
44
|
|
|
48
|
-
/** Promotion predicate matching no token — the no-`path`-rules default (#509). */
|
|
49
|
-
const NO_PROMOTION: PathRuleTokenMatcher = () => false;
|
|
50
|
-
|
|
51
45
|
/** Default yolo reader — yolo disabled unless the composition root injects one. */
|
|
52
46
|
const YOLO_DISABLED = (): boolean => false;
|
|
53
47
|
|
|
@@ -90,15 +84,6 @@ export interface ScopedPermissionManager {
|
|
|
90
84
|
): PermissionCheckResult;
|
|
91
85
|
getToolPermission(toolName: string, agentName?: string): PermissionState;
|
|
92
86
|
getConfigIssues(agentName?: string): string[];
|
|
93
|
-
/**
|
|
94
|
-
* Build a predicate deciding whether a bare bash token should be promoted
|
|
95
|
-
* into the `path` rule-candidate surface (#509).
|
|
96
|
-
*
|
|
97
|
-
* Matches against specific (non-`*`) `path`-surface config rules whose
|
|
98
|
-
* action is `deny` or `ask` — an allow rule never gates, and `"*"` would
|
|
99
|
-
* promote every bare bash argument.
|
|
100
|
-
*/
|
|
101
|
-
getPromotablePathTokenMatcher(agentName?: string): PathRuleTokenMatcher;
|
|
102
87
|
}
|
|
103
88
|
|
|
104
89
|
export interface PermissionManagerOptions extends PolicyLoaderOptions {
|
|
@@ -271,37 +256,6 @@ export class PermissionManager implements ScopedPermissionManager {
|
|
|
271
256
|
return composedRules.filter((r) => r.layer === "config");
|
|
272
257
|
}
|
|
273
258
|
|
|
274
|
-
/**
|
|
275
|
-
* Build a predicate deciding whether a bare bash token should be promoted
|
|
276
|
-
* into the `path` rule-candidate surface (#509).
|
|
277
|
-
*
|
|
278
|
-
* Filters the composed config ruleset to specific (non-`*`) `path`-surface
|
|
279
|
-
* deny/ask patterns, then returns a closure matching a token against them
|
|
280
|
-
* with the platform-correct fold (Windows case-and-separator matching, same
|
|
281
|
-
* as {@link pathMatchOptions} applies for evaluation) so promotion agrees
|
|
282
|
-
* with the later `path`-surface decision.
|
|
283
|
-
*
|
|
284
|
-
* Returns a matcher rejecting every token when no such rule exists — the
|
|
285
|
-
* default-config case is unaffected by promotion.
|
|
286
|
-
*/
|
|
287
|
-
getPromotablePathTokenMatcher(agentName?: string): PathRuleTokenMatcher {
|
|
288
|
-
const { composedRules } = this.resolvePermissions(agentName);
|
|
289
|
-
const patterns = composedRules
|
|
290
|
-
.filter(
|
|
291
|
-
(r) =>
|
|
292
|
-
r.layer === "config" &&
|
|
293
|
-
r.surface === "path" &&
|
|
294
|
-
r.pattern !== "*" &&
|
|
295
|
-
r.action !== "allow",
|
|
296
|
-
)
|
|
297
|
-
.map((r) => r.pattern);
|
|
298
|
-
if (patterns.length === 0) return NO_PROMOTION;
|
|
299
|
-
|
|
300
|
-
const matchOptions = pathMatchOptions("path", this.flavor);
|
|
301
|
-
return (token) =>
|
|
302
|
-
patterns.some((pattern) => wildcardMatch(pattern, token, matchOptions));
|
|
303
|
-
}
|
|
304
|
-
|
|
305
259
|
/**
|
|
306
260
|
* Get the tool-level permission state for a tool, without considering
|
|
307
261
|
* command-level rules. Used for tool injection decisions.
|
|
@@ -20,7 +20,6 @@ import {
|
|
|
20
20
|
resolveToolPreviewLimits,
|
|
21
21
|
type ToolPreviewFormatterOptions,
|
|
22
22
|
} from "./tool-preview-formatter";
|
|
23
|
-
import type { PathRuleTokenMatcher } from "./types";
|
|
24
23
|
|
|
25
24
|
/**
|
|
26
25
|
* Encapsulates all mutable session state and exposes operations instead of
|
|
@@ -243,15 +242,4 @@ export class PermissionSession implements ToolCallGateInputs {
|
|
|
243
242
|
getPathNormalizer(): PathNormalizer {
|
|
244
243
|
return this.pathNormalizer;
|
|
245
244
|
}
|
|
246
|
-
|
|
247
|
-
/**
|
|
248
|
-
* Predicate deciding whether a bare bash token should be promoted into the
|
|
249
|
-
* `path` rule-candidate surface (#509), scoped to the given agent.
|
|
250
|
-
*
|
|
251
|
-
* Straight delegate to `permissionManager.getPromotablePathTokenMatcher` —
|
|
252
|
-
* the manager owns the composed ruleset and the platform-correct match.
|
|
253
|
-
*/
|
|
254
|
-
getPromotablePathTokenMatcher(agentName?: string): PathRuleTokenMatcher {
|
|
255
|
-
return this.permissionManager.getPromotablePathTokenMatcher(agentName);
|
|
256
|
-
}
|
|
257
245
|
}
|
package/src/types.ts
CHANGED
|
@@ -17,17 +17,6 @@ export type {
|
|
|
17
17
|
RuleOrigin,
|
|
18
18
|
};
|
|
19
19
|
|
|
20
|
-
/**
|
|
21
|
-
* Predicate deciding whether a bare bash token should be promoted into the
|
|
22
|
-
* `path` rule-candidate surface.
|
|
23
|
-
*
|
|
24
|
-
* Built by `PermissionManager.getPromotablePathTokenMatcher` from the
|
|
25
|
-
* composed config ruleset (specific, non-`*` `path` deny/ask patterns) and
|
|
26
|
-
* threaded through to `BashPathResolver` so promotion policy stays in the
|
|
27
|
-
* manager while the bash layer only asks the predicate.
|
|
28
|
-
*/
|
|
29
|
-
export type PathRuleTokenMatcher = (token: string) => boolean;
|
|
30
|
-
|
|
31
20
|
/**
|
|
32
21
|
* Per-scope permission config shape after loading and validation.
|
|
33
22
|
* Holds only the flat permission map — all policy is expressed there.
|
package/src/wildcard-matcher.ts
CHANGED
|
@@ -1,10 +1,20 @@
|
|
|
1
1
|
import { expandHomePath } from "./expand-home";
|
|
2
2
|
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
}
|
|
3
|
+
/**
|
|
4
|
+
* A pattern compiled once for repeated matching.
|
|
5
|
+
*
|
|
6
|
+
* Matching is a method rather than an exposed `RegExp` so that both halves of
|
|
7
|
+
* the {@link WildcardMatchOptions} fold stay together: the compiled regex
|
|
8
|
+
* carries the pattern-side folding, and {@link matches} applies the same
|
|
9
|
+
* folding to the value. A caller holding the raw regex could apply one without
|
|
10
|
+
* the other, which is exactly the asymmetry that made forward-slash path rules
|
|
11
|
+
* inert on Windows (#653).
|
|
12
|
+
*/
|
|
13
|
+
export interface CompiledWildcardPattern<TState> {
|
|
14
|
+
readonly pattern: string;
|
|
15
|
+
readonly state: TState;
|
|
16
|
+
matches(value: string): boolean;
|
|
17
|
+
}
|
|
8
18
|
|
|
9
19
|
export type WildcardPatternMatch<TState> = {
|
|
10
20
|
state: TState;
|
|
@@ -17,8 +27,11 @@ export type WildcardPatternMatch<TState> = {
|
|
|
17
27
|
*
|
|
18
28
|
* - `caseInsensitive` compiles the pattern with the `i` flag so a mixed-case
|
|
19
29
|
* pattern matches a lowercased (canonicalized) path value.
|
|
20
|
-
* - `windowsSeparators` rewrites `/` to `\` in the expanded pattern
|
|
21
|
-
*
|
|
30
|
+
* - `windowsSeparators` rewrites `/` to `\` in both the expanded pattern and
|
|
31
|
+
* the matched value, so two spellings of the same path match regardless of
|
|
32
|
+
* which separator either side was written with. Folding only the pattern
|
|
33
|
+
* leaves every forward-slash value (a Git Bash device, an as-typed literal)
|
|
34
|
+
* unmatchable (#653).
|
|
22
35
|
*/
|
|
23
36
|
export interface WildcardMatchOptions {
|
|
24
37
|
caseInsensitive?: boolean;
|
|
@@ -34,10 +47,7 @@ export function compileWildcardPattern<TState>(
|
|
|
34
47
|
state: TState,
|
|
35
48
|
options?: WildcardMatchOptions,
|
|
36
49
|
): CompiledWildcardPattern<TState> {
|
|
37
|
-
|
|
38
|
-
if (options?.windowsSeparators) {
|
|
39
|
-
expanded = expanded.replaceAll("/", "\\");
|
|
40
|
-
}
|
|
50
|
+
const expanded = foldSeparators(expandHomePath(pattern), options);
|
|
41
51
|
let escaped = expanded
|
|
42
52
|
.split("*")
|
|
43
53
|
.map((part) => escapeRegExp(part).replaceAll("\\?", "."))
|
|
@@ -50,10 +60,15 @@ export function compileWildcardPattern<TState>(
|
|
|
50
60
|
escaped = `${escaped.slice(0, -3)}( .*)?`;
|
|
51
61
|
}
|
|
52
62
|
|
|
63
|
+
const regex = new RegExp(
|
|
64
|
+
`^${escaped}$`,
|
|
65
|
+
options?.caseInsensitive ? "si" : "s",
|
|
66
|
+
);
|
|
67
|
+
|
|
53
68
|
return {
|
|
54
69
|
pattern,
|
|
55
70
|
state,
|
|
56
|
-
|
|
71
|
+
matches: (value) => regex.test(foldSeparators(value, options)),
|
|
57
72
|
};
|
|
58
73
|
}
|
|
59
74
|
|
|
@@ -75,7 +90,7 @@ export function findCompiledWildcardMatch<TState>(
|
|
|
75
90
|
patterns: readonly CompiledWildcardPattern<TState>[],
|
|
76
91
|
name: string,
|
|
77
92
|
): WildcardPatternMatch<TState> | null {
|
|
78
|
-
const match = patterns.findLast((p) => p.
|
|
93
|
+
const match = patterns.findLast((p) => p.matches(name));
|
|
79
94
|
if (match === undefined) return null;
|
|
80
95
|
return {
|
|
81
96
|
state: match.state,
|
|
@@ -95,7 +110,17 @@ export function wildcardMatch(
|
|
|
95
110
|
value: string,
|
|
96
111
|
options?: WildcardMatchOptions,
|
|
97
112
|
): boolean {
|
|
98
|
-
return compileWildcardPattern(pattern, null, options).
|
|
113
|
+
return compileWildcardPattern(pattern, null, options).matches(value);
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
/**
|
|
117
|
+
* Apply the `windowsSeparators` half of the fold to one operand.
|
|
118
|
+
*
|
|
119
|
+
* Called for the pattern at compile time and for the value at match time —
|
|
120
|
+
* the fold is an equivalence relation, so both sides must pass through it.
|
|
121
|
+
*/
|
|
122
|
+
function foldSeparators(value: string, options?: WildcardMatchOptions): string {
|
|
123
|
+
return options?.windowsSeparators ? value.replaceAll("/", "\\") : value;
|
|
99
124
|
}
|
|
100
125
|
|
|
101
126
|
export function findCompiledWildcardMatchForNames<TState>(
|