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.
Files changed (83) hide show
  1. package/SPEC.md +24 -3
  2. package/dist/src/channels/contract.d.ts +15 -0
  3. package/dist/src/channels/contract.js +23 -1
  4. package/dist/src/channels/contract.js.map +1 -1
  5. package/dist/src/channels/telegram.d.ts +180 -33
  6. package/dist/src/channels/telegram.js +256 -87
  7. package/dist/src/channels/telegram.js.map +1 -1
  8. package/dist/src/cli/channel-telegram.d.ts +114 -35
  9. package/dist/src/cli/channel-telegram.js +259 -120
  10. package/dist/src/cli/channel-telegram.js.map +1 -1
  11. package/dist/src/cli/doctor.js +33 -13
  12. package/dist/src/cli/doctor.js.map +1 -1
  13. package/dist/src/cli/help.d.ts +2 -0
  14. package/dist/src/cli/help.js +39 -3
  15. package/dist/src/cli/help.js.map +1 -1
  16. package/dist/src/cli/hook-exec-code.d.ts +150 -0
  17. package/dist/src/cli/hook-exec-code.js +357 -0
  18. package/dist/src/cli/hook-exec-code.js.map +1 -0
  19. package/dist/src/cli/hook.d.ts +154 -1
  20. package/dist/src/cli/hook.js +2344 -111
  21. package/dist/src/cli/hook.js.map +1 -1
  22. package/dist/src/cli/instructions.js +9 -6
  23. package/dist/src/cli/instructions.js.map +1 -1
  24. package/dist/src/cli/main.js +9 -0
  25. package/dist/src/cli/main.js.map +1 -1
  26. package/dist/src/cli/study.d.ts +22 -0
  27. package/dist/src/cli/study.js +153 -0
  28. package/dist/src/cli/study.js.map +1 -0
  29. package/dist/src/cli/verb-registry.d.ts +14 -0
  30. package/dist/src/cli/verb-registry.js +37 -0
  31. package/dist/src/cli/verb-registry.js.map +1 -1
  32. package/dist/src/cli/wordmark.d.ts +2 -2
  33. package/dist/src/cli/wordmark.js +2 -2
  34. package/dist/src/core/command-class.d.ts +349 -1
  35. package/dist/src/core/command-class.js +2347 -87
  36. package/dist/src/core/command-class.js.map +1 -1
  37. package/dist/src/core/exec-code.d.ts +77 -0
  38. package/dist/src/core/exec-code.js +60 -0
  39. package/dist/src/core/exec-code.js.map +1 -0
  40. package/dist/src/core/gate.d.ts +38 -0
  41. package/dist/src/core/gate.js +26 -0
  42. package/dist/src/core/gate.js.map +1 -1
  43. package/dist/src/core/git-push-argv.d.ts +111 -0
  44. package/dist/src/core/git-push-argv.js +375 -0
  45. package/dist/src/core/git-push-argv.js.map +1 -0
  46. package/dist/src/core/harness-version.d.ts +71 -30
  47. package/dist/src/core/harness-version.js +101 -34
  48. package/dist/src/core/harness-version.js.map +1 -1
  49. package/dist/src/core/policy-load.d.ts +8 -0
  50. package/dist/src/core/policy-load.js.map +1 -1
  51. package/dist/src/core/sender-identity.d.ts +13 -1
  52. package/dist/src/core/sender-identity.js +15 -0
  53. package/dist/src/core/sender-identity.js.map +1 -1
  54. package/dist/src/core/study-withdraw.d.ts +137 -0
  55. package/dist/src/core/study-withdraw.js +280 -0
  56. package/dist/src/core/study-withdraw.js.map +1 -0
  57. package/dist/src/daemon/git-evidence.d.ts +2 -2
  58. package/dist/src/daemon/git-evidence.js +1 -1
  59. package/dist/src/mcp/server.js +1 -1
  60. package/dist/src/mcp/server.js.map +1 -1
  61. package/docs/README-extended.md +3 -2
  62. package/docs/cli-reference.md +83 -7
  63. package/package.json +2 -2
  64. package/schema/event.schema.json +213 -1
  65. package/schema/fixtures/event/invalid/approval-granted-study.json +23 -0
  66. package/schema/fixtures/event/invalid/approval-rejected-looks-wrong-cli-channel.json +23 -0
  67. package/schema/fixtures/event/invalid/approval-rejected-looks-wrong-operator.json +24 -0
  68. package/schema/fixtures/event/invalid/approval-rejected-study-extra-key.json +24 -0
  69. package/schema/fixtures/event/invalid/approval-rejected-study-unknown-reason.json +23 -0
  70. package/schema/fixtures/event/invalid/approval-rejected-study-withdrawn-email-operator.json +20 -0
  71. package/schema/fixtures/event/invalid/approval-rejected-study-withdrawn-no-operator.json +19 -0
  72. package/schema/fixtures/event/invalid/approval-rejected-study-withdrawn-no-ref.json +19 -0
  73. package/schema/fixtures/event/invalid/approval-rejected-study-withdrawn-wrong-actor.json +20 -0
  74. package/schema/fixtures/event/invalid/approval-rejected-study-withdrawn-wrong-channel.json +20 -0
  75. package/schema/fixtures/event/invalid/approval-revoked-study.json +20 -0
  76. package/schema/fixtures/event/valid/approval-rejected-looks-wrong.json +23 -0
  77. package/schema/fixtures/event/valid/approval-rejected-study-withdrawn.json +20 -0
  78. package/schema/fixtures/policy/invalid/exec-code-custody-boolean.json +11 -0
  79. package/schema/fixtures/policy/invalid/exec-code-execute-code-value.json +11 -0
  80. package/schema/fixtures/policy/invalid/exec-code-unknown-key.json +12 -0
  81. package/schema/fixtures/policy/valid/exec-code-both.json +17 -0
  82. package/schema/fixtures/policy/valid/exec-code-custody.json +11 -0
  83. 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
- const segments = pathSegments(candidate);
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
- if (segment === ".hermes") {
352
- // Recheck 3 (lows): a profile home, `.hermes/profiles/<p>/`, is a home of
353
- // its own with the same organs, so its paths are judged one level down.
354
- const next = segments[index + 1] === "profiles" && segments[index + 2] !== undefined
355
- ? segments[index + 3]
356
- : segments[index + 1];
357
- if (next === undefined ||
358
- next === "config.yaml" ||
359
- next === "config.yml" ||
360
- next === "agent-hooks" ||
361
- next === "approval" ||
362
- next === ".env" ||
363
- next.startsWith(".env.") ||
364
- next === "auth.json" ||
365
- next.startsWith("shell-hooks-allowlist.json") ||
366
- next.startsWith("hooks")) {
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
- const segments = pathSegments(candidate);
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
- if (segment === ".hermes") {
533
- const next = segments[index + 1] === "profiles" && segments[index + 2] !== undefined
534
- ? segments[index + 3]
535
- : segments[index + 1];
536
- if (next === ".env" || next?.startsWith(".env.") === true)
537
- return true;
538
- if (next === "auth.json")
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
- return false;
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
- return CREDENTIAL_WRITE_BINS.includes(basename) || inPlaceSed;
689
- }
690
- function credentialTouch(basename, args, positionals) {
691
- const writesOnly = credentialWritesOnly(basename, args);
692
- if (!writesOnly) {
693
- const named = positionals.find((arg) => isCredentialPath(arg));
694
- if (named !== undefined) {
695
- return { class: CREDENTIAL_CLASS, rule: "credential-path", path: named };
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
- return { text, quoted, substitutions };
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
- if (target === null || target.text === "") {
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 refineGitPush(ctx) {
1304
- const args = ctx.args.slice(1);
1305
- if (hasFlag(args, ["--force", "-f", "--force-with-lease", "--force-if-includes", "--mirror"])) {
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 (hasFlag(args, ["--tags", "--follow-tags"]) ||
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 (hasFlag(args, ["--delete", "-d"])) {
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 = false;
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
- emits: [HARNESS_PROBE_CLASS],
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
- /** `VAR=value` prefixes, which are not the command. */
3413
- const ASSIGNMENT = /^[A-Za-z_][A-Za-z0-9_]*=/u;
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
- const named = strictestProtected(writesOnly ? positionals : positionals.filter((arg) => !isCredentialPath(arg)), protectedPaths, true);
3494
- const fed = strictestProtected(readTargets.filter((target) => !isCredentialPath(target)), protectedPaths);
5018
+ // APRV-522: a credential word whose OWN protected class outranks
5019
+ // `policy.core` stays in the scan. Since `..` resolves in the credential
5020
+ // rule, a `..` spelling can name the environment map while its spelling also
5021
+ // names the log directory; the stricter of the two must not be dropped.
5022
+ const isTheRead = (word) => isCredentialPath(word) &&
5023
+ protectedRank(protectedPathClass(word, protectedPaths) ?? "policy.core") >= protectedRank("policy.core");
5024
+ const named = strictestProtected(writesOnly ? positionals : positionals.filter((arg) => !isTheRead(arg)), protectedPaths, true);
5025
+ const fed = strictestProtected(readTargets.filter((target) => !isTheRead(target)), protectedPaths);
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 = word.slice(0, word.indexOf("="));
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
- /** The directories a shape row's binary may be spelled from, besides a bare name. */
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 && (substituted || !isSystemBinWord(binWord) || !rule.shape(args, assignments)))
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, writerArgs.filter((arg) => !isFlag(arg)))) ??
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
- const steering = assignments.find((word) => isSteeringAssignment(word));
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 ${steering.slice(0, steering.indexOf("="))}=…: it steers which program or file later commands use`,
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
- return writeTargets.length > 0
4040
- ? { ok: true, class: "files.write.workspace", rule: "redirect-write" }
4041
- : { ok: true, class: "read.shell", rule: "assignment" };
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
- const credential = credentialTouch(basename, args, positionals);
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(positionals, readTargets, credentialWritesOnly(basename, args), protectedPaths);
4130
- if (outranking === null) {
6276
+ : outrankingProtected(judged, readTargets, credentialWritesOnly(basename, args) || credentialProtectedScanKeeps(basename), protectedPaths);
6277
+ // Merge of APRV-511 and APRV-522: APRV-522's credential read words include
6278
+ // an upload's option words, so a credential answer is deferred, never taken
6279
+ // here, while a download output outranks it (`policy.core`,
6280
+ // `fetch-output-unknown` answer below), as APRV-511 keeps the upload's own.
6281
+ if (outranking === null && !fetchOutranksCredential) {
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 rule = matchRule(basename, sub, args, substituted, assigned, assignments, bin);
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(positionals, protectedPaths, true);
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
- const escaped = escapedReadTarget(basename, positionals, args, context.readRoots ?? []);
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
- const inner = classifyCommand(script, protectedPaths, { ...context, unwrapShell: false });
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, context);
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
- let cursor = 0;
4389
- while (cursor < words.length && ASSIGNMENT.test(words[cursor]))
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
- let cursor = 0;
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;