@inhuman.tools/policy 0.0.0-stage → 0.2.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/LICENSE.md +7 -0
- package/authorize.scm +4 -0
- package/dist/acknowledge.d.ts +70 -0
- package/dist/acknowledge.d.ts.map +1 -0
- package/dist/acknowledge.js +162 -0
- package/dist/acknowledge.js.map +1 -0
- package/dist/authorize.d.ts +8 -0
- package/dist/authorize.d.ts.map +1 -0
- package/dist/authorize.js +39 -0
- package/dist/authorize.js.map +1 -0
- package/dist/bind.d.ts +6 -0
- package/dist/bind.d.ts.map +1 -0
- package/dist/bind.js +45 -0
- package/dist/bind.js.map +1 -0
- package/dist/check.d.ts +30 -0
- package/dist/check.d.ts.map +1 -0
- package/dist/check.js +389 -0
- package/dist/check.js.map +1 -0
- package/dist/classes.d.ts +23 -0
- package/dist/classes.d.ts.map +1 -0
- package/dist/classes.js +139 -0
- package/dist/classes.js.map +1 -0
- package/dist/compile.d.ts +40 -0
- package/dist/compile.d.ts.map +1 -0
- package/dist/compile.js +311 -0
- package/dist/compile.js.map +1 -0
- package/dist/deadline.d.ts +5 -0
- package/dist/deadline.d.ts.map +1 -0
- package/dist/deadline.js +17 -0
- package/dist/deadline.js.map +1 -0
- package/dist/floor.d.ts +9 -0
- package/dist/floor.d.ts.map +1 -0
- package/dist/floor.js +9 -0
- package/dist/floor.js.map +1 -0
- package/dist/glob-expand.d.ts +10 -0
- package/dist/glob-expand.d.ts.map +1 -0
- package/dist/glob-expand.js +132 -0
- package/dist/glob-expand.js.map +1 -0
- package/dist/guards.d.ts +26 -0
- package/dist/guards.d.ts.map +1 -0
- package/dist/guards.js +419 -0
- package/dist/guards.js.map +1 -0
- package/dist/identity.d.ts +11 -0
- package/dist/identity.d.ts.map +1 -0
- package/dist/identity.js +23 -0
- package/dist/identity.js.map +1 -0
- package/dist/index.d.ts +8 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +8 -0
- package/dist/index.js.map +1 -0
- package/dist/lift.d.ts +4 -0
- package/dist/lift.d.ts.map +1 -0
- package/dist/lift.js +14 -0
- package/dist/lift.js.map +1 -0
- package/dist/path-policy.d.ts +53 -0
- package/dist/path-policy.d.ts.map +1 -0
- package/dist/path-policy.js +248 -0
- package/dist/path-policy.js.map +1 -0
- package/dist/processor.d.ts +71 -0
- package/dist/processor.d.ts.map +1 -0
- package/dist/processor.js +132 -0
- package/dist/processor.js.map +1 -0
- package/dist/reasonable-defaults.d.ts +1448 -0
- package/dist/reasonable-defaults.d.ts.map +1 -0
- package/dist/reasonable-defaults.js +6 -0
- package/dist/reasonable-defaults.js.map +1 -0
- package/dist/reasonable-defaults.json +1662 -0
- package/dist/run-authorization.d.ts +8 -0
- package/dist/run-authorization.d.ts.map +1 -0
- package/dist/run-authorization.js +16 -0
- package/dist/run-authorization.js.map +1 -0
- package/dist/tool-path.d.ts +8 -0
- package/dist/tool-path.d.ts.map +1 -0
- package/dist/tool-path.js +38 -0
- package/dist/tool-path.js.map +1 -0
- package/dist/types.d.ts +87 -0
- package/dist/types.d.ts.map +1 -0
- package/dist/types.js +14 -0
- package/dist/types.js.map +1 -0
- package/dist/verdict.d.ts +15 -0
- package/dist/verdict.d.ts.map +1 -0
- package/dist/verdict.js +55 -0
- package/dist/verdict.js.map +1 -0
- package/package.json +62 -3
- package/src/acknowledge.ts +206 -0
- package/src/authorize.ts +38 -0
- package/src/bind.ts +58 -0
- package/src/check.ts +552 -0
- package/src/classes.ts +156 -0
- package/src/compile.ts +370 -0
- package/src/deadline.ts +15 -0
- package/src/floor.ts +16 -0
- package/src/glob-expand.ts +137 -0
- package/src/guards.ts +428 -0
- package/src/identity.ts +26 -0
- package/src/index.ts +24 -0
- package/src/lift.ts +15 -0
- package/src/path-policy.ts +280 -0
- package/src/processor.ts +187 -0
- package/src/reasonable-defaults.json +1662 -0
- package/src/reasonable-defaults.ts +8 -0
- package/src/run-authorization.ts +23 -0
- package/src/tool-path.ts +43 -0
- package/src/types.ts +102 -0
- package/src/verdict.ts +63 -0
- package/README.md +0 -3
package/src/bind.ts
ADDED
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
import { explainBash } from "./check.js";
|
|
2
|
+
import { compilePolicy, PolicyAssumeError } from "./compile.js";
|
|
3
|
+
import type { Which } from "./identity.js";
|
|
4
|
+
import { explainPath, writeRemainderKind } from "./path-policy.js";
|
|
5
|
+
import { reasonableDefaults } from "./reasonable-defaults.js";
|
|
6
|
+
import { classesOf, type FloorVerdict, type Policy } from "./types.js";
|
|
7
|
+
import { cast } from "./verdict.js";
|
|
8
|
+
|
|
9
|
+
const unresolved: Which = () => undefined;
|
|
10
|
+
|
|
11
|
+
export function bindPolicy(document: unknown, which: Which = unresolved): Policy {
|
|
12
|
+
const compiled = compilePolicy(document);
|
|
13
|
+
const clusters = { read: compiled.read, write: compiled.write };
|
|
14
|
+
const bash = (command: string, cwd?: string): FloorVerdict =>
|
|
15
|
+
compiled.bash === undefined
|
|
16
|
+
? cast("ask", "unmodelled", "bash:no-policy")
|
|
17
|
+
: explainBash(compiled.bash, command, clusters, which, cwd);
|
|
18
|
+
for (const smoke of compiled.assume) {
|
|
19
|
+
const verdict = bash(smoke.command);
|
|
20
|
+
const actual = verdict.disposition;
|
|
21
|
+
PolicyAssumeError.invariant(
|
|
22
|
+
actual === smoke.expect,
|
|
23
|
+
`assume: expected ${smoke.expect}, got ${actual}: ${smoke.command}`,
|
|
24
|
+
{
|
|
25
|
+
expected: smoke.expect,
|
|
26
|
+
actual,
|
|
27
|
+
command: smoke.command,
|
|
28
|
+
},
|
|
29
|
+
);
|
|
30
|
+
if (smoke.expectClass !== undefined) {
|
|
31
|
+
const classes = classesOf(verdict);
|
|
32
|
+
PolicyAssumeError.invariant(
|
|
33
|
+
classes.includes(smoke.expectClass),
|
|
34
|
+
`assume: expected class ${smoke.expectClass}, got ${classes.join(",") || "none"}: ${smoke.command}`,
|
|
35
|
+
{ expected: smoke.expect, actual, command: smoke.command },
|
|
36
|
+
);
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
const path = (cluster: "read" | "write") => {
|
|
40
|
+
const term = cluster === "read" ? compiled.read : compiled.write;
|
|
41
|
+
return (raw: string): FloorVerdict =>
|
|
42
|
+
term === undefined ? cast("ask", "unmodelled", `${cluster}:no-policy`) : explainPath(term, raw);
|
|
43
|
+
};
|
|
44
|
+
const explainRead = path("read");
|
|
45
|
+
const explainWrite = path("write");
|
|
46
|
+
return {
|
|
47
|
+
bash: (command: string, cwd?: string) => bash(command, cwd).disposition,
|
|
48
|
+
explainBash: bash,
|
|
49
|
+
read: (raw: string) => explainRead(raw).disposition,
|
|
50
|
+
explainRead,
|
|
51
|
+
write: (raw: string) => explainWrite(raw).disposition,
|
|
52
|
+
explainWrite,
|
|
53
|
+
writeKind: (raw: string) => (compiled.write === undefined ? null : writeRemainderKind(compiled.write, raw)),
|
|
54
|
+
};
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/** Dist floor. A path-form argv0 stays a path: this binding has no `which`. */
|
|
58
|
+
export const defaultPolicy: Policy = bindPolicy(reasonableDefaults);
|
package/src/check.ts
ADDED
|
@@ -0,0 +1,552 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* String processors over a compiled YAML term. Prep, unparsed→ask, empty I→ask,
|
|
3
|
+
* and the pipeline lattice live here — not on the host.
|
|
4
|
+
*
|
|
5
|
+
* Shape (policy / matcher / subcommands) is one vote. File effects from
|
|
6
|
+
* arg_types / arg_rest_type join on their own lattice — they cannot lift ask.
|
|
7
|
+
* `"--"` present: suffix is re-parsed with that body's flag vocabulary
|
|
8
|
+
* (undeclared dash tokens stay positionals). `"--"` absent: cutoff, consume
|
|
9
|
+
* the delimiter, leftovers are positionals.
|
|
10
|
+
*
|
|
11
|
+
* Every vote carries the class of the rule that cast it (see classes.ts). The verdict of a call
|
|
12
|
+
* is the strictest disposition plus the votes at exactly that disposition (verdict.ts `join`).
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
import { partition, prep } from "@inhuman.tools/policy-bash-disassembler";
|
|
16
|
+
import { minimatch, type MinimatchOptions } from "minimatch";
|
|
17
|
+
|
|
18
|
+
import type { ClassId } from "./classes.js";
|
|
19
|
+
import { flagVocab, valueFlagNames } from "./compile.js";
|
|
20
|
+
import { guardVotes, pathVote } from "./guards.js";
|
|
21
|
+
import { identityName, type Which } from "./identity.js";
|
|
22
|
+
import { type PathTerm } from "./path-policy.js";
|
|
23
|
+
import type {
|
|
24
|
+
ArgType,
|
|
25
|
+
CommandNode,
|
|
26
|
+
Disposition,
|
|
27
|
+
FlagConstraint,
|
|
28
|
+
FloorVerdict,
|
|
29
|
+
LiftKind,
|
|
30
|
+
Matcher,
|
|
31
|
+
UnitIdentity,
|
|
32
|
+
} from "./types.js";
|
|
33
|
+
import { APPROVE, cast, join, withUnit } from "./verdict.js";
|
|
34
|
+
|
|
35
|
+
type PathClusters = {
|
|
36
|
+
readonly read?: PathTerm;
|
|
37
|
+
readonly write?: PathTerm;
|
|
38
|
+
/** Workspace-relative (or extra-root absolute) directory the shell runs in. Relative path tokens re-base on it. */
|
|
39
|
+
readonly cwd?: string;
|
|
40
|
+
/** The shell would expand this question's words (an argument is not literal). */
|
|
41
|
+
readonly expand?: boolean;
|
|
42
|
+
/** `dotglob` is mentioned in the command: `*` may match a leading dot. */
|
|
43
|
+
readonly dotglob?: boolean;
|
|
44
|
+
};
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* Variables that cannot name a program, library, config file, or search path.
|
|
48
|
+
* Everything else in an env-assignment prefix lifts approve to ask.
|
|
49
|
+
*/
|
|
50
|
+
const INERT_ENV = new Set([
|
|
51
|
+
"LANG",
|
|
52
|
+
"LANGUAGE",
|
|
53
|
+
"TZ",
|
|
54
|
+
"NO_COLOR",
|
|
55
|
+
"FORCE_COLOR",
|
|
56
|
+
"CLICOLOR",
|
|
57
|
+
"CLICOLOR_FORCE",
|
|
58
|
+
"TERM",
|
|
59
|
+
"CI",
|
|
60
|
+
]);
|
|
61
|
+
|
|
62
|
+
function inertEnvName(name: string): boolean {
|
|
63
|
+
return INERT_ENV.has(name) || /^LC_[A-Z]+$/.test(name);
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/** What the floor cannot see through: env vars, stdin-appended args, expansions. Only lifts approve. */
|
|
67
|
+
function blindSpot(question: {
|
|
68
|
+
readonly envDelta: Readonly<Record<string, string>>;
|
|
69
|
+
readonly wrapChain: readonly string[];
|
|
70
|
+
readonly nonLiteral: boolean;
|
|
71
|
+
}): LiftKind | undefined {
|
|
72
|
+
if (question.nonLiteral) return "nonliteral";
|
|
73
|
+
if (question.wrapChain.includes("xargs")) return "xargs";
|
|
74
|
+
if (Object.keys(question.envDelta).some((name) => !inertEnvName(name))) return "env";
|
|
75
|
+
return undefined;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
const CUT = "--";
|
|
79
|
+
/** A command whose rules name no class (the dist root names one; a user document may not). */
|
|
80
|
+
const ROOT_CLASS: ClassId = "exec-unknown";
|
|
81
|
+
|
|
82
|
+
type Ctx = {
|
|
83
|
+
readonly toks: readonly string[];
|
|
84
|
+
readonly flags: Readonly<Record<string, readonly string[]>>;
|
|
85
|
+
readonly cutConsumed: boolean;
|
|
86
|
+
};
|
|
87
|
+
|
|
88
|
+
/** What a node hands its children: the tier it votes by default, the class it votes as, and where it is. */
|
|
89
|
+
type Inherited = { readonly policy: Disposition; readonly cls: ClassId; readonly id: string };
|
|
90
|
+
|
|
91
|
+
export function checkBash(
|
|
92
|
+
term: CommandNode,
|
|
93
|
+
command: string,
|
|
94
|
+
clusters: PathClusters = {},
|
|
95
|
+
which?: Which,
|
|
96
|
+
cwd?: string,
|
|
97
|
+
): Disposition {
|
|
98
|
+
return explainBash(term, command, clusters, which, cwd).disposition;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
export function explainBash(
|
|
102
|
+
term: CommandNode,
|
|
103
|
+
command: string,
|
|
104
|
+
clusters: PathClusters = {},
|
|
105
|
+
which?: Which,
|
|
106
|
+
cwd?: string,
|
|
107
|
+
): FloorVerdict {
|
|
108
|
+
const base: PathClusters = cwd === undefined ? clusters : { ...clusters, cwd };
|
|
109
|
+
const dotglob = /\bdotglob\b|\bGLOBIGNORE\b/.test(command);
|
|
110
|
+
const names = new Set(Object.keys(term.subcommands));
|
|
111
|
+
const prepared = prep(command, {
|
|
112
|
+
which,
|
|
113
|
+
valueFlagsFor: (argv0, suffix) => collectSpineValueFlags(term, identityName(argv0, names, which), suffix),
|
|
114
|
+
});
|
|
115
|
+
const seenUnits: UnitIdentity[] = [];
|
|
116
|
+
const hits: FloorVerdict[] = prepared.questions.flatMap((question) => {
|
|
117
|
+
const paths: PathClusters = { ...base, expand: question.nonLiteral, dotglob };
|
|
118
|
+
const argv0 = identityName(question.argv0, names, which);
|
|
119
|
+
const root = rootInherited(term);
|
|
120
|
+
const floorToks = [argv0];
|
|
121
|
+
const floor = evalCommand(
|
|
122
|
+
term,
|
|
123
|
+
{ toks: floorToks, flags: {}, cutConsumed: false },
|
|
124
|
+
walkSpine(term, floorToks, 0, false, {}),
|
|
125
|
+
0,
|
|
126
|
+
root,
|
|
127
|
+
paths,
|
|
128
|
+
);
|
|
129
|
+
const votes = question.readings.map((reading) => {
|
|
130
|
+
const toks = [argv0, ...reading.tokens];
|
|
131
|
+
return evalCommand(
|
|
132
|
+
term,
|
|
133
|
+
{ toks, flags: reading.flags, cutConsumed: false },
|
|
134
|
+
walkSpine(term, toks, 0, false, {}),
|
|
135
|
+
0,
|
|
136
|
+
root,
|
|
137
|
+
paths,
|
|
138
|
+
);
|
|
139
|
+
});
|
|
140
|
+
const shaped = liftReadings(votes, question.overflow === true, floor);
|
|
141
|
+
// A blind spot (env prefix, xargs, non-literal word) lifts an approve to ask, and marks an ask
|
|
142
|
+
// a rule already cast: either way the floor cannot say what will run, so no grant covers it.
|
|
143
|
+
const reason = blindSpot(question);
|
|
144
|
+
const seen =
|
|
145
|
+
reason === undefined || shaped.disposition === "deny" || shaped.disposition === "block"
|
|
146
|
+
? shaped
|
|
147
|
+
: shaped.disposition === "policyApprove"
|
|
148
|
+
? lifted(reason)
|
|
149
|
+
: markLifted(shaped, reason);
|
|
150
|
+
const unit = unitOf(term, argv0, question.readings, names);
|
|
151
|
+
if (unit !== undefined) seenUnits.push(unit);
|
|
152
|
+
return [withUnit(seen, unit), ...redirVotes(question.redirects, { ...base, expand: true, dotglob })];
|
|
153
|
+
});
|
|
154
|
+
hits.push(...redirVotes(prepared.redirects, { ...base, expand: true, dotglob }));
|
|
155
|
+
if (prepared.unparsed.length > 0) hits.push(lifted("unparsed"));
|
|
156
|
+
const verdict = hits.length === 0 ? cast("ask", "unmodelled", "bash:empty") : join(hits);
|
|
157
|
+
return seenUnits.length === 0 ? verdict : { ...verdict, seen: seenUnits };
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
function markLifted(verdict: FloorVerdict, reason: LiftKind): FloorVerdict {
|
|
161
|
+
return { disposition: verdict.disposition, votes: verdict.votes.map((vote) => ({ lift: reason, ...vote })) };
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
/** `bash` → `bash:npm` → `bash:npm/publish`. */
|
|
165
|
+
function childId(parent: string, name: string): string {
|
|
166
|
+
return parent === "bash" ? `bash:${name}` : `${parent}/${name}`;
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
function lifted(reason: LiftKind): FloorVerdict {
|
|
170
|
+
return cast("ask", ROOT_CLASS, `lift:${reason}`, { lift: reason });
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
function rootInherited(term: CommandNode): Inherited {
|
|
174
|
+
return { policy: term.policy ?? "ask", cls: term.class ?? ROOT_CLASS, id: "bash" };
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
/**
|
|
178
|
+
* Grant family of a question: program, the recognized subcommand spine, and `family_args`
|
|
179
|
+
* leading positionals. Readings that disagree (an ambiguous option parse) have no identity.
|
|
180
|
+
*/
|
|
181
|
+
function unitOf(
|
|
182
|
+
term: CommandNode,
|
|
183
|
+
argv0: string,
|
|
184
|
+
readings: readonly { readonly tokens: readonly string[] }[],
|
|
185
|
+
names: ReadonlySet<string>,
|
|
186
|
+
): UnitIdentity | undefined {
|
|
187
|
+
const units = readings.map((reading) => unitOfReading(term, argv0, reading.tokens, names));
|
|
188
|
+
const first = units[0];
|
|
189
|
+
if (first === undefined) return { argv0, spine: [], familyArgs: [] };
|
|
190
|
+
return units.every((unit) => JSON.stringify(unit) === JSON.stringify(first)) ? first : undefined;
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
function unitOfReading(
|
|
194
|
+
term: CommandNode,
|
|
195
|
+
argv0: string,
|
|
196
|
+
tokens: readonly string[],
|
|
197
|
+
names: ReadonlySet<string>,
|
|
198
|
+
): UnitIdentity {
|
|
199
|
+
let node = names.has(argv0) ? commandChild(term, argv0) : undefined;
|
|
200
|
+
const spine: string[] = [];
|
|
201
|
+
let at = 0;
|
|
202
|
+
while (node !== undefined) {
|
|
203
|
+
const next = tokens[at];
|
|
204
|
+
if (next === undefined || !isLeadingPositional(next)) break;
|
|
205
|
+
const child = commandChild(node, next);
|
|
206
|
+
if (child === undefined) break;
|
|
207
|
+
spine.push(next);
|
|
208
|
+
node = child;
|
|
209
|
+
at += 1;
|
|
210
|
+
}
|
|
211
|
+
const arity = node?.familyArgs ?? 0;
|
|
212
|
+
const familyArgs = tokens.slice(at).filter(isLeadingPositional).slice(0, arity);
|
|
213
|
+
return { argv0, spine, familyArgs };
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
function collectSpineValueFlags(term: CommandNode, argv0: string, suffix: readonly string[]): Set<string> {
|
|
217
|
+
const names = new Set<string>();
|
|
218
|
+
addValueFlags(term, names);
|
|
219
|
+
const child = commandChild(term, argv0);
|
|
220
|
+
if (!child) return names;
|
|
221
|
+
collectFromNode(child, suffix, 0, names);
|
|
222
|
+
return names;
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
function collectFromNode(
|
|
226
|
+
node: CommandNode,
|
|
227
|
+
words: readonly string[],
|
|
228
|
+
i: number,
|
|
229
|
+
names: Set<string>,
|
|
230
|
+
cutConsumed = false,
|
|
231
|
+
): void {
|
|
232
|
+
addValueFlags(node, names);
|
|
233
|
+
let at = i;
|
|
234
|
+
let consumed = cutConsumed;
|
|
235
|
+
if (tokenAt(words, at) === CUT && node.dash === undefined && !consumed) {
|
|
236
|
+
at += 1;
|
|
237
|
+
consumed = true;
|
|
238
|
+
}
|
|
239
|
+
if (tokenAt(words, at) === CUT && node.dash !== undefined) return;
|
|
240
|
+
const next = tokenAt(words, at);
|
|
241
|
+
if (next === undefined || !isLeadingPositional(next)) return;
|
|
242
|
+
const child = commandChild(node, next);
|
|
243
|
+
if (child) collectFromNode(child, words, at + 1, names, consumed);
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
function addValueFlags(node: CommandNode, names: Set<string>): void {
|
|
247
|
+
for (const name of valueFlagNames(node)) names.add(name);
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
function isLeadingPositional(word: string): boolean {
|
|
251
|
+
return word !== CUT && (word === "-" || !word.startsWith("-"));
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
function tokenAt(toks: readonly string[], i: number): string | undefined {
|
|
255
|
+
return i >= 0 && i < toks.length ? toks[i] : undefined;
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
function commandChild(node: CommandNode, name: string | undefined): CommandNode | undefined {
|
|
259
|
+
return name !== undefined && Object.hasOwn(node.subcommands, name) ? node.subcommands[name] : undefined;
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
/**
|
|
263
|
+
* Readings of one command (an ambiguous option parse forks them). All agree: that vote. A disagreement
|
|
264
|
+
* between approve, ask and deny is `ask`, and it is a lift: no rule asked, the parse is what is unsure.
|
|
265
|
+
*/
|
|
266
|
+
function liftReadings(evals: readonly FloorVerdict[], overflow: boolean, floor: FloorVerdict): FloorVerdict {
|
|
267
|
+
const blocks = evals.filter((vote) => vote.disposition === "block");
|
|
268
|
+
if (blocks.length > 0 || (overflow && floor.disposition === "block")) {
|
|
269
|
+
return join([...blocks, ...(overflow ? [floor] : [])]);
|
|
270
|
+
}
|
|
271
|
+
if (overflow) return floor.disposition === "deny" ? floor : lifted("overflow");
|
|
272
|
+
if (evals.length === 0) return lifted("readings");
|
|
273
|
+
if (evals.every((vote) => vote.disposition === "deny")) return join(evals);
|
|
274
|
+
if (evals.every((vote) => vote.disposition === "policyApprove")) return APPROVE;
|
|
275
|
+
if (evals.every((vote) => vote.disposition === "ask")) return join(evals);
|
|
276
|
+
return lifted("readings");
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
/** Node vote joined with the node's argument guards (guards.ts): they only tighten. */
|
|
280
|
+
function evalCommand(
|
|
281
|
+
node: CommandNode,
|
|
282
|
+
ctx: Ctx,
|
|
283
|
+
bindings: Readonly<Record<string, number>>,
|
|
284
|
+
depth: number,
|
|
285
|
+
inherited: Inherited,
|
|
286
|
+
paths: PathClusters,
|
|
287
|
+
): FloorVerdict {
|
|
288
|
+
const vote = evalNode(node, ctx, bindings, depth, inherited, paths);
|
|
289
|
+
const extra = guardVotes(node, ctx.toks, ctx.flags, paths);
|
|
290
|
+
return extra.length === 0 ? vote : join([vote, ...extra]);
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
function evalNode(
|
|
294
|
+
node: CommandNode,
|
|
295
|
+
ctx: Ctx,
|
|
296
|
+
bindings: Readonly<Record<string, number>>,
|
|
297
|
+
depth: number,
|
|
298
|
+
inherited: Inherited,
|
|
299
|
+
paths: PathClusters,
|
|
300
|
+
): FloorVerdict {
|
|
301
|
+
const policy = node.policy ?? inherited.policy;
|
|
302
|
+
const cls = node.class ?? inherited.cls;
|
|
303
|
+
const here = bindings;
|
|
304
|
+
let toks = ctx.toks;
|
|
305
|
+
let cutConsumed = ctx.cutConsumed;
|
|
306
|
+
if (toks[0] === CUT && node.dash === undefined && !cutConsumed) {
|
|
307
|
+
toks = toks.slice(1);
|
|
308
|
+
cutConsumed = true;
|
|
309
|
+
}
|
|
310
|
+
const self: Inherited = { policy, cls, id: inherited.id };
|
|
311
|
+
const own = cast(policy, cls, self.id);
|
|
312
|
+
|
|
313
|
+
if (toks[0] === CUT && node.dash !== undefined) {
|
|
314
|
+
const dashVote = evalDash(
|
|
315
|
+
node.dash,
|
|
316
|
+
toks.slice(1),
|
|
317
|
+
here,
|
|
318
|
+
depth + 1,
|
|
319
|
+
{ ...self, id: childId(self.id, "--") },
|
|
320
|
+
paths,
|
|
321
|
+
);
|
|
322
|
+
const matcherHits = matcherVotes(node, toks, ctx.flags, here, depth, self);
|
|
323
|
+
return shapeOf([...matcherHits, dashVote], own);
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
const next = tokenAt(toks, 0);
|
|
327
|
+
const child = commandChild(node, next);
|
|
328
|
+
const matcherHits = matcherVotes(node, toks, ctx.flags, here, depth, self);
|
|
329
|
+
if (child) {
|
|
330
|
+
const childVote = evalCommand(
|
|
331
|
+
child,
|
|
332
|
+
{ toks: toks.slice(1), flags: ctx.flags, cutConsumed },
|
|
333
|
+
here,
|
|
334
|
+
depth + 1,
|
|
335
|
+
{ ...self, id: childId(self.id, next!) },
|
|
336
|
+
paths,
|
|
337
|
+
);
|
|
338
|
+
return shapeOf([...matcherHits, childVote], own);
|
|
339
|
+
}
|
|
340
|
+
|
|
341
|
+
const files = fileVotes(node, stripCut(toks), paths);
|
|
342
|
+
return join([shapeOf(matcherHits, own), ...files]);
|
|
343
|
+
}
|
|
344
|
+
|
|
345
|
+
function matcherVotes(
|
|
346
|
+
node: CommandNode,
|
|
347
|
+
toks: readonly string[],
|
|
348
|
+
flags: Readonly<Record<string, readonly string[]>>,
|
|
349
|
+
bindings: Readonly<Record<string, number>>,
|
|
350
|
+
depth: number,
|
|
351
|
+
self: Inherited,
|
|
352
|
+
): FloorVerdict[] {
|
|
353
|
+
return node.matchers.flatMap((matcher) => {
|
|
354
|
+
const vote = evalMatcher(matcher, toks, flags, bindings, depth, self);
|
|
355
|
+
return vote === undefined ? [] : [vote];
|
|
356
|
+
});
|
|
357
|
+
}
|
|
358
|
+
|
|
359
|
+
function evalDash(
|
|
360
|
+
node: CommandNode,
|
|
361
|
+
suffix: readonly string[],
|
|
362
|
+
bindings: Readonly<Record<string, number>>,
|
|
363
|
+
depth: number,
|
|
364
|
+
inherited: Inherited,
|
|
365
|
+
paths: PathClusters,
|
|
366
|
+
): FloorVerdict {
|
|
367
|
+
const closed = flagVocab(node);
|
|
368
|
+
const valueFlags = valueFlagNames(node);
|
|
369
|
+
const { readings, overflow } = partition(suffix, valueFlags, closed);
|
|
370
|
+
const floor = evalCommand(node, { toks: [], flags: {}, cutConsumed: true }, bindings, depth, inherited, paths);
|
|
371
|
+
if (readings.length === 0) {
|
|
372
|
+
return liftReadings(
|
|
373
|
+
[evalCommand(node, { toks: suffix, flags: {}, cutConsumed: true }, bindings, depth, inherited, paths)],
|
|
374
|
+
overflow,
|
|
375
|
+
floor,
|
|
376
|
+
);
|
|
377
|
+
}
|
|
378
|
+
const votes = readings.map((reading) =>
|
|
379
|
+
evalCommand(
|
|
380
|
+
node,
|
|
381
|
+
{ toks: reading.tokens, flags: reading.flags, cutConsumed: true },
|
|
382
|
+
bindings,
|
|
383
|
+
depth,
|
|
384
|
+
inherited,
|
|
385
|
+
paths,
|
|
386
|
+
),
|
|
387
|
+
);
|
|
388
|
+
return liftReadings(votes, overflow, floor);
|
|
389
|
+
}
|
|
390
|
+
|
|
391
|
+
/** Descending arms override the node's own vote; `block` on the node holds regardless. */
|
|
392
|
+
function shapeOf(hits: readonly FloorVerdict[], own: FloorVerdict): FloorVerdict {
|
|
393
|
+
if (hits.length === 0) return own;
|
|
394
|
+
if (own.disposition === "block") return own;
|
|
395
|
+
return join(hits);
|
|
396
|
+
}
|
|
397
|
+
|
|
398
|
+
function walkSpine(
|
|
399
|
+
node: CommandNode,
|
|
400
|
+
toks: readonly string[],
|
|
401
|
+
depth: number,
|
|
402
|
+
cutConsumed: boolean,
|
|
403
|
+
acc: Record<string, number>,
|
|
404
|
+
): Record<string, number> {
|
|
405
|
+
for (const name of flagVocab(node)) acc[name] = Math.max(acc[name] ?? -1, depth);
|
|
406
|
+
let rest = toks;
|
|
407
|
+
let consumed = cutConsumed;
|
|
408
|
+
if (rest[0] === CUT && node.dash === undefined && !consumed) {
|
|
409
|
+
rest = rest.slice(1);
|
|
410
|
+
consumed = true;
|
|
411
|
+
}
|
|
412
|
+
if (rest[0] === CUT && node.dash !== undefined) {
|
|
413
|
+
return walkSpine(node.dash, rest.slice(1), depth + 1, true, acc);
|
|
414
|
+
}
|
|
415
|
+
const next = tokenAt(rest, 0);
|
|
416
|
+
const child = commandChild(node, next);
|
|
417
|
+
if (child) walkSpine(child, rest.slice(1), depth + 1, consumed, acc);
|
|
418
|
+
return acc;
|
|
419
|
+
}
|
|
420
|
+
|
|
421
|
+
function stripCut(toks: readonly string[]): readonly string[] {
|
|
422
|
+
const i = toks.indexOf(CUT);
|
|
423
|
+
if (i === -1) return toks;
|
|
424
|
+
return [...toks.slice(0, i), ...toks.slice(i + 1)];
|
|
425
|
+
}
|
|
426
|
+
|
|
427
|
+
function matcherLabel(matcher: Matcher): string {
|
|
428
|
+
const parts = [
|
|
429
|
+
...Object.keys(matcher.flags),
|
|
430
|
+
...matcher.toks.map((tok) => `tok:${tok}`),
|
|
431
|
+
...matcher.globs.map((glob) => `glob:${glob}`),
|
|
432
|
+
];
|
|
433
|
+
return parts.length === 0 ? "*" : parts.join("+");
|
|
434
|
+
}
|
|
435
|
+
|
|
436
|
+
function evalMatcher(
|
|
437
|
+
matcher: Matcher,
|
|
438
|
+
toks: readonly string[],
|
|
439
|
+
flags: Readonly<Record<string, readonly string[]>>,
|
|
440
|
+
bindings: Readonly<Record<string, number>>,
|
|
441
|
+
depth: number,
|
|
442
|
+
parent: Inherited,
|
|
443
|
+
): FloorVerdict | undefined {
|
|
444
|
+
if (!flagsMatch(matcher.flags, flags, bindings, depth)) return undefined;
|
|
445
|
+
if (!positionalsMatch(matcher, toks)) return undefined;
|
|
446
|
+
const cls = matcher.class ?? parent.cls;
|
|
447
|
+
const here: Inherited = {
|
|
448
|
+
policy: matcher.policy ?? parent.policy,
|
|
449
|
+
cls,
|
|
450
|
+
id: `${parent.id}[${matcherLabel(matcher)}]`,
|
|
451
|
+
};
|
|
452
|
+
const nested = matcher.matchers
|
|
453
|
+
.map((child) => evalMatcher(child, toks, flags, bindings, depth, here))
|
|
454
|
+
.filter((vote): vote is FloorVerdict => vote !== undefined);
|
|
455
|
+
if (matcher.policy === undefined) {
|
|
456
|
+
if (nested.length === 0) return undefined;
|
|
457
|
+
return join(nested);
|
|
458
|
+
}
|
|
459
|
+
const own = cast(matcher.policy, cls, here.id);
|
|
460
|
+
if (matcher.policy === "block") return join([own, ...nested]);
|
|
461
|
+
if (nested.length === 0) return own;
|
|
462
|
+
return join(nested);
|
|
463
|
+
}
|
|
464
|
+
|
|
465
|
+
function flagsMatch(
|
|
466
|
+
spec: Readonly<Record<string, FlagConstraint>>,
|
|
467
|
+
flags: Readonly<Record<string, readonly string[]>>,
|
|
468
|
+
bindings: Readonly<Record<string, number>>,
|
|
469
|
+
depth: number,
|
|
470
|
+
): boolean {
|
|
471
|
+
const entries = Object.values(spec);
|
|
472
|
+
if (entries.length === 0) return true;
|
|
473
|
+
const applyShadow = entries.length === 1;
|
|
474
|
+
return entries.every((flag) => flagHits(flag, flags, bindings, depth, applyShadow));
|
|
475
|
+
}
|
|
476
|
+
|
|
477
|
+
function flagHits(
|
|
478
|
+
flag: FlagConstraint,
|
|
479
|
+
flags: Readonly<Record<string, readonly string[]>>,
|
|
480
|
+
bindings: Readonly<Record<string, number>>,
|
|
481
|
+
depth: number,
|
|
482
|
+
applyShadow: boolean,
|
|
483
|
+
): boolean {
|
|
484
|
+
const live = applyShadow ? flag.names.filter((name) => (bindings[name] ?? depth) === depth) : flag.names;
|
|
485
|
+
if (live.length === 0) return false;
|
|
486
|
+
const present = live.filter((name) => Object.hasOwn(flags, name));
|
|
487
|
+
if (present.length === 0) return false;
|
|
488
|
+
if (flag.vals.length === 0) return true;
|
|
489
|
+
return present.some((name) => (flags[name] ?? []).some((value) => flag.vals.some((glob) => globMatch(glob, value))));
|
|
490
|
+
}
|
|
491
|
+
|
|
492
|
+
function positionalsMatch(matcher: Matcher, toks: readonly string[]): boolean {
|
|
493
|
+
if (matcher.globs.length === 0 && matcher.toks.length === 0) return true;
|
|
494
|
+
return toks.some((tok) => matcher.toks.includes(tok) || matcher.globs.some((glob) => globMatch(glob, tok)));
|
|
495
|
+
}
|
|
496
|
+
|
|
497
|
+
function fileVotes(node: CommandNode, toks: readonly string[], paths: PathClusters): FloorVerdict[] {
|
|
498
|
+
if (toks.length === 0) return [];
|
|
499
|
+
const votes: FloorVerdict[] = [];
|
|
500
|
+
for (const [i, tok] of toks.entries()) {
|
|
501
|
+
const kind = i < node.argTypes.length ? node.argTypes[i] : node.argRestType;
|
|
502
|
+
if (!kind) continue;
|
|
503
|
+
votes.push(shellPathVote(kind, tok, paths));
|
|
504
|
+
}
|
|
505
|
+
return votes;
|
|
506
|
+
}
|
|
507
|
+
|
|
508
|
+
function shellPathVote(kind: ArgType, path: string, paths: PathClusters): FloorVerdict {
|
|
509
|
+
return pathVote(kind, rebase(paths.cwd, path), paths);
|
|
510
|
+
}
|
|
511
|
+
|
|
512
|
+
/** A relative path is relative to the shell's cwd, not the workspace root. */
|
|
513
|
+
function rebase(cwd: string | undefined, path: string): string {
|
|
514
|
+
if (cwd === undefined || cwd === "" || cwd === "." || path === "" || path.startsWith("/")) return path;
|
|
515
|
+
if (path.startsWith("~") || path.includes("$")) return path;
|
|
516
|
+
return `${cwd}/${path}`;
|
|
517
|
+
}
|
|
518
|
+
|
|
519
|
+
function redirVotes(
|
|
520
|
+
redirects: readonly { readonly operator: string; readonly target: string }[],
|
|
521
|
+
paths: PathClusters,
|
|
522
|
+
): FloorVerdict[] {
|
|
523
|
+
return redirects.flatMap((redirect) => {
|
|
524
|
+
const cluster = redirCluster(redirect.operator);
|
|
525
|
+
if (cluster === undefined) return [];
|
|
526
|
+
return [shellPathVote(cluster === "read" ? "read_path" : "write_path", redirect.target, paths)];
|
|
527
|
+
});
|
|
528
|
+
}
|
|
529
|
+
|
|
530
|
+
function redirCluster(operator: string): "read" | "write" | undefined {
|
|
531
|
+
if (operator.includes("<<")) return undefined;
|
|
532
|
+
if (operator.includes(">")) return "write";
|
|
533
|
+
if (operator.includes("<")) return "read";
|
|
534
|
+
return undefined;
|
|
535
|
+
}
|
|
536
|
+
|
|
537
|
+
/** minimatch treats `/` as a separator. A token is not a path, so `/` is hidden first. */
|
|
538
|
+
const TOKEN_SLASH = "\u0001";
|
|
539
|
+
const TOKEN_GLOB: MinimatchOptions = {
|
|
540
|
+
dot: true,
|
|
541
|
+
nocomment: true,
|
|
542
|
+
nonegate: true,
|
|
543
|
+
nobrace: true,
|
|
544
|
+
noext: true,
|
|
545
|
+
noglobstar: true,
|
|
546
|
+
};
|
|
547
|
+
|
|
548
|
+
function globMatch(pattern: string, value: string): boolean {
|
|
549
|
+
return minimatch(value.replaceAll("/", TOKEN_SLASH), pattern.replaceAll("/", TOKEN_SLASH), TOKEN_GLOB);
|
|
550
|
+
}
|
|
551
|
+
|
|
552
|
+
export { explainPath } from "./path-policy.js";
|