@inhuman.tools/policy 0.0.0-stage → 0.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 (141) hide show
  1. package/authorize.scm +4 -0
  2. package/dist/acknowledge.d.ts +70 -0
  3. package/dist/acknowledge.d.ts.map +1 -0
  4. package/dist/acknowledge.js +162 -0
  5. package/dist/acknowledge.js.map +1 -0
  6. package/dist/authorize.d.ts +8 -0
  7. package/dist/authorize.d.ts.map +1 -0
  8. package/dist/authorize.js +39 -0
  9. package/dist/authorize.js.map +1 -0
  10. package/dist/bind.d.ts +6 -0
  11. package/dist/bind.d.ts.map +1 -0
  12. package/dist/bind.js +45 -0
  13. package/dist/bind.js.map +1 -0
  14. package/dist/check.d.ts +30 -0
  15. package/dist/check.d.ts.map +1 -0
  16. package/dist/check.js +389 -0
  17. package/dist/check.js.map +1 -0
  18. package/dist/classes.d.ts +23 -0
  19. package/dist/classes.d.ts.map +1 -0
  20. package/dist/classes.js +139 -0
  21. package/dist/classes.js.map +1 -0
  22. package/dist/compile.d.ts +40 -0
  23. package/dist/compile.d.ts.map +1 -0
  24. package/dist/compile.js +311 -0
  25. package/dist/compile.js.map +1 -0
  26. package/dist/deadline.d.ts +5 -0
  27. package/dist/deadline.d.ts.map +1 -0
  28. package/dist/deadline.js +17 -0
  29. package/dist/deadline.js.map +1 -0
  30. package/dist/decide.d.ts +64 -0
  31. package/dist/decide.d.ts.map +1 -0
  32. package/dist/decide.js +482 -0
  33. package/dist/decide.js.map +1 -0
  34. package/dist/error-with-invariant.d.ts +9 -0
  35. package/dist/error-with-invariant.d.ts.map +1 -0
  36. package/dist/error-with-invariant.js +12 -0
  37. package/dist/error-with-invariant.js.map +1 -0
  38. package/dist/floor.d.ts +9 -0
  39. package/dist/floor.d.ts.map +1 -0
  40. package/dist/floor.js +9 -0
  41. package/dist/floor.js.map +1 -0
  42. package/dist/glob-expand.d.ts +10 -0
  43. package/dist/glob-expand.d.ts.map +1 -0
  44. package/dist/glob-expand.js +132 -0
  45. package/dist/glob-expand.js.map +1 -0
  46. package/dist/guards.d.ts +26 -0
  47. package/dist/guards.d.ts.map +1 -0
  48. package/dist/guards.js +419 -0
  49. package/dist/guards.js.map +1 -0
  50. package/dist/identity.d.ts +11 -0
  51. package/dist/identity.d.ts.map +1 -0
  52. package/dist/identity.js +23 -0
  53. package/dist/identity.js.map +1 -0
  54. package/dist/index.d.ts +8 -0
  55. package/dist/index.d.ts.map +1 -0
  56. package/dist/index.js +8 -0
  57. package/dist/index.js.map +1 -0
  58. package/dist/license-law.d.ts +43 -0
  59. package/dist/license-law.d.ts.map +1 -0
  60. package/dist/license-law.js +129 -0
  61. package/dist/license-law.js.map +1 -0
  62. package/dist/license.d.ts +3 -0
  63. package/dist/license.d.ts.map +1 -0
  64. package/dist/license.js +2 -0
  65. package/dist/license.js.map +1 -0
  66. package/dist/lift.d.ts +4 -0
  67. package/dist/lift.d.ts.map +1 -0
  68. package/dist/lift.js +14 -0
  69. package/dist/lift.js.map +1 -0
  70. package/dist/path-policy.d.ts +53 -0
  71. package/dist/path-policy.d.ts.map +1 -0
  72. package/dist/path-policy.js +248 -0
  73. package/dist/path-policy.js.map +1 -0
  74. package/dist/peel-cwd.d.ts +8 -0
  75. package/dist/peel-cwd.d.ts.map +1 -0
  76. package/dist/peel-cwd.js +120 -0
  77. package/dist/peel-cwd.js.map +1 -0
  78. package/dist/prep.d.ts +58 -0
  79. package/dist/prep.d.ts.map +1 -0
  80. package/dist/prep.js +690 -0
  81. package/dist/prep.js.map +1 -0
  82. package/dist/processor.d.ts +71 -0
  83. package/dist/processor.d.ts.map +1 -0
  84. package/dist/processor.js +132 -0
  85. package/dist/processor.js.map +1 -0
  86. package/dist/reasonable-defaults.d.ts +1448 -0
  87. package/dist/reasonable-defaults.d.ts.map +1 -0
  88. package/dist/reasonable-defaults.js +6 -0
  89. package/dist/reasonable-defaults.js.map +1 -0
  90. package/dist/reasonable-defaults.json +1662 -0
  91. package/dist/run-authorization.d.ts +8 -0
  92. package/dist/run-authorization.d.ts.map +1 -0
  93. package/dist/run-authorization.js +16 -0
  94. package/dist/run-authorization.js.map +1 -0
  95. package/dist/schemas/tirith.d.ts +58 -0
  96. package/dist/schemas/tirith.d.ts.map +1 -0
  97. package/dist/schemas/tirith.js +23 -0
  98. package/dist/schemas/tirith.js.map +1 -0
  99. package/dist/tirith-units.d.ts +2 -0
  100. package/dist/tirith-units.d.ts.map +1 -0
  101. package/dist/tirith-units.js +227 -0
  102. package/dist/tirith-units.js.map +1 -0
  103. package/dist/tirith.d.ts +24 -0
  104. package/dist/tirith.d.ts.map +1 -0
  105. package/dist/tirith.js +311 -0
  106. package/dist/tirith.js.map +1 -0
  107. package/dist/tool-path.d.ts +8 -0
  108. package/dist/tool-path.d.ts.map +1 -0
  109. package/dist/tool-path.js +38 -0
  110. package/dist/tool-path.js.map +1 -0
  111. package/dist/types.d.ts +87 -0
  112. package/dist/types.d.ts.map +1 -0
  113. package/dist/types.js +14 -0
  114. package/dist/types.js.map +1 -0
  115. package/dist/verdict.d.ts +15 -0
  116. package/dist/verdict.d.ts.map +1 -0
  117. package/dist/verdict.js +55 -0
  118. package/dist/verdict.js.map +1 -0
  119. package/package.json +62 -3
  120. package/src/acknowledge.ts +206 -0
  121. package/src/authorize.ts +38 -0
  122. package/src/bind.ts +58 -0
  123. package/src/check.ts +552 -0
  124. package/src/classes.ts +156 -0
  125. package/src/compile.ts +370 -0
  126. package/src/deadline.ts +15 -0
  127. package/src/floor.ts +16 -0
  128. package/src/glob-expand.ts +137 -0
  129. package/src/guards.ts +428 -0
  130. package/src/identity.ts +26 -0
  131. package/src/index.ts +24 -0
  132. package/src/lift.ts +15 -0
  133. package/src/path-policy.ts +280 -0
  134. package/src/processor.ts +187 -0
  135. package/src/reasonable-defaults.json +1662 -0
  136. package/src/reasonable-defaults.ts +8 -0
  137. package/src/run-authorization.ts +23 -0
  138. package/src/tool-path.ts +43 -0
  139. package/src/types.ts +102 -0
  140. package/src/verdict.ts +63 -0
  141. package/README.md +0 -3
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";
package/src/classes.ts ADDED
@@ -0,0 +1,156 @@
1
+ /**
2
+ * Rule classes: why the floor voted the tier it did. The taxonomy is closed and ships with the
3
+ * harness. A user layer may tag its own rules with one of these ids and configure how each class
4
+ * resolves; it cannot add an id or move a rule between classes.
5
+ *
6
+ * `risky` / `general` are not classes and not tier names. The tier names are "soft-deny"
7
+ * (engine `deny`) and "ask" (engine `ask`).
8
+ */
9
+
10
+ export const CLASS_IDS = [
11
+ "destroy-catastrophic",
12
+ "destroy-local",
13
+ "vcs-history",
14
+ "privilege",
15
+ "credential",
16
+ "control-plane",
17
+ "attack-shape",
18
+ "policy-fault",
19
+ "package-install",
20
+ "publish",
21
+ "remote-write",
22
+ "network-egress",
23
+ "exec-unknown",
24
+ "unmodelled",
25
+ "envelope-read",
26
+ "envelope-write",
27
+ "protected-file",
28
+ "workspace-write",
29
+ "mcp-lookup",
30
+ "mcp-action",
31
+ ] as const;
32
+
33
+ export type ClassId = (typeof CLASS_IDS)[number];
34
+
35
+ export type ClassInfo = {
36
+ /** Short name for prompts and the permissions view. */
37
+ readonly label: string;
38
+ /** Trusted, harness-written description. The soft-deny judge reads it as FLOOR context. */
39
+ readonly description: string;
40
+ /** UI warning colour only. It has no resolution meaning. */
41
+ readonly risky: boolean;
42
+ };
43
+
44
+ export const CLASSES: Readonly<Record<ClassId, ClassInfo>> = {
45
+ "destroy-catastrophic": {
46
+ label: "Destroys the machine or home",
47
+ description: "Irrecoverable machine or home-scale destruction.",
48
+ risky: true,
49
+ },
50
+ "destroy-local": {
51
+ label: "Discards local work",
52
+ description: "Discards uncommitted or untracked work in the tree.",
53
+ risky: true,
54
+ },
55
+ "vcs-history": {
56
+ label: "Rewrites history",
57
+ description: "Rewrites or deletes refs and history, local or remote.",
58
+ risky: true,
59
+ },
60
+ privilege: {
61
+ label: "Elevates privilege",
62
+ description: "Elevates privilege, widens permissions, or changes machine state.",
63
+ risky: true,
64
+ },
65
+ credential: {
66
+ label: "Touches credentials",
67
+ description: "Reads, writes or exports secrets and credential stores.",
68
+ risky: true,
69
+ },
70
+ "control-plane": {
71
+ label: "Changes the controls on the agent",
72
+ description:
73
+ "Changes or bypasses the controls that govern the agent: harness or agent config, VCS hooks, hook bypass, CI workflows, policy files.",
74
+ risky: true,
75
+ },
76
+ "attack-shape": {
77
+ label: "Known attack shape",
78
+ description: "A known attack pattern: pipe-to-shell, reverse shell, exfiltration, suspicious network target.",
79
+ risky: true,
80
+ },
81
+ "policy-fault": {
82
+ label: "Policy fault",
83
+ description: "The gate itself failed: the policy did not compile, the check threw, or the scanner errored.",
84
+ risky: true,
85
+ },
86
+ "package-install": {
87
+ label: "Installs packages",
88
+ description: "Installs or removes packages, or runs foreign install scripts.",
89
+ risky: false,
90
+ },
91
+ publish: {
92
+ label: "Publishes",
93
+ description: "Publishes an artifact or content to a public or shared registry.",
94
+ risky: true,
95
+ },
96
+ "remote-write": {
97
+ label: "Changes remote state",
98
+ description: "Mutates remote state other than publishing: push, PR or issue edits, kubectl, a remote docker host.",
99
+ risky: true,
100
+ },
101
+ "network-egress": {
102
+ label: "Uses the network",
103
+ description: "Sends or fetches data over the network.",
104
+ risky: false,
105
+ },
106
+ "exec-unknown": {
107
+ label: "Runs code the floor cannot see",
108
+ description:
109
+ "Runs code whose effect the floor cannot see: unknown programs, interpreters, scripts, build tasks, eval, env-prefix, xargs, non-literal arguments.",
110
+ risky: false,
111
+ },
112
+ unmodelled: {
113
+ label: "Not precisely modelled",
114
+ description: "A known program whose verb or option the floor does not model precisely.",
115
+ risky: false,
116
+ },
117
+ "envelope-read": {
118
+ label: "Reads outside the workspace",
119
+ description: "Reads a path outside the enabled roots.",
120
+ risky: false,
121
+ },
122
+ "envelope-write": {
123
+ label: "Writes outside the workspace",
124
+ description: "Writes outside the enabled roots, or to an untyped output path.",
125
+ risky: true,
126
+ },
127
+ "protected-file": {
128
+ label: "Writes a protected file",
129
+ description: "Writes an in-tree file the policy carves out (manifests, lockfiles, Makefile).",
130
+ risky: false,
131
+ },
132
+ "workspace-write": {
133
+ label: "Writes in the workspace",
134
+ description: "Ordinary in-tree or scratch write.",
135
+ risky: false,
136
+ },
137
+ "mcp-lookup": {
138
+ label: "MCP look-up",
139
+ description: "An MCP tool whose name scores as a look-up.",
140
+ risky: false,
141
+ },
142
+ "mcp-action": {
143
+ label: "MCP action",
144
+ description: "Any other MCP or unknown tool.",
145
+ risky: false,
146
+ },
147
+ };
148
+
149
+ const IDS: ReadonlySet<string> = new Set(CLASS_IDS);
150
+
151
+ export function isClassId(value: unknown): value is ClassId {
152
+ return typeof value === "string" && IDS.has(value);
153
+ }
154
+
155
+ /** Tier names as users write them. The engine names stay `deny` / `ask`. */
156
+ export const TIER_NAMES = ["soft-deny", "ask"] as const;