approval-md 0.4.5 → 0.4.7
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 +24 -3
- package/dist/src/channels/contract.d.ts +15 -0
- package/dist/src/channels/contract.js +23 -1
- package/dist/src/channels/contract.js.map +1 -1
- package/dist/src/channels/telegram.d.ts +180 -33
- package/dist/src/channels/telegram.js +256 -87
- package/dist/src/channels/telegram.js.map +1 -1
- package/dist/src/cli/channel-telegram.d.ts +114 -35
- package/dist/src/cli/channel-telegram.js +259 -120
- 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.d.ts +2 -0
- package/dist/src/cli/help.js +39 -3
- package/dist/src/cli/help.js.map +1 -1
- 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 +154 -1
- package/dist/src/cli/hook.js +2344 -111
- package/dist/src/cli/hook.js.map +1 -1
- package/dist/src/cli/instructions.js +9 -6
- package/dist/src/cli/instructions.js.map +1 -1
- package/dist/src/cli/main.js +9 -0
- package/dist/src/cli/main.js.map +1 -1
- package/dist/src/cli/study.d.ts +22 -0
- package/dist/src/cli/study.js +153 -0
- package/dist/src/cli/study.js.map +1 -0
- package/dist/src/cli/verb-registry.d.ts +14 -0
- package/dist/src/cli/verb-registry.js +37 -0
- package/dist/src/cli/verb-registry.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 +349 -1
- package/dist/src/core/command-class.js +2347 -87
- package/dist/src/core/command-class.js.map +1 -1
- 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/gate.d.ts +38 -0
- package/dist/src/core/gate.js +26 -0
- package/dist/src/core/gate.js.map +1 -1
- 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/sender-identity.d.ts +13 -1
- package/dist/src/core/sender-identity.js +15 -0
- package/dist/src/core/sender-identity.js.map +1 -1
- package/dist/src/core/study-withdraw.d.ts +137 -0
- package/dist/src/core/study-withdraw.js +280 -0
- package/dist/src/core/study-withdraw.js.map +1 -0
- package/dist/src/daemon/git-evidence.d.ts +2 -2
- package/dist/src/daemon/git-evidence.js +1 -1
- package/dist/src/mcp/server.js +1 -1
- package/dist/src/mcp/server.js.map +1 -1
- package/docs/README-extended.md +3 -2
- package/docs/cli-reference.md +83 -7
- package/package.json +2 -2
- package/schema/event.schema.json +213 -1
- package/schema/fixtures/event/invalid/approval-granted-study.json +23 -0
- package/schema/fixtures/event/invalid/approval-rejected-looks-wrong-cli-channel.json +23 -0
- package/schema/fixtures/event/invalid/approval-rejected-looks-wrong-operator.json +24 -0
- package/schema/fixtures/event/invalid/approval-rejected-study-extra-key.json +24 -0
- package/schema/fixtures/event/invalid/approval-rejected-study-unknown-reason.json +23 -0
- package/schema/fixtures/event/invalid/approval-rejected-study-withdrawn-email-operator.json +20 -0
- package/schema/fixtures/event/invalid/approval-rejected-study-withdrawn-no-operator.json +19 -0
- package/schema/fixtures/event/invalid/approval-rejected-study-withdrawn-no-ref.json +19 -0
- package/schema/fixtures/event/invalid/approval-rejected-study-withdrawn-wrong-actor.json +20 -0
- package/schema/fixtures/event/invalid/approval-rejected-study-withdrawn-wrong-channel.json +20 -0
- package/schema/fixtures/event/invalid/approval-revoked-study.json +20 -0
- package/schema/fixtures/event/valid/approval-rejected-looks-wrong.json +23 -0
- package/schema/fixtures/event/valid/approval-rejected-study-withdrawn.json +20 -0
- 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,7 +47,8 @@
|
|
|
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 } from "./date-argv.js";
|
|
50
|
+
import { isDateDisplay, isZoneValue } from "./date-argv.js";
|
|
51
|
+
import { parseGitPushArgv, splitGitPush } from "./git-push-argv.js";
|
|
51
52
|
import { isBareInfo, isDfDisplay, isFreeDisplay, isHostnameDisplay, isPasteRead, isSeqDisplay, } from "./sysinfo-argv.js";
|
|
52
53
|
import { HARNESS_LAUNCH_PREFIX } from "./policy-match.js";
|
|
53
54
|
import { READ_OUT_OF_SCOPE_CLASS, isUnreadableTarget, readTargetVerdict, readTargetsOf, } from "./read-scope.js";
|
|
@@ -177,6 +178,318 @@ function matchesEntry(segments, entry) {
|
|
|
177
178
|
const offset = segments.length - want.length;
|
|
178
179
|
return want.every((segment, index) => segment === segments[offset + index]);
|
|
179
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
|
+
}
|
|
180
493
|
/**
|
|
181
494
|
* Which protected class does this path name, if any? (APRV-198.)
|
|
182
495
|
*
|
|
@@ -235,7 +548,24 @@ function matchesEntry(segments, entry) {
|
|
|
235
548
|
export function protectedPathClass(candidate, extra = []) {
|
|
236
549
|
if (candidate.length === 0)
|
|
237
550
|
return null;
|
|
238
|
-
|
|
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));
|
|
239
569
|
// 1. The log directory, before anything else that would call it a policy file.
|
|
240
570
|
for (let index = 0; index < segments.length; index += 1) {
|
|
241
571
|
if (segments[index] === ".approval" && segments[index + 1] === "log")
|
|
@@ -348,26 +678,32 @@ export function protectedPathClass(candidate, extra = []) {
|
|
|
348
678
|
// process it starts, which makes it the switch for the gate itself
|
|
349
679
|
// (`HERMES_ACCEPT_HOOKS`) as well as a secret store. Reads stay
|
|
350
680
|
// `account.credential`.
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
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")) {
|
|
367
705
|
return "policy.core";
|
|
368
706
|
}
|
|
369
|
-
if (next === "scripts")
|
|
370
|
-
return HERMES_SCHEDULED_SCRIPT_CLASS;
|
|
371
707
|
}
|
|
372
708
|
// Codex installs its hook through these configuration and script paths.
|
|
373
709
|
if (segment === ".codex") {
|
|
@@ -515,7 +851,80 @@ const CREDENTIAL_CLASS = "account.credential";
|
|
|
515
851
|
export function isCredentialPath(candidate) {
|
|
516
852
|
if (candidate.length === 0)
|
|
517
853
|
return false;
|
|
518
|
-
|
|
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) {
|
|
519
928
|
for (let index = 0; index < segments.length; index += 1) {
|
|
520
929
|
const segment = segments[index];
|
|
521
930
|
// APRV-398. Hermes Agent's own home holds two files of credential material
|
|
@@ -529,13 +938,14 @@ export function isCredentialPath(candidate) {
|
|
|
529
938
|
// whose organ this codebase now recognises, whose credential files it did
|
|
530
939
|
// not, would be a Never-list item the classifier does not enforce — which is
|
|
531
940
|
// exactly the hole APRV-194 was filed for, one harness along.
|
|
532
|
-
|
|
533
|
-
|
|
534
|
-
|
|
535
|
-
|
|
536
|
-
|
|
537
|
-
|
|
538
|
-
|
|
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))
|
|
539
949
|
return true;
|
|
540
950
|
continue;
|
|
541
951
|
}
|
|
@@ -543,7 +953,7 @@ export function isCredentialPath(candidate) {
|
|
|
543
953
|
continue;
|
|
544
954
|
const next = segments[index + 1];
|
|
545
955
|
if (next === undefined)
|
|
546
|
-
|
|
956
|
+
continue;
|
|
547
957
|
if (next.startsWith("vault"))
|
|
548
958
|
return true;
|
|
549
959
|
if (next === "keys")
|
|
@@ -569,6 +979,20 @@ export function isCredentialPath(candidate) {
|
|
|
569
979
|
* exfiltrating one is the one worth naming: a `cp` touching credential material
|
|
570
980
|
* is `account.credential` in either direction. Both classes are gated, so the
|
|
571
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.
|
|
572
996
|
*/
|
|
573
997
|
const CREDENTIAL_WRITE_BINS = [
|
|
574
998
|
"rm",
|
|
@@ -577,11 +1001,9 @@ const CREDENTIAL_WRITE_BINS = [
|
|
|
577
1001
|
"truncate",
|
|
578
1002
|
"chmod",
|
|
579
1003
|
"chown",
|
|
580
|
-
"ln",
|
|
581
1004
|
"touch",
|
|
582
1005
|
"mkdir",
|
|
583
1006
|
"rmdir",
|
|
584
|
-
"git",
|
|
585
1007
|
"dd",
|
|
586
1008
|
"install",
|
|
587
1009
|
];
|
|
@@ -685,16 +1107,205 @@ function credentialWritesOnly(basename, args) {
|
|
|
685
1107
|
const inPlaceSed = basename === "sed" &&
|
|
686
1108
|
(hasFlag(args, ["--in-place"]) ||
|
|
687
1109
|
args.some((arg) => arg.startsWith("-i") && !arg.startsWith("--")));
|
|
688
|
-
|
|
689
|
-
|
|
690
|
-
|
|
691
|
-
|
|
692
|
-
|
|
693
|
-
|
|
694
|
-
|
|
695
|
-
|
|
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;
|
|
696
1285
|
}
|
|
697
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);
|
|
1300
|
+
}
|
|
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
|
+
}
|
|
698
1309
|
for (const word of args) {
|
|
699
1310
|
if (secretEnvReference(word) !== null) {
|
|
700
1311
|
return { class: CREDENTIAL_CLASS, rule: "credential-env" };
|
|
@@ -702,6 +1313,20 @@ function credentialTouch(basename, args, positionals) {
|
|
|
702
1313
|
}
|
|
703
1314
|
return null;
|
|
704
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
|
+
}
|
|
705
1330
|
/**
|
|
706
1331
|
* Is this segment a credential touch through a REDIRECTION? (APRV-503.)
|
|
707
1332
|
*
|
|
@@ -794,6 +1419,9 @@ function lex(command) {
|
|
|
794
1419
|
let redirects = [];
|
|
795
1420
|
let bodySubstitutions = [];
|
|
796
1421
|
let opaque = null;
|
|
1422
|
+
let heredoc = false;
|
|
1423
|
+
let stdinFed = false;
|
|
1424
|
+
let depth = 0;
|
|
797
1425
|
let segmentStart = 0;
|
|
798
1426
|
let index = 0;
|
|
799
1427
|
const pending = [];
|
|
@@ -805,12 +1433,19 @@ function lex(command) {
|
|
|
805
1433
|
redirects,
|
|
806
1434
|
bodySubstitutions,
|
|
807
1435
|
opaque,
|
|
1436
|
+
heredoc,
|
|
1437
|
+
stdinFed,
|
|
1438
|
+
subshell: depth !== 0,
|
|
808
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;
|
|
809
1443
|
}
|
|
810
1444
|
words = [];
|
|
811
1445
|
redirects = [];
|
|
812
1446
|
bodySubstitutions = [];
|
|
813
1447
|
opaque = null;
|
|
1448
|
+
heredoc = false;
|
|
814
1449
|
segmentStart = end;
|
|
815
1450
|
};
|
|
816
1451
|
/**
|
|
@@ -821,6 +1456,9 @@ function lex(command) {
|
|
|
821
1456
|
const readWord = () => {
|
|
822
1457
|
let text = "";
|
|
823
1458
|
let quoted = false;
|
|
1459
|
+
let splits = false;
|
|
1460
|
+
let expands = false;
|
|
1461
|
+
let exact = true;
|
|
824
1462
|
const substitutions = [];
|
|
825
1463
|
const start = index;
|
|
826
1464
|
for (; index < command.length; index += 1) {
|
|
@@ -832,6 +1470,9 @@ function lex(command) {
|
|
|
832
1470
|
if (ch === "\\") {
|
|
833
1471
|
const next = command[index + 1];
|
|
834
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;
|
|
835
1476
|
text += "\\";
|
|
836
1477
|
continue;
|
|
837
1478
|
}
|
|
@@ -858,6 +1499,8 @@ function lex(command) {
|
|
|
858
1499
|
substitutions.push(...scan.substitutions);
|
|
859
1500
|
if (scan.opaque !== null)
|
|
860
1501
|
opaque = scan.opaque;
|
|
1502
|
+
if (!scan.exact)
|
|
1503
|
+
exact = false;
|
|
861
1504
|
quoted = true;
|
|
862
1505
|
index = scan.end;
|
|
863
1506
|
continue;
|
|
@@ -886,9 +1529,28 @@ function lex(command) {
|
|
|
886
1529
|
if (scan === null)
|
|
887
1530
|
return null;
|
|
888
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;
|
|
889
1539
|
index = scan.end;
|
|
890
1540
|
continue;
|
|
891
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;
|
|
892
1554
|
text += ch;
|
|
893
1555
|
}
|
|
894
1556
|
// Nothing consumed means the caller was at a delimiter: no word here. A
|
|
@@ -896,7 +1558,8 @@ function lex(command) {
|
|
|
896
1558
|
// or a backtick substitution that only set the opaque flag).
|
|
897
1559
|
if (index === start)
|
|
898
1560
|
return null;
|
|
899
|
-
|
|
1561
|
+
const assignment = ASSIGNMENT.test(command.slice(start, index));
|
|
1562
|
+
return { text, quoted, substitutions, splits, expands, exact, assignment };
|
|
900
1563
|
};
|
|
901
1564
|
while (index < command.length) {
|
|
902
1565
|
const ch = command[index];
|
|
@@ -956,6 +1619,7 @@ function lex(command) {
|
|
|
956
1619
|
};
|
|
957
1620
|
}
|
|
958
1621
|
if (op === "<<" || op === "<<-") {
|
|
1622
|
+
heredoc = true;
|
|
959
1623
|
while (command[index] === " " || command[index] === "\t")
|
|
960
1624
|
index += 1;
|
|
961
1625
|
const delimiterStart = index;
|
|
@@ -984,6 +1648,14 @@ function lex(command) {
|
|
|
984
1648
|
continue;
|
|
985
1649
|
}
|
|
986
1650
|
if (op === ">&" || op === "<&") {
|
|
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;
|
|
987
1659
|
// `2>&1`, `>&2`, `3<&0`, `2>&-`: a descriptor dup or close, no path
|
|
988
1660
|
// involved. Anything else after `>&` is a FILE (APRV-508 refutation
|
|
989
1661
|
// M1): bash reads `>&word` as `>word 2>&1`, so `paste a >& APPROVAL.md`
|
|
@@ -995,7 +1667,11 @@ function lex(command) {
|
|
|
995
1667
|
while (command[index] === " " || command[index] === "\t")
|
|
996
1668
|
index += 1;
|
|
997
1669
|
const target = readWord();
|
|
998
|
-
|
|
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("") === "") {
|
|
999
1675
|
return { ok: false, detail: `redirection ${op} has no target` };
|
|
1000
1676
|
}
|
|
1001
1677
|
const dup = target.substitutions.length === 0 && (/^\d+-?$/u.test(target.text) || target.text === "-");
|
|
@@ -1019,10 +1695,30 @@ function lex(command) {
|
|
|
1019
1695
|
redirects.push({ op: op === ">>" ? ">>" : op === "<" ? "<" : ">", target });
|
|
1020
1696
|
continue;
|
|
1021
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
|
+
}
|
|
1022
1709
|
if (ch === "&" || ch === "|" || ch === ";" || ch === "(" || ch === ")") {
|
|
1023
1710
|
const boundary = index;
|
|
1711
|
+
// APRV-510: a single `|` (or `|&`) pipes the previous segment into the next.
|
|
1712
|
+
const pipe = ch === "|" && command[index + 1] !== "|";
|
|
1024
1713
|
index += ch === "&" && command[index + 1] === "&" ? 2 : ch === "|" && command[index + 1] === "|" ? 2 : 1;
|
|
1025
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;
|
|
1026
1722
|
segmentStart = index;
|
|
1027
1723
|
continue;
|
|
1028
1724
|
}
|
|
@@ -1069,15 +1765,21 @@ function readDoubleQuoted(command, start) {
|
|
|
1069
1765
|
let text = "";
|
|
1070
1766
|
const substitutions = [];
|
|
1071
1767
|
let opaque = null;
|
|
1768
|
+
// APRV-510, see `LexWord.exact`.
|
|
1769
|
+
let exact = true;
|
|
1072
1770
|
let index = start + 1;
|
|
1073
1771
|
for (; index < command.length; index += 1) {
|
|
1074
1772
|
const ch = command[index];
|
|
1075
1773
|
if (ch === '"')
|
|
1076
|
-
return { text, end: index, substitutions, opaque };
|
|
1774
|
+
return { text, end: index, substitutions, opaque, exact };
|
|
1077
1775
|
if (ch === "\\") {
|
|
1078
1776
|
const next = command[index + 1];
|
|
1079
1777
|
if (next === undefined)
|
|
1080
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;
|
|
1081
1783
|
if (next !== "\n")
|
|
1082
1784
|
text += next;
|
|
1083
1785
|
index += 1;
|
|
@@ -1104,13 +1806,28 @@ function readDoubleQuoted(command, start) {
|
|
|
1104
1806
|
if (scan === null)
|
|
1105
1807
|
return null;
|
|
1106
1808
|
substitutions.push(scan.inner);
|
|
1809
|
+
text += SUBSTITUTION_SENTINEL; // APRV-511 refuter M1, as in `readWord`.
|
|
1107
1810
|
index = scan.end;
|
|
1108
1811
|
continue;
|
|
1109
1812
|
}
|
|
1813
|
+
if (ch === "$")
|
|
1814
|
+
exact = false;
|
|
1110
1815
|
text += ch;
|
|
1111
1816
|
}
|
|
1112
1817
|
return null;
|
|
1113
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"]);
|
|
1114
1831
|
/**
|
|
1115
1832
|
* Scan `(…)` starting at the opening paren, honouring nesting and quotes, and
|
|
1116
1833
|
* return the inner text plus the index of the closing paren.
|
|
@@ -1299,15 +2016,33 @@ function deletedRef(refspec) {
|
|
|
1299
2016
|
return destination;
|
|
1300
2017
|
return null;
|
|
1301
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
|
+
];
|
|
1302
2042
|
/** `git push` — force, release, deletion, trunk and branch turn on flags and refspecs. */
|
|
1303
|
-
function
|
|
1304
|
-
const
|
|
1305
|
-
if (
|
|
1306
|
-
return { class: "vcs.history.rewrite", rule: "git-push-force" };
|
|
1307
|
-
}
|
|
1308
|
-
const positionals = args.filter((arg) => !isFlag(arg));
|
|
1309
|
-
const refspecs = positionals.slice(1);
|
|
1310
|
-
if (refspecs.some((refspec) => refspec.startsWith("+"))) {
|
|
2043
|
+
function pushFactsClass(facts) {
|
|
2044
|
+
const { refspecs } = facts;
|
|
2045
|
+
if (facts.force || refspecs.some((refspec) => refspec.startsWith("+"))) {
|
|
1311
2046
|
return { class: "vcs.history.rewrite", rule: "git-push-force" };
|
|
1312
2047
|
}
|
|
1313
2048
|
// The tag check stays ABOVE the deletion check, deliberately. A tag is the
|
|
@@ -1317,7 +2052,7 @@ function refineGitPush(ctx) {
|
|
|
1317
2052
|
// `git push origin :refs/tags/v1.2.3` and a bulk form that mixes a tag in,
|
|
1318
2053
|
// and APRV-352 asks for a class for branch deletions, not a loosening of the
|
|
1319
2054
|
// tag surface.
|
|
1320
|
-
if (
|
|
2055
|
+
if (facts.tags ||
|
|
1321
2056
|
refspecs.some(isTagRefspec) ||
|
|
1322
2057
|
refspecs.some((word, index) => word === "tag" && index + 1 < refspecs.length)) {
|
|
1323
2058
|
return { class: "release.publish", rule: "git-push-tag" };
|
|
@@ -1327,13 +2062,19 @@ function refineGitPush(ctx) {
|
|
|
1327
2062
|
// class with nothing bound rather than falling through to a push class: an
|
|
1328
2063
|
// invocation whose targets cannot be read is the one that least deserves the
|
|
1329
2064
|
// looser answer.
|
|
1330
|
-
if (
|
|
2065
|
+
if (facts.remove) {
|
|
1331
2066
|
return {
|
|
1332
2067
|
class: REF_DELETE_CLASS,
|
|
1333
2068
|
rule: "git-ref-delete",
|
|
1334
2069
|
...(refspecs.length === 0 ? {} : { path: refspecs.join(" ") }),
|
|
1335
2070
|
};
|
|
1336
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
|
+
}
|
|
1337
2078
|
if (refspecs.length === 0) {
|
|
1338
2079
|
return { class: "vcs.push.main", rule: "git-push-implicit" };
|
|
1339
2080
|
}
|
|
@@ -1345,7 +2086,7 @@ function refineGitPush(ctx) {
|
|
|
1345
2086
|
if (deleted.length > 0) {
|
|
1346
2087
|
return { class: REF_DELETE_CLASS, rule: "git-ref-delete", path: deleted.join(" ") };
|
|
1347
2088
|
}
|
|
1348
|
-
let sawMain =
|
|
2089
|
+
let sawMain = facts.all;
|
|
1349
2090
|
for (const refspec of refspecs) {
|
|
1350
2091
|
const colon = refspec.indexOf(":");
|
|
1351
2092
|
const destination = colon === -1 ? refspec : refspec.slice(colon + 1);
|
|
@@ -1361,6 +2102,46 @@ function refineGitPush(ctx) {
|
|
|
1361
2102
|
? { class: "vcs.push.main", rule: "git-push-main" }
|
|
1362
2103
|
: { class: "vcs.push.branch", rule: "git-push-branch" };
|
|
1363
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
|
+
}
|
|
1364
2145
|
/**
|
|
1365
2146
|
* The class of a delete that only removes the agent's own scratch (APRV-267).
|
|
1366
2147
|
*
|
|
@@ -1560,8 +2341,121 @@ function refineHarness(ctx) {
|
|
|
1560
2341
|
if (name === undefined) {
|
|
1561
2342
|
return { opaque: `${ctx.bin} is an agent harness this table cannot name` };
|
|
1562
2343
|
}
|
|
2344
|
+
if (name === "hermes") {
|
|
2345
|
+
const organWrite = hermesOrganWrite(ctx.args);
|
|
2346
|
+
if (organWrite !== null)
|
|
2347
|
+
return organWrite;
|
|
2348
|
+
}
|
|
1563
2349
|
return harnessRefinement(name, ctx.args);
|
|
1564
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
|
+
}
|
|
1565
2459
|
/**
|
|
1566
2460
|
* `npx`, `tsx`, `tsc`, … — unchanged, except a package runner naming a harness.
|
|
1567
2461
|
*
|
|
@@ -1589,7 +2483,8 @@ const HARNESS_RULES = Object.entries(HARNESS_BINS).map(([bin, name]) => ({
|
|
|
1589
2483
|
id: `${HARNESS_LAUNCH_RULE_PREFIX}${name}`,
|
|
1590
2484
|
bins: [bin],
|
|
1591
2485
|
class: `${HARNESS_LAUNCH_PREFIX}${name}`,
|
|
1592
|
-
|
|
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],
|
|
1593
2488
|
refine: refineHarness,
|
|
1594
2489
|
}));
|
|
1595
2490
|
/**
|
|
@@ -1672,6 +2567,24 @@ function escapedReadTarget(bin, positionals, args, roots) {
|
|
|
1672
2567
|
}
|
|
1673
2568
|
return null;
|
|
1674
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
|
+
}
|
|
1675
2588
|
/** `rm` — everything outside the workspace, and every unreadable path, is manual. */
|
|
1676
2589
|
function refineRm(ctx) {
|
|
1677
2590
|
const recursive = hasFlag(ctx.args, ["--recursive"]) || hasShortFlag(ctx.args, ["r", "R"]);
|
|
@@ -1699,6 +2612,340 @@ function refineRm(ctx) {
|
|
|
1699
2612
|
}
|
|
1700
2613
|
return { class: "files.write.workspace", rule: "rm-workspace" };
|
|
1701
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
|
+
}
|
|
1702
2949
|
/** `git commit --amend` rewrites; a plain commit does not. */
|
|
1703
2950
|
function refineGitCommit(ctx) {
|
|
1704
2951
|
return hasFlag(ctx.args, ["--amend"])
|
|
@@ -2770,6 +4017,210 @@ function refineSkillScript(ctx) {
|
|
|
2770
4017
|
? { class: SKILL_SCRIPT_CLASS, rule: SKILL_SCRIPT_RULE }
|
|
2771
4018
|
: { class: SKILL_SCRIPT_CLASS, rule: SKILL_SCRIPT_RULE, path: script };
|
|
2772
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;
|
|
4130
|
+
return null;
|
|
4131
|
+
}
|
|
4132
|
+
return null;
|
|
4133
|
+
}
|
|
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] };
|
|
4223
|
+
}
|
|
2773
4224
|
/**
|
|
2774
4225
|
* `node` — an inline script is opaque, the gate's own entry point is
|
|
2775
4226
|
* pass-through, a skill script is `exec.local` (APRV-502), and anything else
|
|
@@ -2986,6 +4437,19 @@ export const COMMAND_RULES = [
|
|
|
2986
4437
|
// one that has to be refined rather than matched.
|
|
2987
4438
|
...HARNESS_RULES,
|
|
2988
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
|
+
},
|
|
2989
4453
|
// APRV-193: three of the rules below hand control to code the runtime did not
|
|
2990
4454
|
// author, and they are named in {@link CODE_EXECUTING_RULES}.
|
|
2991
4455
|
{
|
|
@@ -3368,7 +4832,16 @@ const INLINE_SCRIPT_FLAG = /^-[li]*c$/u;
|
|
|
3368
4832
|
* - a substitution in any of the three words, whose effect happens before the
|
|
3369
4833
|
* shell even starts;
|
|
3370
4834
|
* - a nested shell inside the script, refused by {@link ClassifierContext} when
|
|
3371
|
-
* 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.
|
|
3372
4845
|
*
|
|
3373
4846
|
* The BINDING does not move. `cli/hook.ts` binds the outer command and argv
|
|
3374
4847
|
* exactly as APRV-362 built them; what this changes is only which text is
|
|
@@ -3389,6 +4862,12 @@ export function loginShellScript(segment) {
|
|
|
3389
4862
|
return null;
|
|
3390
4863
|
if (script.substitutions.length > 0)
|
|
3391
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;
|
|
3392
4871
|
const base = pathSegments(shell.text).slice(-1)[0] ?? shell.text;
|
|
3393
4872
|
if (!UNWRAPPABLE_SHELLS.has(base))
|
|
3394
4873
|
return null;
|
|
@@ -3409,8 +4888,54 @@ const INLINE_SOURCE_BINS = {
|
|
|
3409
4888
|
// ===========================================================================
|
|
3410
4889
|
// Classification
|
|
3411
4890
|
// ===========================================================================
|
|
3412
|
-
/**
|
|
3413
|
-
|
|
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
|
+
}
|
|
3414
4939
|
/**
|
|
3415
4940
|
* Redirection targets that create no file (APRV-283).
|
|
3416
4941
|
*
|
|
@@ -3490,8 +5015,14 @@ function outranksCredential(surface) {
|
|
|
3490
5015
|
* `tee .approval/env` is). On equal rank the positional is the one named.
|
|
3491
5016
|
*/
|
|
3492
5017
|
function outrankingProtected(positionals, readTargets, writesOnly, protectedPaths) {
|
|
3493
|
-
|
|
3494
|
-
|
|
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);
|
|
3495
5026
|
const found = fed === null || (named !== null && protectedRank(named.surface) <= protectedRank(fed.surface))
|
|
3496
5027
|
? named
|
|
3497
5028
|
: fed;
|
|
@@ -3561,6 +5092,444 @@ function proseNamingProtectedPath(word, surface, protectedPaths) {
|
|
|
3561
5092
|
const head = segments.slice(0, firstSpaced).join("/");
|
|
3562
5093
|
return protectedPathClass(head, protectedPaths) === surface;
|
|
3563
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
|
+
}
|
|
3564
5533
|
/**
|
|
3565
5534
|
* The strictest protected surface named by these words, with the word itself.
|
|
3566
5535
|
*
|
|
@@ -3621,6 +5590,12 @@ export const CODE_EXECUTING_RULES = [
|
|
|
3621
5590
|
*/
|
|
3622
5591
|
SKILL_SCRIPT_RULE,
|
|
3623
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,
|
|
3624
5599
|
/** `npx`, `tsx`, `tsc`, `vitest`, `jest`, `make`, and kin. */
|
|
3625
5600
|
"workspace-tool",
|
|
3626
5601
|
/**
|
|
@@ -3835,21 +5810,36 @@ function sandboxWrapper(words, start) {
|
|
|
3835
5810
|
* goes? (APRV-508 refutation S1; `HOME` and `CDPATH` from the recheck.)
|
|
3836
5811
|
*/
|
|
3837
5812
|
function isSteeringAssignment(word) {
|
|
3838
|
-
const name =
|
|
5813
|
+
const name = assignedName(word);
|
|
3839
5814
|
if (["TZ", "TZDIR", "PATH", "LOCPATH", "GCONV_PATH", "LANG", "LANGUAGE", "HOME", "CDPATH"].includes(name)) {
|
|
3840
5815
|
return true;
|
|
3841
5816
|
}
|
|
3842
5817
|
return name.startsWith("LC_") || name.startsWith("LD_");
|
|
3843
5818
|
}
|
|
3844
|
-
/**
|
|
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
|
+
*/
|
|
3845
5824
|
const SYSTEM_BIN_DIRS = ["/bin/", "/usr/bin/", "/usr/local/bin/", "/sbin/", "/usr/sbin/"];
|
|
3846
5825
|
/**
|
|
3847
5826
|
* Is this command word the system's binary rather than a file the agent can
|
|
3848
5827
|
* place? A bare name is looked up on PATH; a path in a system directory names
|
|
3849
5828
|
* that file. `./date` or `/tmp/x/date` is whatever program sits there, and a
|
|
3850
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).
|
|
3851
5839
|
*/
|
|
3852
|
-
function isSystemBinWord(word) {
|
|
5840
|
+
function isSystemBinWord(word, exact = true) {
|
|
5841
|
+
if (!exact || word.includes("\\"))
|
|
5842
|
+
return false;
|
|
3853
5843
|
if (!word.includes("/"))
|
|
3854
5844
|
return true;
|
|
3855
5845
|
if (word.includes("..") || word.includes("//"))
|
|
@@ -3873,13 +5863,24 @@ function isSystemBinWord(word) {
|
|
|
3873
5863
|
* handed the assignment words so it can admit the harmless ones (`TZ=Zone`)
|
|
3874
5864
|
* and decline the rest; a substituted word declines it outright.
|
|
3875
5865
|
*/
|
|
3876
|
-
function matchRule(bin, sub, args, substituted = false, assigned = false, assignments = [], binWord = bin
|
|
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.
|
|
5870
|
+
*/
|
|
5871
|
+
execShaped = false,
|
|
5872
|
+
/** Is `binWord` the bytes bash runs ({@link LexWord.exact})? (APRV-523 fix round 2, review-b N6.) */
|
|
5873
|
+
binExact = true) {
|
|
3877
5874
|
for (const rule of COMMAND_RULES) {
|
|
3878
5875
|
if (!rule.bins.includes(bin))
|
|
3879
5876
|
continue;
|
|
3880
5877
|
if (rule.probe === true && !isVersionProbe(args))
|
|
3881
5878
|
continue;
|
|
3882
|
-
if (rule.shape !== undefined &&
|
|
5879
|
+
if (rule.shape !== undefined &&
|
|
5880
|
+
(substituted || !isSystemBinWord(binWord, binExact) || !rule.shape(args, assignments))) {
|
|
5881
|
+
continue;
|
|
5882
|
+
}
|
|
5883
|
+
if (rule.execCode === true && !execShaped)
|
|
3883
5884
|
continue;
|
|
3884
5885
|
// APRV-502: see the doc comment above; any of the three declines the row.
|
|
3885
5886
|
if (rule.skillScript === true &&
|
|
@@ -3911,8 +5912,14 @@ function classifySegment(segment, protectedPaths, context) {
|
|
|
3911
5912
|
...segment.words,
|
|
3912
5913
|
...segment.redirects.map((redirect) => redirect.target),
|
|
3913
5914
|
// APRV-504: the `$(…)` of an unquoted heredoc body run when it is read.
|
|
3914
|
-
{ text: "", quoted: false, substitutions: segment.bodySubstitutions },
|
|
5915
|
+
{ text: "", quoted: false, substitutions: segment.bodySubstitutions, splits: false, expands: false },
|
|
3915
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;
|
|
3916
5923
|
for (const word of expanded) {
|
|
3917
5924
|
for (const inner of word.substitutions) {
|
|
3918
5925
|
const nested = classifyCommand(inner, protectedPaths, context);
|
|
@@ -3931,6 +5938,12 @@ function classifySegment(segment, protectedPaths, context) {
|
|
|
3931
5938
|
detail: `command substitution $(${inner}) is ${effectful.join(", ")}; only read.* substitutions run unattended`,
|
|
3932
5939
|
};
|
|
3933
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
|
+
}
|
|
3934
5947
|
}
|
|
3935
5948
|
}
|
|
3936
5949
|
const words = segment.words.map((word) => word.text);
|
|
@@ -3941,6 +5954,9 @@ function classifySegment(segment, protectedPaths, context) {
|
|
|
3941
5954
|
const assigned = cursor > 0;
|
|
3942
5955
|
// APRV-508: the same words, for a `shape` row that admits some.
|
|
3943
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];
|
|
3944
5960
|
// APRV-193. A sandbox wrapper is not a command: it is a room, and what
|
|
3945
5961
|
// matters is what runs inside it. `approval sandbox -- npm install` is
|
|
3946
5962
|
// `deps.add`, and it has to be, in both directions.
|
|
@@ -3999,7 +6015,7 @@ function classifySegment(segment, protectedPaths, context) {
|
|
|
3999
6015
|
const writer = bin === undefined ? null : (pathSegments(bin).slice(-1)[0] ?? bin);
|
|
4000
6016
|
const touched = (writer === null
|
|
4001
6017
|
? null
|
|
4002
|
-
: credentialTouch(writer, writerArgs
|
|
6018
|
+
: credentialTouch(writer, writerArgs)) ??
|
|
4003
6019
|
redirectCredentialTouch(readTargets, redirectTargets);
|
|
4004
6020
|
return {
|
|
4005
6021
|
ok: true,
|
|
@@ -4009,6 +6025,46 @@ function classifySegment(segment, protectedPaths, context) {
|
|
|
4009
6025
|
...(touched === null ? {} : { also: touched }),
|
|
4010
6026
|
};
|
|
4011
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`,
|
|
6066
|
+
};
|
|
6067
|
+
}
|
|
4012
6068
|
if (bin === undefined) {
|
|
4013
6069
|
// A bare input redirection opens its target, and inside `$(…)` it IS the
|
|
4014
6070
|
// read: `$(< ~/.hermes/.env)` is bash for "the file's contents" (APRV-503).
|
|
@@ -4026,19 +6082,35 @@ function classifySegment(segment, protectedPaths, context) {
|
|
|
4026
6082
|
// variables that pick which program runs, which libraries it loads, or
|
|
4027
6083
|
// which file it reads as a zone, a locale or a charset converter are not
|
|
4028
6084
|
// answered as reads.
|
|
4029
|
-
|
|
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;
|
|
4030
6092
|
if (steering !== undefined) {
|
|
4031
6093
|
return {
|
|
4032
6094
|
ok: false,
|
|
4033
6095
|
code: "unclassified",
|
|
4034
|
-
detail: `no rule for the assignment ${
|
|
6096
|
+
detail: `no rule for the assignment ${assignmentHead(steering)}…: it steers which program or file later commands use`,
|
|
4035
6097
|
};
|
|
4036
6098
|
}
|
|
4037
6099
|
// `VAR=value` alone, or a bare redirection. `> file` truncates, so it is a
|
|
4038
6100
|
// write; an assignment on its own touches nothing.
|
|
4039
|
-
|
|
4040
|
-
|
|
4041
|
-
|
|
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" };
|
|
4042
6114
|
}
|
|
4043
6115
|
const basename = pathSegments(bin).slice(-1)[0] ?? bin;
|
|
4044
6116
|
const args = words.slice(cursor + 1);
|
|
@@ -4054,11 +6126,60 @@ function classifySegment(segment, protectedPaths, context) {
|
|
|
4054
6126
|
if (opaqueReason !== undefined) {
|
|
4055
6127
|
return { ok: false, code: "opaque", detail: `${basename} ${opaqueReason}` };
|
|
4056
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;
|
|
4057
6150
|
const inlineFlags = INLINE_SOURCE_BINS[basename];
|
|
4058
|
-
if (inlineFlags !== undefined && hasFlag(args, inlineFlags)) {
|
|
6151
|
+
if (inlineFlags !== undefined && hasFlag(args, inlineFlags) && execShape === null) {
|
|
4059
6152
|
return { ok: false, code: "opaque", detail: `${basename} runs inline source` };
|
|
4060
6153
|
}
|
|
4061
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
|
+
}
|
|
4062
6183
|
// APRV-508 recheck S-R1: `printf -v NAME` assigns NAME, so `printf -v PATH
|
|
4063
6184
|
// %s /tmp/x; cat f` is the bare `PATH=/tmp/x` the assignment rule above
|
|
4064
6185
|
// refuses, spelled through a builtin. A steering NAME (a subscript stripped:
|
|
@@ -4122,12 +6243,42 @@ function classifySegment(segment, protectedPaths, context) {
|
|
|
4122
6243
|
// protected class, the credential touch is carried beside it as a second
|
|
4123
6244
|
// class of the command (`also`), never dropped. Write targets need no check
|
|
4124
6245
|
// here: a protected one has already answered above, carrying it the same way.
|
|
4125
|
-
|
|
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;
|
|
4126
6273
|
const redirectCredential = redirectCredentialTouch(readTargets, redirectTargets);
|
|
4127
6274
|
const outranking = credential === null && redirectCredential === null
|
|
4128
6275
|
? null
|
|
4129
|
-
: outrankingProtected(
|
|
4130
|
-
|
|
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) {
|
|
4131
6282
|
if (credential !== null)
|
|
4132
6283
|
return { ok: true, ...credential };
|
|
4133
6284
|
if (redirectCredential !== null)
|
|
@@ -4138,7 +6289,8 @@ function classifySegment(segment, protectedPaths, context) {
|
|
|
4138
6289
|
const substituted = segment.words
|
|
4139
6290
|
.slice(cursor + 1)
|
|
4140
6291
|
.some((word) => word.substitutions.length > 0);
|
|
4141
|
-
const
|
|
6292
|
+
const binExact = segment.words[cursor].exact;
|
|
6293
|
+
const rule = matchRule(basename, sub, args, substituted, assigned, assignments, bin, execShape !== null, binExact);
|
|
4142
6294
|
if (rule === null) {
|
|
4143
6295
|
return {
|
|
4144
6296
|
ok: false,
|
|
@@ -4148,7 +6300,7 @@ function classifySegment(segment, protectedPaths, context) {
|
|
|
4148
6300
|
: `no rule for ${basename} ${sub}`,
|
|
4149
6301
|
};
|
|
4150
6302
|
}
|
|
4151
|
-
const ctx = { bin: basename, args, positionals, sub, substituted, assigned, context };
|
|
6303
|
+
const ctx = { bin: basename, args, positionals, sub, substituted, assigned, assignments, context };
|
|
4152
6304
|
const refined = rule.refine === undefined ? null : rule.refine(ctx);
|
|
4153
6305
|
if (rule.refine !== undefined && refined === null) {
|
|
4154
6306
|
return { ok: false, code: "opaque", detail: `${basename} runs inline source` };
|
|
@@ -4164,6 +6316,42 @@ function classifySegment(segment, protectedPaths, context) {
|
|
|
4164
6316
|
// matched, verbatim, so an approver is told WHICH refs a deletion names
|
|
4165
6317
|
// rather than being handed a class and left to re-read the command.
|
|
4166
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
|
+
}
|
|
4167
6355
|
// A protected path anywhere in an effectful segment takes that path's class:
|
|
4168
6356
|
// the command is editing the gate, whatever else it is doing. Every
|
|
4169
6357
|
// positional is scanned, source and destination alike, so `cp` stays
|
|
@@ -4212,7 +6400,7 @@ function classifySegment(segment, protectedPaths, context) {
|
|
|
4212
6400
|
also: deferredCredential,
|
|
4213
6401
|
};
|
|
4214
6402
|
}
|
|
4215
|
-
const named = strictestProtected(
|
|
6403
|
+
const named = strictestProtected(judged, protectedPaths, true);
|
|
4216
6404
|
const fed = strictestProtected(readTargets, protectedPaths);
|
|
4217
6405
|
const touched = fed === null || (named !== null && protectedRank(named.surface) <= protectedRank(fed.surface))
|
|
4218
6406
|
? named
|
|
@@ -4242,7 +6430,12 @@ function classifySegment(segment, protectedPaths, context) {
|
|
|
4242
6430
|
// and symlinked cases are deliberately NOT decided here; they are left at
|
|
4243
6431
|
// `read.shell` for the hook's disk pass, which tightens and never loosens.
|
|
4244
6432
|
if (cls === "read.shell" && (context.readRoots ?? []).length > 0) {
|
|
4245
|
-
|
|
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;
|
|
4246
6439
|
if (escaped !== null) {
|
|
4247
6440
|
return {
|
|
4248
6441
|
ok: true,
|
|
@@ -4253,12 +6446,25 @@ function classifySegment(segment, protectedPaths, context) {
|
|
|
4253
6446
|
};
|
|
4254
6447
|
}
|
|
4255
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;
|
|
4256
6461
|
return {
|
|
4257
6462
|
ok: true,
|
|
4258
6463
|
class: cls,
|
|
4259
6464
|
rule: ruleId,
|
|
4260
6465
|
...(boundPath === undefined ? {} : { path: boundPath }),
|
|
4261
6466
|
...(sandbox === null ? {} : { sandbox }),
|
|
6467
|
+
...(execCode === null ? {} : { execCode }),
|
|
4262
6468
|
};
|
|
4263
6469
|
}
|
|
4264
6470
|
/**
|
|
@@ -4293,9 +6499,21 @@ export function classifyCommand(command, protectedPaths = [], context = {}) {
|
|
|
4293
6499
|
if (lexed.segments.length === 0) {
|
|
4294
6500
|
return { ok: false, code: "unclassified", segment: command.trim(), detail: "empty command" };
|
|
4295
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 };
|
|
4296
6507
|
const segments = [];
|
|
4297
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;
|
|
4298
6513
|
for (const segment of lexed.segments) {
|
|
6514
|
+
if (execContext.execCode === true && movesNameResolution(segment)) {
|
|
6515
|
+
execContext = { ...execContext, execCode: false };
|
|
6516
|
+
}
|
|
4299
6517
|
// APRV-380. A LOGIN-SHELL WRAPPER is classified by the script it runs.
|
|
4300
6518
|
// `loginShellScript` states the exact shape and why this is not the second
|
|
4301
6519
|
// parser `OPAQUE_BINS` refuses; the inner classification is run with
|
|
@@ -4303,7 +6521,14 @@ export function classifyCommand(command, protectedPaths = [], context = {}) {
|
|
|
4303
6521
|
// and the recursion is one level deep by construction.
|
|
4304
6522
|
const script = context.unwrapShell === false ? null : loginShellScript(segment);
|
|
4305
6523
|
if (script !== null) {
|
|
4306
|
-
|
|
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
|
+
});
|
|
4307
6532
|
if (!inner.ok) {
|
|
4308
6533
|
// The refusal is the INNER one, reported against the inner segment: an
|
|
4309
6534
|
// operator told `hook-opaque` for `zsh` learns nothing, and one told
|
|
@@ -4320,7 +6545,7 @@ export function classifyCommand(command, protectedPaths = [], context = {}) {
|
|
|
4320
6545
|
}
|
|
4321
6546
|
continue;
|
|
4322
6547
|
}
|
|
4323
|
-
const outcome = classifySegment(segment, protectedPaths,
|
|
6548
|
+
const outcome = classifySegment(segment, protectedPaths, execContext);
|
|
4324
6549
|
if (!outcome.ok) {
|
|
4325
6550
|
return { ok: false, code: outcome.code, segment: segment.text, detail: outcome.detail };
|
|
4326
6551
|
}
|
|
@@ -4330,6 +6555,7 @@ export function classifyCommand(command, protectedPaths = [], context = {}) {
|
|
|
4330
6555
|
rule: outcome.rule,
|
|
4331
6556
|
...(outcome.path === undefined ? {} : { path: outcome.path }),
|
|
4332
6557
|
...(outcome.sandbox === undefined ? {} : { sandbox: outcome.sandbox }),
|
|
6558
|
+
...(outcome.execCode === undefined ? {} : { execCode: outcome.execCode }),
|
|
4333
6559
|
});
|
|
4334
6560
|
if (!classes.includes(outcome.class))
|
|
4335
6561
|
classes.push(outcome.class);
|
|
@@ -4379,19 +6605,33 @@ export function commandSegmentWriteTargets(command) {
|
|
|
4379
6605
|
.map((redirect) => redirect.target.text)
|
|
4380
6606
|
.filter((target) => !isDiscardTarget(target)));
|
|
4381
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
|
+
}
|
|
4382
6622
|
export function commandSegmentShapes(command) {
|
|
4383
6623
|
const lexed = lex(command);
|
|
4384
6624
|
if (!lexed.ok)
|
|
4385
6625
|
return null;
|
|
4386
6626
|
return lexed.segments.map((segment) => {
|
|
4387
6627
|
const words = segment.words.map((word) => word.text);
|
|
4388
|
-
|
|
4389
|
-
|
|
4390
|
-
cursor += 1;
|
|
6628
|
+
const cursor = bashAssignmentCount(segment.words);
|
|
6629
|
+
const script = loginShellScript(segment);
|
|
4391
6630
|
return {
|
|
4392
6631
|
text: segment.text,
|
|
4393
6632
|
bin: words[cursor] ?? null,
|
|
4394
6633
|
args: words.slice(cursor + 1),
|
|
6634
|
+
argFlags: segment.words.slice(cursor + 1).map((word) => ({ splits: word.splits, expands: word.expands })),
|
|
4395
6635
|
writes: segment.redirects
|
|
4396
6636
|
.filter((redirect) => redirect.op !== "<")
|
|
4397
6637
|
.map((redirect) => redirect.target.text)
|
|
@@ -4399,9 +6639,31 @@ export function commandSegmentShapes(command) {
|
|
|
4399
6639
|
reads: segment.redirects
|
|
4400
6640
|
.filter((redirect) => redirect.op === "<")
|
|
4401
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)),
|
|
4402
6651
|
};
|
|
4403
6652
|
});
|
|
4404
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
|
+
}
|
|
4405
6667
|
export function commandSegmentWords(command) {
|
|
4406
6668
|
const lexed = lex(command);
|
|
4407
6669
|
if (!lexed.ok)
|
|
@@ -4409,9 +6671,7 @@ export function commandSegmentWords(command) {
|
|
|
4409
6671
|
const out = [];
|
|
4410
6672
|
for (const segment of lexed.segments) {
|
|
4411
6673
|
const words = segment.words.map((word) => word.text);
|
|
4412
|
-
|
|
4413
|
-
while (cursor < words.length && ASSIGNMENT.test(words[cursor]))
|
|
4414
|
-
cursor += 1;
|
|
6674
|
+
const cursor = bashAssignmentCount(segment.words);
|
|
4415
6675
|
const bin = words[cursor];
|
|
4416
6676
|
if (bin === undefined)
|
|
4417
6677
|
continue;
|