approval-md 0.4.6 → 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 (57) hide show
  1. package/SPEC.md +11 -1
  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 +75 -2
  6. package/dist/src/channels/telegram.js +99 -6
  7. package/dist/src/channels/telegram.js.map +1 -1
  8. package/dist/src/cli/channel-telegram.d.ts +14 -0
  9. package/dist/src/cli/channel-telegram.js +44 -2
  10. package/dist/src/cli/channel-telegram.js.map +1 -1
  11. package/dist/src/cli/help.d.ts +2 -0
  12. package/dist/src/cli/help.js +36 -0
  13. package/dist/src/cli/help.js.map +1 -1
  14. package/dist/src/cli/hook.d.ts +29 -0
  15. package/dist/src/cli/hook.js +178 -15
  16. package/dist/src/cli/hook.js.map +1 -1
  17. package/dist/src/cli/instructions.js +9 -6
  18. package/dist/src/cli/instructions.js.map +1 -1
  19. package/dist/src/cli/main.js +9 -0
  20. package/dist/src/cli/main.js.map +1 -1
  21. package/dist/src/cli/study.d.ts +22 -0
  22. package/dist/src/cli/study.js +153 -0
  23. package/dist/src/cli/study.js.map +1 -0
  24. package/dist/src/cli/verb-registry.d.ts +14 -0
  25. package/dist/src/cli/verb-registry.js +37 -0
  26. package/dist/src/cli/verb-registry.js.map +1 -1
  27. package/dist/src/cli/wordmark.d.ts +2 -2
  28. package/dist/src/cli/wordmark.js +2 -2
  29. package/dist/src/core/gate.d.ts +38 -0
  30. package/dist/src/core/gate.js +26 -0
  31. package/dist/src/core/gate.js.map +1 -1
  32. package/dist/src/core/sender-identity.d.ts +13 -1
  33. package/dist/src/core/sender-identity.js +15 -0
  34. package/dist/src/core/sender-identity.js.map +1 -1
  35. package/dist/src/core/study-withdraw.d.ts +137 -0
  36. package/dist/src/core/study-withdraw.js +280 -0
  37. package/dist/src/core/study-withdraw.js.map +1 -0
  38. package/dist/src/daemon/git-evidence.d.ts +2 -2
  39. package/dist/src/daemon/git-evidence.js +1 -1
  40. package/dist/src/mcp/server.js +1 -1
  41. package/dist/src/mcp/server.js.map +1 -1
  42. package/docs/cli-reference.md +63 -0
  43. package/package.json +2 -2
  44. package/schema/event.schema.json +212 -0
  45. package/schema/fixtures/event/invalid/approval-granted-study.json +23 -0
  46. package/schema/fixtures/event/invalid/approval-rejected-looks-wrong-cli-channel.json +23 -0
  47. package/schema/fixtures/event/invalid/approval-rejected-looks-wrong-operator.json +24 -0
  48. package/schema/fixtures/event/invalid/approval-rejected-study-extra-key.json +24 -0
  49. package/schema/fixtures/event/invalid/approval-rejected-study-unknown-reason.json +23 -0
  50. package/schema/fixtures/event/invalid/approval-rejected-study-withdrawn-email-operator.json +20 -0
  51. package/schema/fixtures/event/invalid/approval-rejected-study-withdrawn-no-operator.json +19 -0
  52. package/schema/fixtures/event/invalid/approval-rejected-study-withdrawn-no-ref.json +19 -0
  53. package/schema/fixtures/event/invalid/approval-rejected-study-withdrawn-wrong-actor.json +20 -0
  54. package/schema/fixtures/event/invalid/approval-rejected-study-withdrawn-wrong-channel.json +20 -0
  55. package/schema/fixtures/event/invalid/approval-revoked-study.json +20 -0
  56. package/schema/fixtures/event/valid/approval-rejected-looks-wrong.json +23 -0
  57. package/schema/fixtures/event/valid/approval-rejected-study-withdrawn.json +20 -0
@@ -1281,6 +1281,35 @@ export declare const POST_TOOL_CODES: readonly [
1281
1281
  export type PostToolCode = (typeof POST_TOOL_CODES)[number];
1282
1282
  /** The environment variable that turns the sandbox requirement on (APRV-193). */
1283
1283
  export declare const REQUIRE_SANDBOX_ENV = "APPROVAL_HOOK_REQUIRE_SANDBOX";
1284
+ /**
1285
+ * The fixed remedy a `fetch-output-unknown` refusal carries when a `$`-first
1286
+ * word in curl's or wget's option slot caused it (0.4.7, EDGEOS-BLOCK).
1287
+ *
1288
+ * Core 0.4.6 (APRV-511) holds `curl "${BASE:-https://h}/x"`: the expansion
1289
+ * could be `-o <organ path>`, so the download's output is unknown, the class is
1290
+ * `policy.core`, and a policy that reserves that class denies outright. The
1291
+ * refusal said why and nothing about the shape that runs. The same word as
1292
+ * `--url`'s value sits in a value slot, where curl reads it as the URL whatever
1293
+ * it starts with, and classifies by its method. wget has no `--url`, so the
1294
+ * line names curl for both.
1295
+ *
1296
+ * Text only: the class, the rule and the code are unchanged, and nothing here
1297
+ * widens a verdict.
1298
+ */
1299
+ export declare const FETCH_DOLLAR_WORD_REMEDY = "Remedy: a word that starts with `$` stands where curl and wget read options, so its value could turn it into `-o <path>` and the download's output cannot be known. If that word is the URL, pass it as the value of `--url` (`curl --url \"$VAR/...\"`), which keeps the output known; wget has no `--url`, so use `curl --url` instead.";
1300
+ /**
1301
+ * {@link FETCH_DOLLAR_WORD_REMEDY}, prefixed with a space, when a segment that
1302
+ * earned `reserved` did so by rule `fetch-output-unknown` on a word that
1303
+ * starts with `$`; otherwise the empty string, so every other cause of the rule
1304
+ * (an unknown option, a config source, an ambiguous prefix, an unplaceable
1305
+ * output value) keeps its refusal byte for byte.
1306
+ *
1307
+ * `fetchOutputs` reports the first unknowable word verbatim, and only its
1308
+ * option-slot test reports a positional word; every other cause reports the
1309
+ * option as spelled, which starts with `-`. So a bound path starting with `$`
1310
+ * is exactly the `$`-first option-slot cause.
1311
+ */
1312
+ export declare function fetchOutputRemedy(segments: readonly ClassifiedSegment[] | undefined, reserved: string): string;
1284
1313
  /**
1285
1314
  * Must this command have been written `approval sandbox -- …`? (APRV-193.)
1286
1315
  *
@@ -2409,6 +2409,109 @@ const NON_OPENING_BINS = new Set([
2409
2409
  ]);
2410
2410
  /** Of those, the ones whose words reach a pipe's next command as text. */
2411
2411
  const PRINTING_BINS = new Set(["echo", "printf"]);
2412
+ /**
2413
+ * A first character the shell may still expand into something else (a
2414
+ * parameter, a substitution, a tilde, an escape, a glob or extended glob, a
2415
+ * brace list, zsh's `=cmd`, `^`, `<N-M>` and `(a|b)` patterns, a process
2416
+ * substitution), so the value curl finally receives may open with `@`. A
2417
+ * leading `-` is here too: a word this walk takes for a value may be an option
2418
+ * curl reads instead (`-K.env`), because the walk does not know every option
2419
+ * that takes a value.
2420
+ */
2421
+ const CURL_VALUE_UNKNOWN_OPENING = /^[\s\-$`~\\*?[\]{}()!+^<>=#%|&;'"]/u;
2422
+ /** Is this option value one curl (or wget) sends as written and never opens? */
2423
+ function curlValueSentAsWritten(value, opensFileWithAt) {
2424
+ if (value.length === 0)
2425
+ return false;
2426
+ // curl reads `-H @FILE` as a file of headers and `--url @FILE` (8.13+) as a
2427
+ // file of URLs. wget has no such spelling; the rule is the same anyway.
2428
+ if (opensFileWithAt && value.startsWith("@"))
2429
+ return false;
2430
+ if (CURL_VALUE_UNKNOWN_OPENING.test(value))
2431
+ return false;
2432
+ // A `file:` URL opens a local file. Which file it reads is the network
2433
+ // classifier's to say, never this pass's; such a value keeps the judgement
2434
+ // it had, and a header that opens `file:` is judged with it, since the walk
2435
+ // may have taken a positional URL for a header value.
2436
+ return !/^file:/iu.test(value);
2437
+ }
2438
+ /**
2439
+ * The curl and wget option values that are not file reads (EDGEOS-BLOCK,
2440
+ * APRV-533), taken out of the argv the credential pass judges.
2441
+ *
2442
+ * Every resident box runs its terminal in `$HERMES_HOME`, which holds
2443
+ * credential material, and the pass reads a word with an unexpanded `$` as a
2444
+ * read of the directory it may land in. So a header carrying `$EDGEOS_API_KEY`
2445
+ * and a `--url "${EDGEOS_API_BASE:-…}/…"` were each a credential read there.
2446
+ * curl opens neither: it reads a header from a file only for `-H @FILE`, and a
2447
+ * URL from a file only for `--url @FILE`. What is taken out:
2448
+ *
2449
+ * - curl's `-H`/`--header` value (separate, or attached as `-HVALUE`) and
2450
+ * wget's `--header` value (separate, or `--header=VALUE`), when it opens
2451
+ * with neither `@` nor a character the shell may still expand
2452
+ * ({@link CURL_VALUE_UNKNOWN_OPENING}). `-H "$X"` and `-H @"$F"` are judged
2453
+ * as before: the expansion may open with `@`.
2454
+ * - curl's `--url` value under the same rule, and additionally only when it
2455
+ * holds no unexpanded span, or names a literal scheme other than `file:`
2456
+ * before its first one (`https://h/${P}`).
2457
+ * - curl's `--url` value opening with an unexpanded span moves to
2458
+ * `spannedUrls`, which the caller judges as the path an `@` or `file:`
2459
+ * expansion would read; a value whose last component is the span (`"$U"`)
2460
+ * stays a read of the working directory, as before.
2461
+ *
2462
+ * Everything else stays: every other option (`-d`, `--data*`, `-F`, `-T`,
2463
+ * `-K`, `-o`, `-b`, `-c`, `--cacert`, `--cert`, `--key`, `-E`,
2464
+ * `--netrc-file`, `--proxy-header`, `--variable`, …), a short-option cluster
2465
+ * ending in `H` (`-sH`), curl's `--header=…` (curl takes no `=` spelling), and
2466
+ * every word after `--`. The pure classifier's text rule
2467
+ * ({@link credentialReadWords}) still reads every one of these words.
2468
+ */
2469
+ function curlValuesNotRead(bin, args) {
2470
+ if (bin !== "curl" && bin !== "wget")
2471
+ return { args, spannedUrls: [] };
2472
+ const curl = bin === "curl";
2473
+ const kept = [];
2474
+ const spannedUrls = [];
2475
+ for (let at = 0; at < args.length; at += 1) {
2476
+ const arg = args[at];
2477
+ if (arg === "--") {
2478
+ kept.push(...args.slice(at));
2479
+ break;
2480
+ }
2481
+ const next = args[at + 1];
2482
+ if ((curl ? arg === "-H" || arg === "--header" : arg === "--header") && next !== undefined) {
2483
+ kept.push(arg);
2484
+ if (curlValueSentAsWritten(next, true))
2485
+ at += 1;
2486
+ continue;
2487
+ }
2488
+ if (curl && arg.startsWith("-H") && arg.length > 2) {
2489
+ if (!curlValueSentAsWritten(arg.slice(2), true))
2490
+ kept.push(arg);
2491
+ continue;
2492
+ }
2493
+ if (!curl && arg.startsWith("--header=")) {
2494
+ if (!curlValueSentAsWritten(arg.slice("--header=".length), true))
2495
+ kept.push(arg);
2496
+ continue;
2497
+ }
2498
+ if (curl && arg === "--url" && next !== undefined) {
2499
+ kept.push(arg);
2500
+ const span = next.search(/[$`]/u);
2501
+ const scheme = /^([A-Za-z][A-Za-z0-9+.-]*):/u.exec(next);
2502
+ if (span === 0) {
2503
+ spannedUrls.push(next);
2504
+ at += 1;
2505
+ }
2506
+ else if (curlValueSentAsWritten(next, true) && (span === -1 || (scheme !== null && scheme[0].length <= span))) {
2507
+ at += 1;
2508
+ }
2509
+ continue;
2510
+ }
2511
+ kept.push(arg);
2512
+ }
2513
+ return { args: kept, spannedUrls };
2514
+ }
2412
2515
  /**
2413
2516
  * Every existing path a glob matches, any component of it a pattern
2414
2517
  * (APRV-522), or `null` when a directory on the way cannot be listed or the
@@ -2673,7 +2776,9 @@ export function credentialReadLandings(command, cwd, env = process.env) {
2673
2776
  return steered.some((tail) => words.some((segments) => steeredReadCouldNameCredential(tail, segments, hermesNames)));
2674
2777
  };
2675
2778
  // `steeredToo` is false only for git's verb word, which git never opens.
2676
- const judgeOne = (word, recursive, steeredToo = true) => {
2779
+ // `homeSpelled` is false only for a curl `--url` value that opens with an
2780
+ // unexpanded span ({@link curlValuesNotRead}); see its use below.
2781
+ const judgeOne = (word, recursive, steeredToo = true, homeSpelled = true) => {
2677
2782
  // `$PWD` is the directory the segment runs in, which is each of `cwds`.
2678
2783
  const pwdRef = /^\$(?:\{PWD\}|PWD)(?=\/|$)/u;
2679
2784
  if (pwdRef.test(word)) {
@@ -2682,7 +2787,7 @@ export function credentialReadLandings(command, cwd, env = process.env) {
2682
2787
  return;
2683
2788
  }
2684
2789
  for (const from of cwds)
2685
- judgeOne(word.replace(pwdRef, from), recursive, steeredToo);
2790
+ judgeOne(word.replace(pwdRef, from), recursive, steeredToo, homeSpelled);
2686
2791
  if (steeredToo && steered.length > 0 && steeredRead(word.replace(pwdRef, "."), recursive))
2687
2792
  hit(word);
2688
2793
  return;
@@ -2749,7 +2854,7 @@ export function credentialReadLandings(command, cwd, env = process.env) {
2749
2854
  const lexical = resolvePathSegments(from, prefix);
2750
2855
  for (const landed of new Set([lexical, canonicalPath(lexical), physicalLanding(from, prefix)])) {
2751
2856
  if ((lastComponent && lands(landed, true)) ||
2752
- landedSpellings(landed, rest, configuredHome).some(unresolvedReadCouldNameCredential)) {
2857
+ landedSpellings(landed, rest, homeSpelled ? configuredHome : null).some(unresolvedReadCouldNameCredential)) {
2753
2858
  hit(word);
2754
2859
  return;
2755
2860
  }
@@ -2799,7 +2904,7 @@ export function credentialReadLandings(command, cwd, env = process.env) {
2799
2904
  hit(physical);
2800
2905
  }
2801
2906
  };
2802
- const judge = (word, recursive, steeredToo = true) => {
2907
+ const judge = (word, recursive, steeredToo = true, homeSpelled = true) => {
2803
2908
  // APRV-524 (review-b M1): a word holding the substitution sentinel is
2804
2909
  // judged a second time with every sentinel removed. That is how the
2805
2910
  // release before APRV-511 read it, its lexer adding nothing for a
@@ -2811,7 +2916,7 @@ export function credentialReadLandings(command, cwd, env = process.env) {
2811
2916
  // name, and one after a glob that matches such a directory on disk are
2812
2917
  // judged too. Both judgements only record, so the stricter stands.
2813
2918
  if (word.includes(SUBSTITUTION_SENTINEL)) {
2814
- judge(word.split(SUBSTITUTION_SENTINEL).join(""), recursive, steeredToo);
2919
+ judge(word.split(SUBSTITUTION_SENTINEL).join(""), recursive, steeredToo, homeSpelled);
2815
2920
  }
2816
2921
  const spellings = new Set();
2817
2922
  for (const parameter of parameterSpellings(word)) {
@@ -2826,7 +2931,7 @@ export function credentialReadLandings(command, cwd, env = process.env) {
2826
2931
  spellings.add(spelling);
2827
2932
  }
2828
2933
  for (const spelling of spellings)
2829
- judgeOne(spelling, recursive, steeredToo);
2934
+ judgeOne(spelling, recursive, steeredToo, homeSpelled);
2830
2935
  };
2831
2936
  for (const target of shape.reads)
2832
2937
  judge(target, false);
@@ -2838,7 +2943,10 @@ export function credentialReadLandings(command, cwd, env = process.env) {
2838
2943
  if (NON_OPENING_BINS.has(bin) && !(PRINTING_BINS.has(bin) && (feedsPipe || substituted)))
2839
2944
  continue;
2840
2945
  const recursive = RECURSIVE_READERS.has(bin) || DIRECTORY_COPIERS.has(bin) || DIRECTORY_READING_BINS.has(bin);
2841
- const words = credentialReadWords(bin, shape.args);
2946
+ // EDGEOS-BLOCK (APRV-533): the curl and wget option values the tool sends
2947
+ // and never opens are not read words ({@link curlValuesNotRead}).
2948
+ const sent = curlValuesNotRead(bin, shape.args);
2949
+ const words = credentialReadWords(bin, sent.args);
2842
2950
  // A copy's destination is WRITTEN, and the write half (the protected
2843
2951
  // scan, the Hermes organ pass) prices it; judged as a read it would make
2844
2952
  // `cp /tmp/x .` in a home a credential read of the home.
@@ -2863,6 +2971,15 @@ export function credentialReadLandings(command, cwd, env = process.env) {
2863
2971
  const verb = verbAt === null ? undefined : shape.args[verbAt];
2864
2972
  for (const [at, word] of words.entries())
2865
2973
  judge(word, recursive, !(at === 0 && verb !== undefined && word === verb));
2974
+ // A `--url` value opening with an unexpanded span may expand to `@FILE`
2975
+ // (curl 8.13+ reads a URL list from FILE) or to a `file:` URL, and either
2976
+ // reads the path the rest of the word spells. It is judged as that path
2977
+ // from every working directory, the configured home not re-spelled as
2978
+ // `$HERMES_HOME`: that spelling makes the known home one more unknown
2979
+ // segment, and two unknowns in a row then read as an approval home's
2980
+ // `vault*`, which the literal landing already rules in or out.
2981
+ for (const word of sent.spannedUrls)
2982
+ judge(word, false, true, false);
2866
2983
  // M-E: a recursive search reads the working directory.
2867
2984
  if (readsWorkingDirectory(bin, shape.args, words))
2868
2985
  judge(".", true);
@@ -6399,17 +6516,20 @@ function resolvedPathClasses(raw, segments, cwd, protectedPaths, readRoots = [],
6399
6516
  add("policy.core");
6400
6517
  }
6401
6518
  };
6402
- const judgeRead = (word, recursive) => {
6519
+ // `homeSpelled` is false only for a curl `--url` value that opens with an
6520
+ // unexpanded span (EDGEOS-BLOCK, APRV-533; {@link curlValuesNotRead}).
6521
+ const judgeRead = (word, recursive, homeSpelled = true) => {
6403
6522
  const braced = braceCandidates(word);
6404
6523
  if (braced === null) {
6405
6524
  add("account.credential");
6406
6525
  return;
6407
6526
  }
6408
- for (const each of braced)
6527
+ for (const each of braced) {
6409
6528
  for (const spelling of tildeReadings(each))
6410
- judgeReadWord(spelling, recursive);
6529
+ judgeReadWord(spelling, recursive, homeSpelled);
6530
+ }
6411
6531
  };
6412
- const judgeReadWord = (word, recursive) => {
6532
+ const judgeReadWord = (word, recursive, homeSpelled = true) => {
6413
6533
  const target = expand(word);
6414
6534
  // Fix round 1 (M-C): an unexpanded tilde prefix lands somewhere unknown.
6415
6535
  if (target.hermesUnknown || target.landingUnknown) {
@@ -6442,7 +6562,7 @@ function resolvedPathClasses(raw, segments, cwd, protectedPaths, readRoots = [],
6442
6562
  if (landing.unknown)
6443
6563
  add("account.credential");
6444
6564
  for (const landed of [resolvePathSegments(spelled), ...landing.paths]) {
6445
- for (const spelling of landedSpellings(landed, rest, configuredHome)) {
6565
+ for (const spelling of landedSpellings(landed, rest, homeSpelled ? configuredHome : null)) {
6446
6566
  if (unresolvedReadCouldNameCredential(spelling))
6447
6567
  add("account.credential");
6448
6568
  }
@@ -6708,8 +6828,16 @@ function resolvedPathClasses(raw, segments, cwd, protectedPaths, readRoots = [],
6708
6828
  const readWords = credentialReadWords(bin, shape.args);
6709
6829
  const recursive = RECURSIVE_READERS.has(bin) || (DIRECTORY_READING_BINS.has(bin) && readWords.length > 0);
6710
6830
  if (segment.class.startsWith("read.") || recursive) {
6711
- for (const operand of operands)
6831
+ // EDGEOS-BLOCK (APRV-533): the curl and wget option values the tool
6832
+ // sends and never opens are not read here either, and a span-led curl
6833
+ // `--url` value is judged as the path an `@` or `file:` expansion would
6834
+ // read, as the credential pass judges it ({@link curlValuesNotRead}).
6835
+ const sent = curlValuesNotRead(bin, shape.args);
6836
+ const readOperands = sent.args === shape.args ? operands : sent.args.filter((arg) => !arg.startsWith("-"));
6837
+ for (const operand of readOperands)
6712
6838
  judgeRead(operand, recursive);
6839
+ for (const url of sent.spannedUrls)
6840
+ judgeRead(url, recursive, false);
6713
6841
  // APRV-511 (cred-asymmetry round 3, N2): a recursive reader that no
6714
6842
  // argument proves names a path walks the working directory.
6715
6843
  if (walksWorkingDirectory(bin, shape.args))
@@ -7509,6 +7637,41 @@ function landingPaths(spelled, volatile = null) {
7509
7637
  }
7510
7638
  /** The environment variable that turns the sandbox requirement on (APRV-193). */
7511
7639
  export const REQUIRE_SANDBOX_ENV = "APPROVAL_HOOK_REQUIRE_SANDBOX";
7640
+ /**
7641
+ * The fixed remedy a `fetch-output-unknown` refusal carries when a `$`-first
7642
+ * word in curl's or wget's option slot caused it (0.4.7, EDGEOS-BLOCK).
7643
+ *
7644
+ * Core 0.4.6 (APRV-511) holds `curl "${BASE:-https://h}/x"`: the expansion
7645
+ * could be `-o <organ path>`, so the download's output is unknown, the class is
7646
+ * `policy.core`, and a policy that reserves that class denies outright. The
7647
+ * refusal said why and nothing about the shape that runs. The same word as
7648
+ * `--url`'s value sits in a value slot, where curl reads it as the URL whatever
7649
+ * it starts with, and classifies by its method. wget has no `--url`, so the
7650
+ * line names curl for both.
7651
+ *
7652
+ * Text only: the class, the rule and the code are unchanged, and nothing here
7653
+ * widens a verdict.
7654
+ */
7655
+ export const FETCH_DOLLAR_WORD_REMEDY = 'Remedy: a word that starts with `$` stands where curl and wget read options, so its value could turn it into `-o <path>` and the download\'s output cannot be known. If that word is the URL, pass it as the value of `--url` (`curl --url "$VAR/..."`), which keeps the output known; wget has no `--url`, so use `curl --url` instead.';
7656
+ /**
7657
+ * {@link FETCH_DOLLAR_WORD_REMEDY}, prefixed with a space, when a segment that
7658
+ * earned `reserved` did so by rule `fetch-output-unknown` on a word that
7659
+ * starts with `$`; otherwise the empty string, so every other cause of the rule
7660
+ * (an unknown option, a config source, an ambiguous prefix, an unplaceable
7661
+ * output value) keeps its refusal byte for byte.
7662
+ *
7663
+ * `fetchOutputs` reports the first unknowable word verbatim, and only its
7664
+ * option-slot test reports a positional word; every other cause reports the
7665
+ * option as spelled, which starts with `-`. So a bound path starting with `$`
7666
+ * is exactly the `$`-first option-slot cause.
7667
+ */
7668
+ export function fetchOutputRemedy(segments, reserved) {
7669
+ const fired = (segments ?? []).some((segment) => segment.class === reserved &&
7670
+ segment.rule === "fetch-output-unknown" &&
7671
+ segment.path !== undefined &&
7672
+ segment.path.startsWith("$"));
7673
+ return fired ? ` ${FETCH_DOLLAR_WORD_REMEDY}` : "";
7674
+ }
7512
7675
  /**
7513
7676
  * Must this command have been written `approval sandbox -- …`? (APRV-193.)
7514
7677
  *
@@ -7692,7 +7855,7 @@ decidedOn) {
7692
7855
  }
7693
7856
  const reserved = classes.find((cls) => resolvePolicy(load, cls).autonomy === "human-only");
7694
7857
  if (reserved !== undefined) {
7695
- return deny(streams, "hook-class-human-only", `${humanOnlyRefusal(reserved, "this command may not run under an agent")} An open window does not reach it: the window suspends what the policy DECIDES, and a human-only class is one the policy reserves to human hands, which a window opened by a human does not lend to an agent (SPEC.md §11.1 invariant 9).`, adapter.kind);
7858
+ return deny(streams, "hook-class-human-only", `${humanOnlyRefusal(reserved, "this command may not run under an agent")} An open window does not reach it: the window suspends what the policy DECIDES, and a human-only class is one the policy reserves to human hands, which a window opened by a human does not lend to an agent (SPEC.md §11.1 invariant 9).${fetchOutputRemedy(described.segments, reserved)}`, adapter.kind);
7696
7859
  }
7697
7860
  }
7698
7861
  // APRV-227, resolved here because here is where a record is written. The
@@ -8276,7 +8439,7 @@ function* decideHarnessSteps(decide) {
8276
8439
  return {
8277
8440
  permission: "deny",
8278
8441
  code: "hook-class-human-only",
8279
- detail: `${humanOnlyRefusal(reserved, "this command may not run under an agent")} The gate's own code for this fact is \`class-human-only\`.`,
8442
+ detail: `${humanOnlyRefusal(reserved, "this command may not run under an agent")} The gate's own code for this fact is \`class-human-only\`.${fetchOutputRemedy(described.segments, reserved)}`,
8280
8443
  };
8281
8444
  }
8282
8445
  // APRV-193, and BELOW the human-only deny for the same reason that one sits