approval-md 0.4.2 → 0.4.5

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.
@@ -47,6 +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";
51
+ import { isBareInfo, isDfDisplay, isFreeDisplay, isHostnameDisplay, isPasteRead, isSeqDisplay, } from "./sysinfo-argv.js";
50
52
  import { HARNESS_LAUNCH_PREFIX } from "./policy-match.js";
51
53
  import { READ_OUT_OF_SCOPE_CLASS, isUnreadableTarget, readTargetVerdict, readTargetsOf, } from "./read-scope.js";
52
54
  /**
@@ -674,11 +676,19 @@ function secretEnvReference(word) {
674
676
  * appear in the log" invariant survives a refusal message that names what it
675
677
  * refused.
676
678
  */
677
- function credentialTouch(basename, args, positionals) {
679
+ /**
680
+ * Does this binary only WRITE the paths it names? ({@link CREDENTIAL_WRITE_BINS}
681
+ * plus `sed -i`.) Then a credential path among its positionals is a write to the
682
+ * gate's directory rather than a read of the material.
683
+ */
684
+ function credentialWritesOnly(basename, args) {
678
685
  const inPlaceSed = basename === "sed" &&
679
686
  (hasFlag(args, ["--in-place"]) ||
680
687
  args.some((arg) => arg.startsWith("-i") && !arg.startsWith("--")));
681
- const writesOnly = CREDENTIAL_WRITE_BINS.includes(basename) || inPlaceSed;
688
+ return CREDENTIAL_WRITE_BINS.includes(basename) || inPlaceSed;
689
+ }
690
+ function credentialTouch(basename, args, positionals) {
691
+ const writesOnly = credentialWritesOnly(basename, args);
682
692
  if (!writesOnly) {
683
693
  const named = positionals.find((arg) => isCredentialPath(arg));
684
694
  if (named !== undefined) {
@@ -692,6 +702,42 @@ function credentialTouch(basename, args, positionals) {
692
702
  }
693
703
  return null;
694
704
  }
705
+ /**
706
+ * Is this segment a credential touch through a REDIRECTION? (APRV-503.)
707
+ *
708
+ * The same two shapes {@link credentialTouch} reads from the argument list, read
709
+ * from the redirection targets, which the lexer keeps out of the word list. An
710
+ * input redirection's direction is known in a way a positional's is not: `<`
711
+ * (and `0<`, `3<`, any descriptor) opens its target for READING, whatever the
712
+ * binary in front of it is, so a credential file there is a read of the
713
+ * material and the {@link CREDENTIAL_WRITE_BINS} exemption does not apply
714
+ * (`tee out.txt < .approval/env` copies the secret out). Until this, `cat <
715
+ * ~/.hermes/.env` was `read.shell` while `cat ~/.hermes/.env` was
716
+ * `account.credential`, and so was `echo "$(< ~/.hermes/.env)"`, the bash idiom
717
+ * whose inner command is nothing but the redirection.
718
+ *
719
+ * A secret-named variable in ANY redirection target, read or write, carries the
720
+ * secret the way it does in an argument: the shell expands it into a file name,
721
+ * and a missing file prints that name on stderr.
722
+ *
723
+ * The rule id is the argument rule's own, so a channel, a doc table and a
724
+ * policy reader see one mechanism; `path` names the target word.
725
+ *
726
+ * This answers WHETHER a redirect touches credential material, not which
727
+ * class the segment takes: `classifySegment` defers it whenever the segment
728
+ * names a protected path that outranks the credential class
729
+ * ({@link outranksCredential}), so a redirect can never loosen an answer.
730
+ */
731
+ function redirectCredentialTouch(readTargets, allTargets) {
732
+ const named = readTargets.find((target) => isCredentialPath(target));
733
+ if (named !== undefined) {
734
+ return { class: CREDENTIAL_CLASS, rule: "credential-path", path: named };
735
+ }
736
+ if (allTargets.some((target) => secretEnvReference(target) !== null)) {
737
+ return { class: CREDENTIAL_CLASS, rule: "credential-env" };
738
+ }
739
+ return null;
740
+ }
695
741
  /** `printenv` prints one variable, or all of them. */
696
742
  function refinePrintenv(ctx) {
697
743
  if (ctx.positionals.length === 0) {
@@ -746,21 +792,24 @@ function lex(command) {
746
792
  const segments = [];
747
793
  let words = [];
748
794
  let redirects = [];
795
+ let bodySubstitutions = [];
749
796
  let opaque = null;
750
797
  let segmentStart = 0;
751
798
  let index = 0;
752
799
  const pending = [];
753
800
  const flush = (end) => {
754
- if (words.length > 0 || redirects.length > 0 || opaque !== null) {
801
+ if (words.length > 0 || redirects.length > 0 || bodySubstitutions.length > 0 || opaque !== null) {
755
802
  segments.push({
756
803
  text: command.slice(segmentStart, end).trim(),
757
804
  words,
758
805
  redirects,
806
+ bodySubstitutions,
759
807
  opaque,
760
808
  });
761
809
  }
762
810
  words = [];
763
811
  redirects = [];
812
+ bodySubstitutions = [];
764
813
  opaque = null;
765
814
  segmentStart = end;
766
815
  };
@@ -863,13 +912,26 @@ function lex(command) {
863
912
  const boundary = index;
864
913
  index += 1;
865
914
  // Heredoc bodies belong to the line that opened them, so they are
866
- // consumed here and never classified. A body is data, not commands.
915
+ // consumed here. A quoted delimiter's body is data. An UNQUOTED one is
916
+ // expanded by the shell when it is read (APRV-504, folded into
917
+ // APRV-508): its `$(…)` run, so they are collected for recursive
918
+ // classification, and a backtick or an arithmetic expansion there is
919
+ // opaque, exactly as inside double quotes.
867
920
  while (pending.length > 0) {
868
921
  const heredoc = pending.shift();
869
922
  const consumed = skipHeredocBody(command, index, heredoc);
870
923
  if (consumed === null) {
871
924
  return { ok: false, detail: `heredoc <<${heredoc.terminator} is never terminated` };
872
925
  }
926
+ if (heredoc.expands) {
927
+ const body = heredocBodyExpansions(command, index, consumed);
928
+ if (body === null) {
929
+ return { ok: false, detail: `heredoc <<${heredoc.terminator} has a substitution that does not close` };
930
+ }
931
+ bodySubstitutions.push(...body.substitutions);
932
+ if (body.opaque !== null)
933
+ opaque = body.opaque;
934
+ }
873
935
  index = consumed;
874
936
  }
875
937
  flush(boundary);
@@ -881,20 +943,71 @@ function lex(command) {
881
943
  if (redirect !== null) {
882
944
  const op = redirect[2];
883
945
  index += redirect[0].length;
946
+ if (op === "<<" && command[index] === "<") {
947
+ // A here-string (`<<<word`). Refused, as it always was, and named
948
+ // (APRV-503): the shell expands its word, and that word is the
949
+ // command's stdin. Parsing it would only ever loosen a refusal
950
+ // (`cat <<< hello` would become a read), while a credential read
951
+ // through it already has spellings that classify, so the refusal is
952
+ // the fail-closed answer and the detail says how to rewrite it.
953
+ return {
954
+ ok: false,
955
+ detail: "a here-string (<<<) is not read by this classifier; pipe the text in with printf or name the file as an argument",
956
+ };
957
+ }
884
958
  if (op === "<<" || op === "<<-") {
885
959
  while (command[index] === " " || command[index] === "\t")
886
960
  index += 1;
961
+ const delimiterStart = index;
887
962
  const terminator = readWord();
888
963
  if (terminator === null) {
889
964
  return { ok: false, detail: "heredoc has no terminator word" };
890
965
  }
891
- pending.push({ terminator: terminator.text, stripTabs: op === "<<-" });
966
+ // APRV-508: bash takes a heredoc delimiter literally (quote removal
967
+ // only, no expansion), while `readWord` sets a substitution's text
968
+ // aside. So `cat <<$(x)` waited here for a terminator line bash never
969
+ // looks for, and every command line after bash's real terminator was
970
+ // swallowed as body and never classified. A delimiter carrying `$` or
971
+ // a backtick is refused rather than guessed at.
972
+ if (/[$`]/u.test(command.slice(delimiterStart, index))) {
973
+ return {
974
+ ok: false,
975
+ detail: "a heredoc delimiter carrying $ or a backtick is not read by this classifier; use a plain word such as EOF",
976
+ };
977
+ }
978
+ const raw = command.slice(delimiterStart, index);
979
+ pending.push({
980
+ terminator: terminator.text,
981
+ stripTabs: op === "<<-",
982
+ expands: !terminator.quoted && !raw.includes("\\"),
983
+ });
892
984
  continue;
893
985
  }
894
986
  if (op === ">&" || op === "<&") {
895
- // `2>&1` and friends: a file descriptor dup, no path involved.
896
- while (index < command.length && /[\d-]/u.test(command[index]))
987
+ // `2>&1`, `>&2`, `3<&0`, `2>&-`: a descriptor dup or close, no path
988
+ // involved. Anything else after `>&` is a FILE (APRV-508 refutation
989
+ // M1): bash reads `>&word` as `>word 2>&1`, so `paste a >& APPROVAL.md`
990
+ // truncates the policy file. The word is read in full, as the shell
991
+ // reads it, and is a dup only when it is exactly digits with an
992
+ // optional trailing `-`, or a lone `-`. A word whose value the text
993
+ // cannot settle (`>&$fd`) is taken as a file: a write is the stricter
994
+ // reading. After `<&` a non-descriptor word is refused.
995
+ while (command[index] === " " || command[index] === "\t")
897
996
  index += 1;
997
+ const target = readWord();
998
+ if (target === null || target.text === "") {
999
+ return { ok: false, detail: `redirection ${op} has no target` };
1000
+ }
1001
+ const dup = target.substitutions.length === 0 && (/^\d+-?$/u.test(target.text) || target.text === "-");
1002
+ if (dup)
1003
+ continue;
1004
+ if (op === "<&") {
1005
+ return {
1006
+ ok: false,
1007
+ detail: `redirection <&${target.text} names no file descriptor; this classifier does not read it`,
1008
+ };
1009
+ }
1010
+ redirects.push({ op: ">", target });
898
1011
  continue;
899
1012
  }
900
1013
  while (command[index] === " " || command[index] === "\t")
@@ -913,6 +1026,18 @@ function lex(command) {
913
1026
  segmentStart = index;
914
1027
  continue;
915
1028
  }
1029
+ // A comment (APRV-508, found by the shell differential). bash ends the
1030
+ // line at a `#` that begins a word, so `cat #<<EOF` is `cat` and the
1031
+ // lines after it are commands; read as a word, the `#<<EOF` opened a
1032
+ // heredoc here and swallowed them. The comment runs to the newline, which
1033
+ // is left for the loop. Only a `#` after a space, a tab, a newline or an
1034
+ // operator begins a word to bash; after a carriage return (whitespace to
1035
+ // this loop, a word character to bash) it does not, and is read as a word.
1036
+ if (ch === "#" && (index === 0 || /[ \t\n;&|()<>]/u.test(command[index - 1]))) {
1037
+ const newline = command.indexOf("\n", index);
1038
+ index = newline === -1 ? command.length : newline;
1039
+ continue;
1040
+ }
916
1041
  const word = readWord();
917
1042
  if (word === null) {
918
1043
  return { ok: false, detail: "unterminated quote or command substitution" };
@@ -1036,6 +1161,45 @@ function closingDoubleQuote(command, start) {
1036
1161
  return null;
1037
1162
  }
1038
1163
  /** Consume a heredoc body; returns the index after it, or `null` if unclosed. */
1164
+ /**
1165
+ * The expansions in an unquoted heredoc body, `command[start, end)`: every
1166
+ * `$(…)` for recursive classification, and a backtick or an arithmetic
1167
+ * expansion as opaque. A backslash escapes the next character, as it does in a
1168
+ * body; quotes are literal there, except inside a `$(…)`, which
1169
+ * {@link readSubstitution} scans as the shell does. `null` when a substitution
1170
+ * runs past the body.
1171
+ */
1172
+ function heredocBodyExpansions(command, start, end) {
1173
+ const substitutions = [];
1174
+ let opaque = null;
1175
+ for (let index = start; index < end; index += 1) {
1176
+ const ch = command[index];
1177
+ if (ch === "\\") {
1178
+ index += 1;
1179
+ continue;
1180
+ }
1181
+ if (ch === "`") {
1182
+ opaque = "backtick command substitution in a heredoc body";
1183
+ continue;
1184
+ }
1185
+ if (ch === "$" && command[index + 1] === "[") {
1186
+ opaque = "arithmetic expansion in a heredoc body";
1187
+ continue;
1188
+ }
1189
+ if (ch === "$" && command[index + 1] === "(") {
1190
+ if (command[index + 2] === "(") {
1191
+ opaque = "arithmetic expansion in a heredoc body";
1192
+ continue;
1193
+ }
1194
+ const scan = readSubstitution(command, index + 1);
1195
+ if (scan === null || scan.end >= end)
1196
+ return null;
1197
+ substitutions.push(scan.inner);
1198
+ index = scan.end;
1199
+ }
1200
+ }
1201
+ return { substitutions, opaque };
1202
+ }
1039
1203
  function skipHeredocBody(command, start, heredoc) {
1040
1204
  let index = start;
1041
1205
  for (;;) {
@@ -2473,9 +2637,143 @@ function refineApprovalVerb(positionals, args = []) {
2473
2637
  function refineApproval(ctx) {
2474
2638
  return (refineApprovalVerb(ctx.positionals, ctx.args) ?? { class: GATE_SELF_CLASS, rule: "approval" });
2475
2639
  }
2640
+ // ---------------------------------------------------------------------------
2641
+ // Skill scripts (APRV-502)
2642
+ // ---------------------------------------------------------------------------
2643
+ /**
2644
+ * The class an interpreter run of a skill script takes: SPEC.md §7's
2645
+ * `exec.local`, "tests, lint, build, scripts inside the workspace". No new
2646
+ * class is minted; the classifier simply had no row that emitted this one.
2647
+ */
2648
+ const SKILL_SCRIPT_CLASS = "exec.local";
2649
+ /** The rule id for `bun` / `python` / `python3` running a skill script. */
2650
+ export const SKILL_SCRIPT_RULE = "skill-script";
2651
+ /** The rule id `refineNode` returns for `node` running a skill script. */
2652
+ export const NODE_SKILL_SCRIPT_RULE = "node-skill-script";
2653
+ /**
2654
+ * The flags an interpreter may carry BEFORE a skill script, per binary
2655
+ * (APRV-502). An ALLOWLIST: a flag outside it, a `--flag=value`, a lone `-`
2656
+ * (stdin) or `--` means the rule does not match at all, so the command keeps
2657
+ * exactly the answer it had before this rule existed (`unclassified` for
2658
+ * `bun --cwd /etc …` and `python3 -m x`, opaque for `python3 -c`, `node -e`).
2659
+ *
2660
+ * Every flag here is inert in the sense that matters: it changes neither WHICH
2661
+ * file runs nor where code comes from. A flag that takes a value (`--cwd`,
2662
+ * `--require`, `--import`, `-W`, `-X`) is absent on purpose, because its value
2663
+ * would be a second thing the interpreter loads or a different directory the
2664
+ * script path is read from. The same flags spelled through the environment
2665
+ * (`BUN_OPTIONS`, `NODE_OPTIONS`, `PYTHONPATH`, `PYTHONSTARTUP`, `HOME`) are
2666
+ * refused the same way: any `NAME=value` prefix on the segment declines the
2667
+ * shape ({@link matchRule}, `RuleContext.assigned`).
2668
+ */
2669
+ const SKILL_SCRIPT_FLAGS = {
2670
+ bun: ["--bun", "--smol"],
2671
+ node: ["--no-warnings", "--enable-source-maps", "--experimental-strip-types"],
2672
+ python: [],
2673
+ python3: [],
2674
+ };
2675
+ /**
2676
+ * Python's inert single-letter flags, alone or clustered (`-I`, `-IB`, `-OO`):
2677
+ * isolated mode, unbuffered, no bytecode, ignore `PYTHON*` variables, no user
2678
+ * site, no `site` import, optimise, quiet. None of them takes a value, so a
2679
+ * cluster cannot hide one; `-c`, `-m`, `-W` and `-X` are not letters here.
2680
+ */
2681
+ const PYTHON_SKILL_SCRIPT_FLAGS = /^-[IuBEsSOq]+$/u;
2682
+ /**
2683
+ * One segment of a skill-script path: non-empty, not `.` or `..`, not
2684
+ * flag-shaped, and free of anything a shell could expand (`$`, backtick, a
2685
+ * glob or brace character, a home shortcut) or a platform could read as a
2686
+ * separator (`\`).
2687
+ */
2688
+ function isSkillScriptSegment(segment) {
2689
+ if (segment.length === 0 || segment === "." || segment === "..")
2690
+ return false;
2691
+ if (segment.startsWith("-"))
2692
+ return false;
2693
+ return !/[*?[\]{}$`~\\]/u.test(segment);
2694
+ }
2695
+ /**
2696
+ * Is `word` a skill-script path, in exactly one of the two shapes (APRV-502)?
2697
+ *
2698
+ * - Relative: `skills/<skill>/scripts/<file>`, with any number of leading
2699
+ * `./` normalised away. Four segments, no more: `skills/x/scripts/sub/f.ts`
2700
+ * and `.hermes/skills/x/scripts/f.ts` are not this shape.
2701
+ * - Absolute: a path ending `.hermes/skills/<skill>/scripts/<file>` or
2702
+ * `.hermes/profiles/<p>/skills/<skill>/scripts/<file>`, the same Hermes-home
2703
+ * grammar {@link protectedPathClass} reads (a profile home is a home of its
2704
+ * own, one level down).
2705
+ *
2706
+ * Every segment of either shape, the absolute prefix included, must pass
2707
+ * {@link isSkillScriptSegment}: an empty segment (`skills//x`), a `.` or `..`
2708
+ * anywhere, a glob, a variable or a backtick, and the shape does not match.
2709
+ * An unexpanded `$HERMES_HOME/skills/…` or `~/.hermes/skills/…` is therefore
2710
+ * not this rule; the text cannot say where it points.
2711
+ *
2712
+ * Text only, like everything in this file. Whether the file really sits in
2713
+ * the directory it was spelled in is `src/cli/hook.ts`'s disk pass, which can
2714
+ * only tighten this answer.
2715
+ */
2716
+ export function isSkillScriptPath(word) {
2717
+ if (word.startsWith("/")) {
2718
+ const parts = word.slice(1).split("/");
2719
+ if (!parts.every(isSkillScriptSegment))
2720
+ return false;
2721
+ const n = parts.length;
2722
+ const tail = (offset) => parts[n - offset] === ".hermes" && parts[n - 4] === "skills" && parts[n - 2] === "scripts";
2723
+ if (n >= 5 && tail(5))
2724
+ return true;
2725
+ return n >= 7 && parts[n - 6] === "profiles" && tail(7);
2726
+ }
2727
+ let rest = word;
2728
+ while (rest.startsWith("./"))
2729
+ rest = rest.slice(2);
2730
+ const parts = rest.split("/");
2731
+ return (parts.length === 4 &&
2732
+ parts.every(isSkillScriptSegment) &&
2733
+ parts[0] === "skills" &&
2734
+ parts[2] === "scripts");
2735
+ }
2736
+ /**
2737
+ * The skill script this interpreter argv runs, or `null` when the argv is not
2738
+ * exactly `[allowlisted flags…] <skill-script path> [arguments…]` (APRV-502).
2739
+ *
2740
+ * Words AFTER the script are the script's own arguments and are not read here:
2741
+ * `python3 skills/agent-commons/scripts/search_forum.py --q x` is the shape.
2742
+ * They are still scanned for protected and credential paths by
2743
+ * `classifySegment`, which runs after the table and can only tighten.
2744
+ */
2745
+ export function skillScriptArg(bin, args) {
2746
+ const allowed = SKILL_SCRIPT_FLAGS[bin];
2747
+ if (allowed === undefined)
2748
+ return null;
2749
+ const python = bin === "python" || bin === "python3";
2750
+ for (const arg of args) {
2751
+ if (!arg.startsWith("-"))
2752
+ return isSkillScriptPath(arg) ? arg : null;
2753
+ if (allowed.includes(arg))
2754
+ continue;
2755
+ if (python && PYTHON_SKILL_SCRIPT_FLAGS.test(arg))
2756
+ continue;
2757
+ // Anything else: a value-taking flag, `--flag=value`, inline code (`-c`,
2758
+ // `-e`, `-m`, `-p`), a lone `-` (stdin) or `--`. Not this rule.
2759
+ return null;
2760
+ }
2761
+ return null;
2762
+ }
2763
+ /** `bun` / `python` / `python3` running a skill script; binds the script path. */
2764
+ function refineSkillScript(ctx) {
2765
+ // `matchRule` only lets this row match when `skillScriptArg` found a script,
2766
+ // so the fallback is unreachable; it is spelled as the row's own answer
2767
+ // rather than as a `null`, which would be read as "opaque".
2768
+ const script = skillScriptArg(ctx.bin, ctx.args);
2769
+ return script === null
2770
+ ? { class: SKILL_SCRIPT_CLASS, rule: SKILL_SCRIPT_RULE }
2771
+ : { class: SKILL_SCRIPT_CLASS, rule: SKILL_SCRIPT_RULE, path: script };
2772
+ }
2476
2773
  /**
2477
2774
  * `node` — an inline script is opaque, the gate's own entry point is
2478
- * pass-through, and anything else is a workspace script.
2775
+ * pass-through, a skill script is `exec.local` (APRV-502), and anything else
2776
+ * is a workspace script.
2479
2777
  */
2480
2778
  function refineNode(ctx) {
2481
2779
  if (hasFlag(ctx.args, ["-e", "--eval", "-p", "--print"]))
@@ -2489,6 +2787,14 @@ function refineNode(ctx) {
2489
2787
  rule: "node-approval-cli",
2490
2788
  });
2491
2789
  }
2790
+ // APRV-502. A substituted word is a hole in the text, and an assignment
2791
+ // prefix (`NODE_OPTIONS='--require …'`) is a flag spelled through the
2792
+ // environment, so either declines the skill-script reading and the segment
2793
+ // keeps the answer it had before.
2794
+ const skill = ctx.substituted || ctx.assigned ? null : skillScriptArg("node", ctx.args);
2795
+ if (skill !== null) {
2796
+ return { class: SKILL_SCRIPT_CLASS, rule: NODE_SKILL_SCRIPT_RULE, path: skill };
2797
+ }
2492
2798
  return { class: "files.write.workspace", rule: "node-script" };
2493
2799
  }
2494
2800
  /**
@@ -2686,9 +2992,24 @@ export const COMMAND_RULES = [
2686
2992
  id: "node",
2687
2993
  bins: ["node"],
2688
2994
  class: "files.write.workspace",
2689
- emits: [GATE_SELF_CLASS, "log.sync", "log.advance", "policy.core"],
2995
+ emits: [GATE_SELF_CLASS, "log.sync", "log.advance", "policy.core", SKILL_SCRIPT_CLASS],
2690
2996
  refine: refineNode,
2691
2997
  },
2998
+ // APRV-502. An interpreter running a skill script, `exec.local`. Below every
2999
+ // `bun` row of the package-manager group, so `bun run x`, `bun install` and
3000
+ // `bun --version` keep their classes; `skillScript` makes the row match ONLY
3001
+ // the exact shape, so every other `bun`, `python` or `python3` argv keeps the
3002
+ // answer it had before the row existed. `node` reaches the same answer
3003
+ // inside {@link refineNode}, because its row has no `subs` to sit beside.
3004
+ // Named in {@link CODE_EXECUTING_RULES}: a skill script is code the runtime
3005
+ // did not author.
3006
+ {
3007
+ id: SKILL_SCRIPT_RULE,
3008
+ bins: ["bun", "python", "python3"],
3009
+ class: SKILL_SCRIPT_CLASS,
3010
+ skillScript: true,
3011
+ refine: refineSkillScript,
3012
+ },
2692
3013
  {
2693
3014
  id: "approval",
2694
3015
  bins: ["approval"],
@@ -2799,9 +3120,67 @@ export const COMMAND_RULES = [
2799
3120
  refine: refinePrintenv,
2800
3121
  },
2801
3122
  // -- reads ---------------------------------------------------------------
3123
+ // APRV-508. `date` prints the time or sets the clock, with one binary and
3124
+ // overlapping flags, so it is a shape row rather than a seat in the read
3125
+ // table: only the display forms {@link isDateDisplay} reads match, and every
3126
+ // setting form (`-s`, `--set`, a bare `MMDDhhmm` operand), every file-reading
3127
+ // form (`-f`, `-r`) and everything else keeps the `unclassified` refusal it
3128
+ // had. The only assignments the row admits are the zone and the locale, with
3129
+ // values that cannot name a file (`TZ=Asia/Kolkata date`).
3130
+ {
3131
+ id: "date-read",
3132
+ bins: ["date"],
3133
+ class: "read.shell",
3134
+ shape: isDateDisplay,
3135
+ },
3136
+ // APRV-508. The system-information readers, each a strict allow-grammar in
3137
+ // `sysinfo-argv.ts`: `hostname NAME` sets the host name, `free -s` loops,
3138
+ // `uptime FILE` reads a utmp file, so none of them is a plain seat in the
3139
+ // read table. No assignment rides in front of any of them.
3140
+ {
3141
+ id: "sysinfo-read",
3142
+ bins: ["uptime", "whoami"],
3143
+ class: "read.shell",
3144
+ shape: (args, assignments) => assignments.length === 0 && isBareInfo(args),
3145
+ },
3146
+ {
3147
+ id: "hostname-read",
3148
+ bins: ["hostname"],
3149
+ class: "read.shell",
3150
+ shape: (args, assignments) => assignments.length === 0 && isHostnameDisplay(args),
3151
+ },
3152
+ {
3153
+ id: "df-read",
3154
+ bins: ["df"],
3155
+ class: "read.shell",
3156
+ shape: (args, assignments) => assignments.length === 0 && isDfDisplay(args),
3157
+ },
3158
+ {
3159
+ id: "free-read",
3160
+ bins: ["free"],
3161
+ class: "read.shell",
3162
+ shape: (args, assignments) => assignments.length === 0 && isFreeDisplay(args),
3163
+ },
3164
+ {
3165
+ id: "seq-read",
3166
+ bins: ["seq"],
3167
+ class: "read.shell",
3168
+ shape: (args, assignments) => assignments.length === 0 && isSeqDisplay(args),
3169
+ },
3170
+ // `paste` reads the files it names, and the read scope takes them exactly as
3171
+ // it takes `cat`'s (`paste: "all"` in read-scope.ts).
3172
+ {
3173
+ id: "paste-read",
3174
+ bins: ["paste"],
3175
+ class: "read.shell",
3176
+ shape: (args, assignments) => assignments.length === 0 && isPasteRead(args),
3177
+ },
2802
3178
  {
2803
3179
  id: "read-shell",
2804
3180
  bins: [
3181
+ // APRV-508: `[` is `test` under its other name; the closing `]` is an
3182
+ // argument. Both read nothing but their operands and the file modes.
3183
+ "[",
2805
3184
  "basename",
2806
3185
  "cat",
2807
3186
  "cd",
@@ -3081,6 +3460,43 @@ function protectedRank(surface) {
3081
3460
  const index = PROTECTED_PRECEDENCE.indexOf(surface);
3082
3461
  return index === -1 ? PROTECTED_PRECEDENCE.indexOf("policy.edit") : index;
3083
3462
  }
3463
+ /**
3464
+ * Does this protected surface outrank `account.credential`? (APRV-503.)
3465
+ *
3466
+ * Where the credential class sits in {@link PROTECTED_PRECEDENCE}: below
3467
+ * `log.mutate` and `policy.core`, above `policy.edit` and its routed
3468
+ * sub-classes. `log.mutate` is the one class the open window refuses with no
3469
+ * policy consulted, and `policy.core` is an edit of the gate's own organs; a
3470
+ * credential READ named by a redirection must never displace either of them,
3471
+ * under any policy's ordering of the three. `policy.edit` is prose about the
3472
+ * gate, and a secret leaving the machine is the stricter statement.
3473
+ */
3474
+ function outranksCredential(surface) {
3475
+ return protectedRank(surface) <= protectedRank("policy.core");
3476
+ }
3477
+ /**
3478
+ * The protected path, if any, that outranks a credential answer for this
3479
+ * segment (APRV-503). `null` when none does, which is when the credential tier
3480
+ * may answer at once.
3481
+ *
3482
+ * Scanned as the protected scan reads the segment: the positionals with the
3483
+ * prose skip, and the `<` targets. A credential file is ALSO a protected path
3484
+ * (`.approval/env` lies under the gate's directory), so the words that ARE the
3485
+ * credential read are left out, or every credential read would outrank itself:
3486
+ * the `<` targets that are credential files, and the credential positionals of
3487
+ * a binary that reads what it names. A write-only binary's credential
3488
+ * positional stays in, because for it that word is a write to the gate's
3489
+ * directory (`tee .approval/env < .approval/vault.enc` is `policy.core`, as
3490
+ * `tee .approval/env` is). On equal rank the positional is the one named.
3491
+ */
3492
+ 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);
3495
+ const found = fed === null || (named !== null && protectedRank(named.surface) <= protectedRank(fed.surface))
3496
+ ? named
3497
+ : fed;
3498
+ return found !== null && outranksCredential(found.surface) ? found : null;
3499
+ }
3084
3500
  /**
3085
3501
  * Is this word PROSE that merely spells a protected path, rather than a path?
3086
3502
  * (APRV-409.)
@@ -3198,6 +3614,13 @@ export const CODE_EXECUTING_RULES = [
3198
3614
  "npm-script",
3199
3615
  /** `node <script>` — the plainest spelling of "run what I just wrote". */
3200
3616
  "node-script",
3617
+ /**
3618
+ * `bun` / `python3` / `node` running `skills/<skill>/scripts/<file>`
3619
+ * (APRV-502). A skill script is a file in the workspace an agent may have
3620
+ * written a minute ago, exactly as `node x.mjs` is.
3621
+ */
3622
+ SKILL_SCRIPT_RULE,
3623
+ NODE_SKILL_SCRIPT_RULE,
3201
3624
  /** `npx`, `tsx`, `tsc`, `vitest`, `jest`, `make`, and kin. */
3202
3625
  "workspace-tool",
3203
3626
  /**
@@ -3406,18 +3829,63 @@ function sandboxWrapper(words, start) {
3406
3829
  // can produce (`docs/sandboxed-exec.md`, and APRV-193's Linux follow-up).
3407
3830
  return null;
3408
3831
  }
3832
+ /**
3833
+ * Does this `NAME=value` set a variable that steers which program runs, what
3834
+ * it loads, which file it reads as a zone, locale or converter, or where `cd`
3835
+ * goes? (APRV-508 refutation S1; `HOME` and `CDPATH` from the recheck.)
3836
+ */
3837
+ function isSteeringAssignment(word) {
3838
+ const name = word.slice(0, word.indexOf("="));
3839
+ if (["TZ", "TZDIR", "PATH", "LOCPATH", "GCONV_PATH", "LANG", "LANGUAGE", "HOME", "CDPATH"].includes(name)) {
3840
+ return true;
3841
+ }
3842
+ return name.startsWith("LC_") || name.startsWith("LD_");
3843
+ }
3844
+ /** The directories a shape row's binary may be spelled from, besides a bare name. */
3845
+ const SYSTEM_BIN_DIRS = ["/bin/", "/usr/bin/", "/usr/local/bin/", "/sbin/", "/usr/sbin/"];
3846
+ /**
3847
+ * Is this command word the system's binary rather than a file the agent can
3848
+ * place? A bare name is looked up on PATH; a path in a system directory names
3849
+ * that file. `./date` or `/tmp/x/date` is whatever program sits there, and a
3850
+ * shape row (APRV-508) does not vouch for it.
3851
+ */
3852
+ function isSystemBinWord(word) {
3853
+ if (!word.includes("/"))
3854
+ return true;
3855
+ if (word.includes("..") || word.includes("//"))
3856
+ return false;
3857
+ return SYSTEM_BIN_DIRS.some((dir) => word.startsWith(dir) && !word.slice(dir.length).includes("/"));
3858
+ }
3409
3859
  /**
3410
3860
  * Find the first table row matching this binary, subcommand and argv.
3411
3861
  *
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.
3862
+ * The argv is read for two purposes only: a `probe` row matches an argv that
3863
+ * is nothing but version or help flags (APRV-397), and a `skillScript` row
3864
+ * matches an argv that runs a skill script (APRV-502). No other row reads it.
3865
+ *
3866
+ * `substituted` and `assigned` are read by the `skillScript` row alone: a
3867
+ * substituted word is a hole in the text, and an assignment prefix is a flag
3868
+ * spelled through the environment (`BUN_OPTIONS='--cwd ../x'` makes bun read
3869
+ * a different directory's file), so either one means the shape is not matched
3870
+ * and the segment keeps the answer it had before (fail closed).
3871
+ *
3872
+ * A `shape` row (APRV-508) reads the argv through its own predicate, which is
3873
+ * handed the assignment words so it can admit the harmless ones (`TZ=Zone`)
3874
+ * and decline the rest; a substituted word declines it outright.
3414
3875
  */
3415
- function matchRule(bin, sub, args) {
3876
+ function matchRule(bin, sub, args, substituted = false, assigned = false, assignments = [], binWord = bin) {
3416
3877
  for (const rule of COMMAND_RULES) {
3417
3878
  if (!rule.bins.includes(bin))
3418
3879
  continue;
3419
3880
  if (rule.probe === true && !isVersionProbe(args))
3420
3881
  continue;
3882
+ if (rule.shape !== undefined && (substituted || !isSystemBinWord(binWord) || !rule.shape(args, assignments)))
3883
+ continue;
3884
+ // APRV-502: see the doc comment above; any of the three declines the row.
3885
+ if (rule.skillScript === true &&
3886
+ (substituted || assigned || skillScriptArg(bin, args) === null)) {
3887
+ continue;
3888
+ }
3421
3889
  if (rule.subs !== undefined) {
3422
3890
  if (sub === null || !rule.subs.includes(sub))
3423
3891
  continue;
@@ -3433,7 +3901,19 @@ function classifySegment(segment, protectedPaths, context) {
3433
3901
  // `$(…)` is classified recursively. A substitution that only reads is inert;
3434
3902
  // anything else taints the segment, because its effect happens before the
3435
3903
  // outer command even starts and the outer class would not describe it.
3436
- for (const word of segment.words) {
3904
+ //
3905
+ // Redirection targets are words too, and the shell expands them the same
3906
+ // way (APRV-508 recheck M-R1): `cat a < "x$(rm -rf y)"` runs the `rm` before
3907
+ // `cat` opens anything. Until this loop read them, a substitution in a
3908
+ // target ran with no class of its own, under `read.shell` for a `<` and
3909
+ // under the write class for a `>`, on 0.4.3 as well.
3910
+ const expanded = [
3911
+ ...segment.words,
3912
+ ...segment.redirects.map((redirect) => redirect.target),
3913
+ // APRV-504: the `$(…)` of an unquoted heredoc body run when it is read.
3914
+ { text: "", quoted: false, substitutions: segment.bodySubstitutions },
3915
+ ];
3916
+ for (const word of expanded) {
3437
3917
  for (const inner of word.substitutions) {
3438
3918
  const nested = classifyCommand(inner, protectedPaths, context);
3439
3919
  if (!nested.ok) {
@@ -3457,6 +3937,10 @@ function classifySegment(segment, protectedPaths, context) {
3457
3937
  let cursor = 0;
3458
3938
  while (cursor < words.length && ASSIGNMENT.test(words[cursor]))
3459
3939
  cursor += 1;
3940
+ // APRV-502: read by the skill-script shape only ({@link matchRule}).
3941
+ const assigned = cursor > 0;
3942
+ // APRV-508: the same words, for a `shape` row that admits some.
3943
+ const assignments = words.slice(0, cursor);
3460
3944
  // APRV-193. A sandbox wrapper is not a command: it is a room, and what
3461
3945
  // matters is what runs inside it. `approval sandbox -- npm install` is
3462
3946
  // `deps.add`, and it has to be, in both directions.
@@ -3492,22 +3976,64 @@ function classifySegment(segment, protectedPaths, context) {
3492
3976
  // a write. `2>/dev/null` is the suffix an agent writes on half its reads,
3493
3977
  // and until this it turned every one of them into `files.write.workspace`.
3494
3978
  .filter((target) => !isDiscardTarget(target));
3979
+ // APRV-503: the paths this segment READS through `<`, at any descriptor. They
3980
+ // are judged where a named argument is judged, below: a credential target is
3981
+ // `account.credential` and a protected one joins the effectful scan.
3982
+ const readTargets = segment.redirects
3983
+ .filter((redirect) => redirect.op === "<")
3984
+ .map((redirect) => redirect.target.text);
3985
+ const redirectTargets = segment.redirects.map((redirect) => redirect.target.text);
3495
3986
  // A redirection onto a protected path is a write to that path, whatever the
3496
3987
  // command in front of it was going to do. The CLASS says which surface was
3497
3988
  // aimed at (APRV-198); the RULE stays `redirect-protected`, because the
3498
3989
  // mechanism is unchanged and the hook's tiers and the channel's protected-path
3499
3990
  // view are keyed on the rule.
3500
3991
  const redirected = strictestProtected(writeTargets, protectedPaths);
3992
+ const bin = words[cursor];
3501
3993
  if (redirected !== null) {
3994
+ // APRV-503 recheck (SHOULD-FIX-R1): a segment that writes a protected path
3995
+ // AND reads credential material (`cat .approval/env > APPROVAL.md`, `cat <
3996
+ // ~/.hermes/.env > APPROVAL.md`) carries both classes. The protected class
3997
+ // still answers the segment; the credential touch rides beside it.
3998
+ const writerArgs = bin === undefined ? [] : words.slice(cursor + 1);
3999
+ const writer = bin === undefined ? null : (pathSegments(bin).slice(-1)[0] ?? bin);
4000
+ const touched = (writer === null
4001
+ ? null
4002
+ : credentialTouch(writer, writerArgs, writerArgs.filter((arg) => !isFlag(arg)))) ??
4003
+ redirectCredentialTouch(readTargets, redirectTargets);
3502
4004
  return {
3503
4005
  ok: true,
3504
4006
  class: redirected.surface,
3505
4007
  rule: "redirect-protected",
3506
4008
  path: redirected.path,
4009
+ ...(touched === null ? {} : { also: touched }),
3507
4010
  };
3508
4011
  }
3509
- const bin = words[cursor];
3510
4012
  if (bin === undefined) {
4013
+ // A bare input redirection opens its target, and inside `$(…)` it IS the
4014
+ // read: `$(< ~/.hermes/.env)` is bash for "the file's contents" (APRV-503).
4015
+ // Nothing here can outrank it: there are no positionals, and a protected
4016
+ // write target has already answered above, so the precedence rule the
4017
+ // binary path applies below has nothing to defer to.
4018
+ const bareCredential = redirectCredentialTouch(readTargets, redirectTargets);
4019
+ if (bareCredential !== null)
4020
+ return { ok: true, ...bareCredential };
4021
+ // APRV-508 refutation S1: an assignment on its own sets a shell variable,
4022
+ // and when that variable is already exported (PATH and LANG always are)
4023
+ // the new value reaches every later command on the line. `TZ=/etc/x;
4024
+ // date` opens the file the date row refuses to let `TZ=/etc/x date`
4025
+ // open, and `PATH=/tmp/x; cat f` runs whatever `cat` sits there. So the
4026
+ // variables that pick which program runs, which libraries it loads, or
4027
+ // which file it reads as a zone, a locale or a charset converter are not
4028
+ // answered as reads.
4029
+ const steering = assignments.find((word) => isSteeringAssignment(word));
4030
+ if (steering !== undefined) {
4031
+ return {
4032
+ ok: false,
4033
+ code: "unclassified",
4034
+ detail: `no rule for the assignment ${steering.slice(0, steering.indexOf("="))}=…: it steers which program or file later commands use`,
4035
+ };
4036
+ }
3511
4037
  // `VAR=value` alone, or a bare redirection. `> file` truncates, so it is a
3512
4038
  // write; an assignment on its own touches nothing.
3513
4039
  return writeTargets.length > 0
@@ -3533,15 +4059,86 @@ function classifySegment(segment, protectedPaths, context) {
3533
4059
  return { ok: false, code: "opaque", detail: `${basename} runs inline source` };
3534
4060
  }
3535
4061
  const positionals = args.filter((arg) => !isFlag(arg));
4062
+ // APRV-508 recheck S-R1: `printf -v NAME` assigns NAME, so `printf -v PATH
4063
+ // %s /tmp/x; cat f` is the bare `PATH=/tmp/x` the assignment rule above
4064
+ // refuses, spelled through a builtin. A steering NAME (a subscript stripped:
4065
+ // `PATH[0]` is PATH), or a NAME the text cannot read, is refused.
4066
+ if (basename === "printf") {
4067
+ for (let at = 0; at < args.length; at += 1) {
4068
+ const arg = args[at];
4069
+ if (arg === "--")
4070
+ break;
4071
+ const name = arg === "-v" ? args[at + 1] : arg.startsWith("-v") ? arg.slice(2) : undefined;
4072
+ if (name === undefined)
4073
+ continue;
4074
+ const bare = name.replace(/\[.*$/su, "");
4075
+ if (/[$`]/u.test(name) || isSteeringAssignment(`${bare}=`)) {
4076
+ return {
4077
+ ok: false,
4078
+ code: "unclassified",
4079
+ detail: `no rule for printf -v ${name}: it sets a variable that steers which program or file later commands use`,
4080
+ };
4081
+ }
4082
+ }
4083
+ }
4084
+ // APRV-508 refutation S3: `test -v 'a[$(id)]'` evaluates the subscript as
4085
+ // arithmetic, and bash expands a `$(…)` inside it, so the operand runs a
4086
+ // command. `[` is `test`; both refuse a `-v`/`-R` operand carrying `[`, and
4087
+ // an unquoted substitution, whose output the shell would split into words
4088
+ // (a `-v` and a subscript among them) that this text never shows.
4089
+ if (basename === "test" || basename === "[") {
4090
+ const operandWords = segment.words.slice(cursor + 1);
4091
+ const probe = args.findIndex((arg, at) => (arg === "-v" || arg === "-R") && (args[at + 1] ?? "").includes("["));
4092
+ if (probe !== -1 || operandWords.some((word) => word.substitutions.length > 0 && !word.quoted)) {
4093
+ return {
4094
+ ok: false,
4095
+ code: "unclassified",
4096
+ detail: `no rule for ${basename} with an array subscript or an unquoted substitution: the shell may evaluate it as a command`,
4097
+ };
4098
+ }
4099
+ }
3536
4100
  // Credential material, below the opaque checks so `sudo cat .approval/env`
3537
4101
  // stays opaque (a refusal) rather than being softened into a request, and
3538
4102
  // above the binary table so a reader the table does not know (`base64`,
3539
4103
  // `xxd`, `less`) is named rather than answered `unclassified` (APRV-194).
4104
+ // APRV-503: the same tier read from the redirection targets, at the same
4105
+ // place in the order (`sudo cat < .approval/env` stays a refusal, and `less
4106
+ // < .approval/env` is named rather than `unclassified`).
4107
+ //
4108
+ // A credential path, named or redirected, never lowers a `log.mutate` or
4109
+ // `policy.core` answer (APRV-503 refutation, BLOCKING-1 and its named-argument
4110
+ // twin). Both classes outrank `account.credential` ({@link
4111
+ // outranksCredential}), and the open window refuses `log.mutate` with no
4112
+ // policy consulted while it bypasses `account.credential` under a policy that
4113
+ // does not load: `rm -rf .approval/log < .approval/env` and `cp .approval/env
4114
+ // .approval/log/events.jsonl` were both relabelled `account.credential` by an
4115
+ // early return here, and so both reached the log through a window. So the
4116
+ // credential answer is taken HERE only when the segment names no protected
4117
+ // path that outranks it. When one is named the answer is DEFERRED: the
4118
+ // segment goes on through the table and the protected scan exactly as it
4119
+ // would without the credential word, a protected class or a refusal stands,
4120
+ // and the credential class answers only where that walk ends on an answer it
4121
+ // is stricter than (a read, `gate.self`). Where the walk ends on the
4122
+ // protected class, the credential touch is carried beside it as a second
4123
+ // class of the command (`also`), never dropped. Write targets need no check
4124
+ // here: a protected one has already answered above, carrying it the same way.
3540
4125
  const credential = credentialTouch(basename, args, positionals);
3541
- if (credential !== null)
3542
- return { ok: true, ...credential };
4126
+ const redirectCredential = redirectCredentialTouch(readTargets, redirectTargets);
4127
+ const outranking = credential === null && redirectCredential === null
4128
+ ? null
4129
+ : outrankingProtected(positionals, readTargets, credentialWritesOnly(basename, args), protectedPaths);
4130
+ if (outranking === null) {
4131
+ if (credential !== null)
4132
+ return { ok: true, ...credential };
4133
+ if (redirectCredential !== null)
4134
+ return { ok: true, ...redirectCredential };
4135
+ }
4136
+ const deferredCredential = credential ?? redirectCredential;
3543
4137
  const sub = positionals[0] ?? null;
3544
- const rule = matchRule(basename, sub, args);
4138
+ const substituted = segment.words
4139
+ .slice(cursor + 1)
4140
+ .some((word) => word.substitutions.length > 0);
4141
+ const rule = matchRule(basename, sub, args, substituted, assigned, assignments, bin);
3545
4142
  if (rule === null) {
3546
4143
  return {
3547
4144
  ok: false,
@@ -3551,10 +4148,7 @@ function classifySegment(segment, protectedPaths, context) {
3551
4148
  : `no rule for ${basename} ${sub}`,
3552
4149
  };
3553
4150
  }
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 };
4151
+ const ctx = { bin: basename, args, positionals, sub, substituted, assigned, context };
3558
4152
  const refined = rule.refine === undefined ? null : rule.refine(ctx);
3559
4153
  if (rule.refine !== undefined && refined === null) {
3560
4154
  return { ok: false, code: "opaque", detail: `${basename} runs inline source` };
@@ -3582,12 +4176,59 @@ function classifySegment(segment, protectedPaths, context) {
3582
4176
  // a directory path is a sentence rather than a file. Redirection targets
3583
4177
  // above, and the apply-patch and file-tool paths elsewhere, are paths by
3584
4178
  // construction and are not offered the skip.
4179
+ //
4180
+ // APRV-503: an INPUT redirection's target is scanned here too, as the named
4181
+ // argument it stands in for (`node x.js < APPROVAL.md` is `node x.js
4182
+ // APPROVAL.md` with the file on stdin), and without the prose skip, because a
4183
+ // redirection target is a path by construction. Its direction is known to be a
4184
+ // read, so this is the stricter of two defensible answers, which is the one
4185
+ // the protected tier takes everywhere else. A read segment keeps `read.*`
4186
+ // either way: `cat < APPROVAL.md` reads the policy as `cat APPROVAL.md` does.
4187
+ //
4188
+ // The rule, stated exactly. The strictest surface among the positionals and
4189
+ // the `<` targets answers; on equal rank the positional's path is the one
4190
+ // bound. So a `<` target can move an effectful segment only to a STRICTER
4191
+ // protected class than its positionals earned (`node CLAUDE.md < APPROVAL.md`
4192
+ // is `policy.core`), never to a looser one, and a segment whose positionals
4193
+ // earn no protected class can only move from its own class to a protected one.
3585
4194
  if (!cls.startsWith("read.") && cls !== GATE_SELF_CLASS) {
4195
+ // A deferred credential answer (above) names the path that outranked it,
4196
+ // which is the strictest protected path among the words that are not the
4197
+ // credential read itself: the segment's answer without that word.
4198
+ //
4199
+ // The credential touch is NOT dropped (APRV-503 recheck, SHOULD-FIX-R1): it
4200
+ // rides beside the protected class as a second class of the command, so
4201
+ // the precedence above decides what the segment is called and never what a
4202
+ // policy gets to see. A policy holding `account.credential` stricter than
4203
+ // `policy.core` still refuses `cp .approval/env APPROVAL.md`, and SPEC.md
4204
+ // §7's "a copy is classified `account.credential` in either direction"
4205
+ // holds as written.
4206
+ if (deferredCredential !== null && outranking !== null) {
4207
+ return {
4208
+ ok: true,
4209
+ class: outranking.surface,
4210
+ rule: "protected-path",
4211
+ path: outranking.path,
4212
+ also: deferredCredential,
4213
+ };
4214
+ }
3586
4215
  const named = strictestProtected(positionals, protectedPaths, true);
3587
- if (named !== null) {
3588
- return { ok: true, class: named.surface, rule: "protected-path", path: named.path };
4216
+ const fed = strictestProtected(readTargets, protectedPaths);
4217
+ const touched = fed === null || (named !== null && protectedRank(named.surface) <= protectedRank(fed.surface))
4218
+ ? named
4219
+ : fed;
4220
+ if (touched !== null) {
4221
+ return { ok: true, class: touched.surface, rule: "protected-path", path: touched.path };
3589
4222
  }
3590
4223
  }
4224
+ // APRV-503: a credential answer deferred above, because the segment also
4225
+ // names a protected path that outranks it. The walk did not end on that
4226
+ // protected class (a reader, or the gate's own CLI, is not scanned), so it
4227
+ // ended on an answer the credential class is stricter than, and the credential
4228
+ // class answers: `cat .approval/log/events.jsonl < .approval/env` reads the
4229
+ // environment map. A named credential is reported ahead of a redirected one.
4230
+ if (deferredCredential !== null)
4231
+ return { ok: true, ...deferredCredential };
3591
4232
  // A read command with a write redirection writes. `ls > out.txt` creates a
3592
4233
  // file, and the class has to say so.
3593
4234
  if (cls.startsWith("read.") && writeTargets.length > 0) {
@@ -3692,6 +4333,20 @@ export function classifyCommand(command, protectedPaths = [], context = {}) {
3692
4333
  });
3693
4334
  if (!classes.includes(outcome.class))
3694
4335
  classes.push(outcome.class);
4336
+ // APRV-503 recheck: the credential touch a protected answer outranked, as
4337
+ // its own entry over the same segment text. The shape a pipeline already
4338
+ // has (one entry per class-bearing part), so every reader of `segments`
4339
+ // and `classes` sees it without learning a new field.
4340
+ if (outcome.also !== undefined) {
4341
+ segments.push({
4342
+ text: segment.text,
4343
+ class: outcome.also.class,
4344
+ rule: outcome.also.rule,
4345
+ ...(outcome.also.path === undefined ? {} : { path: outcome.also.path }),
4346
+ });
4347
+ if (!classes.includes(outcome.also.class))
4348
+ classes.push(outcome.also.class);
4349
+ }
3695
4350
  }
3696
4351
  return { ok: true, segments, classes };
3697
4352
  }