approval-md 0.4.1 → 0.4.3

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 (118) hide show
  1. package/SPEC.md +8 -0
  2. package/dist/src/cli/attest.js +2 -1
  3. package/dist/src/cli/attest.js.map +1 -1
  4. package/dist/src/cli/audit.js +3 -2
  5. package/dist/src/cli/audit.js.map +1 -1
  6. package/dist/src/cli/channel-telegram.js +2 -1
  7. package/dist/src/cli/channel-telegram.js.map +1 -1
  8. package/dist/src/cli/channel-web.js +2 -1
  9. package/dist/src/cli/channel-web.js.map +1 -1
  10. package/dist/src/cli/channel.js +2 -1
  11. package/dist/src/cli/channel.js.map +1 -1
  12. package/dist/src/cli/doctor.js +1 -1
  13. package/dist/src/cli/doctor.js.map +1 -1
  14. package/dist/src/cli/execute.js +4 -3
  15. package/dist/src/cli/execute.js.map +1 -1
  16. package/dist/src/cli/gate.js +2 -1
  17. package/dist/src/cli/gate.js.map +1 -1
  18. package/dist/src/cli/hook.d.ts +32 -2
  19. package/dist/src/cli/hook.js +510 -36
  20. package/dist/src/cli/hook.js.map +1 -1
  21. package/dist/src/cli/log-checkpoint.js +2 -1
  22. package/dist/src/cli/log-checkpoint.js.map +1 -1
  23. package/dist/src/cli/log-unlock.js +2 -1
  24. package/dist/src/cli/log-unlock.js.map +1 -1
  25. package/dist/src/cli/setup-common.js +2 -1
  26. package/dist/src/cli/setup-common.js.map +1 -1
  27. package/dist/src/cli/vault.js +2 -1
  28. package/dist/src/cli/vault.js.map +1 -1
  29. package/dist/src/cli/wordmark.d.ts +2 -2
  30. package/dist/src/cli/wordmark.js +2 -2
  31. package/dist/src/core/attest-bootstrap.js +2 -1
  32. package/dist/src/core/attest-bootstrap.js.map +1 -1
  33. package/dist/src/core/attest.js +5 -4
  34. package/dist/src/core/attest.js.map +1 -1
  35. package/dist/src/core/audit.d.ts +15 -0
  36. package/dist/src/core/audit.js +19 -3
  37. package/dist/src/core/audit.js.map +1 -1
  38. package/dist/src/core/budgets.d.ts +12 -0
  39. package/dist/src/core/budgets.js +54 -1
  40. package/dist/src/core/budgets.js.map +1 -1
  41. package/dist/src/core/checkpoint.js +3 -2
  42. package/dist/src/core/checkpoint.js.map +1 -1
  43. package/dist/src/core/command-class.d.ts +57 -0
  44. package/dist/src/core/command-class.js +188 -10
  45. package/dist/src/core/command-class.js.map +1 -1
  46. package/dist/src/core/decision-refusal.js +2 -1
  47. package/dist/src/core/decision-refusal.js.map +1 -1
  48. package/dist/src/core/delegation.d.ts +109 -0
  49. package/dist/src/core/delegation.js +244 -0
  50. package/dist/src/core/delegation.js.map +1 -0
  51. package/dist/src/core/execute.js +12 -3
  52. package/dist/src/core/execute.js.map +1 -1
  53. package/dist/src/core/gate-window.js +4 -3
  54. package/dist/src/core/gate-window.js.map +1 -1
  55. package/dist/src/core/gate.d.ts +26 -42
  56. package/dist/src/core/gate.js +84 -3
  57. package/dist/src/core/gate.js.map +1 -1
  58. package/dist/src/core/gesture-refusal.js +2 -1
  59. package/dist/src/core/gesture-refusal.js.map +1 -1
  60. package/dist/src/core/identity.d.ts +105 -0
  61. package/dist/src/core/identity.js +122 -0
  62. package/dist/src/core/identity.js.map +1 -0
  63. package/dist/src/core/live-draw.d.ts +12 -2
  64. package/dist/src/core/live-draw.js +12 -2
  65. package/dist/src/core/live-draw.js.map +1 -1
  66. package/dist/src/core/loop.d.ts +11 -0
  67. package/dist/src/core/loop.js +63 -4
  68. package/dist/src/core/loop.js.map +1 -1
  69. package/dist/src/core/policy-diff.js +20 -1
  70. package/dist/src/core/policy-diff.js.map +1 -1
  71. package/dist/src/core/policy-explain.js +18 -2
  72. package/dist/src/core/policy-explain.js.map +1 -1
  73. package/dist/src/core/policy-load.d.ts +38 -1
  74. package/dist/src/core/policy-load.js +34 -2
  75. package/dist/src/core/policy-load.js.map +1 -1
  76. package/dist/src/core/policy-match.js +30 -5
  77. package/dist/src/core/policy-match.js.map +1 -1
  78. package/dist/src/core/policy-proposal.js +3 -2
  79. package/dist/src/core/policy-proposal.js.map +1 -1
  80. package/dist/src/core/token.js +11 -0
  81. package/dist/src/core/token.js.map +1 -1
  82. package/dist/src/core/tool-map.d.ts +125 -0
  83. package/dist/src/core/tool-map.js +179 -0
  84. package/dist/src/core/tool-map.js.map +1 -0
  85. package/dist/src/daemon/git-evidence.d.ts +2 -2
  86. package/dist/src/daemon/git-evidence.js +1 -1
  87. package/docs/README-extended.md +13 -0
  88. package/docs/cli-reference.md +64 -3
  89. package/package.json +2 -2
  90. package/schema/event.schema.json +34 -2
  91. package/schema/fixtures/event/invalid/audit-reviewed-model-actor.json +20 -0
  92. package/schema/fixtures/event/invalid/audit-reviewed-verdict-source-model.json +20 -0
  93. package/schema/fixtures/event/invalid/execution-started-harness-tool-space.json +18 -0
  94. package/schema/fixtures/event/valid/execution-started-harness-tool.json +19 -0
  95. package/schema/fixtures/policy/invalid/delegation-advice-string.json +15 -0
  96. package/schema/fixtures/policy/invalid/delegation-class-duplicate.json +18 -0
  97. package/schema/fixtures/policy/invalid/delegation-class-wildcard.json +17 -0
  98. package/schema/fixtures/policy/invalid/delegation-daily-cap-fractional.json +15 -0
  99. package/schema/fixtures/policy/invalid/delegation-daily-cap-negative.json +15 -0
  100. package/schema/fixtures/policy/invalid/delegation-daily-cap-too-high.json +15 -0
  101. package/schema/fixtures/policy/invalid/delegation-escalate-duplicate.json +18 -0
  102. package/schema/fixtures/policy/invalid/delegation-escalate-unknown.json +17 -0
  103. package/schema/fixtures/policy/invalid/delegation-max-autonomy-alias.json +15 -0
  104. package/schema/fixtures/policy/invalid/delegation-max-autonomy-autonomous.json +15 -0
  105. package/schema/fixtures/policy/invalid/delegation-model-no-version.json +15 -0
  106. package/schema/fixtures/policy/invalid/delegation-model-provider-form.json +15 -0
  107. package/schema/fixtures/policy/invalid/delegation-null.json +7 -0
  108. package/schema/fixtures/policy/invalid/delegation-reviewer-agent.json +17 -0
  109. package/schema/fixtures/policy/invalid/delegation-reviewer-bare-id.json +17 -0
  110. package/schema/fixtures/policy/invalid/delegation-unknown-key.json +16 -0
  111. package/schema/fixtures/policy/invalid/tools-class-wildcard.json +9 -0
  112. package/schema/fixtures/policy/invalid/tools-match-double-star.json +9 -0
  113. package/schema/fixtures/policy/invalid/unmapped-tool-unknown.json +7 -0
  114. package/schema/fixtures/policy/valid/delegation-empty.json +7 -0
  115. package/schema/fixtures/policy/valid/delegation-grammar-full.json +41 -0
  116. package/schema/fixtures/policy/valid/delegation-off.json +15 -0
  117. package/schema/fixtures/policy/valid/tools-mapping.json +17 -0
  118. package/schema/policy.schema.json +126 -0
@@ -2473,9 +2473,143 @@ function refineApprovalVerb(positionals, args = []) {
2473
2473
  function refineApproval(ctx) {
2474
2474
  return (refineApprovalVerb(ctx.positionals, ctx.args) ?? { class: GATE_SELF_CLASS, rule: "approval" });
2475
2475
  }
2476
+ // ---------------------------------------------------------------------------
2477
+ // Skill scripts (APRV-502)
2478
+ // ---------------------------------------------------------------------------
2479
+ /**
2480
+ * The class an interpreter run of a skill script takes: SPEC.md §7's
2481
+ * `exec.local`, "tests, lint, build, scripts inside the workspace". No new
2482
+ * class is minted; the classifier simply had no row that emitted this one.
2483
+ */
2484
+ const SKILL_SCRIPT_CLASS = "exec.local";
2485
+ /** The rule id for `bun` / `python` / `python3` running a skill script. */
2486
+ export const SKILL_SCRIPT_RULE = "skill-script";
2487
+ /** The rule id `refineNode` returns for `node` running a skill script. */
2488
+ export const NODE_SKILL_SCRIPT_RULE = "node-skill-script";
2489
+ /**
2490
+ * The flags an interpreter may carry BEFORE a skill script, per binary
2491
+ * (APRV-502). An ALLOWLIST: a flag outside it, a `--flag=value`, a lone `-`
2492
+ * (stdin) or `--` means the rule does not match at all, so the command keeps
2493
+ * exactly the answer it had before this rule existed (`unclassified` for
2494
+ * `bun --cwd /etc …` and `python3 -m x`, opaque for `python3 -c`, `node -e`).
2495
+ *
2496
+ * Every flag here is inert in the sense that matters: it changes neither WHICH
2497
+ * file runs nor where code comes from. A flag that takes a value (`--cwd`,
2498
+ * `--require`, `--import`, `-W`, `-X`) is absent on purpose, because its value
2499
+ * would be a second thing the interpreter loads or a different directory the
2500
+ * script path is read from. The same flags spelled through the environment
2501
+ * (`BUN_OPTIONS`, `NODE_OPTIONS`, `PYTHONPATH`, `PYTHONSTARTUP`, `HOME`) are
2502
+ * refused the same way: any `NAME=value` prefix on the segment declines the
2503
+ * shape ({@link matchRule}, `RuleContext.assigned`).
2504
+ */
2505
+ const SKILL_SCRIPT_FLAGS = {
2506
+ bun: ["--bun", "--smol"],
2507
+ node: ["--no-warnings", "--enable-source-maps", "--experimental-strip-types"],
2508
+ python: [],
2509
+ python3: [],
2510
+ };
2511
+ /**
2512
+ * Python's inert single-letter flags, alone or clustered (`-I`, `-IB`, `-OO`):
2513
+ * isolated mode, unbuffered, no bytecode, ignore `PYTHON*` variables, no user
2514
+ * site, no `site` import, optimise, quiet. None of them takes a value, so a
2515
+ * cluster cannot hide one; `-c`, `-m`, `-W` and `-X` are not letters here.
2516
+ */
2517
+ const PYTHON_SKILL_SCRIPT_FLAGS = /^-[IuBEsSOq]+$/u;
2518
+ /**
2519
+ * One segment of a skill-script path: non-empty, not `.` or `..`, not
2520
+ * flag-shaped, and free of anything a shell could expand (`$`, backtick, a
2521
+ * glob or brace character, a home shortcut) or a platform could read as a
2522
+ * separator (`\`).
2523
+ */
2524
+ function isSkillScriptSegment(segment) {
2525
+ if (segment.length === 0 || segment === "." || segment === "..")
2526
+ return false;
2527
+ if (segment.startsWith("-"))
2528
+ return false;
2529
+ return !/[*?[\]{}$`~\\]/u.test(segment);
2530
+ }
2531
+ /**
2532
+ * Is `word` a skill-script path, in exactly one of the two shapes (APRV-502)?
2533
+ *
2534
+ * - Relative: `skills/<skill>/scripts/<file>`, with any number of leading
2535
+ * `./` normalised away. Four segments, no more: `skills/x/scripts/sub/f.ts`
2536
+ * and `.hermes/skills/x/scripts/f.ts` are not this shape.
2537
+ * - Absolute: a path ending `.hermes/skills/<skill>/scripts/<file>` or
2538
+ * `.hermes/profiles/<p>/skills/<skill>/scripts/<file>`, the same Hermes-home
2539
+ * grammar {@link protectedPathClass} reads (a profile home is a home of its
2540
+ * own, one level down).
2541
+ *
2542
+ * Every segment of either shape, the absolute prefix included, must pass
2543
+ * {@link isSkillScriptSegment}: an empty segment (`skills//x`), a `.` or `..`
2544
+ * anywhere, a glob, a variable or a backtick, and the shape does not match.
2545
+ * An unexpanded `$HERMES_HOME/skills/…` or `~/.hermes/skills/…` is therefore
2546
+ * not this rule; the text cannot say where it points.
2547
+ *
2548
+ * Text only, like everything in this file. Whether the file really sits in
2549
+ * the directory it was spelled in is `src/cli/hook.ts`'s disk pass, which can
2550
+ * only tighten this answer.
2551
+ */
2552
+ export function isSkillScriptPath(word) {
2553
+ if (word.startsWith("/")) {
2554
+ const parts = word.slice(1).split("/");
2555
+ if (!parts.every(isSkillScriptSegment))
2556
+ return false;
2557
+ const n = parts.length;
2558
+ const tail = (offset) => parts[n - offset] === ".hermes" && parts[n - 4] === "skills" && parts[n - 2] === "scripts";
2559
+ if (n >= 5 && tail(5))
2560
+ return true;
2561
+ return n >= 7 && parts[n - 6] === "profiles" && tail(7);
2562
+ }
2563
+ let rest = word;
2564
+ while (rest.startsWith("./"))
2565
+ rest = rest.slice(2);
2566
+ const parts = rest.split("/");
2567
+ return (parts.length === 4 &&
2568
+ parts.every(isSkillScriptSegment) &&
2569
+ parts[0] === "skills" &&
2570
+ parts[2] === "scripts");
2571
+ }
2572
+ /**
2573
+ * The skill script this interpreter argv runs, or `null` when the argv is not
2574
+ * exactly `[allowlisted flags…] <skill-script path> [arguments…]` (APRV-502).
2575
+ *
2576
+ * Words AFTER the script are the script's own arguments and are not read here:
2577
+ * `python3 skills/agent-commons/scripts/search_forum.py --q x` is the shape.
2578
+ * They are still scanned for protected and credential paths by
2579
+ * `classifySegment`, which runs after the table and can only tighten.
2580
+ */
2581
+ export function skillScriptArg(bin, args) {
2582
+ const allowed = SKILL_SCRIPT_FLAGS[bin];
2583
+ if (allowed === undefined)
2584
+ return null;
2585
+ const python = bin === "python" || bin === "python3";
2586
+ for (const arg of args) {
2587
+ if (!arg.startsWith("-"))
2588
+ return isSkillScriptPath(arg) ? arg : null;
2589
+ if (allowed.includes(arg))
2590
+ continue;
2591
+ if (python && PYTHON_SKILL_SCRIPT_FLAGS.test(arg))
2592
+ continue;
2593
+ // Anything else: a value-taking flag, `--flag=value`, inline code (`-c`,
2594
+ // `-e`, `-m`, `-p`), a lone `-` (stdin) or `--`. Not this rule.
2595
+ return null;
2596
+ }
2597
+ return null;
2598
+ }
2599
+ /** `bun` / `python` / `python3` running a skill script; binds the script path. */
2600
+ function refineSkillScript(ctx) {
2601
+ // `matchRule` only lets this row match when `skillScriptArg` found a script,
2602
+ // so the fallback is unreachable; it is spelled as the row's own answer
2603
+ // rather than as a `null`, which would be read as "opaque".
2604
+ const script = skillScriptArg(ctx.bin, ctx.args);
2605
+ return script === null
2606
+ ? { class: SKILL_SCRIPT_CLASS, rule: SKILL_SCRIPT_RULE }
2607
+ : { class: SKILL_SCRIPT_CLASS, rule: SKILL_SCRIPT_RULE, path: script };
2608
+ }
2476
2609
  /**
2477
2610
  * `node` — an inline script is opaque, the gate's own entry point is
2478
- * pass-through, and anything else is a workspace script.
2611
+ * pass-through, a skill script is `exec.local` (APRV-502), and anything else
2612
+ * is a workspace script.
2479
2613
  */
2480
2614
  function refineNode(ctx) {
2481
2615
  if (hasFlag(ctx.args, ["-e", "--eval", "-p", "--print"]))
@@ -2489,6 +2623,14 @@ function refineNode(ctx) {
2489
2623
  rule: "node-approval-cli",
2490
2624
  });
2491
2625
  }
2626
+ // APRV-502. A substituted word is a hole in the text, and an assignment
2627
+ // prefix (`NODE_OPTIONS='--require …'`) is a flag spelled through the
2628
+ // environment, so either declines the skill-script reading and the segment
2629
+ // keeps the answer it had before.
2630
+ const skill = ctx.substituted || ctx.assigned ? null : skillScriptArg("node", ctx.args);
2631
+ if (skill !== null) {
2632
+ return { class: SKILL_SCRIPT_CLASS, rule: NODE_SKILL_SCRIPT_RULE, path: skill };
2633
+ }
2492
2634
  return { class: "files.write.workspace", rule: "node-script" };
2493
2635
  }
2494
2636
  /**
@@ -2686,9 +2828,24 @@ export const COMMAND_RULES = [
2686
2828
  id: "node",
2687
2829
  bins: ["node"],
2688
2830
  class: "files.write.workspace",
2689
- emits: [GATE_SELF_CLASS, "log.sync", "log.advance", "policy.core"],
2831
+ emits: [GATE_SELF_CLASS, "log.sync", "log.advance", "policy.core", SKILL_SCRIPT_CLASS],
2690
2832
  refine: refineNode,
2691
2833
  },
2834
+ // APRV-502. An interpreter running a skill script, `exec.local`. Below every
2835
+ // `bun` row of the package-manager group, so `bun run x`, `bun install` and
2836
+ // `bun --version` keep their classes; `skillScript` makes the row match ONLY
2837
+ // the exact shape, so every other `bun`, `python` or `python3` argv keeps the
2838
+ // answer it had before the row existed. `node` reaches the same answer
2839
+ // inside {@link refineNode}, because its row has no `subs` to sit beside.
2840
+ // Named in {@link CODE_EXECUTING_RULES}: a skill script is code the runtime
2841
+ // did not author.
2842
+ {
2843
+ id: SKILL_SCRIPT_RULE,
2844
+ bins: ["bun", "python", "python3"],
2845
+ class: SKILL_SCRIPT_CLASS,
2846
+ skillScript: true,
2847
+ refine: refineSkillScript,
2848
+ },
2692
2849
  {
2693
2850
  id: "approval",
2694
2851
  bins: ["approval"],
@@ -3198,6 +3355,13 @@ export const CODE_EXECUTING_RULES = [
3198
3355
  "npm-script",
3199
3356
  /** `node <script>` — the plainest spelling of "run what I just wrote". */
3200
3357
  "node-script",
3358
+ /**
3359
+ * `bun` / `python3` / `node` running `skills/<skill>/scripts/<file>`
3360
+ * (APRV-502). A skill script is a file in the workspace an agent may have
3361
+ * written a minute ago, exactly as `node x.mjs` is.
3362
+ */
3363
+ SKILL_SCRIPT_RULE,
3364
+ NODE_SKILL_SCRIPT_RULE,
3201
3365
  /** `npx`, `tsx`, `tsc`, `vitest`, `jest`, `make`, and kin. */
3202
3366
  "workspace-tool",
3203
3367
  /**
@@ -3409,15 +3573,27 @@ function sandboxWrapper(words, start) {
3409
3573
  /**
3410
3574
  * Find the first table row matching this binary, subcommand and argv.
3411
3575
  *
3412
- * The argv is read for one purpose only (APRV-397): a `probe` row matches an
3413
- * argv that is nothing but version or help flags, and no other row reads it.
3576
+ * The argv is read for two purposes only: a `probe` row matches an argv that
3577
+ * is nothing but version or help flags (APRV-397), and a `skillScript` row
3578
+ * matches an argv that runs a skill script (APRV-502). No other row reads it.
3579
+ *
3580
+ * `substituted` and `assigned` are read by the `skillScript` row alone: a
3581
+ * substituted word is a hole in the text, and an assignment prefix is a flag
3582
+ * spelled through the environment (`BUN_OPTIONS='--cwd ../x'` makes bun read
3583
+ * a different directory's file), so either one means the shape is not matched
3584
+ * and the segment keeps the answer it had before (fail closed).
3414
3585
  */
3415
- function matchRule(bin, sub, args) {
3586
+ function matchRule(bin, sub, args, substituted = false, assigned = false) {
3416
3587
  for (const rule of COMMAND_RULES) {
3417
3588
  if (!rule.bins.includes(bin))
3418
3589
  continue;
3419
3590
  if (rule.probe === true && !isVersionProbe(args))
3420
3591
  continue;
3592
+ // APRV-502: see the doc comment above; any of the three declines the row.
3593
+ if (rule.skillScript === true &&
3594
+ (substituted || assigned || skillScriptArg(bin, args) === null)) {
3595
+ continue;
3596
+ }
3421
3597
  if (rule.subs !== undefined) {
3422
3598
  if (sub === null || !rule.subs.includes(sub))
3423
3599
  continue;
@@ -3457,6 +3633,8 @@ function classifySegment(segment, protectedPaths, context) {
3457
3633
  let cursor = 0;
3458
3634
  while (cursor < words.length && ASSIGNMENT.test(words[cursor]))
3459
3635
  cursor += 1;
3636
+ // APRV-502: read by the skill-script shape only ({@link matchRule}).
3637
+ const assigned = cursor > 0;
3460
3638
  // APRV-193. A sandbox wrapper is not a command: it is a room, and what
3461
3639
  // matters is what runs inside it. `approval sandbox -- npm install` is
3462
3640
  // `deps.add`, and it has to be, in both directions.
@@ -3541,7 +3719,10 @@ function classifySegment(segment, protectedPaths, context) {
3541
3719
  if (credential !== null)
3542
3720
  return { ok: true, ...credential };
3543
3721
  const sub = positionals[0] ?? null;
3544
- const rule = matchRule(basename, sub, args);
3722
+ const substituted = segment.words
3723
+ .slice(cursor + 1)
3724
+ .some((word) => word.substitutions.length > 0);
3725
+ const rule = matchRule(basename, sub, args, substituted, assigned);
3545
3726
  if (rule === null) {
3546
3727
  return {
3547
3728
  ok: false,
@@ -3551,10 +3732,7 @@ function classifySegment(segment, protectedPaths, context) {
3551
3732
  : `no rule for ${basename} ${sub}`,
3552
3733
  };
3553
3734
  }
3554
- const substituted = segment.words
3555
- .slice(cursor + 1)
3556
- .some((word) => word.substitutions.length > 0);
3557
- const ctx = { bin: basename, args, positionals, sub, substituted, context };
3735
+ const ctx = { bin: basename, args, positionals, sub, substituted, assigned, context };
3558
3736
  const refined = rule.refine === undefined ? null : rule.refine(ctx);
3559
3737
  if (rule.refine !== undefined && refined === null) {
3560
3738
  return { ok: false, code: "opaque", detail: `${basename} runs inline source` };