approval-md 0.4.2 → 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.
@@ -10,13 +10,13 @@
10
10
  * half-blocks, no box-drawing, no emoji, because those are the characters that
11
11
  * break in Terminal.app's default font and in every CI log. And it DEGRADES —
12
12
  * with colour off it collapses to a single line, so a pipe, a `NO_COLOR`
13
- * terminal or an ASCII locale gets `approval.md v0.4.2` and no decoration at
13
+ * terminal or an ASCII locale gets `approval.md v0.4.3` and no decoration at
14
14
  * all. The banner is ornament; the name and the version are the information,
15
15
  * and only the information survives the degradation.
16
16
  */
17
17
  import { type Style } from "./style.js";
18
18
  /** Kept in step with package.json by a test rather than by memory. */
19
- export declare const VERSION = "0.4.2";
19
+ export declare const VERSION = "0.4.3";
20
20
  export declare const TAGLINE = "human approval for agent actions";
21
21
  /** The one-line form, and the only form a pipe ever sees. */
22
22
  export declare function plainWordmark(): string;
@@ -10,13 +10,13 @@
10
10
  * half-blocks, no box-drawing, no emoji, because those are the characters that
11
11
  * break in Terminal.app's default font and in every CI log. And it DEGRADES —
12
12
  * with colour off it collapses to a single line, so a pipe, a `NO_COLOR`
13
- * terminal or an ASCII locale gets `approval.md v0.4.2` and no decoration at
13
+ * terminal or an ASCII locale gets `approval.md v0.4.3` and no decoration at
14
14
  * all. The banner is ornament; the name and the version are the information,
15
15
  * and only the information survives the degradation.
16
16
  */
17
17
  import { style as processStyle } from "./style.js";
18
18
  /** Kept in step with package.json by a test rather than by memory. */
19
- export const VERSION = "0.4.2";
19
+ export const VERSION = "0.4.3";
20
20
  export const TAGLINE = "human approval for agent actions";
21
21
  /**
22
22
  * The six-line form. Written as a raw array so no editor reflows it: every
@@ -481,6 +481,15 @@ interface RuleContext {
481
481
  * (APRV-114's fetch refinement) needs to know that one of them is a hole.
482
482
  */
483
483
  substituted: boolean;
484
+ /**
485
+ * Did the segment carry a `NAME=value` assignment before the binary (or
486
+ * before a sandbox wrapper around it)? The classifier strips assignments to
487
+ * find the binary, so a rule never sees them; a rule whose answer rests on
488
+ * the argv saying WHICH file runs needs to know the environment may say
489
+ * otherwise (APRV-502: `BUN_OPTIONS='--cwd ../x'`, `NODE_OPTIONS`,
490
+ * `PYTHONPATH`, `PYTHONSTARTUP`, `HOME`).
491
+ */
492
+ assigned: boolean;
484
493
  /** What the caller knows about the machine (APRV-267). Never read from here. */
485
494
  context: ClassifierContext;
486
495
  }
@@ -531,6 +540,18 @@ export interface CommandRule {
531
540
  * answer it has today.
532
541
  */
533
542
  probe?: boolean;
543
+ /**
544
+ * Match only an argv that runs a skill script (APRV-502): allowlisted inert
545
+ * flags, then a `skills/<skill>/scripts/<file>` path, with no word of the
546
+ * segment carrying a command substitution and no `NAME=value` assignment
547
+ * before the binary ({@link skillScriptArg}, {@link matchRule}).
548
+ *
549
+ * The same device as `probe`, for the same reason: the row is a SHAPE, and
550
+ * an argv outside the shape must not match it at all, so `bun --cwd /etc x`
551
+ * and `python3 -m x` keep the `unclassified` answer they have today rather
552
+ * than being refined into some other refusal.
553
+ */
554
+ skillScript?: boolean;
534
555
  }
535
556
  /**
536
557
  * The suffix that marks a Muse model as training on what it is shown.
@@ -545,6 +566,42 @@ export declare const CONTRIBUTOR_SUFFIX = "-contributor";
545
566
  export declare const READ_OUT_OF_SCOPE_RULE = "read-out-of-scope";
546
567
  /** The rule id for a read target whose expansion the text cannot show. */
547
568
  export declare const READ_UNREADABLE_TARGET_RULE = "read-unreadable-path";
569
+ /** The rule id for `bun` / `python` / `python3` running a skill script. */
570
+ export declare const SKILL_SCRIPT_RULE = "skill-script";
571
+ /** The rule id `refineNode` returns for `node` running a skill script. */
572
+ export declare const NODE_SKILL_SCRIPT_RULE = "node-skill-script";
573
+ /**
574
+ * Is `word` a skill-script path, in exactly one of the two shapes (APRV-502)?
575
+ *
576
+ * - Relative: `skills/<skill>/scripts/<file>`, with any number of leading
577
+ * `./` normalised away. Four segments, no more: `skills/x/scripts/sub/f.ts`
578
+ * and `.hermes/skills/x/scripts/f.ts` are not this shape.
579
+ * - Absolute: a path ending `.hermes/skills/<skill>/scripts/<file>` or
580
+ * `.hermes/profiles/<p>/skills/<skill>/scripts/<file>`, the same Hermes-home
581
+ * grammar {@link protectedPathClass} reads (a profile home is a home of its
582
+ * own, one level down).
583
+ *
584
+ * Every segment of either shape, the absolute prefix included, must pass
585
+ * {@link isSkillScriptSegment}: an empty segment (`skills//x`), a `.` or `..`
586
+ * anywhere, a glob, a variable or a backtick, and the shape does not match.
587
+ * An unexpanded `$HERMES_HOME/skills/…` or `~/.hermes/skills/…` is therefore
588
+ * not this rule; the text cannot say where it points.
589
+ *
590
+ * Text only, like everything in this file. Whether the file really sits in
591
+ * the directory it was spelled in is `src/cli/hook.ts`'s disk pass, which can
592
+ * only tighten this answer.
593
+ */
594
+ export declare function isSkillScriptPath(word: string): boolean;
595
+ /**
596
+ * The skill script this interpreter argv runs, or `null` when the argv is not
597
+ * exactly `[allowlisted flags…] <skill-script path> [arguments…]` (APRV-502).
598
+ *
599
+ * Words AFTER the script are the script's own arguments and are not read here:
600
+ * `python3 skills/agent-commons/scripts/search_forum.py --q x` is the shape.
601
+ * They are still scanned for protected and credential paths by
602
+ * `classifySegment`, which runs after the table and can only tighten.
603
+ */
604
+ export declare function skillScriptArg(bin: string, args: readonly string[]): string | null;
548
605
  /**
549
606
  * The table.
550
607
  *
@@ -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` };