@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.
- package/CHANGELOG.md +36 -0
- package/README.md +26 -6
- package/config/config.example.json +3 -1
- package/dist/public.d.ts +21 -1
- package/docs/configuration.md +184 -5
- package/docs/troubleshooting.md +4 -1
- package/package.json +3 -1
- package/schemas/permissions.schema.json +52 -4
- package/src/access-intent/bash/bash-path-resolver.ts +104 -34
- package/src/access-intent/bash/command-effects.ts +305 -0
- package/src/access-intent/bash/command-enumeration.ts +116 -40
- package/src/access-intent/bash/program.ts +15 -11
- package/src/access-intent/bash/redirect-analysis.ts +98 -0
- package/src/access-intent/bash/token-collection.ts +102 -22
- package/src/access-intent/bash/wrapper-analysis.ts +101 -11
- package/src/access-intent/effect.ts +56 -0
- package/src/access-intent/input-normalizer.ts +2 -2
- package/src/access-intent/path-surfaces.ts +110 -4
- package/src/authority/delegation-envelope.ts +18 -7
- package/src/config-schema.ts +95 -6
- package/src/handlers/gates/bash-command.ts +53 -17
- package/src/handlers/gates/bash-external-directory.ts +41 -8
- package/src/handlers/gates/bash-path-extractor.ts +3 -3
- package/src/handlers/gates/bash-path.ts +31 -13
- package/src/handlers/gates/external-directory-policy.ts +37 -13
- package/src/handlers/gates/external-directory.ts +11 -4
- package/src/handlers/gates/path.ts +12 -5
- package/src/handlers/gates/tool.ts +22 -0
- package/src/normalize.ts +70 -1
- package/src/permission-manager.ts +3 -2
- package/src/permission-resolver.ts +23 -4
- package/src/presentation/path-ask-payload.ts +17 -6
- package/src/restrictiveness.ts +48 -0
- package/src/rule.ts +13 -13
- package/src/scope-merge.ts +8 -2
- package/src/session-rules.ts +19 -8
- package/src/types.ts +22 -1
- 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
|
|
62
|
-
readonly
|
|
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
|
-
|
|
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
|
|
134
|
-
):
|
|
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
|
-
(
|
|
171
|
+
({ path }) => (path.boundaryValue() || path.value()) === key,
|
|
145
172
|
);
|
|
146
|
-
return alreadyPresent
|
|
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
|
-
):
|
|
418
|
-
const seen = new
|
|
419
|
-
const externalPaths:
|
|
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)
|
|
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)
|
|
440
|
-
|
|
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
|
-
|
|
479
|
-
|
|
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
|
-
|
|
488
|
-
|
|
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
|
|
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
|
|
551
|
+
const probed =
|
|
523
552
|
shaped === null
|
|
524
553
|
? this.probeBareToken(token, base)
|
|
525
554
|
: { token: shaped, path: this.buildRuleCandidatePath(shaped, base) };
|
|
526
|
-
if (!
|
|
555
|
+
if (!probed) continue;
|
|
527
556
|
|
|
528
|
-
const matchValues =
|
|
557
|
+
const matchValues = probed.path.matchValues();
|
|
529
558
|
if (matchValues.length === 0) continue;
|
|
530
559
|
|
|
531
560
|
const key = matchValues.join("\0");
|
|
532
|
-
|
|
533
|
-
|
|
534
|
-
|
|
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
|
-
):
|
|
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
|
|
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
|
+
}
|