@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 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
 
@@ -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 when it matches an active, specific (non-`*`) `path` deny/ask rule — 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
- A bare token that matches no specific `path` rule (e.g. `status` in `git status`) is left alone, and this promotion never fires against a `"*"` catch-all only a config that already declares a specific `path` rule is affected.
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
- 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.
618
- POSIX matching remains case-sensitive.
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 and never trigger the gate — the same exclusion that holds on POSIX, so `echo hi > /dev/null` does not prompt.
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@gotgenes/pi-permission-system",
3
- "version": "22.0.0",
3
+ "version": "23.0.1",
4
4
  "description": "Permission enforcement extension for the Pi coding agent.",
5
5
  "type": "module",
6
6
  "exports": {
@@ -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, ...matchAliases]` (or `[]` when empty) and `boundaryValue()` is
124
- * `""` — so no spurious absolute or symlink-resolved rule can match (#393).
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
- const aliases = [...new Set([literal, ...matchAliases.filter(Boolean)])];
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
- classifyPromotedRuleCandidate,
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
- * Also holds an `isPromotablePathToken` predicate (default: promotes nothing)
89
- * deciding whether a bare token that fails the broad rule-candidate shape gate
90
- * should still be promoted because it matches an active, specific `path` rule
91
- * (#509). The resolver never sees the rules themselves — the predicate is
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 and promotion predicate).
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) continue;
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
- const accessPath = this.normalizer.forBashToken(candidate, {
450
- resolveBase,
451
- });
452
- const lexical = accessPath.value();
453
- if (!lexical) continue;
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 the rule-driven promoted
485
- * classifier (`classifyPromotedRuleCandidate`, #509) for a bare token the
486
- * broad classifier rejects for shape promoted only when the injected
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
- classifyTokenAsRuleCandidate(token, this.normalizer.flavor) ??
506
- classifyPromotedRuleCandidate(token, this.isPromotablePathToken);
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 path = this.buildRuleCandidatePath(candidate, base);
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({ token: candidate, path });
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
- * `isPromotablePathToken`, when supplied, promotes a bare filename token
44
- * (e.g. `id_rsa`) into `pathRuleCandidates()` when it matches an active,
45
- * specific `path` deny/ask rule (#509). Defaults to promoting nothing, so
46
- * callers that only read `externalPaths()` (e.g. `bash-path-extractor.ts`)
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
- * - `classifyPromotedRuleCandidate` — rule-driven promotion of a bare filename
8
- * (e.g. `id_rsa`) that `classifyTokenAsRuleCandidate` rejects for shape, but
9
- * which matches an active, specific (non-`*`) `path` deny/ask rule (#509).
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
- * Rule-driven promotion classifier for bare filenames (#509).
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
- * A bare token (`id_rsa`) has none of the shapes `classifyTokenAsRuleCandidate`
97
- * accepts, so it is dropped before rule evaluation by default — most bash
98
- * argument tokens are not file paths (subcommands, branch names, search
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
- * Still runs the shared `rejectNonPathToken` prelude first, so a flag,
105
- * env-assignment, URL, `@scope` token, or regex-shaped token is never
106
- * promoted even if it happens to match a configured pattern.
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 classifyPromotedRuleCandidate(
111
- token: string,
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
- return config
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 { PathRuleTokenMatcher, PermissionCheckResult } from "#src/types";
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.command,
98
- normalizer,
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(
@@ -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, matchAliases?: readonly string[]): AccessPath {
64
- return AccessPath.forLiteral(literal, matchAliases);
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 a rule's separators
92
- // (`/` -> `\`), so a forward-slash value is unmatchable; carry a
93
- // backslash match alias so a natural `/tmp/*` external_directory rule
94
- // still resolves, while `value()` stays as typed for display.
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.
@@ -1,10 +1,20 @@
1
1
  import { expandHomePath } from "./expand-home";
2
2
 
3
- export type CompiledWildcardPattern<TState> = {
4
- pattern: string;
5
- state: TState;
6
- regex: RegExp;
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 so a
21
- * forward-slash pattern matches a backslash-separated path value.
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
- let expanded = expandHomePath(pattern);
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
- regex: new RegExp(`^${escaped}$`, options?.caseInsensitive ? "si" : "s"),
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.regex.test(name));
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).regex.test(value);
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>(