@gotgenes/pi-permission-system 27.0.0 → 27.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (38) hide show
  1. package/CHANGELOG.md +36 -0
  2. package/README.md +26 -6
  3. package/config/config.example.json +3 -1
  4. package/dist/public.d.ts +21 -1
  5. package/docs/configuration.md +184 -5
  6. package/docs/troubleshooting.md +4 -1
  7. package/package.json +3 -1
  8. package/schemas/permissions.schema.json +52 -4
  9. package/src/access-intent/bash/bash-path-resolver.ts +104 -34
  10. package/src/access-intent/bash/command-effects.ts +305 -0
  11. package/src/access-intent/bash/command-enumeration.ts +116 -40
  12. package/src/access-intent/bash/program.ts +15 -11
  13. package/src/access-intent/bash/redirect-analysis.ts +98 -0
  14. package/src/access-intent/bash/token-collection.ts +102 -22
  15. package/src/access-intent/bash/wrapper-analysis.ts +101 -11
  16. package/src/access-intent/effect.ts +56 -0
  17. package/src/access-intent/input-normalizer.ts +2 -2
  18. package/src/access-intent/path-surfaces.ts +110 -4
  19. package/src/authority/delegation-envelope.ts +18 -7
  20. package/src/config-schema.ts +95 -6
  21. package/src/handlers/gates/bash-command.ts +53 -17
  22. package/src/handlers/gates/bash-external-directory.ts +41 -8
  23. package/src/handlers/gates/bash-path-extractor.ts +3 -3
  24. package/src/handlers/gates/bash-path.ts +31 -13
  25. package/src/handlers/gates/external-directory-policy.ts +37 -13
  26. package/src/handlers/gates/external-directory.ts +11 -4
  27. package/src/handlers/gates/path.ts +12 -5
  28. package/src/handlers/gates/tool.ts +22 -0
  29. package/src/normalize.ts +70 -1
  30. package/src/permission-manager.ts +3 -2
  31. package/src/permission-resolver.ts +23 -4
  32. package/src/presentation/path-ask-payload.ts +17 -6
  33. package/src/restrictiveness.ts +48 -0
  34. package/src/rule.ts +13 -13
  35. package/src/scope-merge.ts +8 -2
  36. package/src/session-rules.ts +19 -8
  37. package/src/types.ts +22 -1
  38. package/src/handlers/gates/candidate-check.ts +0 -32
@@ -14,7 +14,13 @@ import {
14
14
  collectPathCandidateTokens,
15
15
  collectRedirectTokens,
16
16
  extractCommandName,
17
+ type PathToken,
17
18
  } from "#src/access-intent/bash/token-collection";
19
+ import {
20
+ mergeTokenEffects,
21
+ type TokenEffect,
22
+ UNPROVEN_EFFECT,
23
+ } from "#src/access-intent/effect";
18
24
  import { normalizePathPolicyLiteral } from "#src/access-intent/path-normalization";
19
25
  import type { PathNormalizer } from "#src/path-normalizer";
20
26
  import { isSafeSystemPath } from "#src/safe-system-paths";
@@ -37,11 +43,19 @@ type EffectiveBase =
37
43
 
38
44
  /**
39
45
  * A path-candidate token paired with the effective working directory projected
40
- * onto the point in the command stream where it appears.
46
+ * onto the point in the command stream where it appears, and the effect its
47
+ * position proved.
41
48
  */
42
49
  interface PathCandidate {
43
50
  readonly token: string;
44
51
  readonly base: EffectiveBase;
52
+ readonly effect: TokenEffect;
53
+ }
54
+
55
+ /** A promoted bare token and its resolved path, before an effect is attached. */
56
+ interface ProbedToken {
57
+ readonly token: string;
58
+ readonly path: AccessPath;
45
59
  }
46
60
 
47
61
  // ── Public output types ──────────────────────────────────────────────────────
@@ -51,6 +65,14 @@ export interface BashPathRuleCandidate {
51
65
  readonly token: string;
52
66
  /** The path's lexical and canonical forms for permission policy matching. */
53
67
  readonly path: AccessPath;
68
+ /** The attributed effect that routes the token to a directional surface. */
69
+ readonly effect: TokenEffect;
70
+ }
71
+
72
+ /** A path resolving outside the working directory, with its attributed effect. */
73
+ export interface BashExternalPath {
74
+ readonly path: AccessPath;
75
+ readonly effect: TokenEffect;
54
76
  }
55
77
 
56
78
  /**
@@ -58,8 +80,8 @@ export interface BashPathRuleCandidate {
58
80
  * directory and platform — the two typed slices {@link BashProgram} exposes.
59
81
  */
60
82
  export interface ResolvedBashPaths {
61
- /** Deduplicated paths resolving outside the working directory (#418). */
62
- readonly externalPaths: readonly AccessPath[];
83
+ /** Deduplicated accesses resolving outside the working directory (#418). */
84
+ readonly externalAccesses: readonly BashExternalPath[];
63
85
  /** Every path-rule token paired with its cd-aware policy values (#393). */
64
86
  readonly ruleCandidates: readonly BashPathRuleCandidate[];
65
87
  }
@@ -116,7 +138,7 @@ export class BashPathResolver {
116
138
  : this.deriveBaseFromCdTarget(CWD_BASE, this.workdir);
117
139
  const candidates = this.collectPathCandidates(rootNode, initialBase);
118
140
  return {
119
- externalPaths: this.withWorkdirExternal(
141
+ externalAccesses: this.withWorkdirExternal(
120
142
  this.projectExternalPaths(candidates),
121
143
  ),
122
144
  ruleCandidates: this.projectRuleCandidates(candidates),
@@ -128,10 +150,15 @@ export class BashPathResolver {
128
150
  * resolves outside the cwd. A real `cd /etc` flags `/etc` via its argument
129
151
  * token; the seeded base carries no such token, so it is added explicitly and
130
152
  * deduplicated against the command's own external tokens (#574).
153
+ *
154
+ * It carries {@link UNPROVEN_EFFECT}: no command word was observed for it and
155
+ * no redirect proved it. When the command's own tokens already named it, the
156
+ * token's attribution stands — an implicit base is not evidence against a
157
+ * proof.
131
158
  */
132
159
  private withWorkdirExternal(
133
- tokenExternals: readonly AccessPath[],
134
- ): AccessPath[] {
160
+ tokenExternals: readonly BashExternalPath[],
161
+ ): BashExternalPath[] {
135
162
  if (this.workdir === undefined) return [...tokenExternals];
136
163
  const wdPath = this.normalizer.forBashToken(this.workdir);
137
164
  const canonical = wdPath.boundaryValue();
@@ -141,9 +168,11 @@ export class BashPathResolver {
141
168
  if (!isExternal) return [...tokenExternals];
142
169
  const key = canonical || wdPath.value();
143
170
  const alreadyPresent = tokenExternals.some(
144
- (p) => (p.boundaryValue() || p.value()) === key,
171
+ ({ path }) => (path.boundaryValue() || path.value()) === key,
145
172
  );
146
- return alreadyPresent ? [...tokenExternals] : [wdPath, ...tokenExternals];
173
+ return alreadyPresent
174
+ ? [...tokenExternals]
175
+ : [{ path: wdPath, effect: UNPROVEN_EFFECT }, ...tokenExternals];
147
176
  }
148
177
 
149
178
  // ── AST walk — collect PathCandidates ──────────────────────────────────
@@ -414,18 +443,20 @@ export class BashPathResolver {
414
443
  */
415
444
  private projectExternalPaths(
416
445
  candidates: readonly PathCandidate[],
417
- ): AccessPath[] {
418
- const seen = new Set<string>();
419
- const externalPaths: AccessPath[] = [];
446
+ ): BashExternalPath[] {
447
+ const seen = new Map<string, number>();
448
+ const externalPaths: BashExternalPath[] = [];
420
449
 
421
- for (const { token, base } of candidates) {
450
+ for (const { token, base, effect } of candidates) {
422
451
  const candidate = classifyTokenAsPathCandidate(token);
423
452
  if (!candidate) {
424
453
  // A bare token the strict shape gate rejects can still escape the tree
425
454
  // through a symlink, so probe it and apply the ordinary boundary
426
455
  // decision to whatever it resolves to (#645).
427
456
  const probed = this.probeBareToken(token, base);
428
- if (probed) this.collectIfExternal(probed.path, seen, externalPaths);
457
+ if (probed) {
458
+ this.collectIfExternal(probed.path, effect, seen, externalPaths);
459
+ }
429
460
  continue;
430
461
  }
431
462
 
@@ -436,9 +467,8 @@ export class BashPathResolver {
436
467
  if (base.kind === "unknown" && this.isRelativeCandidate(candidate)) {
437
468
  const accessPath = this.normalizer.forPath(candidate);
438
469
  const canonical = accessPath.boundaryValue();
439
- if (canonical && !isSafeSystemPath(canonical) && !seen.has(canonical)) {
440
- seen.add(canonical);
441
- externalPaths.push(accessPath);
470
+ if (canonical && !isSafeSystemPath(canonical)) {
471
+ recordExternal(accessPath, effect, canonical, seen, externalPaths);
442
472
  }
443
473
  continue;
444
474
  }
@@ -449,6 +479,7 @@ export class BashPathResolver {
449
479
  : undefined;
450
480
  this.collectIfExternal(
451
481
  this.normalizer.forBashToken(candidate, { resolveBase }),
482
+ effect,
452
483
  seen,
453
484
  externalPaths,
454
485
  );
@@ -475,8 +506,9 @@ export class BashPathResolver {
475
506
  */
476
507
  private collectIfExternal(
477
508
  accessPath: AccessPath,
478
- seen: Set<string>,
479
- out: AccessPath[],
509
+ effect: TokenEffect,
510
+ seen: Map<string, number>,
511
+ out: BashExternalPath[],
480
512
  ): void {
481
513
  const lexical = accessPath.value();
482
514
  if (!lexical) return;
@@ -484,11 +516,8 @@ export class BashPathResolver {
484
516
  const isExternal = canonical
485
517
  ? this.normalizer.isBoundaryOutsideWorkingDirectory(canonical)
486
518
  : true;
487
- const dedupKey = canonical || lexical;
488
- if (isExternal && !seen.has(dedupKey)) {
489
- seen.add(dedupKey);
490
- out.push(accessPath);
491
- }
519
+ if (!isExternal) return;
520
+ recordExternal(accessPath, effect, canonical || lexical, seen, out);
492
521
  }
493
522
 
494
523
  /**
@@ -511,27 +540,38 @@ export class BashPathResolver {
511
540
  private projectRuleCandidates(
512
541
  candidates: readonly PathCandidate[],
513
542
  ): BashPathRuleCandidate[] {
514
- const seen = new Set<string>();
543
+ const seen = new Map<string, number>();
515
544
  const result: BashPathRuleCandidate[] = [];
516
545
 
517
- for (const { token, base } of candidates) {
546
+ for (const { token, base, effect } of candidates) {
518
547
  const shaped = classifyTokenAsRuleCandidate(
519
548
  token,
520
549
  this.normalizer.flavor,
521
550
  );
522
- const candidate =
551
+ const probed =
523
552
  shaped === null
524
553
  ? this.probeBareToken(token, base)
525
554
  : { token: shaped, path: this.buildRuleCandidatePath(shaped, base) };
526
- if (!candidate) continue;
555
+ if (!probed) continue;
527
556
 
528
- const matchValues = candidate.path.matchValues();
557
+ const matchValues = probed.path.matchValues();
529
558
  if (matchValues.length === 0) continue;
530
559
 
531
560
  const key = matchValues.join("\0");
532
- if (seen.has(key)) continue;
533
- seen.add(key);
534
- result.push(candidate);
561
+ const index = seen.get(key);
562
+ if (index !== undefined) {
563
+ // The same resolved path, reached again with its own attribution: fold
564
+ // rather than split, so `cat ~/a > ~/a` stays one entry in the prompt's
565
+ // evidence and two disagreeing proofs land on the bare family.
566
+ const existing = result[index];
567
+ result[index] = {
568
+ ...existing,
569
+ effect: mergeTokenEffects(existing.effect, effect),
570
+ };
571
+ continue;
572
+ }
573
+ seen.set(key, result.length);
574
+ result.push({ ...probed, effect });
535
575
  }
536
576
 
537
577
  return result;
@@ -558,7 +598,7 @@ export class BashPathResolver {
558
598
  private probeBareToken(
559
599
  token: string,
560
600
  base: EffectiveBase,
561
- ): BashPathRuleCandidate | null {
601
+ ): ProbedToken | null {
562
602
  const bare = classifyBareTokenCandidate(token);
563
603
  if (bare === null) return null;
564
604
  if (base.kind !== "known") return null;
@@ -618,11 +658,41 @@ function isBackgrounded(seqNode: TSNode, index: number): boolean {
618
658
  }
619
659
 
620
660
  function tagTokens(
621
- tokens: readonly string[],
661
+ tokens: readonly PathToken[],
622
662
  base: EffectiveBase,
623
663
  out: PathCandidate[],
624
664
  ): void {
625
- for (const token of tokens) out.push({ token, base });
665
+ for (const { token, effect } of tokens) out.push({ token, base, effect });
666
+ }
667
+
668
+ /**
669
+ * Record an external access under `dedupKey`, merging into the entry already
670
+ * there rather than adding a second one.
671
+ *
672
+ * Keeping the effect out of the dedup key is deliberate: `cat ~/a > ~/a` is one
673
+ * path, and splitting it would show `~/a` twice in the ask prompt. Two
674
+ * disagreeing proofs fold to unproven, which routes to the bare family —
675
+ * precisely "consult both" — so the fold loses nothing the gates would have
676
+ * used.
677
+ */
678
+ function recordExternal(
679
+ accessPath: AccessPath,
680
+ effect: TokenEffect,
681
+ dedupKey: string,
682
+ seen: Map<string, number>,
683
+ out: BashExternalPath[],
684
+ ): void {
685
+ const index = seen.get(dedupKey);
686
+ if (index === undefined) {
687
+ seen.set(dedupKey, out.length);
688
+ out.push({ path: accessPath, effect });
689
+ return;
690
+ }
691
+ const existing = out[index];
692
+ out[index] = {
693
+ path: existing.path,
694
+ effect: mergeTokenEffects(existing.effect, effect),
695
+ };
626
696
  }
627
697
 
628
698
  /**
@@ -0,0 +1,305 @@
1
+ import { type TokenEffect, UNPROVEN_EFFECT } from "#src/access-intent/effect";
2
+
3
+ // ── Public surface ─────────────────────────────────────────────────────────
4
+
5
+ /**
6
+ * The effect a command's head word proves for the path tokens that command
7
+ * owns, from the built-in pure-reader core (ADR 0013 §7).
8
+ *
9
+ * A word is core only as a **bare basename**: `./grep` and `/tmp/evil/grep`
10
+ * prove nothing, because a path-qualified head word names a program the core's
11
+ * audit never saw. Rejecting on the separator characters directly — rather
12
+ * than asking a `PathFlavor` — keeps the rule fail-closed on both platforms
13
+ * without reading the host's path language.
14
+ *
15
+ * A guarded word's claim is withdrawn when an argument names one of its
16
+ * write-capable options, yielding `retracted` rather than a write: the command
17
+ * may still only read, so the fail-closed base case is the honest answer and
18
+ * `retracted` is the blame line that says why.
19
+ *
20
+ * Pure and word-based by design — the AST walk that produces the words lives
21
+ * in `token-collection.ts`, the same split `wrapper-analysis.ts` documents.
22
+ */
23
+ export function proveCommandEffect(
24
+ headWord: string,
25
+ argWords: readonly string[],
26
+ ): TokenEffect {
27
+ if (!isBareCoreWord(headWord)) return UNPROVEN_EFFECT;
28
+ const guard = RETRACTION_GUARDS.get(headWord);
29
+ if (guard && argWords.some((word) => retractsClaim(word, guard))) {
30
+ return RETRACTED_EFFECT;
31
+ }
32
+ return CORE_READ_EFFECT;
33
+ }
34
+
35
+ /**
36
+ * The frozen v1 pure-reader core: 21 command words that are read-only for any
37
+ * arguments, in any implementation.
38
+ *
39
+ * Exported so `docs/configuration.md`'s published roster is held to it by a
40
+ * parity test — a listed roster drifts from the code otherwise.
41
+ */
42
+ export const PURE_READER_CORE: ReadonlySet<string> = new Set(
43
+ coreAdmissions().flatMap(({ words }) => words),
44
+ );
45
+
46
+ /**
47
+ * The effect a redirect operator proves for its destination token, or `null`
48
+ * when the redirect names no file at all and no token should be collected.
49
+ *
50
+ * The operator is the whole proof: `> out.txt` writes `out.txt` whatever the
51
+ * command in front of it does, and `< in.txt` reads it. A syntax proof is
52
+ * therefore absolute — it is applied to the destination after the owning
53
+ * command's attribution and is never retracted by it.
54
+ *
55
+ * `>&` and `<&` are the two operators that may name either a file descriptor
56
+ * (`2>&1`, a duplication that touches no file) or a real file (`cmd >& out`).
57
+ * `destinationIsDescriptor` is the parse-tree fact that tells them apart; the
58
+ * `null` it produces is what keeps `2>&1`'s `1` out of the path surface.
59
+ *
60
+ * An operator outside the table proves nothing rather than dropping the token:
61
+ * dropping it would remove a path from the gates entirely, which is the one
62
+ * fail-open direction available here.
63
+ */
64
+ export function redirectDestinationEffect(
65
+ operator: string,
66
+ destinationIsDescriptor: boolean,
67
+ ): TokenEffect | null {
68
+ if (DESCRIPTOR_CAPABLE_OPERATORS.has(operator)) {
69
+ if (destinationIsDescriptor) return null;
70
+ return operator === ">&" ? SYNTAX_WRITE_EFFECT : SYNTAX_READ_EFFECT;
71
+ }
72
+ if (OUTPUT_REDIRECT_OPERATORS.has(operator)) return SYNTAX_WRITE_EFFECT;
73
+ if (INPUT_REDIRECT_OPERATORS.has(operator)) return SYNTAX_READ_EFFECT;
74
+ return UNPROVEN_EFFECT;
75
+ }
76
+
77
+ // ── The redirect operator table ────────────────────────────────────────────
78
+
79
+ /** Operators whose destination the shell truncates, appends to, or creates. */
80
+ const OUTPUT_REDIRECT_OPERATORS: ReadonlySet<string> = new Set([
81
+ ">",
82
+ ">>",
83
+ ">|",
84
+ "&>",
85
+ "&>>",
86
+ ]);
87
+
88
+ /**
89
+ * Operators whose destination the shell reads.
90
+ *
91
+ * `<<<` is a herestring, whose `herestring_redirect` node carries the same
92
+ * shape; its destination is a literal rather than a file in practice, and
93
+ * reading it proves no more than a read either way.
94
+ */
95
+ const INPUT_REDIRECT_OPERATORS: ReadonlySet<string> = new Set(["<", "<<<"]);
96
+
97
+ /** The two operators that may duplicate a descriptor instead of naming a file. */
98
+ const DESCRIPTOR_CAPABLE_OPERATORS: ReadonlySet<string> = new Set([">&", "<&"]);
99
+
100
+ // ── The roster ─────────────────────────────────────────────────────────────
101
+
102
+ /** A group of core words admitted for one shared structural reason. */
103
+ interface CoreAdmission {
104
+ readonly words: readonly string[];
105
+ /** Why the group clears the bar — the audit, kept beside what it admits. */
106
+ readonly reason: string;
107
+ }
108
+
109
+ /**
110
+ * The roster, grouped by admission reason.
111
+ *
112
+ * The bar is **structural**, never popularity: implementation-independent
113
+ * read-only-ness across GNU and BSD alike, no option that redirects output to
114
+ * a file, and effects stable under argument content. A word that fails any of
115
+ * the three is excluded even when it is overwhelmingly used to read.
116
+ *
117
+ * Deliberately excluded, so the audit is auditable:
118
+ *
119
+ * | Word | Why not |
120
+ * | ----------------------------------------------- | -------------------------------------------------------------- |
121
+ * | `awk`, `gawk`, `nawk` | The program text can `print > "file"` — not stable under args |
122
+ * | `sed` | `-i` is in-place, and BSD needs a separate argument where GNU attaches one, so the guard is dialect-variant |
123
+ * | `uniq` | `uniq IN OUT` writes its second positional |
124
+ * | `tee`, `dd`, `split`, `csplit`, `xxd`, `tree`, `curl`, `wget` | Each has a positional or option that writes a file |
125
+ * | `less`, `more` | Interactive shell escape (`!cmd`) and `LESSOPEN` preprocessing |
126
+ * | `file` | `-C`/`--compile` writes a `magic.mgc` file — it reports on its arguments, but not only |
127
+ * | `git`, `pnpm`, `npm`, `node`, `python3`, `gh` | Subcommand- and argument-dependent — the `commandEffects` long tail |
128
+ *
129
+ * Widening the roster only ever loosens, so evidence can add a word as a
130
+ * non-breaking change; a wrong admission is a fail-open, which is why the bar
131
+ * is stated rather than assumed.
132
+ */
133
+ function coreAdmissions(): readonly CoreAdmission[] {
134
+ return [
135
+ {
136
+ words: ["cat", "head", "tail", "wc", "grep", "egrep", "fgrep", "rg"],
137
+ reason:
138
+ "Content readers: no output-file option in any surveyed dialect; output is stdout only",
139
+ },
140
+ {
141
+ words: ["diff"],
142
+ reason: "Writes nothing; `-D` emits merged output to stdout",
143
+ },
144
+ {
145
+ words: ["ls", "stat", "pwd"],
146
+ reason: "Metadata and listing: report only",
147
+ },
148
+ {
149
+ words: ["basename", "dirname", "realpath"],
150
+ reason:
151
+ "Path-string transforms: `realpath` reads the filesystem and writes nothing; the other two touch it at all only to resolve",
152
+ },
153
+ {
154
+ words: ["echo", "which", "cd"],
155
+ reason:
156
+ "No filesystem write: `echo` writes to stdout (a redirect destination is the syntax proof's job, not `echo`'s); `cd` reads a directory to enter it",
157
+ },
158
+ {
159
+ words: ["find", "fd", "sort"],
160
+ reason:
161
+ "Read-only until an argument says otherwise — see RETRACTION_GUARDS",
162
+ },
163
+ ];
164
+ }
165
+
166
+ // ── The retraction guards ──────────────────────────────────────────────────
167
+
168
+ /**
169
+ * The option forms that withdraw a guarded word's read claim.
170
+ *
171
+ * Matching is fail-closed over the forms ADR 0013 §7 names: a long stem
172
+ * matches bare, with an attached `=value`, or as any prefix of itself, and a
173
+ * short letter matches anywhere in a single-dash cluster, which covers the
174
+ * attached-value form (`-oFILE`) too. Over-retraction costs one ask;
175
+ * under-retraction misses a write.
176
+ *
177
+ * The prefix rule exists because GNU `getopt_long` accepts any unambiguous
178
+ * abbreviation, so `sort --out=/tmp/x` reaches the same code `--output` does.
179
+ * Matching every prefix also retracts on an abbreviation the real program
180
+ * would reject as ambiguous — the affordable direction.
181
+ */
182
+ interface RetractionGuard {
183
+ /** Whole argument words, for options that neither cluster nor take `=`. */
184
+ readonly exactWords?: ReadonlySet<string>;
185
+ /** Long stems, matched bare (`--output`) or attached (`--output=/tmp/x`). */
186
+ readonly longStems?: ReadonlySet<string>;
187
+ /** Short letters, matched anywhere in a single-dash cluster (`-uo`). */
188
+ readonly shortLetters?: ReadonlySet<string>;
189
+ }
190
+
191
+ /**
192
+ * The three guarded words and what withdraws each one's claim.
193
+ *
194
+ * All three were chosen because their write options spell identically in GNU
195
+ * and BSD — which is exactly why `sed` is excluded outright rather than
196
+ * guarded. `find`'s options are single-dash long words that never cluster, so
197
+ * they match as exact words; `sort`'s only short option containing `o` is `-o`
198
+ * itself, so the cluster rule cannot over-retract there.
199
+ */
200
+ const RETRACTION_GUARDS: ReadonlyMap<string, RetractionGuard> = new Map([
201
+ [
202
+ "find",
203
+ {
204
+ exactWords: new Set([
205
+ "-exec",
206
+ "-execdir",
207
+ "-ok",
208
+ "-okdir",
209
+ "-delete",
210
+ "-fprint",
211
+ "-fprint0",
212
+ "-fprintf",
213
+ "-fls",
214
+ ]),
215
+ },
216
+ ],
217
+ [
218
+ "fd",
219
+ {
220
+ longStems: new Set(["--exec", "--exec-batch"]),
221
+ shortLetters: new Set(["x", "X"]),
222
+ },
223
+ ],
224
+ [
225
+ "sort",
226
+ {
227
+ longStems: new Set(["--output"]),
228
+ shortLetters: new Set(["o"]),
229
+ },
230
+ ],
231
+ ]);
232
+
233
+ // ── Private helpers ────────────────────────────────────────────────────────
234
+
235
+ /** A core word's proven attribution. */
236
+ const CORE_READ_EFFECT: TokenEffect = { effect: "read", source: "core" };
237
+
238
+ /** A guarded word whose claim an argument withdrew (ADR 0013 §7's blame line). */
239
+ const RETRACTED_EFFECT: TokenEffect = {
240
+ effect: "unproven",
241
+ source: "retracted",
242
+ };
243
+
244
+ /** A redirect destination the operator proves the shell reads. */
245
+ const SYNTAX_READ_EFFECT: TokenEffect = { effect: "read", source: "syntax" };
246
+
247
+ /** A redirect destination the operator proves the shell writes. */
248
+ const SYNTAX_WRITE_EFFECT: TokenEffect = { effect: "write", source: "syntax" };
249
+
250
+ /** The path separators that disqualify a head word from the core, both flavors. */
251
+ const PATH_SEPARATORS = ["/", "\\"];
252
+
253
+ function isBareCoreWord(headWord: string): boolean {
254
+ if (PATH_SEPARATORS.some((separator) => headWord.includes(separator))) {
255
+ return false;
256
+ }
257
+ return PURE_READER_CORE.has(headWord);
258
+ }
259
+
260
+ function retractsClaim(word: string, guard: RetractionGuard): boolean {
261
+ if (guard.exactWords?.has(word)) return true;
262
+ if (matchesLongStem(word, guard.longStems)) return true;
263
+ return matchesShortCluster(word, guard.shortLetters);
264
+ }
265
+
266
+ /**
267
+ * A long option, bare, carrying its value inline, or abbreviated.
268
+ *
269
+ * The name is taken before the first `=`, so `--out=/tmp/x` is tested as
270
+ * `--out` — an abbreviation of `--output` that GNU `getopt_long` resolves to
271
+ * it. A bare `--` abbreviates nothing.
272
+ */
273
+ function matchesLongStem(
274
+ word: string,
275
+ stems: ReadonlySet<string> | undefined,
276
+ ): boolean {
277
+ if (!stems) return false;
278
+ const name = word.split("=")[0];
279
+ if (!name.startsWith("--") || name.length <= 2) return false;
280
+ for (const stem of stems) {
281
+ if (stem.startsWith(name)) return true;
282
+ }
283
+ return false;
284
+ }
285
+
286
+ /**
287
+ * A guarded letter anywhere in a single-dash cluster.
288
+ *
289
+ * The scan runs past the letters into an attached value, which is what makes
290
+ * `-oFILE` retract as surely as `-o FILE` does.
291
+ */
292
+ function matchesShortCluster(
293
+ word: string,
294
+ letters: ReadonlySet<string> | undefined,
295
+ ): boolean {
296
+ if (!letters) return false;
297
+ if (!word.startsWith("-") || word.startsWith("--") || word.length < 2) {
298
+ return false;
299
+ }
300
+ const cluster = word.slice(1);
301
+ for (const letter of letters) {
302
+ if (cluster.includes(letter)) return true;
303
+ }
304
+ return false;
305
+ }