approval-md 0.4.3 → 0.4.6
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/SPEC.md +13 -2
- package/dist/src/channels/telegram.d.ts +105 -31
- package/dist/src/channels/telegram.js +157 -81
- package/dist/src/channels/telegram.js.map +1 -1
- package/dist/src/cli/channel-telegram.d.ts +100 -35
- package/dist/src/cli/channel-telegram.js +216 -119
- package/dist/src/cli/channel-telegram.js.map +1 -1
- package/dist/src/cli/doctor.js +33 -13
- package/dist/src/cli/doctor.js.map +1 -1
- package/dist/src/cli/help.js +3 -3
- package/dist/src/cli/hook-exec-code.d.ts +150 -0
- package/dist/src/cli/hook-exec-code.js +357 -0
- package/dist/src/cli/hook-exec-code.js.map +1 -0
- package/dist/src/cli/hook.d.ts +125 -1
- package/dist/src/cli/hook.js +2177 -107
- package/dist/src/cli/hook.js.map +1 -1
- package/dist/src/cli/wordmark.d.ts +2 -2
- package/dist/src/cli/wordmark.js +2 -2
- package/dist/src/core/command-class.d.ts +368 -1
- package/dist/src/core/command-class.js +2830 -93
- package/dist/src/core/command-class.js.map +1 -1
- package/dist/src/core/date-argv.d.ts +83 -0
- package/dist/src/core/date-argv.js +212 -0
- package/dist/src/core/date-argv.js.map +1 -0
- package/dist/src/core/exec-code.d.ts +77 -0
- package/dist/src/core/exec-code.js +60 -0
- package/dist/src/core/exec-code.js.map +1 -0
- package/dist/src/core/git-push-argv.d.ts +111 -0
- package/dist/src/core/git-push-argv.js +375 -0
- package/dist/src/core/git-push-argv.js.map +1 -0
- package/dist/src/core/harness-version.d.ts +71 -30
- package/dist/src/core/harness-version.js +101 -34
- package/dist/src/core/harness-version.js.map +1 -1
- package/dist/src/core/policy-load.d.ts +8 -0
- package/dist/src/core/policy-load.js.map +1 -1
- package/dist/src/core/read-scope.js +1 -0
- package/dist/src/core/read-scope.js.map +1 -1
- package/dist/src/core/sysinfo-argv.d.ts +48 -0
- package/dist/src/core/sysinfo-argv.js +340 -0
- package/dist/src/core/sysinfo-argv.js.map +1 -0
- package/dist/src/daemon/git-evidence.d.ts +2 -2
- package/dist/src/daemon/git-evidence.js +1 -1
- package/docs/README-extended.md +3 -2
- package/docs/cli-reference.md +20 -7
- package/package.json +2 -2
- package/schema/event.schema.json +1 -1
- package/schema/fixtures/policy/invalid/exec-code-custody-boolean.json +11 -0
- package/schema/fixtures/policy/invalid/exec-code-execute-code-value.json +11 -0
- package/schema/fixtures/policy/invalid/exec-code-unknown-key.json +12 -0
- package/schema/fixtures/policy/valid/exec-code-both.json +17 -0
- package/schema/fixtures/policy/valid/exec-code-custody.json +11 -0
- package/schema/policy.schema.json +23 -0
|
@@ -47,6 +47,9 @@
|
|
|
47
47
|
* of those doors and leave the other open. The import is type-safe in the
|
|
48
48
|
* dependency sense as well: `policy-match.ts` itself imports only types.
|
|
49
49
|
*/
|
|
50
|
+
import { isDateDisplay, isZoneValue } from "./date-argv.js";
|
|
51
|
+
import { parseGitPushArgv, splitGitPush } from "./git-push-argv.js";
|
|
52
|
+
import { isBareInfo, isDfDisplay, isFreeDisplay, isHostnameDisplay, isPasteRead, isSeqDisplay, } from "./sysinfo-argv.js";
|
|
50
53
|
import { HARNESS_LAUNCH_PREFIX } from "./policy-match.js";
|
|
51
54
|
import { READ_OUT_OF_SCOPE_CLASS, isUnreadableTarget, readTargetVerdict, readTargetsOf, } from "./read-scope.js";
|
|
52
55
|
/**
|
|
@@ -175,6 +178,318 @@ function matchesEntry(segments, entry) {
|
|
|
175
178
|
const offset = segments.length - want.length;
|
|
176
179
|
return want.every((segment, index) => segment === segments[offset + index]);
|
|
177
180
|
}
|
|
181
|
+
/** The most words one brace-expanded path may stand for before it is judged unknown. */
|
|
182
|
+
const BRACE_EXPANSION_CAP = 256;
|
|
183
|
+
/**
|
|
184
|
+
* The most group expansions one word may take before it is judged unknown
|
|
185
|
+
* (APRV-511 refuter recheck S(new)). Each step rescans the word once, so this
|
|
186
|
+
* bounds the work a crafted word can cost, including a long run of groups that
|
|
187
|
+
* each collapse to one word (`{s..s}{s..s}…`), which the word cap alone would
|
|
188
|
+
* never stop.
|
|
189
|
+
*/
|
|
190
|
+
const BRACE_EXPANSION_STEPS = 1024;
|
|
191
|
+
/**
|
|
192
|
+
* The words bash's brace expansion makes of `word`, or `null` when there would
|
|
193
|
+
* be more than {@link BRACE_EXPANSION_CAP}, or making them would take more than
|
|
194
|
+
* {@link BRACE_EXPANSION_STEPS} expansions (APRV-511 refuter M2, recheck S(new)).
|
|
195
|
+
*
|
|
196
|
+
* A group is `{` … `}` holding a top-level comma (`{a,b}`, `{,s}`) or a
|
|
197
|
+
* sequence (`{1..3}`, `{a..c}`, `{1..9..2}`); a `{` right after `$` opens a
|
|
198
|
+
* parameter expansion and is never a group, and anything else stays literal,
|
|
199
|
+
* as it does in bash. Groups nest and multiply. The lexer has already removed
|
|
200
|
+
* quoting, so a quoted brace pair reads as a group too, which can only make an
|
|
201
|
+
* answer stricter.
|
|
202
|
+
*
|
|
203
|
+
* The expansion is iterative, never recursive: a work list holds the words
|
|
204
|
+
* still carrying a group, and both bounds are tested BEFORE a word is
|
|
205
|
+
* expanded further, so no input reaches the stack limit or runs unbounded.
|
|
206
|
+
* Every caller reads `null` as the stricter class.
|
|
207
|
+
*/
|
|
208
|
+
export function braceExpansions(word) {
|
|
209
|
+
const results = [];
|
|
210
|
+
const pending = [word];
|
|
211
|
+
let steps = 0;
|
|
212
|
+
while (pending.length > 0) {
|
|
213
|
+
const text = pending.pop();
|
|
214
|
+
const group = firstBraceGroup(text);
|
|
215
|
+
if (group === null) {
|
|
216
|
+
results.push(text);
|
|
217
|
+
if (results.length > BRACE_EXPANSION_CAP)
|
|
218
|
+
return null;
|
|
219
|
+
continue;
|
|
220
|
+
}
|
|
221
|
+
steps += 1;
|
|
222
|
+
if (steps > BRACE_EXPANSION_STEPS)
|
|
223
|
+
return null;
|
|
224
|
+
if (results.length + pending.length + group.alternatives.length > BRACE_EXPANSION_CAP)
|
|
225
|
+
return null;
|
|
226
|
+
// Pushed in reverse so the words come out in bash's left-to-right order.
|
|
227
|
+
for (let index = group.alternatives.length - 1; index >= 0; index -= 1) {
|
|
228
|
+
const next = text.slice(0, group.open) + group.alternatives[index] + text.slice(group.close + 1);
|
|
229
|
+
// Only an expansion that changed the word is expanded again; one that
|
|
230
|
+
// did not (which a well-formed group never makes) is a literal word.
|
|
231
|
+
if (next === text)
|
|
232
|
+
results.push(next);
|
|
233
|
+
else
|
|
234
|
+
pending.push(next);
|
|
235
|
+
}
|
|
236
|
+
}
|
|
237
|
+
return results;
|
|
238
|
+
}
|
|
239
|
+
/**
|
|
240
|
+
* The words a path word may stand for once bash has expanded its brace groups,
|
|
241
|
+
* plus, for any word that still carries a stray `{` or `}` outside a parameter
|
|
242
|
+
* expansion, the same word with the stray braces removed (APRV-511 review).
|
|
243
|
+
* Bash keeps a stray brace literally (`{~/.hermes/.op.env` names a directory
|
|
244
|
+
* called `{~`), but a word whose only distance from an organ or a credential is
|
|
245
|
+
* a stray brace is judged as that organ: the stricter of the two readings.
|
|
246
|
+
* `null` when the expansion exceeds {@link BRACE_EXPANSION_CAP}, unless every
|
|
247
|
+
* group is a sequence or list of plain numbers ({@link numericRepresentative}).
|
|
248
|
+
*/
|
|
249
|
+
export function braceCandidates(word) {
|
|
250
|
+
const expanded = braceExpansions(word) ?? numericRepresentative(word);
|
|
251
|
+
if (expanded === null)
|
|
252
|
+
return null;
|
|
253
|
+
const out = [...expanded];
|
|
254
|
+
for (const each of expanded) {
|
|
255
|
+
const outsideParameters = each.replace(/\$\{[^{}]*\}/gu, "");
|
|
256
|
+
if (/[{}]/u.test(outsideParameters)) {
|
|
257
|
+
const stripped = each.replace(/\$\{[^{}]*\}|[{}]/gu, (match) => (match.length > 1 ? match : ""));
|
|
258
|
+
if (!out.includes(stripped))
|
|
259
|
+
out.push(stripped);
|
|
260
|
+
}
|
|
261
|
+
}
|
|
262
|
+
return out;
|
|
263
|
+
}
|
|
264
|
+
/**
|
|
265
|
+
* A word over the brace cap whose every group yields plain numbers only
|
|
266
|
+
* (`{1..300}`, `notes{1..500}.md`, `d{1..9}{0..99}`), as its FIRST expansion
|
|
267
|
+
* (APRV-511 fix round 3, N-a). Its words differ from one another only in runs
|
|
268
|
+
* of digits, and no organ or credential name turns on a digit, so the first
|
|
269
|
+
* answers for all of them; `echo {1..300}` stays `read.shell` instead of
|
|
270
|
+
* refusing as a credential read. `null` when any group yields anything else
|
|
271
|
+
* (`{a..z}{a..z}` can spell a name), which stays the stricter class.
|
|
272
|
+
*/
|
|
273
|
+
function numericRepresentative(word) {
|
|
274
|
+
let text = word;
|
|
275
|
+
for (let steps = 0; steps < BRACE_EXPANSION_STEPS; steps += 1) {
|
|
276
|
+
const group = firstBraceGroup(text);
|
|
277
|
+
if (group === null)
|
|
278
|
+
return [text];
|
|
279
|
+
const first = group.alternatives[0];
|
|
280
|
+
if (first === undefined || !group.alternatives.every((each) => /^-?\d+$/u.test(each)))
|
|
281
|
+
return null;
|
|
282
|
+
text = text.slice(0, group.open) + first + text.slice(group.close + 1);
|
|
283
|
+
}
|
|
284
|
+
return null;
|
|
285
|
+
}
|
|
286
|
+
/** The longest text between a sequence's braces (`-2147483648..2147483647..1` fits). */
|
|
287
|
+
const BRACE_SEQUENCE_MAX = 64;
|
|
288
|
+
/**
|
|
289
|
+
* The first brace group in `text`, with its alternatives, or `null`.
|
|
290
|
+
*
|
|
291
|
+
* One left-to-right pass pairs every brace (a `${` opener pairs too, so its
|
|
292
|
+
* `}` never closes a group, but it never opens one); an unmatched `{` is
|
|
293
|
+
* literal to bash, which keeps scanning for a later group (APRV-511 review:
|
|
294
|
+
* `.op.en{v,v}{` still expands its group). The first group is the valid pair
|
|
295
|
+
* with the leftmost opener. Linear in `text` (APRV-511 recheck S(new)).
|
|
296
|
+
*/
|
|
297
|
+
function firstBraceGroup(text) {
|
|
298
|
+
const stack = [];
|
|
299
|
+
let best = null;
|
|
300
|
+
for (let at = 0; at < text.length; at += 1) {
|
|
301
|
+
const ch = text[at];
|
|
302
|
+
if (ch === "{")
|
|
303
|
+
stack.push({ open: at, parameter: at > 0 && text[at - 1] === "$", commas: [] });
|
|
304
|
+
else if (ch === "}") {
|
|
305
|
+
const pair = stack.pop();
|
|
306
|
+
if (pair === undefined || pair.parameter)
|
|
307
|
+
continue;
|
|
308
|
+
if (best !== null && best.open < pair.open)
|
|
309
|
+
continue;
|
|
310
|
+
if (pair.commas.length > 0)
|
|
311
|
+
best = { open: pair.open, close: at, commas: pair.commas };
|
|
312
|
+
else if (at - pair.open - 1 <= BRACE_SEQUENCE_MAX && braceSequence(text.slice(pair.open + 1, at)) !== null) {
|
|
313
|
+
best = { open: pair.open, close: at, commas: [] };
|
|
314
|
+
}
|
|
315
|
+
}
|
|
316
|
+
else if (ch === ",") {
|
|
317
|
+
const top = stack[stack.length - 1];
|
|
318
|
+
if (top !== undefined)
|
|
319
|
+
top.commas.push(at);
|
|
320
|
+
}
|
|
321
|
+
}
|
|
322
|
+
if (best === null)
|
|
323
|
+
return null;
|
|
324
|
+
if (best.commas.length === 0) {
|
|
325
|
+
return { open: best.open, close: best.close, alternatives: braceSequence(text.slice(best.open + 1, best.close)) ?? [] };
|
|
326
|
+
}
|
|
327
|
+
const alternatives = [];
|
|
328
|
+
let from = best.open + 1;
|
|
329
|
+
for (const comma of [...best.commas, best.close]) {
|
|
330
|
+
alternatives.push(text.slice(from, comma));
|
|
331
|
+
from = comma + 1;
|
|
332
|
+
}
|
|
333
|
+
return { open: best.open, close: best.close, alternatives };
|
|
334
|
+
}
|
|
335
|
+
/** `1..3`, `a..c`, `1..9..2` as bash expands them, or `null` when not a sequence. */
|
|
336
|
+
function braceSequence(inner) {
|
|
337
|
+
const match = /^(-?\d+|[A-Za-z])\.\.(-?\d+|[A-Za-z])(?:\.\.(-?\d+))?$/u.exec(inner);
|
|
338
|
+
if (match === null)
|
|
339
|
+
return null;
|
|
340
|
+
const [, from, to, stepText] = match;
|
|
341
|
+
const numeric = /^-?\d+$/u.test(from) && /^-?\d+$/u.test(to);
|
|
342
|
+
if (!numeric && (/\d/u.test(from) || /\d/u.test(to)))
|
|
343
|
+
return null;
|
|
344
|
+
const start = numeric ? Number(from) : from.charCodeAt(0);
|
|
345
|
+
const end = numeric ? Number(to) : to.charCodeAt(0);
|
|
346
|
+
const step = Math.max(1, Math.abs(Number(stepText ?? "1")));
|
|
347
|
+
const out = [];
|
|
348
|
+
for (let value = start; start <= end ? value <= end : value >= end; value += start <= end ? step : -step) {
|
|
349
|
+
out.push(numeric ? String(value) : String.fromCharCode(value));
|
|
350
|
+
if (out.length > BRACE_EXPANSION_CAP)
|
|
351
|
+
break;
|
|
352
|
+
}
|
|
353
|
+
return out;
|
|
354
|
+
}
|
|
355
|
+
/**
|
|
356
|
+
* A braced parameter expansion collapsed to its bare name, `${NAME}`, before a
|
|
357
|
+
* path is split into segments (APRV-511 review round 3). An operator inside the
|
|
358
|
+
* braces may carry a slash (`${HERMES_HOME:-/x}/config.yaml`), which would
|
|
359
|
+
* otherwise split the expansion across segments so that neither half reads as
|
|
360
|
+
* the home; collapsed, `${HERMES_HOME:-/x}` is the home, as it is whenever the
|
|
361
|
+
* variable is set, and any other name is a variable segment as before.
|
|
362
|
+
*/
|
|
363
|
+
function collapseBracedVariables(candidate) {
|
|
364
|
+
return candidate.replace(/\$\{([A-Za-z_][A-Za-z0-9_]*)[^}]*\}/gu, "${$1}");
|
|
365
|
+
}
|
|
366
|
+
/**
|
|
367
|
+
* Does this path segment name a Hermes home (APRV-511)?
|
|
368
|
+
*
|
|
369
|
+
* - `"home"`: the `.hermes` segment, compared without case (a case-insensitive
|
|
370
|
+
* filesystem, macOS's default, opens `~/.Hermes/plugins` as the home's), or an
|
|
371
|
+
* unexpanded `$HERMES_HOME` / `${HERMES_HOME}`, which is the home whatever it
|
|
372
|
+
* expands to. Before this the pure classifier read `>> $HERMES_HOME/.env` as
|
|
373
|
+
* a workspace write: the Hermes hook's disk pass expanded it, but a caller
|
|
374
|
+
* with no disk pass (`approval hook classify`, Claude Code, Cursor) did not.
|
|
375
|
+
* - `"maybe"`: any other segment carrying an unexpanded variable (`$X`,
|
|
376
|
+
* `${OUT}`, `pre$N`), which can expand to the home; only the organ and
|
|
377
|
+
* credential rows below it apply, never the bare-home or scheduler rows,
|
|
378
|
+
* since `$OUT` alone or `$OUT/scripts/x.sh` is ordinary work far more often.
|
|
379
|
+
*/
|
|
380
|
+
function hermesHomeKind(segment) {
|
|
381
|
+
if (segment.toLowerCase() === ".hermes")
|
|
382
|
+
return "home";
|
|
383
|
+
if (segment === "$HERMES_HOME" || segment === "${HERMES_HOME}")
|
|
384
|
+
return "home";
|
|
385
|
+
if (/\$(?:[A-Za-z_]|\{)/u.test(segment))
|
|
386
|
+
return "maybe";
|
|
387
|
+
return null;
|
|
388
|
+
}
|
|
389
|
+
/**
|
|
390
|
+
* Judge the part of a path below a Hermes home twice, as spelled and with its
|
|
391
|
+
* `..` segments resolved, and return the strictest answer (APRV-511). A tail
|
|
392
|
+
* whose `..` climbs out of the home has no resolved reading here; the segment
|
|
393
|
+
* walk meets any later home on its own. The `.codex` row's rule, that a `..`
|
|
394
|
+
* under the organ directory is itself the organ, is the precedent; resolving
|
|
395
|
+
* instead keeps `~/.hermes/workspace/../workspace/x` ordinary.
|
|
396
|
+
*/
|
|
397
|
+
function hermesTailClass(tail, judge) {
|
|
398
|
+
const answers = [judge(tail)];
|
|
399
|
+
const resolved = [];
|
|
400
|
+
let escaped = false;
|
|
401
|
+
for (const part of tail) {
|
|
402
|
+
if (part === "..") {
|
|
403
|
+
if (resolved.length === 0) {
|
|
404
|
+
escaped = true;
|
|
405
|
+
break;
|
|
406
|
+
}
|
|
407
|
+
resolved.pop();
|
|
408
|
+
}
|
|
409
|
+
else {
|
|
410
|
+
resolved.push(part);
|
|
411
|
+
}
|
|
412
|
+
}
|
|
413
|
+
if (!escaped && tail.includes(".."))
|
|
414
|
+
answers.push(judge(resolved));
|
|
415
|
+
for (const strictest of ["policy.core", HERMES_SCHEDULED_SCRIPT_CLASS, "credential"]) {
|
|
416
|
+
if (answers.includes(strictest))
|
|
417
|
+
return strictest;
|
|
418
|
+
}
|
|
419
|
+
return null;
|
|
420
|
+
}
|
|
421
|
+
/**
|
|
422
|
+
* The tails a variable segment that MAY be the home can stand over (APRV-511
|
|
423
|
+
* refuter M1): the tail as written, and the rest after each `..` in it, since
|
|
424
|
+
* `$X/../plugins/x` is the plugin directory when `$X` is any directory one
|
|
425
|
+
* level under the home (`$(pwd)/../plugins/x` from the workspace).
|
|
426
|
+
*/
|
|
427
|
+
function maybeHomeTails(tail) {
|
|
428
|
+
const tails = [tail];
|
|
429
|
+
for (const [at, part] of tail.entries())
|
|
430
|
+
if (part === "..")
|
|
431
|
+
tails.push(tail.slice(at + 1));
|
|
432
|
+
return tails;
|
|
433
|
+
}
|
|
434
|
+
/** The tail below a profile home (`profiles/<p>/...`), or the tail itself. */
|
|
435
|
+
function hermesProfileTail(tail) {
|
|
436
|
+
return tail[0]?.toLowerCase() === "profiles" && tail[1] !== undefined ? tail.slice(2) : tail;
|
|
437
|
+
}
|
|
438
|
+
/**
|
|
439
|
+
* The class a write below a Hermes home takes, by the home's organ rows
|
|
440
|
+
* (APRV-398, APRV-445, APRV-511). Names compare without case.
|
|
441
|
+
*/
|
|
442
|
+
function hermesOrganClass(fullTail) {
|
|
443
|
+
const tail = hermesProfileTail(fullTail);
|
|
444
|
+
const next = tail[0]?.toLowerCase();
|
|
445
|
+
if (next === undefined ||
|
|
446
|
+
next === "config.yaml" ||
|
|
447
|
+
next === "config.yml" ||
|
|
448
|
+
next === "agent-hooks" ||
|
|
449
|
+
next === "approval" ||
|
|
450
|
+
next === ".env" ||
|
|
451
|
+
next.startsWith(".env.") ||
|
|
452
|
+
next === "auth.json" ||
|
|
453
|
+
next.startsWith("shell-hooks-allowlist.json") ||
|
|
454
|
+
next.startsWith("hooks") ||
|
|
455
|
+
next === "hermes-agent" ||
|
|
456
|
+
next === "plugins" ||
|
|
457
|
+
next === ".op.env" ||
|
|
458
|
+
next.startsWith(".op.env.")) {
|
|
459
|
+
return "policy.core";
|
|
460
|
+
}
|
|
461
|
+
if (next === "scripts")
|
|
462
|
+
return HERMES_SCHEDULED_SCRIPT_CLASS;
|
|
463
|
+
return null;
|
|
464
|
+
}
|
|
465
|
+
/**
|
|
466
|
+
* Is the file below a Hermes home credential material (APRV-398, APRV-511)?
|
|
467
|
+
*
|
|
468
|
+
* `.env`, `.env.*` and `auth.json`, as before; `.op.env` (and `.op.env.*`),
|
|
469
|
+
* the 1Password bootstrap file Hermes loads into the gateway; and the source
|
|
470
|
+
* install's `hermes-agent/.env` and `.env.*`, its PROJECT_ROOT dotenv, except
|
|
471
|
+
* `.env.example`, the template the install ships. Reading the rest of the
|
|
472
|
+
* runtime's source is not a credential read. Names compare without case.
|
|
473
|
+
*/
|
|
474
|
+
function hermesCredentialTail(fullTail) {
|
|
475
|
+
const tail = hermesProfileTail(fullTail);
|
|
476
|
+
const next = tail[0]?.toLowerCase();
|
|
477
|
+
if (next === undefined)
|
|
478
|
+
return false;
|
|
479
|
+
if (next === ".env" || next.startsWith(".env."))
|
|
480
|
+
return true;
|
|
481
|
+
if (next === "auth.json")
|
|
482
|
+
return true;
|
|
483
|
+
if (next === ".op.env" || next.startsWith(".op.env."))
|
|
484
|
+
return true;
|
|
485
|
+
if (next === "hermes-agent") {
|
|
486
|
+
const file = tail[1]?.toLowerCase();
|
|
487
|
+
// `.env.example` exactly: on a case-sensitive filesystem `.env.EXAMPLE` is
|
|
488
|
+
// another file, and only the template the install ships is exempt.
|
|
489
|
+
return file !== undefined && (file === ".env" || (file.startsWith(".env.") && tail[1] !== ".env.example"));
|
|
490
|
+
}
|
|
491
|
+
return false;
|
|
492
|
+
}
|
|
178
493
|
/**
|
|
179
494
|
* Which protected class does this path name, if any? (APRV-198.)
|
|
180
495
|
*
|
|
@@ -233,7 +548,24 @@ function matchesEntry(segments, entry) {
|
|
|
233
548
|
export function protectedPathClass(candidate, extra = []) {
|
|
234
549
|
if (candidate.length === 0)
|
|
235
550
|
return null;
|
|
236
|
-
|
|
551
|
+
// APRV-511 refuter M2: bash expands an unquoted brace group before the
|
|
552
|
+
// command sees the word, so `~/.hermes/plugin{s,s}/x` IS the plugin
|
|
553
|
+
// directory. Each expansion is judged and the strictest answers; a group too
|
|
554
|
+
// large to expand is the stricter class.
|
|
555
|
+
const expansions = braceCandidates(candidate);
|
|
556
|
+
if (expansions === null)
|
|
557
|
+
return "policy.core";
|
|
558
|
+
let strictest = null;
|
|
559
|
+
for (const expansion of expansions) {
|
|
560
|
+
const cls = protectedPathClassOfWord(expansion, extra);
|
|
561
|
+
if (cls !== null && (strictest === null || protectedRank(cls) < protectedRank(strictest)))
|
|
562
|
+
strictest = cls;
|
|
563
|
+
}
|
|
564
|
+
return strictest;
|
|
565
|
+
}
|
|
566
|
+
/** {@link protectedPathClass} for one word that brace expansion has already made. */
|
|
567
|
+
function protectedPathClassOfWord(candidate, extra) {
|
|
568
|
+
const segments = pathSegments(collapseBracedVariables(candidate));
|
|
237
569
|
// 1. The log directory, before anything else that would call it a policy file.
|
|
238
570
|
for (let index = 0; index < segments.length; index += 1) {
|
|
239
571
|
if (segments[index] === ".approval" && segments[index + 1] === "log")
|
|
@@ -346,26 +678,32 @@ export function protectedPathClass(candidate, extra = []) {
|
|
|
346
678
|
// process it starts, which makes it the switch for the gate itself
|
|
347
679
|
// (`HERMES_ACCEPT_HOOKS`) as well as a secret store. Reads stay
|
|
348
680
|
// `account.credential`.
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
681
|
+
//
|
|
682
|
+
// APRV-511 adds the home's three other organs, from the custody refutation
|
|
683
|
+
// (M1) and Hermes v2026.9.24's own loaders. `hermes-agent/` is the runtime's
|
|
684
|
+
// source install on an Agent Village box: its `.env` is the PROJECT_ROOT
|
|
685
|
+
// `.env` that `load_hermes_dotenv` loads into the gateway after the home's
|
|
686
|
+
// (setting any name the home's left absent), its `plugins/` is the bundled
|
|
687
|
+
// plugin directory, and the rest is the code the gateway runs. `plugins/` is
|
|
688
|
+
// where user plugins load from, the gate's own `av-approval` among them, so
|
|
689
|
+
// an edit there is an edit of the gate. `.op.env` is loaded the same way as
|
|
690
|
+
// the project `.env` (`override=False`). A WRITE to any of them is
|
|
691
|
+
// `policy.core`; a READ of `.op.env` or `hermes-agent/.env` is
|
|
692
|
+
// `account.credential` (`isCredentialPath`), and a read of the rest of the
|
|
693
|
+
// runtime's source stays what it was.
|
|
694
|
+
// APRV-511: the home is matched by the `.hermes` segment in any case, by an
|
|
695
|
+
// unexpanded `$HERMES_HOME`/`${HERMES_HOME}` (which IS the home), and by any
|
|
696
|
+
// other unexpanded variable (which MAY be the home, so only its organ rows
|
|
697
|
+
// apply); the tail is judged as spelled and with its `..` resolved. See
|
|
698
|
+
// {@link hermesHomeKind} and {@link hermesOrganClass}.
|
|
699
|
+
const home = segment === undefined ? null : hermesHomeKind(segment);
|
|
700
|
+
if (home !== null) {
|
|
701
|
+
const cls = hermesTailClass(segments.slice(index + 1), hermesOrganClass);
|
|
702
|
+
if (home === "home" && cls !== null)
|
|
703
|
+
return cls;
|
|
704
|
+
if (home === "maybe" && maybeHomeTails(segments.slice(index + 1)).some((tail) => tail.length > 0 && hermesTailClass(tail, hermesOrganClass) === "policy.core")) {
|
|
365
705
|
return "policy.core";
|
|
366
706
|
}
|
|
367
|
-
if (next === "scripts")
|
|
368
|
-
return HERMES_SCHEDULED_SCRIPT_CLASS;
|
|
369
707
|
}
|
|
370
708
|
// Codex installs its hook through these configuration and script paths.
|
|
371
709
|
if (segment === ".codex") {
|
|
@@ -513,7 +851,80 @@ const CREDENTIAL_CLASS = "account.credential";
|
|
|
513
851
|
export function isCredentialPath(candidate) {
|
|
514
852
|
if (candidate.length === 0)
|
|
515
853
|
return false;
|
|
516
|
-
|
|
854
|
+
// APRV-511 refuter M2: any brace expansion that names a credential is one.
|
|
855
|
+
const expansions = braceCandidates(candidate);
|
|
856
|
+
if (expansions === null)
|
|
857
|
+
return true;
|
|
858
|
+
return expansions.some((expansion) => isCredentialWord(expansion));
|
|
859
|
+
}
|
|
860
|
+
/**
|
|
861
|
+
* {@link isCredentialPath} for one word that brace expansion has already made.
|
|
862
|
+
*
|
|
863
|
+
* Merge of APRV-511 and APRV-522: the word is judged as written and with its
|
|
864
|
+
* braced variables collapsed (APRV-511), and each of those on every splitting,
|
|
865
|
+
* as written and with `..` resolved, case folded and not (APRV-522). True when
|
|
866
|
+
* any reading names credential material, so neither side's rule is narrowed.
|
|
867
|
+
*/
|
|
868
|
+
function isCredentialWord(word) {
|
|
869
|
+
const collapsed = collapseBracedVariables(word);
|
|
870
|
+
const candidates = collapsed === word ? [word] : [word, collapsed];
|
|
871
|
+
return candidates.some((candidate) => credentialWordReadings(candidate));
|
|
872
|
+
}
|
|
873
|
+
function credentialWordReadings(candidate) {
|
|
874
|
+
// APRV-522 (M3). Judged on two spellings of the same path, and true when
|
|
875
|
+
// either one names credential material:
|
|
876
|
+
//
|
|
877
|
+
// - the segments as written, case folded, which is how the rows always read
|
|
878
|
+
// (a `..` before the approval home keeps its `.approval` run);
|
|
879
|
+
// - the same segments with `..` resolved lexically, so a `..` out of a
|
|
880
|
+
// sibling directory inside the approval home lands on the file it names.
|
|
881
|
+
// Pure text cannot say what a symlinked parent does to
|
|
882
|
+
// a `..`; the hook's disk pass judges the physical landing as well.
|
|
883
|
+
//
|
|
884
|
+
// Case is folded on every platform. macOS and Windows volumes are case
|
|
885
|
+
// insensitive by default, so an upper-case spelling opens the environment map
|
|
886
|
+
// there; on a case-sensitive volume the fold names a different file as a
|
|
887
|
+
// credential, which costs an approval prompt and never an exposure.
|
|
888
|
+
//
|
|
889
|
+
// APRV-522 fix round 2 (path-parser differential): a backslash separates
|
|
890
|
+
// segments only on Windows. On macOS and Linux it is a character of a name,
|
|
891
|
+
// so a profile directory whose name holds one is ONE segment to the kernel
|
|
892
|
+
// and two to the split above, which moves the profile row's offset. When the
|
|
893
|
+
// path holds a backslash, the `/`-only reading is judged as well.
|
|
894
|
+
const splittings = [pathSegments(candidate)];
|
|
895
|
+
if (candidate.includes("\\")) {
|
|
896
|
+
splittings.push(candidate.split(/\/+/u).filter((segment) => segment.length > 0 && segment !== "."));
|
|
897
|
+
}
|
|
898
|
+
return splittings.some((segments) => credentialSegmentsAsWrittenOrResolved(segments));
|
|
899
|
+
}
|
|
900
|
+
/**
|
|
901
|
+
* The credential rows over one splitting, as written and with `..` resolved,
|
|
902
|
+
* case folded (APRV-522) and as spelled (APRV-511's rows read an unexpanded
|
|
903
|
+
* `$HERMES_HOME` by its exact spelling, which the fold would turn into a
|
|
904
|
+
* "maybe" home).
|
|
905
|
+
*/
|
|
906
|
+
function credentialSegmentsAsWrittenOrResolved(segments) {
|
|
907
|
+
const folded = segments.map((segment) => segment.toLowerCase());
|
|
908
|
+
const spellings = folded.every((segment, index) => segment === segments[index]) ? [folded] : [folded, [...segments]];
|
|
909
|
+
for (const written of spellings) {
|
|
910
|
+
if (credentialSegments(written))
|
|
911
|
+
return true;
|
|
912
|
+
if (!written.includes(".."))
|
|
913
|
+
continue;
|
|
914
|
+
const resolved = [];
|
|
915
|
+
for (const segment of written) {
|
|
916
|
+
if (segment === ".." && resolved.length > 0 && resolved[resolved.length - 1] !== "..")
|
|
917
|
+
resolved.pop();
|
|
918
|
+
else
|
|
919
|
+
resolved.push(segment);
|
|
920
|
+
}
|
|
921
|
+
if (credentialSegments(resolved))
|
|
922
|
+
return true;
|
|
923
|
+
}
|
|
924
|
+
return false;
|
|
925
|
+
}
|
|
926
|
+
/** The credential rows over already-split segments. */
|
|
927
|
+
function credentialSegments(segments) {
|
|
517
928
|
for (let index = 0; index < segments.length; index += 1) {
|
|
518
929
|
const segment = segments[index];
|
|
519
930
|
// APRV-398. Hermes Agent's own home holds two files of credential material
|
|
@@ -527,13 +938,14 @@ export function isCredentialPath(candidate) {
|
|
|
527
938
|
// whose organ this codebase now recognises, whose credential files it did
|
|
528
939
|
// not, would be a Never-list item the classifier does not enforce — which is
|
|
529
940
|
// exactly the hole APRV-194 was filed for, one harness along.
|
|
530
|
-
|
|
531
|
-
|
|
532
|
-
|
|
533
|
-
|
|
534
|
-
|
|
535
|
-
|
|
536
|
-
|
|
941
|
+
// APRV-511: the same home matching as `protectedPathClass` (any case, an
|
|
942
|
+
// unexpanded `$HERMES_HOME`, any other unexpanded variable, `..` resolved
|
|
943
|
+
// in the tail); see {@link hermesCredentialTail}.
|
|
944
|
+
const home = segment === undefined ? null : hermesHomeKind(segment);
|
|
945
|
+
if (home !== null) {
|
|
946
|
+
const credential = (tail) => hermesTailClass(tail, (t) => (hermesCredentialTail(t) ? "credential" : null)) !== null;
|
|
947
|
+
const tails = home === "maybe" ? maybeHomeTails(segments.slice(index + 1)) : [segments.slice(index + 1)];
|
|
948
|
+
if (tails.some(credential))
|
|
537
949
|
return true;
|
|
538
950
|
continue;
|
|
539
951
|
}
|
|
@@ -541,7 +953,7 @@ export function isCredentialPath(candidate) {
|
|
|
541
953
|
continue;
|
|
542
954
|
const next = segments[index + 1];
|
|
543
955
|
if (next === undefined)
|
|
544
|
-
|
|
956
|
+
continue;
|
|
545
957
|
if (next.startsWith("vault"))
|
|
546
958
|
return true;
|
|
547
959
|
if (next === "keys")
|
|
@@ -567,6 +979,20 @@ export function isCredentialPath(candidate) {
|
|
|
567
979
|
* exfiltrating one is the one worth naming: a `cp` touching credential material
|
|
568
980
|
* is `account.credential` in either direction. Both classes are gated, so the
|
|
569
981
|
* choice is about what the approver is told, not about whether they are asked.
|
|
982
|
+
*
|
|
983
|
+
* `git` left this list with APRV-522 (M2). It was here for `git add`, `git rm`
|
|
984
|
+
* and `git checkout -- <path>` (still exempt, through
|
|
985
|
+
* {@link GIT_PATH_WRITE_OPTIONS}), and it made the credential rule decline every
|
|
986
|
+
* git verb, the readers included: diff and grep without an index, log with a
|
|
987
|
+
* patch and show of a `<rev>:<path>` print the file, and classified
|
|
988
|
+
* `read.shell`. git is direction-blind the way `cp` is, so
|
|
989
|
+
* it is judged the way `cp` is, and its credential words stay in the protected
|
|
990
|
+
* scan ({@link credentialProtectedScanKeeps}), so a writing verb keeps its
|
|
991
|
+
* `policy.core` answer with the credential class carried beside it.
|
|
992
|
+
*
|
|
993
|
+
* `ln` left with it. A link to the environment map is a second name for the
|
|
994
|
+
* secret that a later read can open, so its source is a read of
|
|
995
|
+
* the material; it is kept in the protected scan for the same reason git is.
|
|
570
996
|
*/
|
|
571
997
|
const CREDENTIAL_WRITE_BINS = [
|
|
572
998
|
"rm",
|
|
@@ -575,11 +1001,9 @@ const CREDENTIAL_WRITE_BINS = [
|
|
|
575
1001
|
"truncate",
|
|
576
1002
|
"chmod",
|
|
577
1003
|
"chown",
|
|
578
|
-
"ln",
|
|
579
1004
|
"touch",
|
|
580
1005
|
"mkdir",
|
|
581
1006
|
"rmdir",
|
|
582
|
-
"git",
|
|
583
1007
|
"dd",
|
|
584
1008
|
"install",
|
|
585
1009
|
];
|
|
@@ -674,17 +1098,214 @@ function secretEnvReference(word) {
|
|
|
674
1098
|
* appear in the log" invariant survives a refusal message that names what it
|
|
675
1099
|
* refused.
|
|
676
1100
|
*/
|
|
677
|
-
|
|
1101
|
+
/**
|
|
1102
|
+
* Does this binary only WRITE the paths it names? ({@link CREDENTIAL_WRITE_BINS}
|
|
1103
|
+
* plus `sed -i`.) Then a credential path among its positionals is a write to the
|
|
1104
|
+
* gate's directory rather than a read of the material.
|
|
1105
|
+
*/
|
|
1106
|
+
function credentialWritesOnly(basename, args) {
|
|
678
1107
|
const inPlaceSed = basename === "sed" &&
|
|
679
1108
|
(hasFlag(args, ["--in-place"]) ||
|
|
680
1109
|
args.some((arg) => arg.startsWith("-i") && !arg.startsWith("--")));
|
|
681
|
-
const
|
|
682
|
-
|
|
683
|
-
|
|
684
|
-
|
|
685
|
-
|
|
1110
|
+
const gitPathWrite = basename === "git" && gitWritesNamedPathsOnly(args);
|
|
1111
|
+
return CREDENTIAL_WRITE_BINS.includes(basename) || inPlaceSed || gitPathWrite;
|
|
1112
|
+
}
|
|
1113
|
+
/**
|
|
1114
|
+
* Does this git argv only WRITE the paths it names? (APRV-522.) `true` only
|
|
1115
|
+
* when the verb is read unambiguously, is one of
|
|
1116
|
+
* {@link GIT_PATH_WRITE_OPTIONS}, and every option before `--` is on that
|
|
1117
|
+
* verb's allow-list.
|
|
1118
|
+
*
|
|
1119
|
+
* The verb is found the way APRV-520's {@link gitGlobalRefusal} reads the
|
|
1120
|
+
* globals: zero-value globals are skipped, and ANY other flag before the verb
|
|
1121
|
+
* (a global that takes a value, `-C dir`, `-c k=v`, `--git-dir x`, a joined
|
|
1122
|
+
* `--work-tree=`, or one this table does not know) leaves the verb unknown, so
|
|
1123
|
+
* the argv is a READER. `classifySegment` refuses those globals anyway; this
|
|
1124
|
+
* does not rely on that ordering.
|
|
1125
|
+
*
|
|
1126
|
+
* The options are a closed list, never a deny-list: git accepts any unique
|
|
1127
|
+
* prefix of a long option (`--pat` is `--patch`), several verbs print the
|
|
1128
|
+
* file's hunks (`git add -p`, `git checkout -p`, `git restore --patch`), and
|
|
1129
|
+
* `git checkout-index --prefix=/tmp/` writes a copy elsewhere. So an option
|
|
1130
|
+
* not listed for the verb (an abbreviation, a short letter bundled with one
|
|
1131
|
+
* not listed, `-fp`, an unknown flag) makes the argv a reader, and the
|
|
1132
|
+
* credential rule judges what it names.
|
|
1133
|
+
*/
|
|
1134
|
+
/**
|
|
1135
|
+
* Where git's verb sits in its argv, read the way {@link gitWritesNamedPathsOnly}
|
|
1136
|
+
* reads it (APRV-522 fix round 2): only zero-value globals may come before it,
|
|
1137
|
+
* and any other flag leaves the verb unknown (`null`). The hook's credential
|
|
1138
|
+
* pass uses it so that, under a directory a `CDPATH` lookup may have chosen,
|
|
1139
|
+
* the verb word is not judged as a path: git never opens its verb.
|
|
1140
|
+
*/
|
|
1141
|
+
export function gitVerbIndex(args) {
|
|
1142
|
+
let at = 0;
|
|
1143
|
+
while (at < args.length && isFlag(args[at])) {
|
|
1144
|
+
if (!GIT_ZERO_VALUE_GLOBALS.includes(args[at]))
|
|
1145
|
+
return null;
|
|
1146
|
+
at += 1;
|
|
1147
|
+
}
|
|
1148
|
+
return at < args.length ? at : null;
|
|
1149
|
+
}
|
|
1150
|
+
function gitWritesNamedPathsOnly(args) {
|
|
1151
|
+
let at = 0;
|
|
1152
|
+
while (at < args.length && isFlag(args[at])) {
|
|
1153
|
+
if (!GIT_ZERO_VALUE_GLOBALS.includes(args[at]))
|
|
1154
|
+
return false;
|
|
1155
|
+
at += 1;
|
|
1156
|
+
}
|
|
1157
|
+
const verb = args[at];
|
|
1158
|
+
if (verb === undefined || !Object.hasOwn(GIT_PATH_WRITE_OPTIONS, verb))
|
|
1159
|
+
return false;
|
|
1160
|
+
const allowed = GIT_PATH_WRITE_OPTIONS[verb];
|
|
1161
|
+
for (const arg of args.slice(at + 1)) {
|
|
1162
|
+
if (arg === "--")
|
|
1163
|
+
break;
|
|
1164
|
+
if (!isFlag(arg))
|
|
1165
|
+
continue;
|
|
1166
|
+
if (arg.startsWith("--")) {
|
|
1167
|
+
if (allowed.long.includes(arg))
|
|
1168
|
+
continue;
|
|
1169
|
+
if (allowed.joined.some((prefix) => arg.startsWith(prefix)))
|
|
1170
|
+
continue;
|
|
1171
|
+
return false;
|
|
1172
|
+
}
|
|
1173
|
+
if (!Array.from(arg.slice(1)).every((letter) => allowed.short.includes(letter)))
|
|
1174
|
+
return false;
|
|
1175
|
+
}
|
|
1176
|
+
return true;
|
|
1177
|
+
}
|
|
1178
|
+
/**
|
|
1179
|
+
* The git verbs whose named paths are only WRITTEN in the worktree or the
|
|
1180
|
+
* index (APRV-522), each with the options (git 2.50) known not to print file
|
|
1181
|
+
* content or copy it elsewhere. The exemption git had as a whole, narrowed to
|
|
1182
|
+
* the verbs and spellings it was for. Every other verb, an unknown one
|
|
1183
|
+
* included, reads what it names; `stash` is absent on purpose (`git stash
|
|
1184
|
+
* show -p` prints). `--pathspec-from-file=` is never listed: it reads a file.
|
|
1185
|
+
*/
|
|
1186
|
+
const GIT_PATH_WRITE_OPTIONS = {
|
|
1187
|
+
add: {
|
|
1188
|
+
short: "fuANnv",
|
|
1189
|
+
long: ["--force", "--update", "--all", "--no-all", "--intent-to-add", "--dry-run", "--verbose", "--ignore-errors", "--ignore-missing", "--renormalize", "--sparse", "--ignore-removal", "--no-ignore-removal"],
|
|
1190
|
+
joined: ["--chmod="],
|
|
1191
|
+
},
|
|
1192
|
+
rm: {
|
|
1193
|
+
short: "frnq",
|
|
1194
|
+
long: ["--force", "--dry-run", "--cached", "--quiet", "--ignore-unmatch", "--sparse"],
|
|
1195
|
+
joined: [],
|
|
1196
|
+
},
|
|
1197
|
+
mv: { short: "fknv", long: ["--force", "--dry-run", "--verbose", "--sparse"], joined: [] },
|
|
1198
|
+
restore: {
|
|
1199
|
+
short: "sSWqm",
|
|
1200
|
+
long: ["--source", "--staged", "--worktree", "--quiet", "--ours", "--theirs", "--merge", "--ignore-unmerged", "--overlay", "--no-overlay", "--progress", "--no-progress"],
|
|
1201
|
+
joined: ["--source="],
|
|
1202
|
+
},
|
|
1203
|
+
checkout: {
|
|
1204
|
+
short: "fqmbB",
|
|
1205
|
+
long: ["--force", "--quiet", "--ours", "--theirs", "--merge", "--overlay", "--no-overlay", "--ignore-skip-worktree-bits", "--progress", "--no-progress"],
|
|
1206
|
+
joined: [],
|
|
1207
|
+
},
|
|
1208
|
+
switch: {
|
|
1209
|
+
short: "cCdfqm",
|
|
1210
|
+
long: ["--create", "--force-create", "--detach", "--force", "--discard-changes", "--quiet", "--merge", "--progress", "--no-progress"],
|
|
1211
|
+
joined: [],
|
|
1212
|
+
},
|
|
1213
|
+
reset: { short: "qN", long: ["--quiet", "--soft", "--mixed", "--hard", "--keep", "--merge", "--no-refresh"], joined: [] },
|
|
1214
|
+
clean: { short: "fdxXnq", long: ["--force", "--dry-run", "--quiet"], joined: [] },
|
|
1215
|
+
"update-index": {
|
|
1216
|
+
short: "q",
|
|
1217
|
+
long: ["--add", "--remove", "--force-remove", "--refresh", "--really-refresh", "--assume-unchanged", "--no-assume-unchanged", "--skip-worktree", "--no-skip-worktree"],
|
|
1218
|
+
joined: ["--chmod="],
|
|
1219
|
+
},
|
|
1220
|
+
"checkout-index": { short: "fuqna", long: ["--force", "--index", "--quiet", "--no-create", "--all"], joined: [] },
|
|
1221
|
+
};
|
|
1222
|
+
/**
|
|
1223
|
+
* Binaries whose credential words stay in the protected-path scan although
|
|
1224
|
+
* they READ them (APRV-522). `git` is direction-blind: one verb writes the
|
|
1225
|
+
* gate's directory and another prints the secret, from the same argv shape. Keeping its credential words in the scan means a
|
|
1226
|
+
* writing verb keeps `policy.core` (the stricter surface by
|
|
1227
|
+
* {@link outranksCredential}) with `account.credential` carried beside it, and
|
|
1228
|
+
* a reading verb, which the protected scan never reads, answers
|
|
1229
|
+
* `account.credential`.
|
|
1230
|
+
*/
|
|
1231
|
+
function credentialProtectedScanKeeps(basename) {
|
|
1232
|
+
return basename === "git" || basename === "ln";
|
|
1233
|
+
}
|
|
1234
|
+
/**
|
|
1235
|
+
* The words of a segment the credential rule reads as paths (APRV-522), for
|
|
1236
|
+
* the pure classifier and the hook's disk pass alike: one judgement, so the two
|
|
1237
|
+
* cannot disagree about which words a reader opens.
|
|
1238
|
+
*
|
|
1239
|
+
* Empty for a binary that only WRITES what it names
|
|
1240
|
+
* ({@link credentialWritesOnly}). Otherwise:
|
|
1241
|
+
*
|
|
1242
|
+
* - every positional (the words that are not flags, a flag's separate value
|
|
1243
|
+
* included);
|
|
1244
|
+
* - the value of every `--name=value` flag and of every short flag carrying
|
|
1245
|
+
* its value attached (`-fFILE`), because a reader's file operand may arrive
|
|
1246
|
+
* inside its own option (M6);
|
|
1247
|
+
* - the file half of an upload spelling, `@FILE` and `name=@FILE;type=…`, and
|
|
1248
|
+
* `name=<FILE`, which curl reads the same way;
|
|
1249
|
+
* - for git, the path half of a `<rev>:<path>` or `:<path>` word, pathspec
|
|
1250
|
+
* magic (`:(top)`, `:/`) stripped, because show and cat-file print the blob
|
|
1251
|
+
* such a word names.
|
|
1252
|
+
*
|
|
1253
|
+
* Over-reading a word costs an approval prompt when it happens to spell a
|
|
1254
|
+
* credential path; under-reading one costs the property.
|
|
1255
|
+
*/
|
|
1256
|
+
export function credentialReadWords(basename, args) {
|
|
1257
|
+
if (credentialWritesOnly(basename, args))
|
|
1258
|
+
return [];
|
|
1259
|
+
const words = [];
|
|
1260
|
+
for (const arg of args) {
|
|
1261
|
+
if (!isFlag(arg)) {
|
|
1262
|
+
words.push(arg);
|
|
1263
|
+
}
|
|
1264
|
+
else if (arg.startsWith("--")) {
|
|
1265
|
+
const equals = arg.indexOf("=");
|
|
1266
|
+
if (equals !== -1 && equals < arg.length - 1)
|
|
1267
|
+
words.push(arg.slice(equals + 1));
|
|
1268
|
+
}
|
|
1269
|
+
else if (arg.length > 2) {
|
|
1270
|
+
words.push(arg.slice(2));
|
|
1271
|
+
const equals = arg.indexOf("=");
|
|
1272
|
+
if (equals !== -1 && equals < arg.length - 1)
|
|
1273
|
+
words.push(arg.slice(equals + 1));
|
|
1274
|
+
}
|
|
1275
|
+
}
|
|
1276
|
+
// A snapshot: the loop appends to `words`.
|
|
1277
|
+
const spelled = words.slice();
|
|
1278
|
+
for (const word of spelled) {
|
|
1279
|
+
for (let at = word.search(/[@<]/u); at !== -1;) {
|
|
1280
|
+
const rest = (word.slice(at + 1).split(";")[0] ?? "").trim();
|
|
1281
|
+
if (rest.length > 0)
|
|
1282
|
+
words.push(rest);
|
|
1283
|
+
const next = word.slice(at + 1).search(/[@<]/u);
|
|
1284
|
+
at = next === -1 ? -1 : at + 1 + next;
|
|
1285
|
+
}
|
|
1286
|
+
}
|
|
1287
|
+
if (basename !== "git")
|
|
1288
|
+
return words;
|
|
1289
|
+
const out = [];
|
|
1290
|
+
for (const word of words) {
|
|
1291
|
+
out.push(word);
|
|
1292
|
+
for (let at = word.indexOf(":"); at !== -1; at = word.indexOf(":", at + 1)) {
|
|
1293
|
+
let rest = word.slice(at + 1);
|
|
1294
|
+
if (rest.startsWith("(")) {
|
|
1295
|
+
const close = rest.indexOf(")");
|
|
1296
|
+
rest = close === -1 ? rest.slice(1) : rest.slice(close + 1);
|
|
1297
|
+
}
|
|
1298
|
+
if (rest.length > 0)
|
|
1299
|
+
out.push(rest);
|
|
686
1300
|
}
|
|
687
1301
|
}
|
|
1302
|
+
return out;
|
|
1303
|
+
}
|
|
1304
|
+
function credentialTouch(basename, args) {
|
|
1305
|
+
const named = credentialReadWords(basename, args).find((word) => isCredentialPath(word));
|
|
1306
|
+
if (named !== undefined) {
|
|
1307
|
+
return { class: CREDENTIAL_CLASS, rule: "credential-path", path: named };
|
|
1308
|
+
}
|
|
688
1309
|
for (const word of args) {
|
|
689
1310
|
if (secretEnvReference(word) !== null) {
|
|
690
1311
|
return { class: CREDENTIAL_CLASS, rule: "credential-env" };
|
|
@@ -692,6 +1313,56 @@ function credentialTouch(basename, args, positionals) {
|
|
|
692
1313
|
}
|
|
693
1314
|
return null;
|
|
694
1315
|
}
|
|
1316
|
+
/**
|
|
1317
|
+
* The credential an upload or request-body option reads (fix round 4,
|
|
1318
|
+
* M(L5f-1)): a source path that is a credential file, else an embedded value
|
|
1319
|
+
* whose file the text cannot read ({@link uploadSourcePaths}), which may be
|
|
1320
|
+
* one. The rule id is the argument rule's own.
|
|
1321
|
+
*/
|
|
1322
|
+
function uploadCredentialTouch(sources, unknown) {
|
|
1323
|
+
const named = sources.find((path) => isCredentialPath(path));
|
|
1324
|
+
if (named !== undefined)
|
|
1325
|
+
return { class: CREDENTIAL_CLASS, rule: "credential-path", path: named };
|
|
1326
|
+
if (unknown !== null)
|
|
1327
|
+
return { class: CREDENTIAL_CLASS, rule: "credential-path", path: unknown };
|
|
1328
|
+
return null;
|
|
1329
|
+
}
|
|
1330
|
+
/**
|
|
1331
|
+
* Is this segment a credential touch through a REDIRECTION? (APRV-503.)
|
|
1332
|
+
*
|
|
1333
|
+
* The same two shapes {@link credentialTouch} reads from the argument list, read
|
|
1334
|
+
* from the redirection targets, which the lexer keeps out of the word list. An
|
|
1335
|
+
* input redirection's direction is known in a way a positional's is not: `<`
|
|
1336
|
+
* (and `0<`, `3<`, any descriptor) opens its target for READING, whatever the
|
|
1337
|
+
* binary in front of it is, so a credential file there is a read of the
|
|
1338
|
+
* material and the {@link CREDENTIAL_WRITE_BINS} exemption does not apply
|
|
1339
|
+
* (`tee out.txt < .approval/env` copies the secret out). Until this, `cat <
|
|
1340
|
+
* ~/.hermes/.env` was `read.shell` while `cat ~/.hermes/.env` was
|
|
1341
|
+
* `account.credential`, and so was `echo "$(< ~/.hermes/.env)"`, the bash idiom
|
|
1342
|
+
* whose inner command is nothing but the redirection.
|
|
1343
|
+
*
|
|
1344
|
+
* A secret-named variable in ANY redirection target, read or write, carries the
|
|
1345
|
+
* secret the way it does in an argument: the shell expands it into a file name,
|
|
1346
|
+
* and a missing file prints that name on stderr.
|
|
1347
|
+
*
|
|
1348
|
+
* The rule id is the argument rule's own, so a channel, a doc table and a
|
|
1349
|
+
* policy reader see one mechanism; `path` names the target word.
|
|
1350
|
+
*
|
|
1351
|
+
* This answers WHETHER a redirect touches credential material, not which
|
|
1352
|
+
* class the segment takes: `classifySegment` defers it whenever the segment
|
|
1353
|
+
* names a protected path that outranks the credential class
|
|
1354
|
+
* ({@link outranksCredential}), so a redirect can never loosen an answer.
|
|
1355
|
+
*/
|
|
1356
|
+
function redirectCredentialTouch(readTargets, allTargets) {
|
|
1357
|
+
const named = readTargets.find((target) => isCredentialPath(target));
|
|
1358
|
+
if (named !== undefined) {
|
|
1359
|
+
return { class: CREDENTIAL_CLASS, rule: "credential-path", path: named };
|
|
1360
|
+
}
|
|
1361
|
+
if (allTargets.some((target) => secretEnvReference(target) !== null)) {
|
|
1362
|
+
return { class: CREDENTIAL_CLASS, rule: "credential-env" };
|
|
1363
|
+
}
|
|
1364
|
+
return null;
|
|
1365
|
+
}
|
|
695
1366
|
/** `printenv` prints one variable, or all of them. */
|
|
696
1367
|
function refinePrintenv(ctx) {
|
|
697
1368
|
if (ctx.positionals.length === 0) {
|
|
@@ -746,22 +1417,35 @@ function lex(command) {
|
|
|
746
1417
|
const segments = [];
|
|
747
1418
|
let words = [];
|
|
748
1419
|
let redirects = [];
|
|
1420
|
+
let bodySubstitutions = [];
|
|
749
1421
|
let opaque = null;
|
|
1422
|
+
let heredoc = false;
|
|
1423
|
+
let stdinFed = false;
|
|
1424
|
+
let depth = 0;
|
|
750
1425
|
let segmentStart = 0;
|
|
751
1426
|
let index = 0;
|
|
752
1427
|
const pending = [];
|
|
753
1428
|
const flush = (end) => {
|
|
754
|
-
if (words.length > 0 || redirects.length > 0 || opaque !== null) {
|
|
1429
|
+
if (words.length > 0 || redirects.length > 0 || bodySubstitutions.length > 0 || opaque !== null) {
|
|
755
1430
|
segments.push({
|
|
756
1431
|
text: command.slice(segmentStart, end).trim(),
|
|
757
1432
|
words,
|
|
758
1433
|
redirects,
|
|
1434
|
+
bodySubstitutions,
|
|
759
1435
|
opaque,
|
|
1436
|
+
heredoc,
|
|
1437
|
+
stdinFed,
|
|
1438
|
+
subshell: depth !== 0,
|
|
760
1439
|
});
|
|
1440
|
+
// Kept across an EMPTY flush, so the `&` of `|&` (or a newline after a
|
|
1441
|
+
// trailing `|`) does not drop the pipe into the next real segment.
|
|
1442
|
+
stdinFed = false;
|
|
761
1443
|
}
|
|
762
1444
|
words = [];
|
|
763
1445
|
redirects = [];
|
|
1446
|
+
bodySubstitutions = [];
|
|
764
1447
|
opaque = null;
|
|
1448
|
+
heredoc = false;
|
|
765
1449
|
segmentStart = end;
|
|
766
1450
|
};
|
|
767
1451
|
/**
|
|
@@ -772,6 +1456,9 @@ function lex(command) {
|
|
|
772
1456
|
const readWord = () => {
|
|
773
1457
|
let text = "";
|
|
774
1458
|
let quoted = false;
|
|
1459
|
+
let splits = false;
|
|
1460
|
+
let expands = false;
|
|
1461
|
+
let exact = true;
|
|
775
1462
|
const substitutions = [];
|
|
776
1463
|
const start = index;
|
|
777
1464
|
for (; index < command.length; index += 1) {
|
|
@@ -783,6 +1470,9 @@ function lex(command) {
|
|
|
783
1470
|
if (ch === "\\") {
|
|
784
1471
|
const next = command[index + 1];
|
|
785
1472
|
if (next === undefined) {
|
|
1473
|
+
// APRV-510 (refuter N1): a backslash ending the input is dropped by
|
|
1474
|
+
// a shell that reads the line as a script, so the text is not its.
|
|
1475
|
+
exact = false;
|
|
786
1476
|
text += "\\";
|
|
787
1477
|
continue;
|
|
788
1478
|
}
|
|
@@ -809,6 +1499,8 @@ function lex(command) {
|
|
|
809
1499
|
substitutions.push(...scan.substitutions);
|
|
810
1500
|
if (scan.opaque !== null)
|
|
811
1501
|
opaque = scan.opaque;
|
|
1502
|
+
if (!scan.exact)
|
|
1503
|
+
exact = false;
|
|
812
1504
|
quoted = true;
|
|
813
1505
|
index = scan.end;
|
|
814
1506
|
continue;
|
|
@@ -837,9 +1529,28 @@ function lex(command) {
|
|
|
837
1529
|
if (scan === null)
|
|
838
1530
|
return null;
|
|
839
1531
|
substitutions.push(scan.inner);
|
|
1532
|
+
// APRV-511 refuter M1: the substitution's OUTPUT is part of the word,
|
|
1533
|
+
// and a path rule must not read `$(echo ~/.hermes)/plugins/x` as
|
|
1534
|
+
// `/plugins/x`. The sentinel is an unexpanded variable to every rule
|
|
1535
|
+
// after the lexer: it may be the home (the organ and credential rows
|
|
1536
|
+
// apply) and it decides the directory (the Hermes disk pass).
|
|
1537
|
+
text += SUBSTITUTION_SENTINEL;
|
|
1538
|
+
splits = true;
|
|
840
1539
|
index = scan.end;
|
|
841
1540
|
continue;
|
|
842
1541
|
}
|
|
1542
|
+
if (ch === "$")
|
|
1543
|
+
splits = true;
|
|
1544
|
+
else if (ch === "{" || ch === "*" || ch === "?" || ch === "[")
|
|
1545
|
+
expands = true;
|
|
1546
|
+
// APRV-510: what the shell would still expand in an unquoted run. The
|
|
1547
|
+
// text is kept as it always was; only the `exact` flag records it.
|
|
1548
|
+
if (ch === "$" || ch === "*" || ch === "?" || ch === "[" || ch === "{")
|
|
1549
|
+
exact = false;
|
|
1550
|
+
// Bash expands an unquoted `~` at a word's start and after `=` or `:` in
|
|
1551
|
+
// an assignment-shaped argument; any unquoted `~` is treated as one.
|
|
1552
|
+
if (ch === "~")
|
|
1553
|
+
exact = false;
|
|
843
1554
|
text += ch;
|
|
844
1555
|
}
|
|
845
1556
|
// Nothing consumed means the caller was at a delimiter: no word here. A
|
|
@@ -847,7 +1558,8 @@ function lex(command) {
|
|
|
847
1558
|
// or a backtick substitution that only set the opaque flag).
|
|
848
1559
|
if (index === start)
|
|
849
1560
|
return null;
|
|
850
|
-
|
|
1561
|
+
const assignment = ASSIGNMENT.test(command.slice(start, index));
|
|
1562
|
+
return { text, quoted, substitutions, splits, expands, exact, assignment };
|
|
851
1563
|
};
|
|
852
1564
|
while (index < command.length) {
|
|
853
1565
|
const ch = command[index];
|
|
@@ -863,13 +1575,26 @@ function lex(command) {
|
|
|
863
1575
|
const boundary = index;
|
|
864
1576
|
index += 1;
|
|
865
1577
|
// Heredoc bodies belong to the line that opened them, so they are
|
|
866
|
-
// consumed here
|
|
1578
|
+
// consumed here. A quoted delimiter's body is data. An UNQUOTED one is
|
|
1579
|
+
// expanded by the shell when it is read (APRV-504, folded into
|
|
1580
|
+
// APRV-508): its `$(…)` run, so they are collected for recursive
|
|
1581
|
+
// classification, and a backtick or an arithmetic expansion there is
|
|
1582
|
+
// opaque, exactly as inside double quotes.
|
|
867
1583
|
while (pending.length > 0) {
|
|
868
1584
|
const heredoc = pending.shift();
|
|
869
1585
|
const consumed = skipHeredocBody(command, index, heredoc);
|
|
870
1586
|
if (consumed === null) {
|
|
871
1587
|
return { ok: false, detail: `heredoc <<${heredoc.terminator} is never terminated` };
|
|
872
1588
|
}
|
|
1589
|
+
if (heredoc.expands) {
|
|
1590
|
+
const body = heredocBodyExpansions(command, index, consumed);
|
|
1591
|
+
if (body === null) {
|
|
1592
|
+
return { ok: false, detail: `heredoc <<${heredoc.terminator} has a substitution that does not close` };
|
|
1593
|
+
}
|
|
1594
|
+
bodySubstitutions.push(...body.substitutions);
|
|
1595
|
+
if (body.opaque !== null)
|
|
1596
|
+
opaque = body.opaque;
|
|
1597
|
+
}
|
|
873
1598
|
index = consumed;
|
|
874
1599
|
}
|
|
875
1600
|
flush(boundary);
|
|
@@ -881,20 +1606,84 @@ function lex(command) {
|
|
|
881
1606
|
if (redirect !== null) {
|
|
882
1607
|
const op = redirect[2];
|
|
883
1608
|
index += redirect[0].length;
|
|
1609
|
+
if (op === "<<" && command[index] === "<") {
|
|
1610
|
+
// A here-string (`<<<word`). Refused, as it always was, and named
|
|
1611
|
+
// (APRV-503): the shell expands its word, and that word is the
|
|
1612
|
+
// command's stdin. Parsing it would only ever loosen a refusal
|
|
1613
|
+
// (`cat <<< hello` would become a read), while a credential read
|
|
1614
|
+
// through it already has spellings that classify, so the refusal is
|
|
1615
|
+
// the fail-closed answer and the detail says how to rewrite it.
|
|
1616
|
+
return {
|
|
1617
|
+
ok: false,
|
|
1618
|
+
detail: "a here-string (<<<) is not read by this classifier; pipe the text in with printf or name the file as an argument",
|
|
1619
|
+
};
|
|
1620
|
+
}
|
|
884
1621
|
if (op === "<<" || op === "<<-") {
|
|
1622
|
+
heredoc = true;
|
|
885
1623
|
while (command[index] === " " || command[index] === "\t")
|
|
886
1624
|
index += 1;
|
|
1625
|
+
const delimiterStart = index;
|
|
887
1626
|
const terminator = readWord();
|
|
888
1627
|
if (terminator === null) {
|
|
889
1628
|
return { ok: false, detail: "heredoc has no terminator word" };
|
|
890
1629
|
}
|
|
891
|
-
|
|
1630
|
+
// APRV-508: bash takes a heredoc delimiter literally (quote removal
|
|
1631
|
+
// only, no expansion), while `readWord` sets a substitution's text
|
|
1632
|
+
// aside. So `cat <<$(x)` waited here for a terminator line bash never
|
|
1633
|
+
// looks for, and every command line after bash's real terminator was
|
|
1634
|
+
// swallowed as body and never classified. A delimiter carrying `$` or
|
|
1635
|
+
// a backtick is refused rather than guessed at.
|
|
1636
|
+
if (/[$`]/u.test(command.slice(delimiterStart, index))) {
|
|
1637
|
+
return {
|
|
1638
|
+
ok: false,
|
|
1639
|
+
detail: "a heredoc delimiter carrying $ or a backtick is not read by this classifier; use a plain word such as EOF",
|
|
1640
|
+
};
|
|
1641
|
+
}
|
|
1642
|
+
const raw = command.slice(delimiterStart, index);
|
|
1643
|
+
pending.push({
|
|
1644
|
+
terminator: terminator.text,
|
|
1645
|
+
stripTabs: op === "<<-",
|
|
1646
|
+
expands: !terminator.quoted && !raw.includes("\\"),
|
|
1647
|
+
});
|
|
892
1648
|
continue;
|
|
893
1649
|
}
|
|
894
1650
|
if (op === ">&" || op === "<&") {
|
|
895
|
-
//
|
|
896
|
-
|
|
1651
|
+
// APRV-510: a dup ONTO stdin (`<&3`, `0<&3`, `<&-`) feeds the command
|
|
1652
|
+
// input the text never names. Set before the dup or refusal below, so
|
|
1653
|
+
// every `<&` form closes `exec.code` for the segment it feeds. Bash's
|
|
1654
|
+
// `n>&m` is `dup2(m, n)` whichever way the arrow points, so a `>&`
|
|
1655
|
+
// whose descriptor prefix is 0 (`0>&3`, `0>& 1`, `00>&9`) feeds stdin
|
|
1656
|
+
// exactly as `0<&3` does and closes the class too (refuter rS1).
|
|
1657
|
+
if (op === "<&" || (op === ">&" && /^0+$/u.test(redirect[1] ?? "")))
|
|
1658
|
+
stdinFed = true;
|
|
1659
|
+
// `2>&1`, `>&2`, `3<&0`, `2>&-`: a descriptor dup or close, no path
|
|
1660
|
+
// involved. Anything else after `>&` is a FILE (APRV-508 refutation
|
|
1661
|
+
// M1): bash reads `>&word` as `>word 2>&1`, so `paste a >& APPROVAL.md`
|
|
1662
|
+
// truncates the policy file. The word is read in full, as the shell
|
|
1663
|
+
// reads it, and is a dup only when it is exactly digits with an
|
|
1664
|
+
// optional trailing `-`, or a lone `-`. A word whose value the text
|
|
1665
|
+
// cannot settle (`>&$fd`) is taken as a file: a write is the stricter
|
|
1666
|
+
// reading. After `<&` a non-descriptor word is refused.
|
|
1667
|
+
while (command[index] === " " || command[index] === "\t")
|
|
897
1668
|
index += 1;
|
|
1669
|
+
const target = readWord();
|
|
1670
|
+
// A target that is nothing but `$(…)` (`>&$(x)`) keeps the refusal it
|
|
1671
|
+
// had before the substitution sentinel (APRV-511): the sentinel stands
|
|
1672
|
+
// for output this classifier cannot settle, and bash may read that
|
|
1673
|
+
// output as a descriptor or a file, so neither answer is admitted.
|
|
1674
|
+
if (target === null || target.text.split(SUBSTITUTION_SENTINEL).join("") === "") {
|
|
1675
|
+
return { ok: false, detail: `redirection ${op} has no target` };
|
|
1676
|
+
}
|
|
1677
|
+
const dup = target.substitutions.length === 0 && (/^\d+-?$/u.test(target.text) || target.text === "-");
|
|
1678
|
+
if (dup)
|
|
1679
|
+
continue;
|
|
1680
|
+
if (op === "<&") {
|
|
1681
|
+
return {
|
|
1682
|
+
ok: false,
|
|
1683
|
+
detail: `redirection <&${target.text} names no file descriptor; this classifier does not read it`,
|
|
1684
|
+
};
|
|
1685
|
+
}
|
|
1686
|
+
redirects.push({ op: ">", target });
|
|
898
1687
|
continue;
|
|
899
1688
|
}
|
|
900
1689
|
while (command[index] === " " || command[index] === "\t")
|
|
@@ -906,13 +1695,45 @@ function lex(command) {
|
|
|
906
1695
|
redirects.push({ op: op === ">>" ? ">>" : op === "<" ? "<" : ">", target });
|
|
907
1696
|
continue;
|
|
908
1697
|
}
|
|
1698
|
+
// APRV-521 (review-b M1): `NAME=(…)` and `NAME+=(…)` are bash's compound
|
|
1699
|
+
// (array) assignment, not a word followed by a subshell. Read as a
|
|
1700
|
+
// subshell, the list's words became a command and whatever followed the
|
|
1701
|
+
// `)` lost the assignment in front of it. Refused: a `(` straight after an
|
|
1702
|
+
// unquoted `=` is that assignment or a syntax error to bash.
|
|
1703
|
+
if (ch === "(" && command[index - 1] === "=") {
|
|
1704
|
+
return {
|
|
1705
|
+
ok: false,
|
|
1706
|
+
detail: "a compound (array) assignment NAME=(…) is not read by this classifier; assign the elements one by one",
|
|
1707
|
+
};
|
|
1708
|
+
}
|
|
909
1709
|
if (ch === "&" || ch === "|" || ch === ";" || ch === "(" || ch === ")") {
|
|
910
1710
|
const boundary = index;
|
|
1711
|
+
// APRV-510: a single `|` (or `|&`) pipes the previous segment into the next.
|
|
1712
|
+
const pipe = ch === "|" && command[index + 1] !== "|";
|
|
911
1713
|
index += ch === "&" && command[index + 1] === "&" ? 2 : ch === "|" && command[index + 1] === "|" ? 2 : 1;
|
|
912
1714
|
flush(boundary);
|
|
1715
|
+
// APRV-510 (refuter rS2): the subshell depth, read by the next flush.
|
|
1716
|
+
if (ch === "(")
|
|
1717
|
+
depth += 1;
|
|
1718
|
+
if (ch === ")")
|
|
1719
|
+
depth -= 1;
|
|
1720
|
+
if (pipe)
|
|
1721
|
+
stdinFed = true;
|
|
913
1722
|
segmentStart = index;
|
|
914
1723
|
continue;
|
|
915
1724
|
}
|
|
1725
|
+
// A comment (APRV-508, found by the shell differential). bash ends the
|
|
1726
|
+
// line at a `#` that begins a word, so `cat #<<EOF` is `cat` and the
|
|
1727
|
+
// lines after it are commands; read as a word, the `#<<EOF` opened a
|
|
1728
|
+
// heredoc here and swallowed them. The comment runs to the newline, which
|
|
1729
|
+
// is left for the loop. Only a `#` after a space, a tab, a newline or an
|
|
1730
|
+
// operator begins a word to bash; after a carriage return (whitespace to
|
|
1731
|
+
// this loop, a word character to bash) it does not, and is read as a word.
|
|
1732
|
+
if (ch === "#" && (index === 0 || /[ \t\n;&|()<>]/u.test(command[index - 1]))) {
|
|
1733
|
+
const newline = command.indexOf("\n", index);
|
|
1734
|
+
index = newline === -1 ? command.length : newline;
|
|
1735
|
+
continue;
|
|
1736
|
+
}
|
|
916
1737
|
const word = readWord();
|
|
917
1738
|
if (word === null) {
|
|
918
1739
|
return { ok: false, detail: "unterminated quote or command substitution" };
|
|
@@ -944,15 +1765,21 @@ function readDoubleQuoted(command, start) {
|
|
|
944
1765
|
let text = "";
|
|
945
1766
|
const substitutions = [];
|
|
946
1767
|
let opaque = null;
|
|
1768
|
+
// APRV-510, see `LexWord.exact`.
|
|
1769
|
+
let exact = true;
|
|
947
1770
|
let index = start + 1;
|
|
948
1771
|
for (; index < command.length; index += 1) {
|
|
949
1772
|
const ch = command[index];
|
|
950
1773
|
if (ch === '"')
|
|
951
|
-
return { text, end: index, substitutions, opaque };
|
|
1774
|
+
return { text, end: index, substitutions, opaque, exact };
|
|
952
1775
|
if (ch === "\\") {
|
|
953
1776
|
const next = command[index + 1];
|
|
954
1777
|
if (next === undefined)
|
|
955
1778
|
break;
|
|
1779
|
+
// The shell keeps the backslash before any other character; this lexer
|
|
1780
|
+
// drops it, so the text is no longer the shell's bytes.
|
|
1781
|
+
if (!DOUBLE_QUOTE_ESCAPES.has(next))
|
|
1782
|
+
exact = false;
|
|
956
1783
|
if (next !== "\n")
|
|
957
1784
|
text += next;
|
|
958
1785
|
index += 1;
|
|
@@ -979,13 +1806,28 @@ function readDoubleQuoted(command, start) {
|
|
|
979
1806
|
if (scan === null)
|
|
980
1807
|
return null;
|
|
981
1808
|
substitutions.push(scan.inner);
|
|
1809
|
+
text += SUBSTITUTION_SENTINEL; // APRV-511 refuter M1, as in `readWord`.
|
|
982
1810
|
index = scan.end;
|
|
983
1811
|
continue;
|
|
984
1812
|
}
|
|
1813
|
+
if (ch === "$")
|
|
1814
|
+
exact = false;
|
|
985
1815
|
text += ch;
|
|
986
1816
|
}
|
|
987
1817
|
return null;
|
|
988
1818
|
}
|
|
1819
|
+
/**
|
|
1820
|
+
* What a `$(…)` leaves in a word's text (APRV-511 refuter M1): an unexpanded
|
|
1821
|
+
* variable, which is exactly what the shell's substitution is to a classifier
|
|
1822
|
+
* that does not run it. The name cannot collide with a real expansion a
|
|
1823
|
+
* command would carry, and it is braced so no brace-expansion reading applies.
|
|
1824
|
+
*/
|
|
1825
|
+
export const SUBSTITUTION_SENTINEL = "${__APPROVAL_SUBST__}";
|
|
1826
|
+
/**
|
|
1827
|
+
* The characters a backslash escapes inside double quotes, in the shell's own
|
|
1828
|
+
* reading (APRV-510): before anything else the backslash is kept.
|
|
1829
|
+
*/
|
|
1830
|
+
const DOUBLE_QUOTE_ESCAPES = new Set(["$", "`", '"', "\\", "\n"]);
|
|
989
1831
|
/**
|
|
990
1832
|
* Scan `(…)` starting at the opening paren, honouring nesting and quotes, and
|
|
991
1833
|
* return the inner text plus the index of the closing paren.
|
|
@@ -1036,6 +1878,45 @@ function closingDoubleQuote(command, start) {
|
|
|
1036
1878
|
return null;
|
|
1037
1879
|
}
|
|
1038
1880
|
/** Consume a heredoc body; returns the index after it, or `null` if unclosed. */
|
|
1881
|
+
/**
|
|
1882
|
+
* The expansions in an unquoted heredoc body, `command[start, end)`: every
|
|
1883
|
+
* `$(…)` for recursive classification, and a backtick or an arithmetic
|
|
1884
|
+
* expansion as opaque. A backslash escapes the next character, as it does in a
|
|
1885
|
+
* body; quotes are literal there, except inside a `$(…)`, which
|
|
1886
|
+
* {@link readSubstitution} scans as the shell does. `null` when a substitution
|
|
1887
|
+
* runs past the body.
|
|
1888
|
+
*/
|
|
1889
|
+
function heredocBodyExpansions(command, start, end) {
|
|
1890
|
+
const substitutions = [];
|
|
1891
|
+
let opaque = null;
|
|
1892
|
+
for (let index = start; index < end; index += 1) {
|
|
1893
|
+
const ch = command[index];
|
|
1894
|
+
if (ch === "\\") {
|
|
1895
|
+
index += 1;
|
|
1896
|
+
continue;
|
|
1897
|
+
}
|
|
1898
|
+
if (ch === "`") {
|
|
1899
|
+
opaque = "backtick command substitution in a heredoc body";
|
|
1900
|
+
continue;
|
|
1901
|
+
}
|
|
1902
|
+
if (ch === "$" && command[index + 1] === "[") {
|
|
1903
|
+
opaque = "arithmetic expansion in a heredoc body";
|
|
1904
|
+
continue;
|
|
1905
|
+
}
|
|
1906
|
+
if (ch === "$" && command[index + 1] === "(") {
|
|
1907
|
+
if (command[index + 2] === "(") {
|
|
1908
|
+
opaque = "arithmetic expansion in a heredoc body";
|
|
1909
|
+
continue;
|
|
1910
|
+
}
|
|
1911
|
+
const scan = readSubstitution(command, index + 1);
|
|
1912
|
+
if (scan === null || scan.end >= end)
|
|
1913
|
+
return null;
|
|
1914
|
+
substitutions.push(scan.inner);
|
|
1915
|
+
index = scan.end;
|
|
1916
|
+
}
|
|
1917
|
+
}
|
|
1918
|
+
return { substitutions, opaque };
|
|
1919
|
+
}
|
|
1039
1920
|
function skipHeredocBody(command, start, heredoc) {
|
|
1040
1921
|
let index = start;
|
|
1041
1922
|
for (;;) {
|
|
@@ -1135,15 +2016,33 @@ function deletedRef(refspec) {
|
|
|
1135
2016
|
return destination;
|
|
1136
2017
|
return null;
|
|
1137
2018
|
}
|
|
2019
|
+
/**
|
|
2020
|
+
* The reading the classifier has always made: flags by exact spelling, and
|
|
2021
|
+
* every non-flag word after the first a refspec. Kept as a floor beneath the
|
|
2022
|
+
* git-grammar reading, so no spelling classifies looser than it did.
|
|
2023
|
+
*/
|
|
2024
|
+
function wordPushFacts(args) {
|
|
2025
|
+
return {
|
|
2026
|
+
force: hasFlag(args, ["--force", "-f", "--force-with-lease", "--force-if-includes", "--mirror"]),
|
|
2027
|
+
tags: hasFlag(args, ["--tags", "--follow-tags"]),
|
|
2028
|
+
remove: hasFlag(args, ["--delete", "-d"]),
|
|
2029
|
+
prune: false,
|
|
2030
|
+
all: false,
|
|
2031
|
+
refspecs: args.filter((arg) => !isFlag(arg)).slice(1),
|
|
2032
|
+
};
|
|
2033
|
+
}
|
|
2034
|
+
/** `git push` classes, strictest first: the order {@link pushFactsClass} checks them in. */
|
|
2035
|
+
const PUSH_CLASS_ORDER = [
|
|
2036
|
+
"vcs.history.rewrite",
|
|
2037
|
+
"release.publish",
|
|
2038
|
+
REF_DELETE_CLASS,
|
|
2039
|
+
"vcs.push.main",
|
|
2040
|
+
"vcs.push.branch",
|
|
2041
|
+
];
|
|
1138
2042
|
/** `git push` — force, release, deletion, trunk and branch turn on flags and refspecs. */
|
|
1139
|
-
function
|
|
1140
|
-
const
|
|
1141
|
-
if (
|
|
1142
|
-
return { class: "vcs.history.rewrite", rule: "git-push-force" };
|
|
1143
|
-
}
|
|
1144
|
-
const positionals = args.filter((arg) => !isFlag(arg));
|
|
1145
|
-
const refspecs = positionals.slice(1);
|
|
1146
|
-
if (refspecs.some((refspec) => refspec.startsWith("+"))) {
|
|
2043
|
+
function pushFactsClass(facts) {
|
|
2044
|
+
const { refspecs } = facts;
|
|
2045
|
+
if (facts.force || refspecs.some((refspec) => refspec.startsWith("+"))) {
|
|
1147
2046
|
return { class: "vcs.history.rewrite", rule: "git-push-force" };
|
|
1148
2047
|
}
|
|
1149
2048
|
// The tag check stays ABOVE the deletion check, deliberately. A tag is the
|
|
@@ -1153,7 +2052,7 @@ function refineGitPush(ctx) {
|
|
|
1153
2052
|
// `git push origin :refs/tags/v1.2.3` and a bulk form that mixes a tag in,
|
|
1154
2053
|
// and APRV-352 asks for a class for branch deletions, not a loosening of the
|
|
1155
2054
|
// tag surface.
|
|
1156
|
-
if (
|
|
2055
|
+
if (facts.tags ||
|
|
1157
2056
|
refspecs.some(isTagRefspec) ||
|
|
1158
2057
|
refspecs.some((word, index) => word === "tag" && index + 1 < refspecs.length)) {
|
|
1159
2058
|
return { class: "release.publish", rule: "git-push-tag" };
|
|
@@ -1163,13 +2062,19 @@ function refineGitPush(ctx) {
|
|
|
1163
2062
|
// class with nothing bound rather than falling through to a push class: an
|
|
1164
2063
|
// invocation whose targets cannot be read is the one that least deserves the
|
|
1165
2064
|
// looser answer.
|
|
1166
|
-
if (
|
|
2065
|
+
if (facts.remove) {
|
|
1167
2066
|
return {
|
|
1168
2067
|
class: REF_DELETE_CLASS,
|
|
1169
2068
|
rule: "git-ref-delete",
|
|
1170
2069
|
...(refspecs.length === 0 ? {} : { path: refspecs.join(" ") }),
|
|
1171
2070
|
};
|
|
1172
2071
|
}
|
|
2072
|
+
// `--prune` (APRV-517) removes every remote ref a refspec pattern no longer
|
|
2073
|
+
// matches. Which refs those are is a fact about the remote, so nothing is
|
|
2074
|
+
// bound: the refspecs are not the refs it removes.
|
|
2075
|
+
if (facts.prune) {
|
|
2076
|
+
return { class: REF_DELETE_CLASS, rule: "git-push-prune" };
|
|
2077
|
+
}
|
|
1173
2078
|
if (refspecs.length === 0) {
|
|
1174
2079
|
return { class: "vcs.push.main", rule: "git-push-implicit" };
|
|
1175
2080
|
}
|
|
@@ -1181,7 +2086,7 @@ function refineGitPush(ctx) {
|
|
|
1181
2086
|
if (deleted.length > 0) {
|
|
1182
2087
|
return { class: REF_DELETE_CLASS, rule: "git-ref-delete", path: deleted.join(" ") };
|
|
1183
2088
|
}
|
|
1184
|
-
let sawMain =
|
|
2089
|
+
let sawMain = facts.all;
|
|
1185
2090
|
for (const refspec of refspecs) {
|
|
1186
2091
|
const colon = refspec.indexOf(":");
|
|
1187
2092
|
const destination = colon === -1 ? refspec : refspec.slice(colon + 1);
|
|
@@ -1197,6 +2102,46 @@ function refineGitPush(ctx) {
|
|
|
1197
2102
|
? { class: "vcs.push.main", rule: "git-push-main" }
|
|
1198
2103
|
: { class: "vcs.push.branch", rule: "git-push-branch" };
|
|
1199
2104
|
}
|
|
2105
|
+
/**
|
|
2106
|
+
* `git push`, read twice and answered by the stricter reading (APRV-517).
|
|
2107
|
+
*
|
|
2108
|
+
* The first reading is git's own grammar ({@link parseGitPushArgv}): short
|
|
2109
|
+
* bundles (`-ud`), unique-prefix abbreviations (`--del`), `--prune`, the
|
|
2110
|
+
* values that option words consume, and the global options in front of
|
|
2111
|
+
* `push`. Anything that grammar cannot walk is answered without a guess: a
|
|
2112
|
+
* word the shell may expand, or an environment that steers git's
|
|
2113
|
+
* configuration, is `vcs.history.rewrite` (rule `git-push-unreadable`), the
|
|
2114
|
+
* strictest class this row emits, because such a word can hold `+main`,
|
|
2115
|
+
* `:main` or `-d`; an argv git itself would refuse, or one that names a
|
|
2116
|
+
* far-side program, is opaque.
|
|
2117
|
+
*
|
|
2118
|
+
* The second reading is the word scan this row has always made, kept as a
|
|
2119
|
+
* floor, so a command whose verdict moves only ever moves stricter. Where the
|
|
2120
|
+
* two agree on the class, the grammar's answer is kept: its rule and bound
|
|
2121
|
+
* refs are the ones git will act on.
|
|
2122
|
+
*/
|
|
2123
|
+
function refineGitPush(ctx) {
|
|
2124
|
+
const floor = pushFactsClass(wordPushFacts(ctx.args.slice(1)));
|
|
2125
|
+
const split = splitGitPush(ctx.args);
|
|
2126
|
+
if (split === null)
|
|
2127
|
+
return { opaque: "git push: the push subcommand is not where the classifier found it" };
|
|
2128
|
+
const parsed = parseGitPushArgv(split.words, ctx.assignments, ctx.substituted, split.globals);
|
|
2129
|
+
if (!parsed.ok) {
|
|
2130
|
+
if (parsed.kind === "unreadable")
|
|
2131
|
+
return { class: "vcs.history.rewrite", rule: "git-push-unreadable" };
|
|
2132
|
+
return { opaque: parsed.detail };
|
|
2133
|
+
}
|
|
2134
|
+
const { reading } = parsed;
|
|
2135
|
+
const exact = pushFactsClass({
|
|
2136
|
+
force: reading.force,
|
|
2137
|
+
tags: reading.tags,
|
|
2138
|
+
remove: reading.remove,
|
|
2139
|
+
prune: reading.prune,
|
|
2140
|
+
all: reading.all,
|
|
2141
|
+
refspecs: reading.positionals.slice(1),
|
|
2142
|
+
});
|
|
2143
|
+
return PUSH_CLASS_ORDER.indexOf(floor.class) < PUSH_CLASS_ORDER.indexOf(exact.class) ? floor : exact;
|
|
2144
|
+
}
|
|
1200
2145
|
/**
|
|
1201
2146
|
* The class of a delete that only removes the agent's own scratch (APRV-267).
|
|
1202
2147
|
*
|
|
@@ -1396,8 +2341,121 @@ function refineHarness(ctx) {
|
|
|
1396
2341
|
if (name === undefined) {
|
|
1397
2342
|
return { opaque: `${ctx.bin} is an agent harness this table cannot name` };
|
|
1398
2343
|
}
|
|
2344
|
+
if (name === "hermes") {
|
|
2345
|
+
const organWrite = hermesOrganWrite(ctx.args);
|
|
2346
|
+
if (organWrite !== null)
|
|
2347
|
+
return organWrite;
|
|
2348
|
+
}
|
|
1399
2349
|
return harnessRefinement(name, ctx.args);
|
|
1400
2350
|
}
|
|
2351
|
+
/** The rule id a `hermes` subcommand that rewrites the home's organs takes (APRV-511). */
|
|
2352
|
+
const HERMES_ORGAN_WRITE_RULE = "hermes-organ-write";
|
|
2353
|
+
/**
|
|
2354
|
+
* `hermes` subcommands that rewrite the gate's organs, each with the verbs that
|
|
2355
|
+
* only read (APRV-511), read off Hermes v2026.9.24's parsers and dispatchers. A
|
|
2356
|
+
* verb not on the read list is the write, so a verb a later Hermes adds is
|
|
2357
|
+
* assumed to change something. `bare` is what the group does with no verb:
|
|
2358
|
+
* `hermes config` shows the configuration and `hermes hooks` prints usage, while
|
|
2359
|
+
* bare `hermes plugins` opens the interactive enable/disable toggle.
|
|
2360
|
+
*
|
|
2361
|
+
* - `plugins` installs, updates, removes, enables and disables code under
|
|
2362
|
+
* `$HERMES_HOME/plugins/` and the `plugins.enabled`/`disabled` lists in
|
|
2363
|
+
* `config.yaml`; `pack install` does the same in bulk.
|
|
2364
|
+
* - `config` `set`/`unset`/`edit`/`migrate` rewrite `config.yaml`.
|
|
2365
|
+
* - `hooks` `revoke` (`remove`, `rm`) rewrites the consent allowlist.
|
|
2366
|
+
* - `update` rewrites `$HERMES_HOME/hermes-agent/`, and `uninstall` removes it.
|
|
2367
|
+
*
|
|
2368
|
+
* A write through the CLI is the same act as the file write it performs, so it
|
|
2369
|
+
* takes the same class, `policy.core`. On an Agent Village box the overlay
|
|
2370
|
+
* installs and enables plugins at provisioning, outside any hooked tool call.
|
|
2371
|
+
* Every other subcommand keeps `harness.launch.hermes`.
|
|
2372
|
+
*/
|
|
2373
|
+
const HERMES_ORGAN_SUBCOMMANDS = {
|
|
2374
|
+
plugins: {
|
|
2375
|
+
reads: ["search", "browse", "validate", "list", "ls", "capabilities", "doctor", "compat", "show", "info"],
|
|
2376
|
+
bare: "write",
|
|
2377
|
+
},
|
|
2378
|
+
config: { reads: ["show", "get", "path", "env-path", "check"], bare: "read" },
|
|
2379
|
+
hooks: { reads: ["list", "ls", "test", "doctor"], bare: "read" },
|
|
2380
|
+
update: { reads: [], bare: "write" },
|
|
2381
|
+
uninstall: { reads: [], bare: "write" },
|
|
2382
|
+
};
|
|
2383
|
+
/**
|
|
2384
|
+
* The `policy.core` answer for a `hermes` argv that rewrites an organ, or
|
|
2385
|
+
* `null` for every other argv (APRV-511).
|
|
2386
|
+
*
|
|
2387
|
+
* The classifier does not know which of Hermes's flags take a value, so a word
|
|
2388
|
+
* directly after a flag written without `=` may be that flag's value, and every
|
|
2389
|
+
* reading below that depends on such a word resolves to the write:
|
|
2390
|
+
*
|
|
2391
|
+
* - The subcommand is the first word that is not a flag, skipping a word that
|
|
2392
|
+
* directly follows a flag (`hermes -p work plugins install x`). A group word
|
|
2393
|
+
* in that position is still taken as the group, the stricter reading.
|
|
2394
|
+
* - The verb is the first word after the group that is not a flag. When it
|
|
2395
|
+
* directly follows a flag (`hermes plugins --source list install x`, where
|
|
2396
|
+
* `list` may be the value), the verb is unknown and the answer is the write.
|
|
2397
|
+
* - A help request (`--help`, `-h`) keeps the earlier answer only as a flag in
|
|
2398
|
+
* its own right: argparse prints usage and exits before any handler runs. It
|
|
2399
|
+
* is not honoured when it directly follows a flag (`hermes plugins install
|
|
2400
|
+
* --name --help`, where it may be the value), nor after `--`.
|
|
2401
|
+
* - An end-of-options `--` anywhere in the argv makes every later word a
|
|
2402
|
+
* positional to the real CLI (`hermes plugins install x -- --help` installs),
|
|
2403
|
+
* so an organ group's argv carrying one is the write, whatever else it says,
|
|
2404
|
+
* and so is an organ group word after a leading `--` (`hermes -- plugins
|
|
2405
|
+
* install x`), which argparse still dispatches to the subcommand.
|
|
2406
|
+
*
|
|
2407
|
+
* `hermes plugins --help install x` is a read: the `plugins` parser meets
|
|
2408
|
+
* `--help` before the verb and exits with usage.
|
|
2409
|
+
*/
|
|
2410
|
+
function hermesOrganWrite(args) {
|
|
2411
|
+
const write = { class: "policy.core", rule: HERMES_ORGAN_WRITE_RULE, path: args.join(" ") };
|
|
2412
|
+
/** Could `args[index]` be the value of the flag before it? */
|
|
2413
|
+
const mayBeValue = (index) => {
|
|
2414
|
+
const previous = index === 0 ? undefined : args[index - 1];
|
|
2415
|
+
return previous !== undefined && previous.startsWith("-") && previous !== "--" && !previous.includes("=");
|
|
2416
|
+
};
|
|
2417
|
+
let at = -1;
|
|
2418
|
+
for (const [index, arg] of args.entries()) {
|
|
2419
|
+
if (arg === "--") {
|
|
2420
|
+
// argparse still hands a subcommand word after `--` to its subparsers
|
|
2421
|
+
// (`hermes -- plugins install x`), so an organ group there is the write.
|
|
2422
|
+
return args.slice(index + 1).some((word) => Object.hasOwn(HERMES_ORGAN_SUBCOMMANDS, word)) ? write : null;
|
|
2423
|
+
}
|
|
2424
|
+
if (arg.startsWith("-"))
|
|
2425
|
+
continue;
|
|
2426
|
+
if (Object.hasOwn(HERMES_ORGAN_SUBCOMMANDS, arg)) {
|
|
2427
|
+
at = index;
|
|
2428
|
+
break;
|
|
2429
|
+
}
|
|
2430
|
+
if (mayBeValue(index))
|
|
2431
|
+
continue;
|
|
2432
|
+
return null;
|
|
2433
|
+
}
|
|
2434
|
+
if (at === -1)
|
|
2435
|
+
return null;
|
|
2436
|
+
if (args.includes("--"))
|
|
2437
|
+
return write;
|
|
2438
|
+
const helped = args.some((arg, index) => (arg === "--help" || arg === "-h") && !mayBeValue(index));
|
|
2439
|
+
if (helped)
|
|
2440
|
+
return null;
|
|
2441
|
+
const spec = HERMES_ORGAN_SUBCOMMANDS[args[at]];
|
|
2442
|
+
const verbAt = args.findIndex((arg, index) => index > at && !arg.startsWith("-"));
|
|
2443
|
+
if (verbAt === -1)
|
|
2444
|
+
return spec.bare === "read" ? null : write;
|
|
2445
|
+
if (mayBeValue(verbAt))
|
|
2446
|
+
return write;
|
|
2447
|
+
const verb = args[verbAt];
|
|
2448
|
+
if (spec.reads.includes(verb))
|
|
2449
|
+
return null;
|
|
2450
|
+
// `plugins pack` writes only in its `install` form.
|
|
2451
|
+
if (args[at] === "plugins" && verb === "pack") {
|
|
2452
|
+
const formAt = args.findIndex((arg, index) => index > verbAt && !arg.startsWith("-"));
|
|
2453
|
+
const form = formAt === -1 ? undefined : args[formAt];
|
|
2454
|
+
if ((form === "show" || form === "export") && !mayBeValue(formAt))
|
|
2455
|
+
return null;
|
|
2456
|
+
}
|
|
2457
|
+
return write;
|
|
2458
|
+
}
|
|
1401
2459
|
/**
|
|
1402
2460
|
* `npx`, `tsx`, `tsc`, … — unchanged, except a package runner naming a harness.
|
|
1403
2461
|
*
|
|
@@ -1425,7 +2483,8 @@ const HARNESS_RULES = Object.entries(HARNESS_BINS).map(([bin, name]) => ({
|
|
|
1425
2483
|
id: `${HARNESS_LAUNCH_RULE_PREFIX}${name}`,
|
|
1426
2484
|
bins: [bin],
|
|
1427
2485
|
class: `${HARNESS_LAUNCH_PREFIX}${name}`,
|
|
1428
|
-
|
|
2486
|
+
// APRV-511: `hermes plugins install` and its kin rewrite the home's organs.
|
|
2487
|
+
emits: name === "hermes" ? [HARNESS_PROBE_CLASS, "policy.core"] : [HARNESS_PROBE_CLASS],
|
|
1429
2488
|
refine: refineHarness,
|
|
1430
2489
|
}));
|
|
1431
2490
|
/**
|
|
@@ -1508,6 +2567,24 @@ function escapedReadTarget(bin, positionals, args, roots) {
|
|
|
1508
2567
|
}
|
|
1509
2568
|
return null;
|
|
1510
2569
|
}
|
|
2570
|
+
/**
|
|
2571
|
+
* The first `<` target the text places outside every root, or `null`
|
|
2572
|
+
* (APRV-522, S2). The same verdict a positional read target gets: a `<` opens
|
|
2573
|
+
* its target for reading, so a reader fed a file through `<` reads the file
|
|
2574
|
+
* it would read named as a positional. A relative target is `needs-disk` and is left to the
|
|
2575
|
+
* hook's disk pass, as a relative positional is.
|
|
2576
|
+
*/
|
|
2577
|
+
function redirectReadEscape(readTargets, roots) {
|
|
2578
|
+
for (const target of readTargets) {
|
|
2579
|
+
if (readTargetVerdict(target, roots) === "out-of-scope") {
|
|
2580
|
+
return {
|
|
2581
|
+
path: target,
|
|
2582
|
+
rule: isUnreadableTarget(target) ? READ_UNREADABLE_TARGET_RULE : READ_OUT_OF_SCOPE_RULE,
|
|
2583
|
+
};
|
|
2584
|
+
}
|
|
2585
|
+
}
|
|
2586
|
+
return null;
|
|
2587
|
+
}
|
|
1511
2588
|
/** `rm` — everything outside the workspace, and every unreadable path, is manual. */
|
|
1512
2589
|
function refineRm(ctx) {
|
|
1513
2590
|
const recursive = hasFlag(ctx.args, ["--recursive"]) || hasShortFlag(ctx.args, ["r", "R"]);
|
|
@@ -1535,6 +2612,340 @@ function refineRm(ctx) {
|
|
|
1535
2612
|
}
|
|
1536
2613
|
return { class: "files.write.workspace", rule: "rm-workspace" };
|
|
1537
2614
|
}
|
|
2615
|
+
// ---------------------------------------------------------------------------
|
|
2616
|
+
// git global options (APRV-520)
|
|
2617
|
+
// ---------------------------------------------------------------------------
|
|
2618
|
+
/**
|
|
2619
|
+
* The git global options that take a value, read from git 2.50.1's
|
|
2620
|
+
* `handle_options` table (the options git(1) lists between `git` and
|
|
2621
|
+
* `<command>`, plus the undocumented `--shallow-file`; the strings are in the
|
|
2622
|
+
* installed binary). The separate spelling consumes the NEXT argv word, so the
|
|
2623
|
+
* first positional this classifier reads is that value and not the subcommand:
|
|
2624
|
+
* `git -C status push --force origin main` is a force push in the directory
|
|
2625
|
+
* `status`, and it classified as `read.shell`. The `=`-joined long spellings
|
|
2626
|
+
* shift nothing, but they repoint the repository, its configuration or (for
|
|
2627
|
+
* `--exec-path=`) the directory git runs its helpers and hooks from, which is
|
|
2628
|
+
* the same question this table answers for the separate spelling: what does
|
|
2629
|
+
* the command act on? So every one of them leaves the segment `unclassified`,
|
|
2630
|
+
* which the hook refuses (`hook-unclassified`, in every mode, an open window
|
|
2631
|
+
* included), rather than this classifier re-deriving the subcommand.
|
|
2632
|
+
* `--super-prefix` is gone from git 2.50 and kept here because an older git
|
|
2633
|
+
* still consumes its value.
|
|
2634
|
+
*/
|
|
2635
|
+
const GIT_VALUE_GLOBALS = [
|
|
2636
|
+
"-C",
|
|
2637
|
+
"-c",
|
|
2638
|
+
"--config-env",
|
|
2639
|
+
"--git-dir",
|
|
2640
|
+
"--work-tree",
|
|
2641
|
+
"--namespace",
|
|
2642
|
+
"--super-prefix",
|
|
2643
|
+
"--attr-source",
|
|
2644
|
+
"--shallow-file",
|
|
2645
|
+
];
|
|
2646
|
+
/** The `=`-joined long spellings git accepts for a value-taking global. */
|
|
2647
|
+
const GIT_JOINED_VALUE_GLOBALS = [
|
|
2648
|
+
"--config-env=",
|
|
2649
|
+
"--git-dir=",
|
|
2650
|
+
"--work-tree=",
|
|
2651
|
+
"--namespace=",
|
|
2652
|
+
"--super-prefix=",
|
|
2653
|
+
"--attr-source=",
|
|
2654
|
+
"--exec-path=",
|
|
2655
|
+
];
|
|
2656
|
+
/**
|
|
2657
|
+
* The git global options that take no value, exactly as git 2.50.1 spells
|
|
2658
|
+
* them (no prefix abbreviation: git compares each one whole). Each is skipped
|
|
2659
|
+
* as it was before APRV-520. `--html-path`, `--man-path`, `--info-path`, a bare
|
|
2660
|
+
* `--exec-path` and `--list-cmds=` print and exit before any subcommand runs,
|
|
2661
|
+
* so reading on past them only ever answers stricter than git behaves.
|
|
2662
|
+
*/
|
|
2663
|
+
const GIT_ZERO_VALUE_GLOBALS = [
|
|
2664
|
+
"-p",
|
|
2665
|
+
"--paginate",
|
|
2666
|
+
"-P",
|
|
2667
|
+
"--no-pager",
|
|
2668
|
+
"--no-replace-objects",
|
|
2669
|
+
"--no-lazy-fetch",
|
|
2670
|
+
"--bare",
|
|
2671
|
+
"--literal-pathspecs",
|
|
2672
|
+
"--no-literal-pathspecs",
|
|
2673
|
+
"--glob-pathspecs",
|
|
2674
|
+
"--noglob-pathspecs",
|
|
2675
|
+
"--icase-pathspecs",
|
|
2676
|
+
"--no-optional-locks",
|
|
2677
|
+
"--no-advice",
|
|
2678
|
+
"--html-path",
|
|
2679
|
+
"--man-path",
|
|
2680
|
+
"--info-path",
|
|
2681
|
+
"--exec-path",
|
|
2682
|
+
];
|
|
2683
|
+
/**
|
|
2684
|
+
* The options at which git stops reading globals: it runs `git version` or
|
|
2685
|
+
* `git help` with the rest of the line as that command's arguments. Left to
|
|
2686
|
+
* the handling they had before APRV-520.
|
|
2687
|
+
*/
|
|
2688
|
+
const GIT_GLOBALS_END = ["-h", "--help", "-v", "--version"];
|
|
2689
|
+
/**
|
|
2690
|
+
* Why the git global options in front of the subcommand leave this segment
|
|
2691
|
+
* `unclassified`, or `null` when they do not (APRV-520). Only the words before
|
|
2692
|
+
* the first non-flag word are globals; after it they belong to the subcommand
|
|
2693
|
+
* (`git commit -C <commit>` is the commit's own option).
|
|
2694
|
+
*/
|
|
2695
|
+
function gitGlobalRefusal(args) {
|
|
2696
|
+
for (const word of args) {
|
|
2697
|
+
if (!isFlag(word) || GIT_GLOBALS_END.includes(word))
|
|
2698
|
+
return null;
|
|
2699
|
+
if (GIT_ZERO_VALUE_GLOBALS.includes(word) || word.startsWith("--list-cmds="))
|
|
2700
|
+
continue;
|
|
2701
|
+
if (GIT_VALUE_GLOBALS.includes(word)) {
|
|
2702
|
+
return `no rule for git ${word} <value>: the global option takes the next word as its value, so the first positional is not the subcommand`;
|
|
2703
|
+
}
|
|
2704
|
+
const joined = GIT_JOINED_VALUE_GLOBALS.find((prefix) => word.startsWith(prefix));
|
|
2705
|
+
if (joined !== undefined) {
|
|
2706
|
+
return `no rule for git ${joined}<value>: the global option repoints what the subcommand acts on`;
|
|
2707
|
+
}
|
|
2708
|
+
return `no rule for the git global option ${word}: an option this classifier does not know may take the next word as its value`;
|
|
2709
|
+
}
|
|
2710
|
+
return null;
|
|
2711
|
+
}
|
|
2712
|
+
// ---------------------------------------------------------------------------
|
|
2713
|
+
// Environment assignments in front of git (APRV-521)
|
|
2714
|
+
// ---------------------------------------------------------------------------
|
|
2715
|
+
/**
|
|
2716
|
+
* The `NAME=value` words that may stand in front of `git`, compared as whole
|
|
2717
|
+
* words (APRV-521). A CLOSED list: any other assignment in front of `git`
|
|
2718
|
+
* leaves the segment `unclassified`, because git(1)'s ENVIRONMENT section is
|
|
2719
|
+
* mostly variables that run a program (`GIT_PAGER`, `GIT_EDITOR`,
|
|
2720
|
+
* `GIT_SEQUENCE_EDITOR`, `GIT_SSH`, `GIT_SSH_COMMAND`, `GIT_ASKPASS`,
|
|
2721
|
+
* `GIT_EXTERNAL_DIFF`), repoint the repository (`GIT_DIR`, `GIT_WORK_TREE`,
|
|
2722
|
+
* `GIT_COMMON_DIR`, `GIT_INDEX_FILE`, `GIT_OBJECT_DIRECTORY`,
|
|
2723
|
+
* `GIT_ALTERNATE_OBJECT_DIRECTORIES`, `GIT_NAMESPACE`,
|
|
2724
|
+
* `GIT_CEILING_DIRECTORIES`, `GIT_DISCOVERY_ACROSS_FILESYSTEM`), redirect the
|
|
2725
|
+
* configuration (`GIT_CONFIG_GLOBAL`, `GIT_CONFIG_SYSTEM`,
|
|
2726
|
+
* `GIT_CONFIG_NOSYSTEM`, and git-config(1)'s `GIT_CONFIG_COUNT`,
|
|
2727
|
+
* `GIT_CONFIG_KEY_<n>`, `GIT_CONFIG_VALUE_<n>`; `GIT_CONFIG_PARAMETERS` is the
|
|
2728
|
+
* internal carrier of `-c`), move the helper directory (`GIT_EXEC_PATH`), or
|
|
2729
|
+
* write a file (`GIT_TRACE*=<absolute path>` appends to it). `GIT_PAGER=evil
|
|
2730
|
+
* git log` ran `evil` and classified `read.shell`.
|
|
2731
|
+
*
|
|
2732
|
+
* Each entry below can only remove a program git would otherwise run, or
|
|
2733
|
+
* swap it for a no-op, and none names a path, a repository or configuration:
|
|
2734
|
+
*
|
|
2735
|
+
* - `GIT_TERMINAL_PROMPT=0`: git(1), "If this Boolean environment variable is
|
|
2736
|
+
* set to false, git will not prompt on the terminal". It silences a prompt.
|
|
2737
|
+
* - `GIT_PAGER=cat`: git(1), "If it is set to an empty string or to the value
|
|
2738
|
+
* "cat", Git will not launch a pager". No program runs.
|
|
2739
|
+
* - `PAGER=cat`: git(1) GIT_PAGER "overrides $PAGER", and the same `cat` test
|
|
2740
|
+
* applies to whichever source supplied the pager. Where `GIT_PAGER` or
|
|
2741
|
+
* `core.pager` is set, that pager runs with or without this word.
|
|
2742
|
+
* - `GIT_EDITOR=true`, `GIT_EDITOR=:`: git(1) GIT_EDITOR "overrides $EDITOR and
|
|
2743
|
+
* $VISUAL". git skips the editor outright for `:` and runs the no-op `true`
|
|
2744
|
+
* otherwise, so the file is left as git wrote it. `GIT_SEQUENCE_EDITOR`
|
|
2745
|
+
* still outranks it for a rebase todo list, exactly as without the word.
|
|
2746
|
+
* - `EDITOR=true`, `EDITOR=:`, `VISUAL=true`, `VISUAL=:`: the fallbacks below
|
|
2747
|
+
* `GIT_EDITOR` and `core.editor`, set to the same no-ops. These four and the
|
|
2748
|
+
* two above are APRV-433's (#648) harmless-assignment list, kept equal so a
|
|
2749
|
+
* spelling that list judges local is not refused here first.
|
|
2750
|
+
*
|
|
2751
|
+
* {@link isHarmlessGitAssignment} also admits the locale and the zone with a
|
|
2752
|
+
* plain value. git(1) names neither: git reads them only through the C library
|
|
2753
|
+
* (`setlocale`, `localtime`), and a value with no `/` and no `..` is looked up
|
|
2754
|
+
* inside the system's locale or zone database, never as a file the agent
|
|
2755
|
+
* chose. `LANGUAGE` is left out (git(1) does not name it either, and its
|
|
2756
|
+
* colon list is read by gettext).
|
|
2757
|
+
*/
|
|
2758
|
+
const GIT_HARMLESS_ASSIGNMENTS = [
|
|
2759
|
+
"GIT_TERMINAL_PROMPT=0",
|
|
2760
|
+
"GIT_PAGER=cat",
|
|
2761
|
+
"PAGER=cat",
|
|
2762
|
+
"GIT_EDITOR=true",
|
|
2763
|
+
"GIT_EDITOR=:",
|
|
2764
|
+
"EDITOR=true",
|
|
2765
|
+
"EDITOR=:",
|
|
2766
|
+
"VISUAL=true",
|
|
2767
|
+
"VISUAL=:",
|
|
2768
|
+
];
|
|
2769
|
+
/** The locale variables, POSIX's and glibc's, admitted with a plain value. */
|
|
2770
|
+
const GIT_LOCALE_NAMES = [
|
|
2771
|
+
"LANG",
|
|
2772
|
+
"LC_ALL",
|
|
2773
|
+
"LC_COLLATE",
|
|
2774
|
+
"LC_CTYPE",
|
|
2775
|
+
"LC_MESSAGES",
|
|
2776
|
+
"LC_MONETARY",
|
|
2777
|
+
"LC_NUMERIC",
|
|
2778
|
+
"LC_TIME",
|
|
2779
|
+
"LC_ADDRESS",
|
|
2780
|
+
"LC_IDENTIFICATION",
|
|
2781
|
+
"LC_MEASUREMENT",
|
|
2782
|
+
"LC_NAME",
|
|
2783
|
+
"LC_PAPER",
|
|
2784
|
+
"LC_TELEPHONE",
|
|
2785
|
+
];
|
|
2786
|
+
/** A locale name (`C`, `POSIX`, `C.UTF-8`, `en_US.UTF-8`, `sr_RS@latin`), never a path. */
|
|
2787
|
+
const PLAIN_LOCALE = /^[A-Za-z][A-Za-z0-9_@.-]*$/u;
|
|
2788
|
+
/**
|
|
2789
|
+
* May this assignment word stand in front of `git` without changing what the
|
|
2790
|
+
* subcommand runs, reads or writes ({@link GIT_HARMLESS_ASSIGNMENTS})? A word
|
|
2791
|
+
* carrying a command substitution never may: its value is not in the text.
|
|
2792
|
+
*/
|
|
2793
|
+
function isHarmlessGitAssignment(word) {
|
|
2794
|
+
if (word.substitutions.length > 0)
|
|
2795
|
+
return false;
|
|
2796
|
+
const text = word.text;
|
|
2797
|
+
// Only the plain `NAME=value` spelling ({@link PLAIN_ASSIGNMENT}).
|
|
2798
|
+
if (!PLAIN_ASSIGNMENT.test(text))
|
|
2799
|
+
return false;
|
|
2800
|
+
if (GIT_HARMLESS_ASSIGNMENTS.includes(text))
|
|
2801
|
+
return true;
|
|
2802
|
+
const equals = text.indexOf("=");
|
|
2803
|
+
if (equals <= 0)
|
|
2804
|
+
return false;
|
|
2805
|
+
const name = text.slice(0, equals);
|
|
2806
|
+
const value = text.slice(equals + 1);
|
|
2807
|
+
if (name === "TZ")
|
|
2808
|
+
return isZoneValue(value);
|
|
2809
|
+
if (GIT_LOCALE_NAMES.includes(name))
|
|
2810
|
+
return PLAIN_LOCALE.test(value) && !value.includes("..");
|
|
2811
|
+
return false;
|
|
2812
|
+
}
|
|
2813
|
+
/**
|
|
2814
|
+
* Why the assignments in front of `git` leave the segment `unclassified`, or
|
|
2815
|
+
* `null` when every one is harmless (APRV-521).
|
|
2816
|
+
*/
|
|
2817
|
+
function gitAssignmentRefusal(prefix) {
|
|
2818
|
+
const word = prefix.find((candidate) => !isHarmlessGitAssignment(candidate));
|
|
2819
|
+
if (word === undefined)
|
|
2820
|
+
return null;
|
|
2821
|
+
return `no rule for ${assignmentHead(word.text)}… in front of git: an environment assignment can run a program, repoint the repository or its configuration, or write a file`;
|
|
2822
|
+
}
|
|
2823
|
+
/**
|
|
2824
|
+
* Is this a variable git, or a program git starts (its pager, editor, ssh,
|
|
2825
|
+
* askpass helper, hooks), reads to pick a program, a library, a repository,
|
|
2826
|
+
* configuration or a file to write (APRV-521)? Read by the command-line scan
|
|
2827
|
+
* ({@link gitEnvironmentWrite}) and the bare-assignment rule, where a value
|
|
2828
|
+
* reaches git only if the name was already exported: so the scan is bounded
|
|
2829
|
+
* to these names, and an ordinary script variable (`b=$(git branch
|
|
2830
|
+
* --show-current); git push origin "$b"`) is left alone. In front of `git`
|
|
2831
|
+
* itself no name is trusted: {@link gitAssignmentRefusal} is a closed list.
|
|
2832
|
+
*/
|
|
2833
|
+
function isGitInheritedName(name) {
|
|
2834
|
+
if (/^(?:GIT_|XDG_|LESS|LC_|LD_|DYLD_)/u.test(name))
|
|
2835
|
+
return true;
|
|
2836
|
+
return [
|
|
2837
|
+
"PAGER",
|
|
2838
|
+
"EDITOR",
|
|
2839
|
+
"VISUAL",
|
|
2840
|
+
"LV",
|
|
2841
|
+
"HOME",
|
|
2842
|
+
"PATH",
|
|
2843
|
+
"TMPDIR",
|
|
2844
|
+
"SSH_ASKPASS",
|
|
2845
|
+
"BASH_ENV",
|
|
2846
|
+
"ENV",
|
|
2847
|
+
"LANG",
|
|
2848
|
+
"LANGUAGE",
|
|
2849
|
+
"TZ",
|
|
2850
|
+
"TZDIR",
|
|
2851
|
+
"LOCPATH",
|
|
2852
|
+
"GCONV_PATH",
|
|
2853
|
+
"GNUPGHOME",
|
|
2854
|
+
].includes(name);
|
|
2855
|
+
}
|
|
2856
|
+
/** The builtins that set or export a variable named in their arguments. */
|
|
2857
|
+
const SHELL_SETTING_BUILTINS = ["export", "declare", "typeset", "readonly", "local"];
|
|
2858
|
+
/**
|
|
2859
|
+
* POSIX's special builtins: an assignment in front of one persists in the
|
|
2860
|
+
* shell (in `sh` and in bash's POSIX mode) rather than reaching that command
|
|
2861
|
+
* alone.
|
|
2862
|
+
*/
|
|
2863
|
+
const POSIX_SPECIAL_BUILTINS = [
|
|
2864
|
+
":",
|
|
2865
|
+
".",
|
|
2866
|
+
"break",
|
|
2867
|
+
"continue",
|
|
2868
|
+
"eval",
|
|
2869
|
+
"exec",
|
|
2870
|
+
"exit",
|
|
2871
|
+
"export",
|
|
2872
|
+
"readonly",
|
|
2873
|
+
"return",
|
|
2874
|
+
"set",
|
|
2875
|
+
"shift",
|
|
2876
|
+
"times",
|
|
2877
|
+
"trap",
|
|
2878
|
+
"unset",
|
|
2879
|
+
];
|
|
2880
|
+
/**
|
|
2881
|
+
* `${NAME=word}` and `${NAME:=word}` assign NAME in the shell that expands
|
|
2882
|
+
* them, and so do `${NAME[i]=word}` and `${NAME[i]:=word}` (review-b N2).
|
|
2883
|
+
*/
|
|
2884
|
+
const PARAMETER_ASSIGNMENT = /\$\{([A-Za-z_][A-Za-z0-9_]*)(?:\[[^\]]*\])?:?=/u;
|
|
2885
|
+
/**
|
|
2886
|
+
* The first variable git could inherit ({@link isGitInheritedName}) that this
|
|
2887
|
+
* command line's own shell sets to something other than a harmless value, or
|
|
2888
|
+
* `null` (APRV-521). Counted: a bare `NAME=value` (it reaches every later
|
|
2889
|
+
* command when NAME is already exported), an assignment in front of a POSIX
|
|
2890
|
+
* special builtin, the arguments of `export`, `declare`, `typeset`, `readonly`
|
|
2891
|
+
* and `local` (a bare `export NAME` exports a value the text does not show),
|
|
2892
|
+
* `printf -v NAME`, and `${NAME=word}` / `${NAME:=word}` in any word.
|
|
2893
|
+
*
|
|
2894
|
+
* The whole line is read, not just the part before a `git` segment: a loop
|
|
2895
|
+
* body runs again after its own later assignments. A subshell or a pipeline
|
|
2896
|
+
* stage that keeps its assignment to itself is counted too, which only ever
|
|
2897
|
+
* refuses more.
|
|
2898
|
+
*/
|
|
2899
|
+
function gitEnvironmentWrite(segments) {
|
|
2900
|
+
const nameOf = assignedName;
|
|
2901
|
+
for (const segment of segments) {
|
|
2902
|
+
const words = segment.words;
|
|
2903
|
+
let cursor = 0;
|
|
2904
|
+
while (cursor < words.length && ASSIGNMENT.test(words[cursor].text))
|
|
2905
|
+
cursor += 1;
|
|
2906
|
+
const binWord = words[cursor];
|
|
2907
|
+
const base = binWord === undefined ? null : (pathSegments(binWord.text).slice(-1)[0] ?? binWord.text);
|
|
2908
|
+
if (base === null || POSIX_SPECIAL_BUILTINS.includes(base)) {
|
|
2909
|
+
for (const word of words.slice(0, cursor)) {
|
|
2910
|
+
if (isGitInheritedName(nameOf(word.text)) && !isHarmlessGitAssignment(word))
|
|
2911
|
+
return nameOf(word.text);
|
|
2912
|
+
}
|
|
2913
|
+
}
|
|
2914
|
+
if (base !== null && SHELL_SETTING_BUILTINS.includes(base)) {
|
|
2915
|
+
for (const word of words.slice(cursor + 1)) {
|
|
2916
|
+
if (isFlag(word.text))
|
|
2917
|
+
continue;
|
|
2918
|
+
const name = nameOf(word.text);
|
|
2919
|
+
// The name as written, subscript included: `a[$x]` is not a name the text settles.
|
|
2920
|
+
const written = word.text.includes("=") ? word.text.slice(0, word.text.indexOf("=")) : word.text;
|
|
2921
|
+
if (/[$`]/u.test(written))
|
|
2922
|
+
return name;
|
|
2923
|
+
if (isGitInheritedName(name) && !isHarmlessGitAssignment(word))
|
|
2924
|
+
return name;
|
|
2925
|
+
}
|
|
2926
|
+
}
|
|
2927
|
+
if (base === "printf") {
|
|
2928
|
+
const args = words.slice(cursor + 1).map((word) => word.text);
|
|
2929
|
+
for (let at = 0; at < args.length; at += 1) {
|
|
2930
|
+
const arg = args[at];
|
|
2931
|
+
if (arg === "--")
|
|
2932
|
+
break;
|
|
2933
|
+
const name = arg === "-v" ? args[at + 1] : arg.startsWith("-v") ? arg.slice(2) : undefined;
|
|
2934
|
+
if (name === undefined)
|
|
2935
|
+
continue;
|
|
2936
|
+
const bare = name.replace(/\[.*$/su, "");
|
|
2937
|
+
if (/[$`]/u.test(bare) || isGitInheritedName(bare))
|
|
2938
|
+
return bare;
|
|
2939
|
+
}
|
|
2940
|
+
}
|
|
2941
|
+
for (const word of [...words, ...segment.redirects.map((redirect) => redirect.target)]) {
|
|
2942
|
+
const match = PARAMETER_ASSIGNMENT.exec(word.text);
|
|
2943
|
+
if (match !== null && isGitInheritedName(match[1]))
|
|
2944
|
+
return match[1];
|
|
2945
|
+
}
|
|
2946
|
+
}
|
|
2947
|
+
return null;
|
|
2948
|
+
}
|
|
1538
2949
|
/** `git commit --amend` rewrites; a plain commit does not. */
|
|
1539
2950
|
function refineGitCommit(ctx) {
|
|
1540
2951
|
return hasFlag(ctx.args, ["--amend"])
|
|
@@ -2590,21 +4001,225 @@ export function skillScriptArg(bin, args) {
|
|
|
2590
4001
|
continue;
|
|
2591
4002
|
if (python && PYTHON_SKILL_SCRIPT_FLAGS.test(arg))
|
|
2592
4003
|
continue;
|
|
2593
|
-
// Anything else: a value-taking flag, `--flag=value`, inline code (`-c`,
|
|
2594
|
-
// `-e`, `-m`, `-p`), a lone `-` (stdin) or `--`. Not this rule.
|
|
4004
|
+
// Anything else: a value-taking flag, `--flag=value`, inline code (`-c`,
|
|
4005
|
+
// `-e`, `-m`, `-p`), a lone `-` (stdin) or `--`. Not this rule.
|
|
4006
|
+
return null;
|
|
4007
|
+
}
|
|
4008
|
+
return null;
|
|
4009
|
+
}
|
|
4010
|
+
/** `bun` / `python` / `python3` running a skill script; binds the script path. */
|
|
4011
|
+
function refineSkillScript(ctx) {
|
|
4012
|
+
// `matchRule` only lets this row match when `skillScriptArg` found a script,
|
|
4013
|
+
// so the fallback is unreachable; it is spelled as the row's own answer
|
|
4014
|
+
// rather than as a `null`, which would be read as "opaque".
|
|
4015
|
+
const script = skillScriptArg(ctx.bin, ctx.args);
|
|
4016
|
+
return script === null
|
|
4017
|
+
? { class: SKILL_SCRIPT_CLASS, rule: SKILL_SCRIPT_RULE }
|
|
4018
|
+
: { class: SKILL_SCRIPT_CLASS, rule: SKILL_SCRIPT_RULE, path: script };
|
|
4019
|
+
}
|
|
4020
|
+
// ---------------------------------------------------------------------------
|
|
4021
|
+
// Opaque agent code (APRV-510)
|
|
4022
|
+
// ---------------------------------------------------------------------------
|
|
4023
|
+
/**
|
|
4024
|
+
* The class an opaque program takes once a policy opens it: an inline program
|
|
4025
|
+
* or an interpreter run of a file the gate cannot read as a skill script.
|
|
4026
|
+
* Under `exec.*` (SPEC.md §7) beside `exec.local`, and deliberately not under
|
|
4027
|
+
* it: `exec.local` is code in the workspace the runtime may name, this is code
|
|
4028
|
+
* whose effect nothing here reads. A record of it says the program EXISTED
|
|
4029
|
+
* (its hash and length), never what it did: every tool call the program then
|
|
4030
|
+
* makes through the harness is its own hook event, and a shell command it
|
|
4031
|
+
* spawns is not seen at all.
|
|
4032
|
+
*/
|
|
4033
|
+
export const EXEC_CODE_CLASS = "exec.code";
|
|
4034
|
+
/** The table row that matches the `exec.code` shape. */
|
|
4035
|
+
export const EXEC_CODE_ROW = "exec-code";
|
|
4036
|
+
/** The rule id of an inline program (`python3 -c`, `node -e`, ...). */
|
|
4037
|
+
export const EXEC_CODE_INLINE_RULE = "exec-code-inline";
|
|
4038
|
+
/** The rule id of an interpreter running a file (`python3 x.py`, `bun x.ts`). */
|
|
4039
|
+
export const EXEC_CODE_FILE_RULE = "exec-code-file";
|
|
4040
|
+
/**
|
|
4041
|
+
* The inline-program flag of each interpreter the shape knows (APRV-510).
|
|
4042
|
+
* `deno` is absent because its inline form is a subcommand (`deno eval`).
|
|
4043
|
+
* `node -p` / `--print` is absent on purpose: the brief's list, kept tight.
|
|
4044
|
+
*/
|
|
4045
|
+
const EXEC_CODE_INLINE_FLAGS = {
|
|
4046
|
+
python: ["-c"],
|
|
4047
|
+
python3: ["-c"],
|
|
4048
|
+
node: ["-e", "--eval"],
|
|
4049
|
+
bun: ["-e", "--eval"],
|
|
4050
|
+
perl: ["-e", "-E"],
|
|
4051
|
+
ruby: ["-e"],
|
|
4052
|
+
};
|
|
4053
|
+
/** Every binary the `exec.code` row may match. */
|
|
4054
|
+
const EXEC_CODE_BINS = [...Object.keys(EXEC_CODE_INLINE_FLAGS), "deno"];
|
|
4055
|
+
/**
|
|
4056
|
+
* The interpreters whose FILE runs take `exec.code`. `node <file>` is absent:
|
|
4057
|
+
* the table already answers it (`node-script`, `files.write.workspace`), and a
|
|
4058
|
+
* name the table classifies from its arguments is not re-read here.
|
|
4059
|
+
*/
|
|
4060
|
+
const EXEC_CODE_FILE_BINS = new Set(["python", "python3", "bun"]);
|
|
4061
|
+
/**
|
|
4062
|
+
* The file a `bun` run must name to be read as a file rather than a
|
|
4063
|
+
* subcommand: bun prefers a subcommand over a file of the same name, so only a
|
|
4064
|
+
* word that cannot be one (a script extension) is taken.
|
|
4065
|
+
*/
|
|
4066
|
+
const BUN_SCRIPT_FILE = /\.(?:[cm]?[jt]s|[jt]sx)$/u;
|
|
4067
|
+
/**
|
|
4068
|
+
* The `exec.code` shape of an interpreter argv, or `null` (APRV-510).
|
|
4069
|
+
*
|
|
4070
|
+
* An ALLOWLIST, like {@link skillScriptArg}, and the same flags are inert here:
|
|
4071
|
+
*
|
|
4072
|
+
* - inline: `[inert flags…] <inline flag> <program>`, and NOTHING after the
|
|
4073
|
+
* program, for every interpreter. A word after it is handed to the program
|
|
4074
|
+
* (`sys.argv`, `process.argv`, `ARGV`), and a one-line loader
|
|
4075
|
+
* (`python3 -c 'import sys; exec(sys.argv[1])' '<real program>'`) would put
|
|
4076
|
+
* the real program there, outside the hash and inside the argv the payload
|
|
4077
|
+
* carries verbatim (security review of APRV-510, finding 3a). `deno` is
|
|
4078
|
+
* exactly `deno eval <program>`;
|
|
4079
|
+
* - file (python, python3 and bun only): `[inert flags…] <path> [args…]`,
|
|
4080
|
+
* where the path is not a skill script (those are `exec.local`, APRV-502)
|
|
4081
|
+
* and, for `bun`, carries a script extension.
|
|
4082
|
+
*
|
|
4083
|
+
* Anything else is not the shape: a value-taking flag, `--flag=value`, `-m`,
|
|
4084
|
+
* a lone `-` (stdin), an inline flag with no program, two programs, a cluster
|
|
4085
|
+
* hiding the inline flag (`-Ic`), and, when `words` is given, a source word the
|
|
4086
|
+
* shell would still change (`LexWord.exact`) or one built from a substitution:
|
|
4087
|
+
* the hash binds the program's bytes, so the word must BE them.
|
|
4088
|
+
*/
|
|
4089
|
+
export function execCodeShape(bin, args, words = null) {
|
|
4090
|
+
const exactAt = (index) => {
|
|
4091
|
+
if (words === null)
|
|
4092
|
+
return true;
|
|
4093
|
+
const word = words[index];
|
|
4094
|
+
return word !== undefined && word.exact && word.substitutions.length === 0;
|
|
4095
|
+
};
|
|
4096
|
+
if (bin === "deno") {
|
|
4097
|
+
if (args.length !== 2 || args[0] !== "eval")
|
|
4098
|
+
return null;
|
|
4099
|
+
return exactAt(1) ? { mode: "inline", index: 1 } : null;
|
|
4100
|
+
}
|
|
4101
|
+
const inline = EXEC_CODE_INLINE_FLAGS[bin];
|
|
4102
|
+
if (inline === undefined)
|
|
4103
|
+
return null;
|
|
4104
|
+
const python = bin === "python" || bin === "python3";
|
|
4105
|
+
const inert = SKILL_SCRIPT_FLAGS[bin] ?? [];
|
|
4106
|
+
for (const [position, arg] of args.entries()) {
|
|
4107
|
+
if (inline.includes(arg)) {
|
|
4108
|
+
const index = position + 1;
|
|
4109
|
+
if (index !== args.length - 1)
|
|
4110
|
+
return null;
|
|
4111
|
+
return exactAt(index) ? { mode: "inline", index } : null;
|
|
4112
|
+
}
|
|
4113
|
+
if (!arg.startsWith("-")) {
|
|
4114
|
+
if (!EXEC_CODE_FILE_BINS.has(bin))
|
|
4115
|
+
return null;
|
|
4116
|
+
if (isSkillScriptPath(arg))
|
|
4117
|
+
return null;
|
|
4118
|
+
// Refuter N5: a device or process path is a descriptor, not a program
|
|
4119
|
+
// file (`/dev/stdin`, `/dev/fd/0`, `/proc/self/fd/0`).
|
|
4120
|
+
if (arg.startsWith("/dev/") || arg.startsWith("/proc/"))
|
|
4121
|
+
return null;
|
|
4122
|
+
if (bin === "bun" && !BUN_SCRIPT_FILE.test(arg))
|
|
4123
|
+
return null;
|
|
4124
|
+
return exactAt(position) ? { mode: "file", index: position } : null;
|
|
4125
|
+
}
|
|
4126
|
+
if (inert.includes(arg))
|
|
4127
|
+
continue;
|
|
4128
|
+
if (python && PYTHON_SKILL_SCRIPT_FLAGS.test(arg))
|
|
4129
|
+
continue;
|
|
2595
4130
|
return null;
|
|
2596
4131
|
}
|
|
2597
4132
|
return null;
|
|
2598
4133
|
}
|
|
2599
|
-
/**
|
|
2600
|
-
|
|
2601
|
-
|
|
2602
|
-
|
|
2603
|
-
|
|
2604
|
-
|
|
2605
|
-
|
|
2606
|
-
|
|
2607
|
-
|
|
4134
|
+
/**
|
|
4135
|
+
* Is `bin` an interpreter spelling the `exec.code` row accepts? A bare name
|
|
4136
|
+
* (found on the PATH the harness's shell started with: a segment earlier in
|
|
4137
|
+
* the same command that can change name resolution closes the class for the
|
|
4138
|
+
* rest of the line, see {@link movesNameResolution}), or an absolute path in
|
|
4139
|
+
* one of the `bin` directories of {@link SYSTEM_BIN_DIRS}: `/bin`, `/usr/bin`
|
|
4140
|
+
* and `/usr/local/bin` (security review of APRV-510, finding 1).
|
|
4141
|
+
*
|
|
4142
|
+
* One list for the whole classifier (refuter N6, the reconcile with APRV-508):
|
|
4143
|
+
* a shape row's binary and an interpreter are spelled from the same system
|
|
4144
|
+
* directories, read by the same {@link isSystemBinWord} (`..` and `//`
|
|
4145
|
+
* refused). The interpreter reads the stricter part of it: the `sbin`
|
|
4146
|
+
* directories hold administrative tools, never an interpreter, and
|
|
4147
|
+
* `/opt/homebrew/bin` (admitted before the reconcile) is in neither. It is a
|
|
4148
|
+
* rule about SPELLING rather than about who can write there:
|
|
4149
|
+
* `/usr/local/bin` is often owned by the user on macOS (refuter N3), and a
|
|
4150
|
+
* bare `python3` resolves through it anyway. What the rule refuses is a
|
|
4151
|
+
* spelling the agent chooses outright (`/home/hermes/x/bin/python3`, a venv,
|
|
4152
|
+
* the workspace, a Homebrew path). Anything else, a relative path
|
|
4153
|
+
* (`./python3`, `.venv/bin/python`) included, keeps today's answer.
|
|
4154
|
+
*/
|
|
4155
|
+
function isExecCodeInterpreter(bin) {
|
|
4156
|
+
if (!bin.includes("/"))
|
|
4157
|
+
return true;
|
|
4158
|
+
const cut = bin.lastIndexOf("/");
|
|
4159
|
+
if (cut >= bin.length - 1 || !isSystemBinWord(bin))
|
|
4160
|
+
return false;
|
|
4161
|
+
return !bin.slice(0, cut + 1).endsWith("/sbin/");
|
|
4162
|
+
}
|
|
4163
|
+
/**
|
|
4164
|
+
* The builtins that can change which program a bare name runs: `PATH` (`export`,
|
|
4165
|
+
* `declare`, `set`), a function or alias of that name, the hash table.
|
|
4166
|
+
*/
|
|
4167
|
+
const NAME_RESOLUTION_BUILTINS = new Set([
|
|
4168
|
+
"export",
|
|
4169
|
+
"declare",
|
|
4170
|
+
"typeset",
|
|
4171
|
+
"readonly",
|
|
4172
|
+
"local",
|
|
4173
|
+
"set",
|
|
4174
|
+
"unset",
|
|
4175
|
+
"alias",
|
|
4176
|
+
"unalias",
|
|
4177
|
+
"hash",
|
|
4178
|
+
"enable",
|
|
4179
|
+
"builtin",
|
|
4180
|
+
"command",
|
|
4181
|
+
"function",
|
|
4182
|
+
// Refuter S4: builtins that WRITE a shell variable (PATH among them).
|
|
4183
|
+
"read",
|
|
4184
|
+
"readarray",
|
|
4185
|
+
"mapfile",
|
|
4186
|
+
"getopts",
|
|
4187
|
+
"let",
|
|
4188
|
+
]);
|
|
4189
|
+
/**
|
|
4190
|
+
* Can this segment change what a LATER bare interpreter name resolves to?
|
|
4191
|
+
* (Security review of APRV-510, finding 1.) A bare assignment (`PATH=/tmp/x`,
|
|
4192
|
+
* which reaches the lookup when `PATH` is exported, as it always is) or one of
|
|
4193
|
+
* {@link NAME_RESOLUTION_BUILTINS}. Text only, and deliberately broad: a
|
|
4194
|
+
* segment this answers `true` for only closes `exec.code` for the rest of the
|
|
4195
|
+
* command line, which leaves those segments their pre-APRV-510 answer.
|
|
4196
|
+
*/
|
|
4197
|
+
function movesNameResolution(segment) {
|
|
4198
|
+
const words = segment.words.map((word) => word.text);
|
|
4199
|
+
let cursor = 0;
|
|
4200
|
+
while (cursor < words.length && ASSIGNMENT.test(words[cursor]))
|
|
4201
|
+
cursor += 1;
|
|
4202
|
+
const bin = words[cursor];
|
|
4203
|
+
if (bin === undefined)
|
|
4204
|
+
return cursor > 0;
|
|
4205
|
+
const base = pathSegments(bin).slice(-1)[0] ?? bin;
|
|
4206
|
+
// Refuter S4: `printf -v PATH …` assigns a variable, and the `printf` row
|
|
4207
|
+
// reads it as `read.shell`.
|
|
4208
|
+
if (base === "printf" && words.slice(cursor + 1).some((word) => word.startsWith("-v"))) {
|
|
4209
|
+
return true;
|
|
4210
|
+
}
|
|
4211
|
+
return NAME_RESOLUTION_BUILTINS.has(base);
|
|
4212
|
+
}
|
|
4213
|
+
/** The `exec.code` row's answer: inline or file, with the file bound as the path. */
|
|
4214
|
+
function refineExecCode(ctx) {
|
|
4215
|
+
const shape = execCodeShape(ctx.bin, ctx.args);
|
|
4216
|
+
if (shape === null || shape.mode === "inline") {
|
|
4217
|
+
// `matchRule` lets this row match only the shape, so `null` is unreachable;
|
|
4218
|
+
// it is answered as the row's own class rather than as a `null` refinement,
|
|
4219
|
+
// which this table reads as "opaque".
|
|
4220
|
+
return { class: EXEC_CODE_CLASS, rule: EXEC_CODE_INLINE_RULE };
|
|
4221
|
+
}
|
|
4222
|
+
return { class: EXEC_CODE_CLASS, rule: EXEC_CODE_FILE_RULE, path: ctx.args[shape.index] };
|
|
2608
4223
|
}
|
|
2609
4224
|
/**
|
|
2610
4225
|
* `node` — an inline script is opaque, the gate's own entry point is
|
|
@@ -2822,6 +4437,19 @@ export const COMMAND_RULES = [
|
|
|
2822
4437
|
// one that has to be refined rather than matched.
|
|
2823
4438
|
...HARNESS_RULES,
|
|
2824
4439
|
// -- workspace tools -----------------------------------------------------
|
|
4440
|
+
// APRV-510. Opaque agent code, matched ONLY when the caller opened the class
|
|
4441
|
+
// and the argv is exactly the shape ({@link execCodeShape}). Above `node`, so
|
|
4442
|
+
// `node -e` reaches it before `refineNode` answers opaque; below every `bun`
|
|
4443
|
+
// row of the package-manager group, so a `bun` argv the table already
|
|
4444
|
+
// classifies keeps its class. With the class closed the row never matches,
|
|
4445
|
+
// and every argv keeps the answer it had before the row existed.
|
|
4446
|
+
{
|
|
4447
|
+
id: EXEC_CODE_ROW,
|
|
4448
|
+
bins: EXEC_CODE_BINS,
|
|
4449
|
+
class: EXEC_CODE_CLASS,
|
|
4450
|
+
execCode: true,
|
|
4451
|
+
refine: refineExecCode,
|
|
4452
|
+
},
|
|
2825
4453
|
// APRV-193: three of the rules below hand control to code the runtime did not
|
|
2826
4454
|
// author, and they are named in {@link CODE_EXECUTING_RULES}.
|
|
2827
4455
|
{
|
|
@@ -2956,9 +4584,67 @@ export const COMMAND_RULES = [
|
|
|
2956
4584
|
refine: refinePrintenv,
|
|
2957
4585
|
},
|
|
2958
4586
|
// -- reads ---------------------------------------------------------------
|
|
4587
|
+
// APRV-508. `date` prints the time or sets the clock, with one binary and
|
|
4588
|
+
// overlapping flags, so it is a shape row rather than a seat in the read
|
|
4589
|
+
// table: only the display forms {@link isDateDisplay} reads match, and every
|
|
4590
|
+
// setting form (`-s`, `--set`, a bare `MMDDhhmm` operand), every file-reading
|
|
4591
|
+
// form (`-f`, `-r`) and everything else keeps the `unclassified` refusal it
|
|
4592
|
+
// had. The only assignments the row admits are the zone and the locale, with
|
|
4593
|
+
// values that cannot name a file (`TZ=Asia/Kolkata date`).
|
|
4594
|
+
{
|
|
4595
|
+
id: "date-read",
|
|
4596
|
+
bins: ["date"],
|
|
4597
|
+
class: "read.shell",
|
|
4598
|
+
shape: isDateDisplay,
|
|
4599
|
+
},
|
|
4600
|
+
// APRV-508. The system-information readers, each a strict allow-grammar in
|
|
4601
|
+
// `sysinfo-argv.ts`: `hostname NAME` sets the host name, `free -s` loops,
|
|
4602
|
+
// `uptime FILE` reads a utmp file, so none of them is a plain seat in the
|
|
4603
|
+
// read table. No assignment rides in front of any of them.
|
|
4604
|
+
{
|
|
4605
|
+
id: "sysinfo-read",
|
|
4606
|
+
bins: ["uptime", "whoami"],
|
|
4607
|
+
class: "read.shell",
|
|
4608
|
+
shape: (args, assignments) => assignments.length === 0 && isBareInfo(args),
|
|
4609
|
+
},
|
|
4610
|
+
{
|
|
4611
|
+
id: "hostname-read",
|
|
4612
|
+
bins: ["hostname"],
|
|
4613
|
+
class: "read.shell",
|
|
4614
|
+
shape: (args, assignments) => assignments.length === 0 && isHostnameDisplay(args),
|
|
4615
|
+
},
|
|
4616
|
+
{
|
|
4617
|
+
id: "df-read",
|
|
4618
|
+
bins: ["df"],
|
|
4619
|
+
class: "read.shell",
|
|
4620
|
+
shape: (args, assignments) => assignments.length === 0 && isDfDisplay(args),
|
|
4621
|
+
},
|
|
4622
|
+
{
|
|
4623
|
+
id: "free-read",
|
|
4624
|
+
bins: ["free"],
|
|
4625
|
+
class: "read.shell",
|
|
4626
|
+
shape: (args, assignments) => assignments.length === 0 && isFreeDisplay(args),
|
|
4627
|
+
},
|
|
4628
|
+
{
|
|
4629
|
+
id: "seq-read",
|
|
4630
|
+
bins: ["seq"],
|
|
4631
|
+
class: "read.shell",
|
|
4632
|
+
shape: (args, assignments) => assignments.length === 0 && isSeqDisplay(args),
|
|
4633
|
+
},
|
|
4634
|
+
// `paste` reads the files it names, and the read scope takes them exactly as
|
|
4635
|
+
// it takes `cat`'s (`paste: "all"` in read-scope.ts).
|
|
4636
|
+
{
|
|
4637
|
+
id: "paste-read",
|
|
4638
|
+
bins: ["paste"],
|
|
4639
|
+
class: "read.shell",
|
|
4640
|
+
shape: (args, assignments) => assignments.length === 0 && isPasteRead(args),
|
|
4641
|
+
},
|
|
2959
4642
|
{
|
|
2960
4643
|
id: "read-shell",
|
|
2961
4644
|
bins: [
|
|
4645
|
+
// APRV-508: `[` is `test` under its other name; the closing `]` is an
|
|
4646
|
+
// argument. Both read nothing but their operands and the file modes.
|
|
4647
|
+
"[",
|
|
2962
4648
|
"basename",
|
|
2963
4649
|
"cat",
|
|
2964
4650
|
"cd",
|
|
@@ -3146,7 +4832,16 @@ const INLINE_SCRIPT_FLAG = /^-[li]*c$/u;
|
|
|
3146
4832
|
* - a substitution in any of the three words, whose effect happens before the
|
|
3147
4833
|
* shell even starts;
|
|
3148
4834
|
* - a nested shell inside the script, refused by {@link ClassifierContext} when
|
|
3149
|
-
* the recursion runs (the inner classification unwraps nothing)
|
|
4835
|
+
* the recursion runs (the inner classification unwraps nothing);
|
|
4836
|
+
* - a shell word that is not the system's binary ({@link isSystemBinWord}): a
|
|
4837
|
+
* relative path, or an absolute one outside the system-bin directories, is
|
|
4838
|
+
* whatever program sits there, which its last path segment does not name
|
|
4839
|
+
* (APRV-523 fix round, review-b S1);
|
|
4840
|
+
* - a shell word that is assignment-shaped, in its source spelling
|
|
4841
|
+
* ({@link LexWord.assignment}) or its dequoted text ({@link ASSIGNMENT}).
|
|
4842
|
+
* bash runs a word whose name is quoted or escaped as a program named by the
|
|
4843
|
+
* whole word, and {@link classifySegment} refuses that segment; unwrapping
|
|
4844
|
+
* it here would answer before that check runs.
|
|
3150
4845
|
*
|
|
3151
4846
|
* The BINDING does not move. `cli/hook.ts` binds the outer command and argv
|
|
3152
4847
|
* exactly as APRV-362 built them; what this changes is only which text is
|
|
@@ -3167,6 +4862,12 @@ export function loginShellScript(segment) {
|
|
|
3167
4862
|
return null;
|
|
3168
4863
|
if (script.substitutions.length > 0)
|
|
3169
4864
|
return null;
|
|
4865
|
+
// APRV-523 fix round (review-b S1): only the system's shell, named as
|
|
4866
|
+
// written. A bare name and a system-bin path keep the route.
|
|
4867
|
+
if (shell.assignment || ASSIGNMENT.test(shell.text))
|
|
4868
|
+
return null;
|
|
4869
|
+
if (!isSystemBinWord(shell.text, shell.exact))
|
|
4870
|
+
return null;
|
|
3170
4871
|
const base = pathSegments(shell.text).slice(-1)[0] ?? shell.text;
|
|
3171
4872
|
if (!UNWRAPPABLE_SHELLS.has(base))
|
|
3172
4873
|
return null;
|
|
@@ -3187,8 +4888,54 @@ const INLINE_SOURCE_BINS = {
|
|
|
3187
4888
|
// ===========================================================================
|
|
3188
4889
|
// Classification
|
|
3189
4890
|
// ===========================================================================
|
|
3190
|
-
/**
|
|
3191
|
-
|
|
4891
|
+
/**
|
|
4892
|
+
* `VAR=value` prefixes, which are not the command: every spelling of bash's
|
|
4893
|
+
* assignment word (APRV-521, review-b M1). A name, an optional `[subscript]`,
|
|
4894
|
+
* then `=` or the append operator `+=`: `NAME=v`, `NAME+=v`, `NAME[i]=v`,
|
|
4895
|
+
* `NAME[i]+=v`. bash puts each one in the environment of the command it
|
|
4896
|
+
* precedes, so a cursor that stopped at `NAME+=v` took that word for the
|
|
4897
|
+
* program and judged the real one as its argument. The compound form
|
|
4898
|
+
* `NAME=(…)` never reaches here: the tokenizer refuses it ({@link lex}).
|
|
4899
|
+
*
|
|
4900
|
+
* bash applies this grammar to the word as written (APRV-523). Applied to the
|
|
4901
|
+
* source spelling ({@link LexWord.assignment}) it is bash's rule: a quote, a
|
|
4902
|
+
* backslash, a `$`, a backtick or a brace in the name is none of
|
|
4903
|
+
* `[A-Za-z0-9_]`, so such a word is a command word. Applied to a word's
|
|
4904
|
+
* dequoted `text` it says only that the word is assignment-SHAPED, which is
|
|
4905
|
+
* what the deliberately broad line scans read ({@link gitEnvironmentWrite},
|
|
4906
|
+
* {@link movesNameResolution}): they count both readings and only refuse more.
|
|
4907
|
+
*/
|
|
4908
|
+
const ASSIGNMENT = /^[A-Za-z_][A-Za-z0-9_]*(?:\[[^\]]*\])?\+?=/u;
|
|
4909
|
+
/**
|
|
4910
|
+
* The one spelling a rule may read past: a bare name and `=`. An append keeps
|
|
4911
|
+
* whatever value the name already had, so its text does not say what the
|
|
4912
|
+
* command will see, and a subscript writes an array element. Either one in
|
|
4913
|
+
* front of a command leaves the segment `unclassified`.
|
|
4914
|
+
*/
|
|
4915
|
+
const PLAIN_ASSIGNMENT = /^[A-Za-z_][A-Za-z0-9_]*=/u;
|
|
4916
|
+
/**
|
|
4917
|
+
* A command word that opens like an assignment with a subscript (`NAME[`) but
|
|
4918
|
+
* is not one {@link ASSIGNMENT} reads. bash reads the subscript as a bracketed
|
|
4919
|
+
* pair in command position, spaces and operators included, so a word this
|
|
4920
|
+
* tokenizer split there may still be one assignment to bash, ahead of the
|
|
4921
|
+
* real program. No program is named like this, so it is refused, not guessed.
|
|
4922
|
+
*/
|
|
4923
|
+
const ASSIGNMENT_LIKE = /^[A-Za-z_][A-Za-z0-9_]*\[/u;
|
|
4924
|
+
/**
|
|
4925
|
+
* The variable an assignment word sets: the name in front of `=`, of the
|
|
4926
|
+
* append operator `+=`, or of a `[subscript]`. `GIT_PAGER+=x` sets GIT_PAGER
|
|
4927
|
+
* and `PATH[0]=x` sets PATH. A word with no `=` is returned without its
|
|
4928
|
+
* subscript and `+`.
|
|
4929
|
+
*/
|
|
4930
|
+
function assignedName(word) {
|
|
4931
|
+
const equals = word.indexOf("=");
|
|
4932
|
+
const head = equals === -1 ? word : word.slice(0, equals);
|
|
4933
|
+
return head.replace(/\[.*$/su, "").replace(/\+$/u, "");
|
|
4934
|
+
}
|
|
4935
|
+
/** The assignment word's spelling up to its value (`NAME=`, `NAME+=`, `NAME[i]=`), for a refusal detail. */
|
|
4936
|
+
function assignmentHead(word) {
|
|
4937
|
+
return ASSIGNMENT.exec(word)?.[0] ?? `${assignedName(word)}=`;
|
|
4938
|
+
}
|
|
3192
4939
|
/**
|
|
3193
4940
|
* Redirection targets that create no file (APRV-283).
|
|
3194
4941
|
*
|
|
@@ -3238,6 +4985,49 @@ function protectedRank(surface) {
|
|
|
3238
4985
|
const index = PROTECTED_PRECEDENCE.indexOf(surface);
|
|
3239
4986
|
return index === -1 ? PROTECTED_PRECEDENCE.indexOf("policy.edit") : index;
|
|
3240
4987
|
}
|
|
4988
|
+
/**
|
|
4989
|
+
* Does this protected surface outrank `account.credential`? (APRV-503.)
|
|
4990
|
+
*
|
|
4991
|
+
* Where the credential class sits in {@link PROTECTED_PRECEDENCE}: below
|
|
4992
|
+
* `log.mutate` and `policy.core`, above `policy.edit` and its routed
|
|
4993
|
+
* sub-classes. `log.mutate` is the one class the open window refuses with no
|
|
4994
|
+
* policy consulted, and `policy.core` is an edit of the gate's own organs; a
|
|
4995
|
+
* credential READ named by a redirection must never displace either of them,
|
|
4996
|
+
* under any policy's ordering of the three. `policy.edit` is prose about the
|
|
4997
|
+
* gate, and a secret leaving the machine is the stricter statement.
|
|
4998
|
+
*/
|
|
4999
|
+
function outranksCredential(surface) {
|
|
5000
|
+
return protectedRank(surface) <= protectedRank("policy.core");
|
|
5001
|
+
}
|
|
5002
|
+
/**
|
|
5003
|
+
* The protected path, if any, that outranks a credential answer for this
|
|
5004
|
+
* segment (APRV-503). `null` when none does, which is when the credential tier
|
|
5005
|
+
* may answer at once.
|
|
5006
|
+
*
|
|
5007
|
+
* Scanned as the protected scan reads the segment: the positionals with the
|
|
5008
|
+
* prose skip, and the `<` targets. A credential file is ALSO a protected path
|
|
5009
|
+
* (`.approval/env` lies under the gate's directory), so the words that ARE the
|
|
5010
|
+
* credential read are left out, or every credential read would outrank itself:
|
|
5011
|
+
* the `<` targets that are credential files, and the credential positionals of
|
|
5012
|
+
* a binary that reads what it names. A write-only binary's credential
|
|
5013
|
+
* positional stays in, because for it that word is a write to the gate's
|
|
5014
|
+
* directory (`tee .approval/env < .approval/vault.enc` is `policy.core`, as
|
|
5015
|
+
* `tee .approval/env` is). On equal rank the positional is the one named.
|
|
5016
|
+
*/
|
|
5017
|
+
function outrankingProtected(positionals, readTargets, writesOnly, protectedPaths) {
|
|
5018
|
+
// APRV-522: a credential word whose OWN protected class outranks
|
|
5019
|
+
// `policy.core` stays in the scan. Since `..` resolves in the credential
|
|
5020
|
+
// rule, a `..` spelling can name the environment map while its spelling also
|
|
5021
|
+
// names the log directory; the stricter of the two must not be dropped.
|
|
5022
|
+
const isTheRead = (word) => isCredentialPath(word) &&
|
|
5023
|
+
protectedRank(protectedPathClass(word, protectedPaths) ?? "policy.core") >= protectedRank("policy.core");
|
|
5024
|
+
const named = strictestProtected(writesOnly ? positionals : positionals.filter((arg) => !isTheRead(arg)), protectedPaths, true);
|
|
5025
|
+
const fed = strictestProtected(readTargets.filter((target) => !isTheRead(target)), protectedPaths);
|
|
5026
|
+
const found = fed === null || (named !== null && protectedRank(named.surface) <= protectedRank(fed.surface))
|
|
5027
|
+
? named
|
|
5028
|
+
: fed;
|
|
5029
|
+
return found !== null && outranksCredential(found.surface) ? found : null;
|
|
5030
|
+
}
|
|
3241
5031
|
/**
|
|
3242
5032
|
* Is this word PROSE that merely spells a protected path, rather than a path?
|
|
3243
5033
|
* (APRV-409.)
|
|
@@ -3302,6 +5092,444 @@ function proseNamingProtectedPath(word, surface, protectedPaths) {
|
|
|
3302
5092
|
const head = segments.slice(0, firstSpaced).join("/");
|
|
3303
5093
|
return protectedPathClass(head, protectedPaths) === surface;
|
|
3304
5094
|
}
|
|
5095
|
+
const LITERAL_ARG = { splits: false, expands: false };
|
|
5096
|
+
/**
|
|
5097
|
+
* Could the shell turn this argv word into an option the program then obeys,
|
|
5098
|
+
* or into a different NUMBER of words (APRV-511 fix round 3, S(open), the class
|
|
5099
|
+
* rule)? For `curl`, `wget`, `cp`, `mv`, `ln` and `install`, an option may name
|
|
5100
|
+
* a write target (`-o`, `-O`, `-t`, `--output-dir`, `-P`), so a word whose
|
|
5101
|
+
* expansion this text cannot see makes the whole argv unknowable:
|
|
5102
|
+
*
|
|
5103
|
+
* - in a slot where the program reads OPTIONS (`option`): a word that begins
|
|
5104
|
+
* with a variable or a substitution (`$OPTS`, `"$U"`, `${…}`), whose value
|
|
5105
|
+
* may begin with `-`, quoted or not; an unquoted variable or substitution
|
|
5106
|
+
* anywhere in it, whose value may split into `-o FILE`; an unquoted glob at
|
|
5107
|
+
* its head, which may match a file named `-o…`; an unquoted brace group that
|
|
5108
|
+
* makes a word beginning with `-` or `$`, or too many words to know
|
|
5109
|
+
* (`curl {-o,~/.hermes/config.yaml}`);
|
|
5110
|
+
* - in an option's VALUE slot: an unquoted variable or substitution, or an
|
|
5111
|
+
* unquoted brace or glob that may make other than one word, because the
|
|
5112
|
+
* words after the first are read as options again.
|
|
5113
|
+
*
|
|
5114
|
+
* Words after `--` are operands and never reach this test.
|
|
5115
|
+
*/
|
|
5116
|
+
function argvWordUnknowable(word, flags, slot) {
|
|
5117
|
+
if (flags.splits)
|
|
5118
|
+
return true;
|
|
5119
|
+
if (slot === "option" && word.startsWith("$"))
|
|
5120
|
+
return true;
|
|
5121
|
+
if (!flags.expands)
|
|
5122
|
+
return false;
|
|
5123
|
+
const words = braceExpansions(word);
|
|
5124
|
+
if (words === null)
|
|
5125
|
+
return true;
|
|
5126
|
+
if (slot === "value")
|
|
5127
|
+
return words.length !== 1 || /[*?[]/u.test(word);
|
|
5128
|
+
return words.some((each) => /^[-$*?[]/u.test(each));
|
|
5129
|
+
}
|
|
5130
|
+
/**
|
|
5131
|
+
* The first word of a `cp`, `mv`, `ln` or `install` argv that makes its write
|
|
5132
|
+
* targets unknowable (APRV-511 fix round 3, S(open)), or `null`. GNU permutes
|
|
5133
|
+
* options, so every word before `--` is in an option slot.
|
|
5134
|
+
*/
|
|
5135
|
+
export function copyArgvUnknown(args, flags = []) {
|
|
5136
|
+
for (const [at, arg] of args.entries()) {
|
|
5137
|
+
if (arg === "--")
|
|
5138
|
+
return null;
|
|
5139
|
+
if (argvWordUnknowable(arg, flags[at] ?? LITERAL_ARG, "option"))
|
|
5140
|
+
return arg;
|
|
5141
|
+
}
|
|
5142
|
+
return null;
|
|
5143
|
+
}
|
|
5144
|
+
/**
|
|
5145
|
+
* The destination directories a `cp`, `mv`, `ln` or `install` argv names by
|
|
5146
|
+
* flag (APRV-511 refuter S3, recheck S(open)): `-t DIR`, `-tDIR`, `-t` anywhere
|
|
5147
|
+
* in a short cluster (`-vtDIR`, `-rt DIR`, `-ftDIR`, `-stDIR`), and the long
|
|
5148
|
+
* option by any unique prefix GNU accepts (`--target-directory=DIR`,
|
|
5149
|
+
* `--target-dir=DIR`, `--t DIR`). A cluster is read from its first `t` even
|
|
5150
|
+
* where an earlier letter would have taken the rest as its own value: one more
|
|
5151
|
+
* target judged can only make the answer stricter. Words after `--` are
|
|
5152
|
+
* operands. An absent value is not returned (the operand scan judges the rest).
|
|
5153
|
+
*/
|
|
5154
|
+
export function copyTargetDirectories(args) {
|
|
5155
|
+
const out = [];
|
|
5156
|
+
for (let at = 0; at < args.length; at += 1) {
|
|
5157
|
+
const arg = args[at];
|
|
5158
|
+
if (arg === "--")
|
|
5159
|
+
break;
|
|
5160
|
+
if (arg.startsWith("--")) {
|
|
5161
|
+
const eq = arg.indexOf("=");
|
|
5162
|
+
const name = eq === -1 ? arg.slice(2) : arg.slice(2, eq);
|
|
5163
|
+
if (name.length === 0 || !"target-directory".startsWith(name))
|
|
5164
|
+
continue;
|
|
5165
|
+
const value = eq === -1 ? args[at + 1] : arg.slice(eq + 1);
|
|
5166
|
+
if (value !== undefined)
|
|
5167
|
+
out.push(value);
|
|
5168
|
+
continue;
|
|
5169
|
+
}
|
|
5170
|
+
if (!arg.startsWith("-") || arg.length < 2)
|
|
5171
|
+
continue;
|
|
5172
|
+
const t = arg.indexOf("t", 1);
|
|
5173
|
+
if (t === -1)
|
|
5174
|
+
continue;
|
|
5175
|
+
const value = t + 1 < arg.length ? arg.slice(t + 1) : args[at + 1];
|
|
5176
|
+
if (value !== undefined)
|
|
5177
|
+
out.push(value);
|
|
5178
|
+
}
|
|
5179
|
+
return out;
|
|
5180
|
+
}
|
|
5181
|
+
const bodySource = (kind) => ({ value: true, effect: "source", source: kind });
|
|
5182
|
+
const safe = (value) => ({ value, effect: "safe" });
|
|
5183
|
+
/** curl's long options this classifier knows, by full name. */
|
|
5184
|
+
const CURL_LONG_OPTIONS = {
|
|
5185
|
+
output: { value: true, effect: "file" },
|
|
5186
|
+
"dump-header": { value: true, effect: "file" },
|
|
5187
|
+
"cookie-jar": { value: true, effect: "file" },
|
|
5188
|
+
trace: { value: true, effect: "file" },
|
|
5189
|
+
"trace-ascii": { value: true, effect: "file" },
|
|
5190
|
+
stderr: { value: true, effect: "file" },
|
|
5191
|
+
"etag-save": { value: true, effect: "file" },
|
|
5192
|
+
hsts: { value: true, effect: "file" },
|
|
5193
|
+
"alt-svc": { value: true, effect: "file" },
|
|
5194
|
+
libcurl: { value: true, effect: "file" },
|
|
5195
|
+
"write-out": { value: true, effect: "format" },
|
|
5196
|
+
"output-dir": { value: true, effect: "dir" },
|
|
5197
|
+
"remote-name": { value: false, effect: "remote" },
|
|
5198
|
+
"remote-name-all": { value: false, effect: "remote" },
|
|
5199
|
+
config: { value: true, effect: "config" },
|
|
5200
|
+
// Fix round 4 (M(L5f-1)): the files a request body or an upload reads.
|
|
5201
|
+
"upload-file": bodySource("file"),
|
|
5202
|
+
data: bodySource("data"),
|
|
5203
|
+
"data-raw": bodySource("data"),
|
|
5204
|
+
"data-binary": bodySource("data"),
|
|
5205
|
+
"data-ascii": bodySource("data"),
|
|
5206
|
+
json: bodySource("data"),
|
|
5207
|
+
"data-urlencode": bodySource("urlencode"),
|
|
5208
|
+
form: bodySource("form"),
|
|
5209
|
+
"form-string": bodySource("form"),
|
|
5210
|
+
...Object.fromEntries([
|
|
5211
|
+
"silent", "show-error", "fail", "fail-with-body", "fail-early", "location", "location-trusted", "insecure",
|
|
5212
|
+
"verbose", "include", "head", "get", "compressed", "http1.0", "http1.1", "http2", "http2-prior-knowledge",
|
|
5213
|
+
"http3", "ipv4", "ipv6", "no-buffer", "no-progress-meter", "progress-bar", "globoff", "netrc",
|
|
5214
|
+
"netrc-optional", "no-keepalive", "raw", "tcp-nodelay", "ssl", "ssl-reqd", "tlsv1", "tlsv1.0", "tlsv1.1",
|
|
5215
|
+
"tlsv1.2", "tlsv1.3", "anyauth", "basic", "digest", "ntlm", "negotiate", "no-sessionid", "disable",
|
|
5216
|
+
"path-as-is", "create-dirs", "remote-header-name", "remote-time", "list-only", "append", "crlf",
|
|
5217
|
+
"ignore-content-length", "junk-session-cookies", "post301", "post302", "post303", "styled-output",
|
|
5218
|
+
"no-styled-output", "suppress-connect-headers", "false-start", "tr-encoding", "ssl-no-revoke",
|
|
5219
|
+
"proxy-insecure", "retry-all-errors", "retry-connrefused", "parallel", "parallel-immediate", "skip-existing",
|
|
5220
|
+
"no-clobber", "no-alpn", "no-npn", "manual", "help", "version", "disable-epsv", "disable-eprt",
|
|
5221
|
+
].map((name) => [name, safe(false)])),
|
|
5222
|
+
...Object.fromEntries([
|
|
5223
|
+
"header", "request", "user-agent", "user", "referer", "max-time", "connect-timeout", "retry", "retry-delay",
|
|
5224
|
+
"retry-max-time", "range", "url", "proxy", "proxy-user", "resolve", "connect-to", "cacert", "capath", "cert",
|
|
5225
|
+
"key", "cert-type", "key-type", "pass", "cookie", "max-filesize", "max-redirs", "limit-rate", "speed-limit",
|
|
5226
|
+
"speed-time", "time-cond", "interface", "dns-servers", "oauth2-bearer", "aws-sigv4",
|
|
5227
|
+
"keepalive-time", "expect100-timeout", "local-port", "ciphers", "tls-max", "proto", "proto-redir",
|
|
5228
|
+
"proto-default", "request-target", "unix-socket", "url-query", "parallel-max", "quote", "mail-from",
|
|
5229
|
+
"mail-rcpt", "preproxy", "noproxy", "socks5", "socks5-hostname", "continue-at", "etag-compare", "variable",
|
|
5230
|
+
].map((name) => [name, safe(true)])),
|
|
5231
|
+
};
|
|
5232
|
+
/** curl's short options this classifier knows. */
|
|
5233
|
+
const CURL_SHORT_OPTIONS = {
|
|
5234
|
+
o: { value: true, effect: "file" },
|
|
5235
|
+
D: { value: true, effect: "file" },
|
|
5236
|
+
c: { value: true, effect: "file" },
|
|
5237
|
+
w: { value: true, effect: "format" },
|
|
5238
|
+
O: { value: false, effect: "remote" },
|
|
5239
|
+
K: { value: true, effect: "config" },
|
|
5240
|
+
T: bodySource("file"),
|
|
5241
|
+
d: bodySource("data"),
|
|
5242
|
+
F: bodySource("form"),
|
|
5243
|
+
...Object.fromEntries(["s", "S", "f", "L", "k", "v", "i", "I", "G", "N", "n", "q", "j", "J", "l", "R", "Z", "0", "1", "2", "3", "4", "6", "#", "a", "B", "M", "p", "g", "h", "V"].map((letter) => [letter, safe(false)])),
|
|
5244
|
+
...Object.fromEntries(["H", "X", "A", "u", "e", "m", "r", "x", "U", "E", "b", "Y", "y", "z", "Q", "C", "t", "P"].map((letter) => [letter, safe(true)])),
|
|
5245
|
+
};
|
|
5246
|
+
/** wget's long options this classifier knows, by full name. */
|
|
5247
|
+
const WGET_LONG_OPTIONS = {
|
|
5248
|
+
"output-document": { value: true, effect: "file" },
|
|
5249
|
+
"output-file": { value: true, effect: "file" },
|
|
5250
|
+
"append-output": { value: true, effect: "file" },
|
|
5251
|
+
"save-cookies": { value: true, effect: "file" },
|
|
5252
|
+
"warc-file": { value: true, effect: "file" },
|
|
5253
|
+
"rejected-log": { value: true, effect: "file" },
|
|
5254
|
+
"directory-prefix": { value: true, effect: "dir" },
|
|
5255
|
+
execute: { value: true, effect: "config" },
|
|
5256
|
+
config: { value: true, effect: "config" },
|
|
5257
|
+
"post-file": bodySource("file"),
|
|
5258
|
+
"body-file": bodySource("file"),
|
|
5259
|
+
"input-file": bodySource("file"),
|
|
5260
|
+
...Object.fromEntries([
|
|
5261
|
+
"quiet", "verbose", "no-verbose", "server-response", "spider", "no-check-certificate", "continue",
|
|
5262
|
+
"timestamping", "no-clobber", "inet4-only", "inet6-only", "no-cache", "no-cookies", "ignore-length",
|
|
5263
|
+
"content-disposition", "trust-server-names", "show-progress", "no-dns-cache", "debug", "https-only",
|
|
5264
|
+
"no-parent", "no-directories", "force-directories", "no-host-directories", "adjust-extension",
|
|
5265
|
+
"recursive", "mirror", "page-requisites", "convert-links", "help", "version", "no-config", "keep-session-cookies",
|
|
5266
|
+
"auth-no-challenge", "no-http-keep-alive", "retry-connrefused", "force-html", "span-hosts", "relative",
|
|
5267
|
+
].map((name) => [name, safe(false)])),
|
|
5268
|
+
...Object.fromEntries([
|
|
5269
|
+
"tries", "timeout", "wait", "user-agent", "header", "user", "password", "http-user", "http-password",
|
|
5270
|
+
"post-data", "method", "body-data", "limit-rate", "level", "accept", "reject",
|
|
5271
|
+
"domains", "exclude-domains", "referer", "max-redirect", "ca-certificate", "ca-directory", "certificate",
|
|
5272
|
+
"private-key", "base", "dns-timeout", "connect-timeout", "read-timeout", "waitretry", "quota",
|
|
5273
|
+
"restrict-file-names", "local-encoding", "remote-encoding", "progress", "bind-address", "cut-dirs",
|
|
5274
|
+
"load-cookies", "include-directories", "exclude-directories", "secure-protocol", "proxy-user",
|
|
5275
|
+
"proxy-password", "https-proxy", "http-proxy",
|
|
5276
|
+
].map((name) => [name, safe(true)])),
|
|
5277
|
+
};
|
|
5278
|
+
/** wget's short options this classifier knows (`-n` takes its sub-letters). */
|
|
5279
|
+
const WGET_SHORT_OPTIONS = {
|
|
5280
|
+
O: { value: true, effect: "file" },
|
|
5281
|
+
o: { value: true, effect: "file" },
|
|
5282
|
+
a: { value: true, effect: "file" },
|
|
5283
|
+
P: { value: true, effect: "dir" },
|
|
5284
|
+
e: { value: true, effect: "config" },
|
|
5285
|
+
// `-b` backgrounds and writes `wget-log` into the working directory.
|
|
5286
|
+
b: { value: false, effect: "remote" },
|
|
5287
|
+
i: bodySource("file"),
|
|
5288
|
+
...Object.fromEntries(["q", "v", "V", "h", "S", "c", "N", "r", "m", "p", "k", "K", "E", "H", "L", "x", "4", "6", "F"].map((letter) => [letter, safe(false)])),
|
|
5289
|
+
...Object.fromEntries(["t", "T", "w", "U", "l", "A", "R", "D", "B", "Q", "I", "X"].map((letter) => [letter, safe(true)])),
|
|
5290
|
+
};
|
|
5291
|
+
/**
|
|
5292
|
+
* A long option by its full name or by a unique prefix, as both tools accept
|
|
5293
|
+
* abbreviations (GNU wget always; curl's parser tries the same). `null` when
|
|
5294
|
+
* the name is unknown or ambiguous.
|
|
5295
|
+
*/
|
|
5296
|
+
function fetchLongOption(table, name) {
|
|
5297
|
+
const exact = table[name];
|
|
5298
|
+
if (exact !== undefined)
|
|
5299
|
+
return exact;
|
|
5300
|
+
const matches = Object.keys(table).filter((known) => known.startsWith(name));
|
|
5301
|
+
return matches.length === 1 ? table[matches[0]] : null;
|
|
5302
|
+
}
|
|
5303
|
+
/**
|
|
5304
|
+
* Where `curl` and `wget` write (APRV-511 refuter S1 and review): `files`,
|
|
5305
|
+
* paths written by name (`curl -o F`, `-sSLoF`, `--output F`, `--outp=F`,
|
|
5306
|
+
* `-D`, `-c`, `--trace`, `--stderr`; `wget -O F`, `--output-doc=F`, `-o`/`-a`
|
|
5307
|
+
* logs), and `directories`, directories written with a name the server
|
|
5308
|
+
* chooses (`curl -O` into `--output-dir` or `.`; `wget` without `-O` into
|
|
5309
|
+
* `-P`/`--directory-p…` or `.`). `unknown` names the first option that makes
|
|
5310
|
+
* the outputs unknowable: one this table does not know, an ambiguous prefix,
|
|
5311
|
+
* or a config source (`curl -K`/`--config`, `wget -e`/`--execute`/`--config`).
|
|
5312
|
+
* `-` is standard output and writes nothing. Any other binary writes nothing.
|
|
5313
|
+
*
|
|
5314
|
+
* Fix round 4 (M(L5f-1)): `sources` are the files an upload or request-body
|
|
5315
|
+
* option READS ({@link uploadSourcePaths}), in every spelling: separated
|
|
5316
|
+
* (`-T F`, `--data @F`), joined by `=` (`--upload-file=F`, `--data=@F`) and
|
|
5317
|
+
* glued (`-TF`, `-sd@F`, `-Ff=@F`). `sourceUnknown` is the first option word
|
|
5318
|
+
* whose EMBEDDED value names a file this text cannot read (a variable, a
|
|
5319
|
+
* substitution, a glob or a brace in it). A separated value is a word of its
|
|
5320
|
+
* own and is judged as the positional it also is.
|
|
5321
|
+
*/
|
|
5322
|
+
export function fetchOutputs(bin, args, flags = []) {
|
|
5323
|
+
const files = [];
|
|
5324
|
+
const directories = [];
|
|
5325
|
+
let unknown = null;
|
|
5326
|
+
const sources = [];
|
|
5327
|
+
let sourceUnknown = null;
|
|
5328
|
+
if (bin !== "curl" && bin !== "wget")
|
|
5329
|
+
return { files, directories, unknown, sources, sourceUnknown };
|
|
5330
|
+
const longTable = bin === "curl" ? CURL_LONG_OPTIONS : WGET_LONG_OPTIONS;
|
|
5331
|
+
const shortTable = bin === "curl" ? CURL_SHORT_OPTIONS : WGET_SHORT_OPTIONS;
|
|
5332
|
+
let remote = false;
|
|
5333
|
+
let named = false;
|
|
5334
|
+
let directory = null;
|
|
5335
|
+
// curl's `-o`/`--output` values, which `--output-dir` places (recheck S(open)).
|
|
5336
|
+
const curlNamed = [];
|
|
5337
|
+
// Fix round 3 (S(open)): curl fills `#1`, `#2`… in an output name from the
|
|
5338
|
+
// URL's glob (`'https://e/{s}' -o ~/.hermes/plugin#1/x`), so a value holding
|
|
5339
|
+
// `#<digit>` names a file this text cannot place.
|
|
5340
|
+
const placeable = (value) => !value.includes("$") && !/#\d/u.test(value);
|
|
5341
|
+
// `word` is the option word itself when the value is embedded in it.
|
|
5342
|
+
const apply = (option, value, spelled, word) => {
|
|
5343
|
+
if (option.effect === "config")
|
|
5344
|
+
unknown ??= spelled;
|
|
5345
|
+
else if (option.effect === "source") {
|
|
5346
|
+
if (value === undefined)
|
|
5347
|
+
return;
|
|
5348
|
+
const paths = uploadSourcePaths(option.source ?? "file", value);
|
|
5349
|
+
if (paths !== null)
|
|
5350
|
+
sources.push(...paths);
|
|
5351
|
+
else if (word !== undefined)
|
|
5352
|
+
sourceUnknown ??= word;
|
|
5353
|
+
}
|
|
5354
|
+
else if (option.effect === "file") {
|
|
5355
|
+
// Recheck S(open): an absent value, or one a variable or a substitution
|
|
5356
|
+
// decides, is a target this classifier cannot place: the stricter class.
|
|
5357
|
+
if (value === undefined || !placeable(value))
|
|
5358
|
+
unknown ??= spelled;
|
|
5359
|
+
else {
|
|
5360
|
+
files.push(value);
|
|
5361
|
+
if (bin === "wget" && (spelled === "-O" || spelled.startsWith("--output-d")))
|
|
5362
|
+
named = true;
|
|
5363
|
+
if (bin === "curl" && (spelled === "-o" || spelled === "--output"))
|
|
5364
|
+
curlNamed.push(value);
|
|
5365
|
+
}
|
|
5366
|
+
}
|
|
5367
|
+
else if (option.effect === "dir") {
|
|
5368
|
+
if (value === undefined || !placeable(value))
|
|
5369
|
+
unknown ??= spelled;
|
|
5370
|
+
else
|
|
5371
|
+
directory = value;
|
|
5372
|
+
}
|
|
5373
|
+
else if (option.effect === "remote")
|
|
5374
|
+
remote = true;
|
|
5375
|
+
else if (option.effect === "format") {
|
|
5376
|
+
// `-w @FILE` (or `@-`) reads the format from elsewhere, so its
|
|
5377
|
+
// `%output{…}` targets are unknowable; otherwise each `%output{FILE}`
|
|
5378
|
+
// (`%output{>>FILE}` appends) is a file curl writes.
|
|
5379
|
+
if (value === undefined || value.startsWith("@")) {
|
|
5380
|
+
unknown ??= spelled;
|
|
5381
|
+
return;
|
|
5382
|
+
}
|
|
5383
|
+
for (const match of value.matchAll(/%output\{(?:>>)?([^}]*)\}/gu)) {
|
|
5384
|
+
const target = match[1] ?? "";
|
|
5385
|
+
if (target.length === 0 || !placeable(target))
|
|
5386
|
+
unknown ??= spelled;
|
|
5387
|
+
else
|
|
5388
|
+
files.push(target);
|
|
5389
|
+
}
|
|
5390
|
+
// A `%output{` without its closing brace still names some file.
|
|
5391
|
+
if (/%output\{(?![^}]*\})/u.test(value))
|
|
5392
|
+
unknown ??= spelled;
|
|
5393
|
+
}
|
|
5394
|
+
};
|
|
5395
|
+
/** Fix round 3 (S(open)): the word at `at`, in `slot`, may become an option. */
|
|
5396
|
+
const unknowable = (at, slot) => at < args.length && argvWordUnknowable(args[at], flags[at] ?? LITERAL_ARG, slot);
|
|
5397
|
+
for (let at = 0; at < args.length; at += 1) {
|
|
5398
|
+
const arg = args[at];
|
|
5399
|
+
if (arg === "--")
|
|
5400
|
+
break;
|
|
5401
|
+
if (unknowable(at, "option")) {
|
|
5402
|
+
unknown ??= arg;
|
|
5403
|
+
continue;
|
|
5404
|
+
}
|
|
5405
|
+
if (arg.startsWith("--")) {
|
|
5406
|
+
const eq = arg.indexOf("=");
|
|
5407
|
+
const name = eq === -1 ? arg.slice(2) : arg.slice(2, eq);
|
|
5408
|
+
// `--no-<option>` turns a boolean off and writes nothing more.
|
|
5409
|
+
const option = fetchLongOption(longTable, name) ?? (name.startsWith("no-") ? fetchLongOption(longTable, name.slice(3)) : null);
|
|
5410
|
+
if (option === null) {
|
|
5411
|
+
unknown ??= arg;
|
|
5412
|
+
continue;
|
|
5413
|
+
}
|
|
5414
|
+
let value;
|
|
5415
|
+
if (option.value) {
|
|
5416
|
+
value = eq === -1 ? args[at + 1] : arg.slice(eq + 1);
|
|
5417
|
+
if (eq === -1) {
|
|
5418
|
+
if (unknowable(at + 1, "value"))
|
|
5419
|
+
unknown ??= arg;
|
|
5420
|
+
at += 1;
|
|
5421
|
+
}
|
|
5422
|
+
}
|
|
5423
|
+
apply(option, value, `--${Object.keys(longTable).find((known) => longTable[known] === option) ?? name}`, eq === -1 ? undefined : arg);
|
|
5424
|
+
continue;
|
|
5425
|
+
}
|
|
5426
|
+
if (arg.startsWith("-") && arg.length > 1) {
|
|
5427
|
+
const letters = arg.slice(1);
|
|
5428
|
+
for (let index = 0; index < letters.length; index += 1) {
|
|
5429
|
+
const letter = letters[index];
|
|
5430
|
+
if (bin === "wget" && letter === "n") {
|
|
5431
|
+
// `-nv`, `-nc`, `-nd`, `-nH`, `-np`: wget reads every letter after
|
|
5432
|
+
// `n` as one of those; any other letter there is not a known form.
|
|
5433
|
+
if (!/^[vcdHp]+$/u.test(letters.slice(index + 1)))
|
|
5434
|
+
unknown ??= arg;
|
|
5435
|
+
break;
|
|
5436
|
+
}
|
|
5437
|
+
const option = shortTable[letter];
|
|
5438
|
+
if (option === undefined) {
|
|
5439
|
+
unknown ??= arg;
|
|
5440
|
+
break;
|
|
5441
|
+
}
|
|
5442
|
+
if (option.value) {
|
|
5443
|
+
const rest = letters.slice(index + 1);
|
|
5444
|
+
let value = rest;
|
|
5445
|
+
if (rest.length === 0) {
|
|
5446
|
+
value = args[at + 1];
|
|
5447
|
+
if (unknowable(at + 1, "value"))
|
|
5448
|
+
unknown ??= arg;
|
|
5449
|
+
at += 1;
|
|
5450
|
+
}
|
|
5451
|
+
apply(option, value, `-${letter}`, rest.length === 0 ? undefined : arg);
|
|
5452
|
+
break;
|
|
5453
|
+
}
|
|
5454
|
+
apply(option, undefined, `-${letter}`);
|
|
5455
|
+
}
|
|
5456
|
+
}
|
|
5457
|
+
}
|
|
5458
|
+
if (bin === "curl" && remote)
|
|
5459
|
+
directories.push(directory ?? ".");
|
|
5460
|
+
// Recheck S(open): curl stores every `-o` output in `--output-dir` too, so a
|
|
5461
|
+
// named output lands there (`--output-dir <organ> -o x.py`); fix round 3:
|
|
5462
|
+
// an ABSOLUTE one too, as curl joins the two strings whatever the value
|
|
5463
|
+
// (`--output-dir ~/.hermes -o /plugins/x.py` writes `~/.hermes/plugins/x.py`).
|
|
5464
|
+
// The value as spelled stays judged as well: the stricter of the two readings.
|
|
5465
|
+
// (`directory` is assigned inside `apply`, which the compiler cannot follow.)
|
|
5466
|
+
const outputDir = directory;
|
|
5467
|
+
if (bin === "curl" && outputDir !== null) {
|
|
5468
|
+
for (const value of curlNamed) {
|
|
5469
|
+
if (value !== "-")
|
|
5470
|
+
files.push(`${outputDir.replace(/\/+$/u, "")}/${value.replace(/^\/+/u, "")}`);
|
|
5471
|
+
}
|
|
5472
|
+
}
|
|
5473
|
+
if (bin === "wget" && (!named || remote))
|
|
5474
|
+
directories.push(directory ?? ".");
|
|
5475
|
+
return { files: files.filter((file) => file !== "-"), directories, unknown, sources, sourceUnknown };
|
|
5476
|
+
}
|
|
5477
|
+
/**
|
|
5478
|
+
* The file an upload or request-body option value reads, for each reading of
|
|
5479
|
+
* it (fix round 4, M(L5f-1)): `[]` when the value reads no file (`--data=hello`,
|
|
5480
|
+
* `-F name=value`), `null` when the text cannot say which file it reads.
|
|
5481
|
+
*
|
|
5482
|
+
* curl reads a `--data`/`-d`/`--json` file only when the value begins with `@`;
|
|
5483
|
+
* `--data-urlencode` when an `@` comes before any `=` (`@F`, `name@F`); `-F`
|
|
5484
|
+
* when the part after the first `=` begins with `@` or `<`, and there the path
|
|
5485
|
+
* may be double-quoted and followed by `;type=…` or a `,` list, so the path is
|
|
5486
|
+
* judged whole, unquoted, and cut at each `;` and `,` (one more path judged can
|
|
5487
|
+
* only be stricter).
|
|
5488
|
+
* `--data-raw` and `--form-string` never read a file, and are read the same
|
|
5489
|
+
* way all the same: the stricter of two answers. The round-3 class rule, for a
|
|
5490
|
+
* value the option word carries: a variable or a substitution (`$`, which
|
|
5491
|
+
* the substitution sentinel also begins with), a glob (`*`, `?`, `[`; curl
|
|
5492
|
+
* globs `-T` itself) or a brace group (`{`) in the path, or a variable where
|
|
5493
|
+
* the `@` would be, makes the file unknowable.
|
|
5494
|
+
*/
|
|
5495
|
+
function uploadSourcePaths(kind, value) {
|
|
5496
|
+
const literal = (text) => !/[$`*?[{]/u.test(text);
|
|
5497
|
+
if (kind === "file")
|
|
5498
|
+
return literal(value) ? [value] : null;
|
|
5499
|
+
if (kind === "data") {
|
|
5500
|
+
if (value.startsWith("$"))
|
|
5501
|
+
return null;
|
|
5502
|
+
if (!value.startsWith("@"))
|
|
5503
|
+
return [];
|
|
5504
|
+
const file = value.slice(1);
|
|
5505
|
+
return literal(file) ? [file] : null;
|
|
5506
|
+
}
|
|
5507
|
+
if (kind === "urlencode") {
|
|
5508
|
+
const at = value.search(/[@=$]/u);
|
|
5509
|
+
if (at === -1 || value[at] === "=")
|
|
5510
|
+
return [];
|
|
5511
|
+
if (value[at] === "$")
|
|
5512
|
+
return null;
|
|
5513
|
+
const file = value.slice(at + 1);
|
|
5514
|
+
return literal(file) ? [file] : null;
|
|
5515
|
+
}
|
|
5516
|
+
const eq = value.search(/[=$]/u);
|
|
5517
|
+
if (eq === -1)
|
|
5518
|
+
return [];
|
|
5519
|
+
if (value[eq] === "$")
|
|
5520
|
+
return null;
|
|
5521
|
+
const rest = value.slice(eq + 1);
|
|
5522
|
+
if (rest.startsWith("$"))
|
|
5523
|
+
return null;
|
|
5524
|
+
if (!rest.startsWith("@") && !rest.startsWith("<"))
|
|
5525
|
+
return [];
|
|
5526
|
+
const file = rest.slice(1);
|
|
5527
|
+
if (!literal(file))
|
|
5528
|
+
return null;
|
|
5529
|
+
// curl lets the path be double-quoted (`f=@"x;y";type=…`): judged unquoted too.
|
|
5530
|
+
const unquoted = file.replaceAll('"', "");
|
|
5531
|
+
return [...new Set([file, unquoted, ...file.split(/[;,]/u), ...unquoted.split(/[;,]/u)])].filter((path) => path.length > 0);
|
|
5532
|
+
}
|
|
3305
5533
|
/**
|
|
3306
5534
|
* The strictest protected surface named by these words, with the word itself.
|
|
3307
5535
|
*
|
|
@@ -3362,6 +5590,12 @@ export const CODE_EXECUTING_RULES = [
|
|
|
3362
5590
|
*/
|
|
3363
5591
|
SKILL_SCRIPT_RULE,
|
|
3364
5592
|
NODE_SKILL_SCRIPT_RULE,
|
|
5593
|
+
/**
|
|
5594
|
+
* `python3 -c`, `node -e` and kin, and an interpreter running a file that is
|
|
5595
|
+
* not a skill script, once a policy opens `exec.code` (APRV-510).
|
|
5596
|
+
*/
|
|
5597
|
+
EXEC_CODE_INLINE_RULE,
|
|
5598
|
+
EXEC_CODE_FILE_RULE,
|
|
3365
5599
|
/** `npx`, `tsx`, `tsc`, `vitest`, `jest`, `make`, and kin. */
|
|
3366
5600
|
"workspace-tool",
|
|
3367
5601
|
/**
|
|
@@ -3570,6 +5804,48 @@ function sandboxWrapper(words, start) {
|
|
|
3570
5804
|
// can produce (`docs/sandboxed-exec.md`, and APRV-193's Linux follow-up).
|
|
3571
5805
|
return null;
|
|
3572
5806
|
}
|
|
5807
|
+
/**
|
|
5808
|
+
* Does this `NAME=value` set a variable that steers which program runs, what
|
|
5809
|
+
* it loads, which file it reads as a zone, locale or converter, or where `cd`
|
|
5810
|
+
* goes? (APRV-508 refutation S1; `HOME` and `CDPATH` from the recheck.)
|
|
5811
|
+
*/
|
|
5812
|
+
function isSteeringAssignment(word) {
|
|
5813
|
+
const name = assignedName(word);
|
|
5814
|
+
if (["TZ", "TZDIR", "PATH", "LOCPATH", "GCONV_PATH", "LANG", "LANGUAGE", "HOME", "CDPATH"].includes(name)) {
|
|
5815
|
+
return true;
|
|
5816
|
+
}
|
|
5817
|
+
return name.startsWith("LC_") || name.startsWith("LD_");
|
|
5818
|
+
}
|
|
5819
|
+
/**
|
|
5820
|
+
* The directories a shape row's binary (APRV-508) and an `exec.code`
|
|
5821
|
+
* interpreter (APRV-510, {@link isExecCodeInterpreter}) may be spelled from,
|
|
5822
|
+
* besides a bare name. The classifier's one system-bin list.
|
|
5823
|
+
*/
|
|
5824
|
+
const SYSTEM_BIN_DIRS = ["/bin/", "/usr/bin/", "/usr/local/bin/", "/sbin/", "/usr/sbin/"];
|
|
5825
|
+
/**
|
|
5826
|
+
* Is this command word the system's binary rather than a file the agent can
|
|
5827
|
+
* place? A bare name is looked up on PATH; a path in a system directory names
|
|
5828
|
+
* that file. `./date` or `/tmp/x/date` is whatever program sits there, and a
|
|
5829
|
+
* shape row (APRV-508) does not vouch for it.
|
|
5830
|
+
*
|
|
5831
|
+
* Only a word whose `text` is the bytes bash runs can be judged by its text
|
|
5832
|
+
* (APRV-523 fix round 2, review-b S1-R and N6). A word that is not
|
|
5833
|
+
* {@link LexWord.exact} still changes under expansion, and bash may then see a
|
|
5834
|
+
* slash this text does not show; a backslash in `text` is a path separator to
|
|
5835
|
+
* {@link pathSegments} but a plain character to bash, so the basename read
|
|
5836
|
+
* from it is not the program's name. Either is refused here, before the bare
|
|
5837
|
+
* name is admitted. Callers holding only a string pass no flag: theirs is
|
|
5838
|
+
* already exact ({@link isExecCodeInterpreter} runs on the exact-only shape).
|
|
5839
|
+
*/
|
|
5840
|
+
function isSystemBinWord(word, exact = true) {
|
|
5841
|
+
if (!exact || word.includes("\\"))
|
|
5842
|
+
return false;
|
|
5843
|
+
if (!word.includes("/"))
|
|
5844
|
+
return true;
|
|
5845
|
+
if (word.includes("..") || word.includes("//"))
|
|
5846
|
+
return false;
|
|
5847
|
+
return SYSTEM_BIN_DIRS.some((dir) => word.startsWith(dir) && !word.slice(dir.length).includes("/"));
|
|
5848
|
+
}
|
|
3573
5849
|
/**
|
|
3574
5850
|
* Find the first table row matching this binary, subcommand and argv.
|
|
3575
5851
|
*
|
|
@@ -3582,13 +5858,30 @@ function sandboxWrapper(words, start) {
|
|
|
3582
5858
|
* spelled through the environment (`BUN_OPTIONS='--cwd ../x'` makes bun read
|
|
3583
5859
|
* a different directory's file), so either one means the shape is not matched
|
|
3584
5860
|
* and the segment keeps the answer it had before (fail closed).
|
|
5861
|
+
*
|
|
5862
|
+
* A `shape` row (APRV-508) reads the argv through its own predicate, which is
|
|
5863
|
+
* handed the assignment words so it can admit the harmless ones (`TZ=Zone`)
|
|
5864
|
+
* and decline the rest; a substituted word declines it outright.
|
|
5865
|
+
*/
|
|
5866
|
+
function matchRule(bin, sub, args, substituted = false, assigned = false, assignments = [], binWord = bin,
|
|
5867
|
+
/**
|
|
5868
|
+
* Did the caller open `exec.code` AND does this segment have the shape?
|
|
5869
|
+
* (APRV-510.) Computed by `classifySegment`, which holds the words.
|
|
3585
5870
|
*/
|
|
3586
|
-
|
|
5871
|
+
execShaped = false,
|
|
5872
|
+
/** Is `binWord` the bytes bash runs ({@link LexWord.exact})? (APRV-523 fix round 2, review-b N6.) */
|
|
5873
|
+
binExact = true) {
|
|
3587
5874
|
for (const rule of COMMAND_RULES) {
|
|
3588
5875
|
if (!rule.bins.includes(bin))
|
|
3589
5876
|
continue;
|
|
3590
5877
|
if (rule.probe === true && !isVersionProbe(args))
|
|
3591
5878
|
continue;
|
|
5879
|
+
if (rule.shape !== undefined &&
|
|
5880
|
+
(substituted || !isSystemBinWord(binWord, binExact) || !rule.shape(args, assignments))) {
|
|
5881
|
+
continue;
|
|
5882
|
+
}
|
|
5883
|
+
if (rule.execCode === true && !execShaped)
|
|
5884
|
+
continue;
|
|
3592
5885
|
// APRV-502: see the doc comment above; any of the three declines the row.
|
|
3593
5886
|
if (rule.skillScript === true &&
|
|
3594
5887
|
(substituted || assigned || skillScriptArg(bin, args) === null)) {
|
|
@@ -3609,7 +5902,25 @@ function classifySegment(segment, protectedPaths, context) {
|
|
|
3609
5902
|
// `$(…)` is classified recursively. A substitution that only reads is inert;
|
|
3610
5903
|
// anything else taints the segment, because its effect happens before the
|
|
3611
5904
|
// outer command even starts and the outer class would not describe it.
|
|
3612
|
-
|
|
5905
|
+
//
|
|
5906
|
+
// Redirection targets are words too, and the shell expands them the same
|
|
5907
|
+
// way (APRV-508 recheck M-R1): `cat a < "x$(rm -rf y)"` runs the `rm` before
|
|
5908
|
+
// `cat` opens anything. Until this loop read them, a substitution in a
|
|
5909
|
+
// target ran with no class of its own, under `read.shell` for a `<` and
|
|
5910
|
+
// under the write class for a `>`, on 0.4.3 as well.
|
|
5911
|
+
const expanded = [
|
|
5912
|
+
...segment.words,
|
|
5913
|
+
...segment.redirects.map((redirect) => redirect.target),
|
|
5914
|
+
// APRV-504: the `$(…)` of an unquoted heredoc body run when it is read.
|
|
5915
|
+
{ text: "", quoted: false, substitutions: segment.bodySubstitutions, splits: false, expands: false },
|
|
5916
|
+
];
|
|
5917
|
+
// APRV-522 (S2): the first read inside a substitution that the read scope
|
|
5918
|
+
// places outside every root. A substitution that only reads is inert, and its
|
|
5919
|
+
// out-of-scope read used to vanish with it, and the outer segment was
|
|
5920
|
+
// `read.shell`. It tightens a `read.shell` answer below, as a named target
|
|
5921
|
+
// would.
|
|
5922
|
+
let substitutedEscape = null;
|
|
5923
|
+
for (const word of expanded) {
|
|
3613
5924
|
for (const inner of word.substitutions) {
|
|
3614
5925
|
const nested = classifyCommand(inner, protectedPaths, context);
|
|
3615
5926
|
if (!nested.ok) {
|
|
@@ -3627,6 +5938,12 @@ function classifySegment(segment, protectedPaths, context) {
|
|
|
3627
5938
|
detail: `command substitution $(${inner}) is ${effectful.join(", ")}; only read.* substitutions run unattended`,
|
|
3628
5939
|
};
|
|
3629
5940
|
}
|
|
5941
|
+
if (substitutedEscape === null) {
|
|
5942
|
+
const escaped = nested.segments.find((found) => found.class === READ_OUT_OF_SCOPE_CLASS);
|
|
5943
|
+
if (escaped !== undefined) {
|
|
5944
|
+
substitutedEscape = { path: escaped.path ?? inner, rule: escaped.rule };
|
|
5945
|
+
}
|
|
5946
|
+
}
|
|
3630
5947
|
}
|
|
3631
5948
|
}
|
|
3632
5949
|
const words = segment.words.map((word) => word.text);
|
|
@@ -3635,6 +5952,11 @@ function classifySegment(segment, protectedPaths, context) {
|
|
|
3635
5952
|
cursor += 1;
|
|
3636
5953
|
// APRV-502: read by the skill-script shape only ({@link matchRule}).
|
|
3637
5954
|
const assigned = cursor > 0;
|
|
5955
|
+
// APRV-508: the same words, for a `shape` row that admits some.
|
|
5956
|
+
const assignments = words.slice(0, cursor);
|
|
5957
|
+
// APRV-521 (review-b M1): the word after the assignments, before a sandbox
|
|
5958
|
+
// wrapper moves the cursor. Judged below the protected-redirect answer.
|
|
5959
|
+
const afterAssignments = words[cursor];
|
|
3638
5960
|
// APRV-193. A sandbox wrapper is not a command: it is a room, and what
|
|
3639
5961
|
// matters is what runs inside it. `approval sandbox -- npm install` is
|
|
3640
5962
|
// `deps.add`, and it has to be, in both directions.
|
|
@@ -3670,27 +5992,125 @@ function classifySegment(segment, protectedPaths, context) {
|
|
|
3670
5992
|
// a write. `2>/dev/null` is the suffix an agent writes on half its reads,
|
|
3671
5993
|
// and until this it turned every one of them into `files.write.workspace`.
|
|
3672
5994
|
.filter((target) => !isDiscardTarget(target));
|
|
5995
|
+
// APRV-503: the paths this segment READS through `<`, at any descriptor. They
|
|
5996
|
+
// are judged where a named argument is judged, below: a credential target is
|
|
5997
|
+
// `account.credential` and a protected one joins the effectful scan.
|
|
5998
|
+
const readTargets = segment.redirects
|
|
5999
|
+
.filter((redirect) => redirect.op === "<")
|
|
6000
|
+
.map((redirect) => redirect.target.text);
|
|
6001
|
+
const redirectTargets = segment.redirects.map((redirect) => redirect.target.text);
|
|
3673
6002
|
// A redirection onto a protected path is a write to that path, whatever the
|
|
3674
6003
|
// command in front of it was going to do. The CLASS says which surface was
|
|
3675
6004
|
// aimed at (APRV-198); the RULE stays `redirect-protected`, because the
|
|
3676
6005
|
// mechanism is unchanged and the hook's tiers and the channel's protected-path
|
|
3677
6006
|
// view are keyed on the rule.
|
|
3678
6007
|
const redirected = strictestProtected(writeTargets, protectedPaths);
|
|
6008
|
+
const bin = words[cursor];
|
|
3679
6009
|
if (redirected !== null) {
|
|
6010
|
+
// APRV-503 recheck (SHOULD-FIX-R1): a segment that writes a protected path
|
|
6011
|
+
// AND reads credential material (`cat .approval/env > APPROVAL.md`, `cat <
|
|
6012
|
+
// ~/.hermes/.env > APPROVAL.md`) carries both classes. The protected class
|
|
6013
|
+
// still answers the segment; the credential touch rides beside it.
|
|
6014
|
+
const writerArgs = bin === undefined ? [] : words.slice(cursor + 1);
|
|
6015
|
+
const writer = bin === undefined ? null : (pathSegments(bin).slice(-1)[0] ?? bin);
|
|
6016
|
+
const touched = (writer === null
|
|
6017
|
+
? null
|
|
6018
|
+
: credentialTouch(writer, writerArgs)) ??
|
|
6019
|
+
redirectCredentialTouch(readTargets, redirectTargets);
|
|
3680
6020
|
return {
|
|
3681
6021
|
ok: true,
|
|
3682
6022
|
class: redirected.surface,
|
|
3683
6023
|
rule: "redirect-protected",
|
|
3684
6024
|
path: redirected.path,
|
|
6025
|
+
...(touched === null ? {} : { also: touched }),
|
|
6026
|
+
};
|
|
6027
|
+
}
|
|
6028
|
+
// APRV-521 (review-b M1). Every spelling of bash's assignment word is read
|
|
6029
|
+
// past by the cursor above ({@link ASSIGNMENT}), and only the plain one is
|
|
6030
|
+
// judged by the rules below. An append (`NAME+=v`) or a subscripted write
|
|
6031
|
+
// (`NAME[i]=v`) leaves the segment `unclassified`, in front of git or any
|
|
6032
|
+
// other program and on its own: its text does not say what value the
|
|
6033
|
+
// command inherits. So does a command word that opens like a subscripted
|
|
6034
|
+
// assignment the grammar cannot read in full ({@link ASSIGNMENT_LIKE}).
|
|
6035
|
+
//
|
|
6036
|
+
// APRV-523. The cursor above reads past every assignment-SHAPED word, judged
|
|
6037
|
+
// on its dequoted text. bash reads past only the words it accepts as
|
|
6038
|
+
// assignments as written ({@link LexWord.assignment}): a word whose name is
|
|
6039
|
+
// quoted or escaped is the program bash runs, named by the whole word, and
|
|
6040
|
+
// the word after it is that program's argument. Where the two stop at
|
|
6041
|
+
// different words this classifier would judge a program that does not run,
|
|
6042
|
+
// so the segment is `unclassified`. No program this table admits is named
|
|
6043
|
+
// like an assignment, so the word is refused, not resolved.
|
|
6044
|
+
const bashCursor = bashAssignmentCount(segment.words);
|
|
6045
|
+
if (bashCursor !== assignments.length) {
|
|
6046
|
+
const runs = words[Math.min(bashCursor, assignments.length)];
|
|
6047
|
+
return {
|
|
6048
|
+
ok: false,
|
|
6049
|
+
code: "unclassified",
|
|
6050
|
+
detail: `no rule for ${assignmentHead(runs)}…: a word whose name is quoted or escaped is a command word to bash, not an assignment`,
|
|
6051
|
+
};
|
|
6052
|
+
}
|
|
6053
|
+
const unplain = assignments.find((word) => !PLAIN_ASSIGNMENT.test(word));
|
|
6054
|
+
if (unplain !== undefined) {
|
|
6055
|
+
return {
|
|
6056
|
+
ok: false,
|
|
6057
|
+
code: "unclassified",
|
|
6058
|
+
detail: `no rule for ${assignmentHead(unplain)}…: an append or subscripted assignment, whose value the text does not settle`,
|
|
6059
|
+
};
|
|
6060
|
+
}
|
|
6061
|
+
if (afterAssignments !== undefined && ASSIGNMENT_LIKE.test(afterAssignments)) {
|
|
6062
|
+
return {
|
|
6063
|
+
ok: false,
|
|
6064
|
+
code: "unclassified",
|
|
6065
|
+
detail: `no rule for ${afterAssignments.slice(0, afterAssignments.indexOf("[") + 1)}…: a subscripted assignment this classifier cannot read in full`,
|
|
3685
6066
|
};
|
|
3686
6067
|
}
|
|
3687
|
-
const bin = words[cursor];
|
|
3688
6068
|
if (bin === undefined) {
|
|
6069
|
+
// A bare input redirection opens its target, and inside `$(…)` it IS the
|
|
6070
|
+
// read: `$(< ~/.hermes/.env)` is bash for "the file's contents" (APRV-503).
|
|
6071
|
+
// Nothing here can outrank it: there are no positionals, and a protected
|
|
6072
|
+
// write target has already answered above, so the precedence rule the
|
|
6073
|
+
// binary path applies below has nothing to defer to.
|
|
6074
|
+
const bareCredential = redirectCredentialTouch(readTargets, redirectTargets);
|
|
6075
|
+
if (bareCredential !== null)
|
|
6076
|
+
return { ok: true, ...bareCredential };
|
|
6077
|
+
// APRV-508 refutation S1: an assignment on its own sets a shell variable,
|
|
6078
|
+
// and when that variable is already exported (PATH and LANG always are)
|
|
6079
|
+
// the new value reaches every later command on the line. `TZ=/etc/x;
|
|
6080
|
+
// date` opens the file the date row refuses to let `TZ=/etc/x date`
|
|
6081
|
+
// open, and `PATH=/tmp/x; cat f` runs whatever `cat` sits there. So the
|
|
6082
|
+
// variables that pick which program runs, which libraries it loads, or
|
|
6083
|
+
// which file it reads as a zone, a locale or a charset converter are not
|
|
6084
|
+
// answered as reads.
|
|
6085
|
+
// APRV-521: a variable git or its pager, editor, ssh or hooks read joins
|
|
6086
|
+
// them, unless the word is one git(1) shows to be harmless.
|
|
6087
|
+
const steering = assignments.find((word) => isSteeringAssignment(word)) ??
|
|
6088
|
+
segment.words
|
|
6089
|
+
.slice(0, cursor)
|
|
6090
|
+
.find((word) => isGitInheritedName(assignedName(word.text)) && !isHarmlessGitAssignment(word))
|
|
6091
|
+
?.text;
|
|
6092
|
+
if (steering !== undefined) {
|
|
6093
|
+
return {
|
|
6094
|
+
ok: false,
|
|
6095
|
+
code: "unclassified",
|
|
6096
|
+
detail: `no rule for the assignment ${assignmentHead(steering)}…: it steers which program or file later commands use`,
|
|
6097
|
+
};
|
|
6098
|
+
}
|
|
3689
6099
|
// `VAR=value` alone, or a bare redirection. `> file` truncates, so it is a
|
|
3690
6100
|
// write; an assignment on its own touches nothing.
|
|
3691
|
-
|
|
3692
|
-
|
|
3693
|
-
|
|
6101
|
+
if (writeTargets.length > 0) {
|
|
6102
|
+
return { ok: true, class: "files.write.workspace", rule: "redirect-write" };
|
|
6103
|
+
}
|
|
6104
|
+
// APRV-522 (S2): inside a substitution, a bare `<` redirection IS the
|
|
6105
|
+
// read, and an assignment reads through its substitution, so both take
|
|
6106
|
+
// the read scope a named target takes.
|
|
6107
|
+
const bareEscape = (context.readRoots ?? []).length > 0
|
|
6108
|
+
? (redirectReadEscape(readTargets, context.readRoots ?? []) ?? substitutedEscape)
|
|
6109
|
+
: null;
|
|
6110
|
+
if (bareEscape !== null) {
|
|
6111
|
+
return { ok: true, class: READ_OUT_OF_SCOPE_CLASS, rule: bareEscape.rule, path: bareEscape.path };
|
|
6112
|
+
}
|
|
6113
|
+
return { ok: true, class: "read.shell", rule: "assignment" };
|
|
3694
6114
|
}
|
|
3695
6115
|
const basename = pathSegments(bin).slice(-1)[0] ?? bin;
|
|
3696
6116
|
const args = words.slice(cursor + 1);
|
|
@@ -3706,23 +6126,171 @@ function classifySegment(segment, protectedPaths, context) {
|
|
|
3706
6126
|
if (opaqueReason !== undefined) {
|
|
3707
6127
|
return { ok: false, code: "opaque", detail: `${basename} ${opaqueReason}` };
|
|
3708
6128
|
}
|
|
6129
|
+
// APRV-510. The `exec.code` shape, read only when the caller opened the
|
|
6130
|
+
// class, and declined by anything that would make the hashed words differ
|
|
6131
|
+
// from the program that runs: an assignment prefix (`PYTHONSTARTUP`,
|
|
6132
|
+
// `NODE_OPTIONS`), a substitution anywhere in the segment, a heredoc, any
|
|
6133
|
+
// redirection other than a write to a discard device (stdin is a second
|
|
6134
|
+
// program, a write target is a second effect), a segment inside a subshell
|
|
6135
|
+
// (a redirection after its `)` feeds the program's stdin; refuter rS2), an
|
|
6136
|
+
// interpreter spelled by a relative path, or a source word the shell would
|
|
6137
|
+
// still change.
|
|
6138
|
+
const execWords = segment.words.slice(cursor + 1);
|
|
6139
|
+
const execShape = context.execCode === true &&
|
|
6140
|
+
!assigned &&
|
|
6141
|
+
!segment.heredoc &&
|
|
6142
|
+
!segment.stdinFed &&
|
|
6143
|
+
!segment.subshell &&
|
|
6144
|
+
segment.words.every((word) => word.substitutions.length === 0) &&
|
|
6145
|
+
segment.redirects.every((redirect) => redirect.op !== "<" && isDiscardTarget(redirect.target.text)) &&
|
|
6146
|
+
(segment.words[cursor]?.exact ?? false) &&
|
|
6147
|
+
isExecCodeInterpreter(bin)
|
|
6148
|
+
? execCodeShape(basename, args, execWords)
|
|
6149
|
+
: null;
|
|
3709
6150
|
const inlineFlags = INLINE_SOURCE_BINS[basename];
|
|
3710
|
-
if (inlineFlags !== undefined && hasFlag(args, inlineFlags)) {
|
|
6151
|
+
if (inlineFlags !== undefined && hasFlag(args, inlineFlags) && execShape === null) {
|
|
3711
6152
|
return { ok: false, code: "opaque", detail: `${basename} runs inline source` };
|
|
3712
6153
|
}
|
|
3713
6154
|
const positionals = args.filter((arg) => !isFlag(arg));
|
|
6155
|
+
// APRV-520: a git global option that takes a value, or one this table does
|
|
6156
|
+
// not know, leaves the segment unclassified ({@link gitGlobalRefusal}). It is
|
|
6157
|
+
// checked before the credential and table reads below, because the first
|
|
6158
|
+
// positional they would read may be the option's value rather than the
|
|
6159
|
+
// subcommand, and refused rather than parsed so no spelling of it can turn
|
|
6160
|
+
// into an answer.
|
|
6161
|
+
//
|
|
6162
|
+
// APRV-521: so does an environment assignment in front of git that is not on
|
|
6163
|
+
// the closed list git(1) shows to be harmless ({@link gitAssignmentRefusal};
|
|
6164
|
+
// the words before a sandbox wrapper count, since the wrapper passes the
|
|
6165
|
+
// environment on), and a variable git could inherit that the line's own
|
|
6166
|
+
// shell sets ({@link gitEnvironmentWrite}). `GIT_PAGER=<cmd> git log` runs
|
|
6167
|
+
// `<cmd>`, and `GIT_DIR=x git status` reads another repository.
|
|
6168
|
+
if (basename === "git") {
|
|
6169
|
+
const assignmentRefusal = gitAssignmentRefusal(segment.words.slice(0, assignments.length));
|
|
6170
|
+
if (assignmentRefusal !== null)
|
|
6171
|
+
return { ok: false, code: "unclassified", detail: assignmentRefusal };
|
|
6172
|
+
if (context.shellEnvWrite !== undefined) {
|
|
6173
|
+
return {
|
|
6174
|
+
ok: false,
|
|
6175
|
+
code: "unclassified",
|
|
6176
|
+
detail: `no rule for git after ${context.shellEnvWrite} is set on the same command line: git and the programs it starts inherit the shell's exported environment`,
|
|
6177
|
+
};
|
|
6178
|
+
}
|
|
6179
|
+
const refusal = gitGlobalRefusal(args);
|
|
6180
|
+
if (refusal !== null)
|
|
6181
|
+
return { ok: false, code: "unclassified", detail: refusal };
|
|
6182
|
+
}
|
|
6183
|
+
// APRV-508 recheck S-R1: `printf -v NAME` assigns NAME, so `printf -v PATH
|
|
6184
|
+
// %s /tmp/x; cat f` is the bare `PATH=/tmp/x` the assignment rule above
|
|
6185
|
+
// refuses, spelled through a builtin. A steering NAME (a subscript stripped:
|
|
6186
|
+
// `PATH[0]` is PATH), or a NAME the text cannot read, is refused.
|
|
6187
|
+
if (basename === "printf") {
|
|
6188
|
+
for (let at = 0; at < args.length; at += 1) {
|
|
6189
|
+
const arg = args[at];
|
|
6190
|
+
if (arg === "--")
|
|
6191
|
+
break;
|
|
6192
|
+
const name = arg === "-v" ? args[at + 1] : arg.startsWith("-v") ? arg.slice(2) : undefined;
|
|
6193
|
+
if (name === undefined)
|
|
6194
|
+
continue;
|
|
6195
|
+
const bare = name.replace(/\[.*$/su, "");
|
|
6196
|
+
if (/[$`]/u.test(name) || isSteeringAssignment(`${bare}=`)) {
|
|
6197
|
+
return {
|
|
6198
|
+
ok: false,
|
|
6199
|
+
code: "unclassified",
|
|
6200
|
+
detail: `no rule for printf -v ${name}: it sets a variable that steers which program or file later commands use`,
|
|
6201
|
+
};
|
|
6202
|
+
}
|
|
6203
|
+
}
|
|
6204
|
+
}
|
|
6205
|
+
// APRV-508 refutation S3: `test -v 'a[$(id)]'` evaluates the subscript as
|
|
6206
|
+
// arithmetic, and bash expands a `$(…)` inside it, so the operand runs a
|
|
6207
|
+
// command. `[` is `test`; both refuse a `-v`/`-R` operand carrying `[`, and
|
|
6208
|
+
// an unquoted substitution, whose output the shell would split into words
|
|
6209
|
+
// (a `-v` and a subscript among them) that this text never shows.
|
|
6210
|
+
if (basename === "test" || basename === "[") {
|
|
6211
|
+
const operandWords = segment.words.slice(cursor + 1);
|
|
6212
|
+
const probe = args.findIndex((arg, at) => (arg === "-v" || arg === "-R") && (args[at + 1] ?? "").includes("["));
|
|
6213
|
+
if (probe !== -1 || operandWords.some((word) => word.substitutions.length > 0 && !word.quoted)) {
|
|
6214
|
+
return {
|
|
6215
|
+
ok: false,
|
|
6216
|
+
code: "unclassified",
|
|
6217
|
+
detail: `no rule for ${basename} with an array subscript or an unquoted substitution: the shell may evaluate it as a command`,
|
|
6218
|
+
};
|
|
6219
|
+
}
|
|
6220
|
+
}
|
|
3714
6221
|
// Credential material, below the opaque checks so `sudo cat .approval/env`
|
|
3715
6222
|
// stays opaque (a refusal) rather than being softened into a request, and
|
|
3716
6223
|
// above the binary table so a reader the table does not know (`base64`,
|
|
3717
6224
|
// `xxd`, `less`) is named rather than answered `unclassified` (APRV-194).
|
|
3718
|
-
|
|
3719
|
-
|
|
3720
|
-
|
|
6225
|
+
// APRV-503: the same tier read from the redirection targets, at the same
|
|
6226
|
+
// place in the order (`sudo cat < .approval/env` stays a refusal, and `less
|
|
6227
|
+
// < .approval/env` is named rather than `unclassified`).
|
|
6228
|
+
//
|
|
6229
|
+
// A credential path, named or redirected, never lowers a `log.mutate` or
|
|
6230
|
+
// `policy.core` answer (APRV-503 refutation, BLOCKING-1 and its named-argument
|
|
6231
|
+
// twin). Both classes outrank `account.credential` ({@link
|
|
6232
|
+
// outranksCredential}), and the open window refuses `log.mutate` with no
|
|
6233
|
+
// policy consulted while it bypasses `account.credential` under a policy that
|
|
6234
|
+
// does not load: `rm -rf .approval/log < .approval/env` and `cp .approval/env
|
|
6235
|
+
// .approval/log/events.jsonl` were both relabelled `account.credential` by an
|
|
6236
|
+
// early return here, and so both reached the log through a window. So the
|
|
6237
|
+
// credential answer is taken HERE only when the segment names no protected
|
|
6238
|
+
// path that outranks it. When one is named the answer is DEFERRED: the
|
|
6239
|
+
// segment goes on through the table and the protected scan exactly as it
|
|
6240
|
+
// would without the credential word, a protected class or a refusal stands,
|
|
6241
|
+
// and the credential class answers only where that walk ends on an answer it
|
|
6242
|
+
// is stricter than (a read, `gate.self`). Where the walk ends on the
|
|
6243
|
+
// protected class, the credential touch is carried beside it as a second
|
|
6244
|
+
// class of the command (`also`), never dropped. Write targets need no check
|
|
6245
|
+
// here: a protected one has already answered above, carrying it the same way.
|
|
6246
|
+
//
|
|
6247
|
+
// Fix round 4 (M(L5f-1)): the file a curl or wget upload or request-body
|
|
6248
|
+
// option reads is judged with the positionals, so `--upload-file=F`, `-TF`,
|
|
6249
|
+
// `--data=@F`, `-Ff=@F`, wget `--post-file=F`, `--body-file=F` and
|
|
6250
|
+
// `--input-file=F` take the class `-T F` and `--data @F` take (a credential
|
|
6251
|
+
// file `account.credential`, an organ its class). An embedded value whose
|
|
6252
|
+
// file the text cannot read is a credential read of an unknown file. The
|
|
6253
|
+
// upload's answer never displaces a download output that outranks the
|
|
6254
|
+
// credential class (`policy.core`, `fetch-output-unknown`): those answer below.
|
|
6255
|
+
const argFlags = segment.words
|
|
6256
|
+
.slice(cursor + 1)
|
|
6257
|
+
.map((word) => ({ splits: word.splits, expands: word.expands }));
|
|
6258
|
+
const fetched = fetchOutputs(basename, args, argFlags);
|
|
6259
|
+
const judged = fetched.sources.length === 0 ? positionals : [...positionals, ...fetched.sources];
|
|
6260
|
+
const fetchedOutputProtected = strictestProtected([...fetched.files, ...fetched.directories.filter((directory) => directory !== ".")], protectedPaths);
|
|
6261
|
+
const fetchOutranksCredential = fetched.unknown !== null || (fetchedOutputProtected !== null && outranksCredential(fetchedOutputProtected.surface));
|
|
6262
|
+
const uploadCredential = fetchOutranksCredential ? null : uploadCredentialTouch(fetched.sources, fetched.sourceUnknown);
|
|
6263
|
+
// Merge of APRV-511 and APRV-522: APRV-522's credential read words (option
|
|
6264
|
+
// and redirect targets, git's readers) and APRV-511's upload sources. An
|
|
6265
|
+
// upload option word APRV-522 also reads is named by the file it sends, as
|
|
6266
|
+
// APRV-511 names it; a positional or an environment reference keeps its own.
|
|
6267
|
+
const argCredential = credentialTouch(basename, args);
|
|
6268
|
+
const credential = argCredential === null
|
|
6269
|
+
? uploadCredential
|
|
6270
|
+
: uploadCredential !== null && argCredential.path !== undefined && !positionals.includes(argCredential.path)
|
|
6271
|
+
? uploadCredential
|
|
6272
|
+
: argCredential;
|
|
6273
|
+
const redirectCredential = redirectCredentialTouch(readTargets, redirectTargets);
|
|
6274
|
+
const outranking = credential === null && redirectCredential === null
|
|
6275
|
+
? null
|
|
6276
|
+
: outrankingProtected(judged, readTargets, credentialWritesOnly(basename, args) || credentialProtectedScanKeeps(basename), protectedPaths);
|
|
6277
|
+
// Merge of APRV-511 and APRV-522: APRV-522's credential read words include
|
|
6278
|
+
// an upload's option words, so a credential answer is deferred, never taken
|
|
6279
|
+
// here, while a download output outranks it (`policy.core`,
|
|
6280
|
+
// `fetch-output-unknown` answer below), as APRV-511 keeps the upload's own.
|
|
6281
|
+
if (outranking === null && !fetchOutranksCredential) {
|
|
6282
|
+
if (credential !== null)
|
|
6283
|
+
return { ok: true, ...credential };
|
|
6284
|
+
if (redirectCredential !== null)
|
|
6285
|
+
return { ok: true, ...redirectCredential };
|
|
6286
|
+
}
|
|
6287
|
+
const deferredCredential = credential ?? redirectCredential;
|
|
3721
6288
|
const sub = positionals[0] ?? null;
|
|
3722
6289
|
const substituted = segment.words
|
|
3723
6290
|
.slice(cursor + 1)
|
|
3724
6291
|
.some((word) => word.substitutions.length > 0);
|
|
3725
|
-
const
|
|
6292
|
+
const binExact = segment.words[cursor].exact;
|
|
6293
|
+
const rule = matchRule(basename, sub, args, substituted, assigned, assignments, bin, execShape !== null, binExact);
|
|
3726
6294
|
if (rule === null) {
|
|
3727
6295
|
return {
|
|
3728
6296
|
ok: false,
|
|
@@ -3732,7 +6300,7 @@ function classifySegment(segment, protectedPaths, context) {
|
|
|
3732
6300
|
: `no rule for ${basename} ${sub}`,
|
|
3733
6301
|
};
|
|
3734
6302
|
}
|
|
3735
|
-
const ctx = { bin: basename, args, positionals, sub, substituted, assigned, context };
|
|
6303
|
+
const ctx = { bin: basename, args, positionals, sub, substituted, assigned, assignments, context };
|
|
3736
6304
|
const refined = rule.refine === undefined ? null : rule.refine(ctx);
|
|
3737
6305
|
if (rule.refine !== undefined && refined === null) {
|
|
3738
6306
|
return { ok: false, code: "opaque", detail: `${basename} runs inline source` };
|
|
@@ -3748,6 +6316,42 @@ function classifySegment(segment, protectedPaths, context) {
|
|
|
3748
6316
|
// matched, verbatim, so an approver is told WHICH refs a deletion names
|
|
3749
6317
|
// rather than being handed a class and left to re-read the command.
|
|
3750
6318
|
const boundPath = refined !== null && "path" in refined ? refined.path : undefined;
|
|
6319
|
+
// APRV-511 refuter S1: a download's output file is a write target, whatever
|
|
6320
|
+
// class the fetch earned (`curl -o ~/.hermes/plugins/x/__init__.py URL` is
|
|
6321
|
+
// `read.web` by its method). A protected output answers with its class, as a
|
|
6322
|
+
// redirection onto it would.
|
|
6323
|
+
// Refuter S3: the destination directory a copy, move, link or install names
|
|
6324
|
+
// in a flag (`-t<dir>`, `--target-directory=<dir>`) is a write target too;
|
|
6325
|
+
// `-t <dir>` already reaches the positional scan below.
|
|
6326
|
+
const copyFamily = ["cp", "mv", "ln", "install"].includes(basename);
|
|
6327
|
+
const targetDirectories = copyFamily ? copyTargetDirectories(args) : [];
|
|
6328
|
+
// Fix round 3 (S(open)): what the shell may still do to each argv word
|
|
6329
|
+
// (`argFlags`, and `fetched` from it, are read above the credential tier).
|
|
6330
|
+
// A directory a download writes server-chosen names into is judged as the
|
|
6331
|
+
// directory: an organ directory, or the home root, is an organ write.
|
|
6332
|
+
const fetchedDirectories = fetched.directories.filter((directory) => directory !== ".");
|
|
6333
|
+
const fetchedProtected = strictestProtected([...fetched.files, ...targetDirectories, ...fetchedDirectories], protectedPaths);
|
|
6334
|
+
if (fetchedProtected !== null) {
|
|
6335
|
+
return { ok: true, class: fetchedProtected.surface, rule: "protected-path", path: fetchedProtected.path };
|
|
6336
|
+
}
|
|
6337
|
+
// APRV-511 review: a download whose output this table cannot know (an
|
|
6338
|
+
// option it does not know, an ambiguous prefix, a config file or string) is
|
|
6339
|
+
// a write to an unknown target, and an unknown target may be an organ.
|
|
6340
|
+
if (fetched.unknown !== null) {
|
|
6341
|
+
return { ok: true, class: "policy.core", rule: "fetch-output-unknown", path: fetched.unknown };
|
|
6342
|
+
}
|
|
6343
|
+
// Fix round 3 (S(open)): a copy, move or link argv word the shell may still
|
|
6344
|
+
// turn into an option (`cp $X f`, `cp *.md d/`, `mv {-t,DIR} f`) may name
|
|
6345
|
+
// the destination, so the destination is unknown: `policy.core`, unless a
|
|
6346
|
+
// named path already earns a class at least as strict (the log).
|
|
6347
|
+
const copyUnknown = copyFamily ? copyArgvUnknown(args, argFlags) : null;
|
|
6348
|
+
if (copyUnknown !== null) {
|
|
6349
|
+
const named = strictestProtected([...positionals, ...readTargets], protectedPaths);
|
|
6350
|
+
if (named !== null && protectedRank(named.surface) <= protectedRank("policy.core")) {
|
|
6351
|
+
return { ok: true, class: named.surface, rule: "protected-path", path: named.path };
|
|
6352
|
+
}
|
|
6353
|
+
return { ok: true, class: "policy.core", rule: "copy-target-unknown", path: copyUnknown };
|
|
6354
|
+
}
|
|
3751
6355
|
// A protected path anywhere in an effectful segment takes that path's class:
|
|
3752
6356
|
// the command is editing the gate, whatever else it is doing. Every
|
|
3753
6357
|
// positional is scanned, source and destination alike, so `cp` stays
|
|
@@ -3760,12 +6364,59 @@ function classifySegment(segment, protectedPaths, context) {
|
|
|
3760
6364
|
// a directory path is a sentence rather than a file. Redirection targets
|
|
3761
6365
|
// above, and the apply-patch and file-tool paths elsewhere, are paths by
|
|
3762
6366
|
// construction and are not offered the skip.
|
|
6367
|
+
//
|
|
6368
|
+
// APRV-503: an INPUT redirection's target is scanned here too, as the named
|
|
6369
|
+
// argument it stands in for (`node x.js < APPROVAL.md` is `node x.js
|
|
6370
|
+
// APPROVAL.md` with the file on stdin), and without the prose skip, because a
|
|
6371
|
+
// redirection target is a path by construction. Its direction is known to be a
|
|
6372
|
+
// read, so this is the stricter of two defensible answers, which is the one
|
|
6373
|
+
// the protected tier takes everywhere else. A read segment keeps `read.*`
|
|
6374
|
+
// either way: `cat < APPROVAL.md` reads the policy as `cat APPROVAL.md` does.
|
|
6375
|
+
//
|
|
6376
|
+
// The rule, stated exactly. The strictest surface among the positionals and
|
|
6377
|
+
// the `<` targets answers; on equal rank the positional's path is the one
|
|
6378
|
+
// bound. So a `<` target can move an effectful segment only to a STRICTER
|
|
6379
|
+
// protected class than its positionals earned (`node CLAUDE.md < APPROVAL.md`
|
|
6380
|
+
// is `policy.core`), never to a looser one, and a segment whose positionals
|
|
6381
|
+
// earn no protected class can only move from its own class to a protected one.
|
|
3763
6382
|
if (!cls.startsWith("read.") && cls !== GATE_SELF_CLASS) {
|
|
3764
|
-
|
|
3765
|
-
|
|
3766
|
-
|
|
6383
|
+
// A deferred credential answer (above) names the path that outranked it,
|
|
6384
|
+
// which is the strictest protected path among the words that are not the
|
|
6385
|
+
// credential read itself: the segment's answer without that word.
|
|
6386
|
+
//
|
|
6387
|
+
// The credential touch is NOT dropped (APRV-503 recheck, SHOULD-FIX-R1): it
|
|
6388
|
+
// rides beside the protected class as a second class of the command, so
|
|
6389
|
+
// the precedence above decides what the segment is called and never what a
|
|
6390
|
+
// policy gets to see. A policy holding `account.credential` stricter than
|
|
6391
|
+
// `policy.core` still refuses `cp .approval/env APPROVAL.md`, and SPEC.md
|
|
6392
|
+
// §7's "a copy is classified `account.credential` in either direction"
|
|
6393
|
+
// holds as written.
|
|
6394
|
+
if (deferredCredential !== null && outranking !== null) {
|
|
6395
|
+
return {
|
|
6396
|
+
ok: true,
|
|
6397
|
+
class: outranking.surface,
|
|
6398
|
+
rule: "protected-path",
|
|
6399
|
+
path: outranking.path,
|
|
6400
|
+
also: deferredCredential,
|
|
6401
|
+
};
|
|
6402
|
+
}
|
|
6403
|
+
const named = strictestProtected(judged, protectedPaths, true);
|
|
6404
|
+
const fed = strictestProtected(readTargets, protectedPaths);
|
|
6405
|
+
const touched = fed === null || (named !== null && protectedRank(named.surface) <= protectedRank(fed.surface))
|
|
6406
|
+
? named
|
|
6407
|
+
: fed;
|
|
6408
|
+
if (touched !== null) {
|
|
6409
|
+
return { ok: true, class: touched.surface, rule: "protected-path", path: touched.path };
|
|
3767
6410
|
}
|
|
3768
6411
|
}
|
|
6412
|
+
// APRV-503: a credential answer deferred above, because the segment also
|
|
6413
|
+
// names a protected path that outranks it. The walk did not end on that
|
|
6414
|
+
// protected class (a reader, or the gate's own CLI, is not scanned), so it
|
|
6415
|
+
// ended on an answer the credential class is stricter than, and the credential
|
|
6416
|
+
// class answers: `cat .approval/log/events.jsonl < .approval/env` reads the
|
|
6417
|
+
// environment map. A named credential is reported ahead of a redirected one.
|
|
6418
|
+
if (deferredCredential !== null)
|
|
6419
|
+
return { ok: true, ...deferredCredential };
|
|
3769
6420
|
// A read command with a write redirection writes. `ls > out.txt` creates a
|
|
3770
6421
|
// file, and the class has to say so.
|
|
3771
6422
|
if (cls.startsWith("read.") && writeTargets.length > 0) {
|
|
@@ -3779,7 +6430,12 @@ function classifySegment(segment, protectedPaths, context) {
|
|
|
3779
6430
|
// and symlinked cases are deliberately NOT decided here; they are left at
|
|
3780
6431
|
// `read.shell` for the hook's disk pass, which tightens and never loosens.
|
|
3781
6432
|
if (cls === "read.shell" && (context.readRoots ?? []).length > 0) {
|
|
3782
|
-
|
|
6433
|
+
// APRV-522 (S2): a `<` target is opened for reading whatever the binary
|
|
6434
|
+
// is, so it is scoped for every binary, and a substitution's escaped read
|
|
6435
|
+
// counts as the segment's own.
|
|
6436
|
+
const escaped = escapedReadTarget(basename, positionals, args, context.readRoots ?? []) ??
|
|
6437
|
+
redirectReadEscape(readTargets, context.readRoots ?? []) ??
|
|
6438
|
+
substitutedEscape;
|
|
3783
6439
|
if (escaped !== null) {
|
|
3784
6440
|
return {
|
|
3785
6441
|
ok: true,
|
|
@@ -3790,12 +6446,25 @@ function classifySegment(segment, protectedPaths, context) {
|
|
|
3790
6446
|
};
|
|
3791
6447
|
}
|
|
3792
6448
|
}
|
|
6449
|
+
// APRV-510: the program, for the hook to hash. Attached only when the
|
|
6450
|
+
// segment's FINAL class is `exec.code`: a protected or credential answer
|
|
6451
|
+
// above has already replaced it, and carries no program.
|
|
6452
|
+
const execCode = cls === EXEC_CODE_CLASS && rule.id === EXEC_CODE_ROW && execShape !== null
|
|
6453
|
+
? {
|
|
6454
|
+
interpreter: basename,
|
|
6455
|
+
mode: execShape.mode,
|
|
6456
|
+
source: args[execShape.index],
|
|
6457
|
+
argv: words.slice(cursor),
|
|
6458
|
+
sourceIndex: execShape.index + 1,
|
|
6459
|
+
}
|
|
6460
|
+
: null;
|
|
3793
6461
|
return {
|
|
3794
6462
|
ok: true,
|
|
3795
6463
|
class: cls,
|
|
3796
6464
|
rule: ruleId,
|
|
3797
6465
|
...(boundPath === undefined ? {} : { path: boundPath }),
|
|
3798
6466
|
...(sandbox === null ? {} : { sandbox }),
|
|
6467
|
+
...(execCode === null ? {} : { execCode }),
|
|
3799
6468
|
};
|
|
3800
6469
|
}
|
|
3801
6470
|
/**
|
|
@@ -3830,9 +6499,21 @@ export function classifyCommand(command, protectedPaths = [], context = {}) {
|
|
|
3830
6499
|
if (lexed.segments.length === 0) {
|
|
3831
6500
|
return { ok: false, code: "unclassified", segment: command.trim(), detail: "empty command" };
|
|
3832
6501
|
}
|
|
6502
|
+
// APRV-521: a variable git could inherit, set by this line's own shell,
|
|
6503
|
+
// reaches every git segment on the line and in its substitutions.
|
|
6504
|
+
const shellEnvWrite = context.shellEnvWrite ?? gitEnvironmentWrite(lexed.segments) ?? undefined;
|
|
6505
|
+
if (shellEnvWrite !== undefined)
|
|
6506
|
+
context = { ...context, shellEnvWrite };
|
|
3833
6507
|
const segments = [];
|
|
3834
6508
|
const classes = [];
|
|
6509
|
+
// APRV-510 (security review, finding 1): once a segment may have changed
|
|
6510
|
+
// what a bare name resolves to, `exec.code` is closed for the rest of the
|
|
6511
|
+
// line, so a later `python3 -c` keeps its pre-APRV-510 refusal.
|
|
6512
|
+
let execContext = context;
|
|
3835
6513
|
for (const segment of lexed.segments) {
|
|
6514
|
+
if (execContext.execCode === true && movesNameResolution(segment)) {
|
|
6515
|
+
execContext = { ...execContext, execCode: false };
|
|
6516
|
+
}
|
|
3836
6517
|
// APRV-380. A LOGIN-SHELL WRAPPER is classified by the script it runs.
|
|
3837
6518
|
// `loginShellScript` states the exact shape and why this is not the second
|
|
3838
6519
|
// parser `OPAQUE_BINS` refuses; the inner classification is run with
|
|
@@ -3840,7 +6521,14 @@ export function classifyCommand(command, protectedPaths = [], context = {}) {
|
|
|
3840
6521
|
// and the recursion is one level deep by construction.
|
|
3841
6522
|
const script = context.unwrapShell === false ? null : loginShellScript(segment);
|
|
3842
6523
|
if (script !== null) {
|
|
3843
|
-
|
|
6524
|
+
// APRV-510: and with `exec.code` closed. `bash -c` and `sh -c` stay out of
|
|
6525
|
+
// the class's scope, so a program inside an unwrapped shell script keeps
|
|
6526
|
+
// the refusal it had before the class existed.
|
|
6527
|
+
const inner = classifyCommand(script, protectedPaths, {
|
|
6528
|
+
...context,
|
|
6529
|
+
unwrapShell: false,
|
|
6530
|
+
execCode: false,
|
|
6531
|
+
});
|
|
3844
6532
|
if (!inner.ok) {
|
|
3845
6533
|
// The refusal is the INNER one, reported against the inner segment: an
|
|
3846
6534
|
// operator told `hook-opaque` for `zsh` learns nothing, and one told
|
|
@@ -3857,7 +6545,7 @@ export function classifyCommand(command, protectedPaths = [], context = {}) {
|
|
|
3857
6545
|
}
|
|
3858
6546
|
continue;
|
|
3859
6547
|
}
|
|
3860
|
-
const outcome = classifySegment(segment, protectedPaths,
|
|
6548
|
+
const outcome = classifySegment(segment, protectedPaths, execContext);
|
|
3861
6549
|
if (!outcome.ok) {
|
|
3862
6550
|
return { ok: false, code: outcome.code, segment: segment.text, detail: outcome.detail };
|
|
3863
6551
|
}
|
|
@@ -3867,9 +6555,24 @@ export function classifyCommand(command, protectedPaths = [], context = {}) {
|
|
|
3867
6555
|
rule: outcome.rule,
|
|
3868
6556
|
...(outcome.path === undefined ? {} : { path: outcome.path }),
|
|
3869
6557
|
...(outcome.sandbox === undefined ? {} : { sandbox: outcome.sandbox }),
|
|
6558
|
+
...(outcome.execCode === undefined ? {} : { execCode: outcome.execCode }),
|
|
3870
6559
|
});
|
|
3871
6560
|
if (!classes.includes(outcome.class))
|
|
3872
6561
|
classes.push(outcome.class);
|
|
6562
|
+
// APRV-503 recheck: the credential touch a protected answer outranked, as
|
|
6563
|
+
// its own entry over the same segment text. The shape a pipeline already
|
|
6564
|
+
// has (one entry per class-bearing part), so every reader of `segments`
|
|
6565
|
+
// and `classes` sees it without learning a new field.
|
|
6566
|
+
if (outcome.also !== undefined) {
|
|
6567
|
+
segments.push({
|
|
6568
|
+
text: segment.text,
|
|
6569
|
+
class: outcome.also.class,
|
|
6570
|
+
rule: outcome.also.rule,
|
|
6571
|
+
...(outcome.also.path === undefined ? {} : { path: outcome.also.path }),
|
|
6572
|
+
});
|
|
6573
|
+
if (!classes.includes(outcome.also.class))
|
|
6574
|
+
classes.push(outcome.also.class);
|
|
6575
|
+
}
|
|
3873
6576
|
}
|
|
3874
6577
|
return { ok: true, segments, classes };
|
|
3875
6578
|
}
|
|
@@ -3902,19 +6605,33 @@ export function commandSegmentWriteTargets(command) {
|
|
|
3902
6605
|
.map((redirect) => redirect.target.text)
|
|
3903
6606
|
.filter((target) => !isDiscardTarget(target)));
|
|
3904
6607
|
}
|
|
6608
|
+
/**
|
|
6609
|
+
* How many words open the segment that bash takes as assignments, judged on
|
|
6610
|
+
* each word as written ({@link LexWord.assignment}, APRV-523). The word after
|
|
6611
|
+
* them is the program bash runs: a word whose name is quoted or escaped is
|
|
6612
|
+
* that program, not an assignment, so the shapes below name it as the binary.
|
|
6613
|
+
* On a line the classifier answers the count equals its own cursor's, because
|
|
6614
|
+
* {@link classifySegment} refuses any segment where the two differ.
|
|
6615
|
+
*/
|
|
6616
|
+
function bashAssignmentCount(words) {
|
|
6617
|
+
let cursor = 0;
|
|
6618
|
+
while (cursor < words.length && words[cursor].assignment)
|
|
6619
|
+
cursor += 1;
|
|
6620
|
+
return cursor;
|
|
6621
|
+
}
|
|
3905
6622
|
export function commandSegmentShapes(command) {
|
|
3906
6623
|
const lexed = lex(command);
|
|
3907
6624
|
if (!lexed.ok)
|
|
3908
6625
|
return null;
|
|
3909
6626
|
return lexed.segments.map((segment) => {
|
|
3910
6627
|
const words = segment.words.map((word) => word.text);
|
|
3911
|
-
|
|
3912
|
-
|
|
3913
|
-
cursor += 1;
|
|
6628
|
+
const cursor = bashAssignmentCount(segment.words);
|
|
6629
|
+
const script = loginShellScript(segment);
|
|
3914
6630
|
return {
|
|
3915
6631
|
text: segment.text,
|
|
3916
6632
|
bin: words[cursor] ?? null,
|
|
3917
6633
|
args: words.slice(cursor + 1),
|
|
6634
|
+
argFlags: segment.words.slice(cursor + 1).map((word) => ({ splits: word.splits, expands: word.expands })),
|
|
3918
6635
|
writes: segment.redirects
|
|
3919
6636
|
.filter((redirect) => redirect.op !== "<")
|
|
3920
6637
|
.map((redirect) => redirect.target.text)
|
|
@@ -3922,9 +6639,31 @@ export function commandSegmentShapes(command) {
|
|
|
3922
6639
|
reads: segment.redirects
|
|
3923
6640
|
.filter((redirect) => redirect.op === "<")
|
|
3924
6641
|
.map((redirect) => redirect.target.text),
|
|
6642
|
+
stdinFed: segment.stdinFed,
|
|
6643
|
+
substitutions: [
|
|
6644
|
+
...segment.words.flatMap((word) => word.substitutions),
|
|
6645
|
+
...segment.redirects.flatMap((redirect) => redirect.target.substitutions),
|
|
6646
|
+
...segment.bodySubstitutions,
|
|
6647
|
+
],
|
|
6648
|
+
assignments: words.slice(0, cursor),
|
|
6649
|
+
script,
|
|
6650
|
+
opaqueShell: script === null && inlineShellFlagged(words[cursor] ?? null, words.slice(cursor + 1)),
|
|
3925
6651
|
};
|
|
3926
6652
|
});
|
|
3927
6653
|
}
|
|
6654
|
+
/**
|
|
6655
|
+
* Is this a known shell handed an inline-script flag anywhere in its argv?
|
|
6656
|
+
* (APRV-522 fix round 1, M-A.) Broader than {@link INLINE_SCRIPT_FLAG} on
|
|
6657
|
+
* purpose: any short bundle carrying `c` counts, wherever it sits.
|
|
6658
|
+
*/
|
|
6659
|
+
function inlineShellFlagged(bin, args) {
|
|
6660
|
+
if (bin === null)
|
|
6661
|
+
return false;
|
|
6662
|
+
const base = pathSegments(bin).slice(-1)[0] ?? bin;
|
|
6663
|
+
if (!UNWRAPPABLE_SHELLS.has(base))
|
|
6664
|
+
return false;
|
|
6665
|
+
return args.some((arg) => /^-[A-Za-z]*c[A-Za-z]*$/u.test(arg) || arg === "--command");
|
|
6666
|
+
}
|
|
3928
6667
|
export function commandSegmentWords(command) {
|
|
3929
6668
|
const lexed = lex(command);
|
|
3930
6669
|
if (!lexed.ok)
|
|
@@ -3932,9 +6671,7 @@ export function commandSegmentWords(command) {
|
|
|
3932
6671
|
const out = [];
|
|
3933
6672
|
for (const segment of lexed.segments) {
|
|
3934
6673
|
const words = segment.words.map((word) => word.text);
|
|
3935
|
-
|
|
3936
|
-
while (cursor < words.length && ASSIGNMENT.test(words[cursor]))
|
|
3937
|
-
cursor += 1;
|
|
6674
|
+
const cursor = bashAssignmentCount(segment.words);
|
|
3938
6675
|
const bin = words[cursor];
|
|
3939
6676
|
if (bin === undefined)
|
|
3940
6677
|
continue;
|