approval-md 0.4.3 → 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 (;;) {
@@ -2956,9 +3120,67 @@ export const COMMAND_RULES = [
2956
3120
  refine: refinePrintenv,
2957
3121
  },
2958
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
+ },
2959
3178
  {
2960
3179
  id: "read-shell",
2961
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
+ "[",
2962
3184
  "basename",
2963
3185
  "cat",
2964
3186
  "cd",
@@ -3238,6 +3460,43 @@ function protectedRank(surface) {
3238
3460
  const index = PROTECTED_PRECEDENCE.indexOf(surface);
3239
3461
  return index === -1 ? PROTECTED_PRECEDENCE.indexOf("policy.edit") : index;
3240
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
+ }
3241
3500
  /**
3242
3501
  * Is this word PROSE that merely spells a protected path, rather than a path?
3243
3502
  * (APRV-409.)
@@ -3570,6 +3829,33 @@ function sandboxWrapper(words, start) {
3570
3829
  // can produce (`docs/sandboxed-exec.md`, and APRV-193's Linux follow-up).
3571
3830
  return null;
3572
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
+ }
3573
3859
  /**
3574
3860
  * Find the first table row matching this binary, subcommand and argv.
3575
3861
  *
@@ -3582,13 +3868,19 @@ function sandboxWrapper(words, start) {
3582
3868
  * spelled through the environment (`BUN_OPTIONS='--cwd ../x'` makes bun read
3583
3869
  * a different directory's file), so either one means the shape is not matched
3584
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.
3585
3875
  */
3586
- function matchRule(bin, sub, args, substituted = false, assigned = false) {
3876
+ function matchRule(bin, sub, args, substituted = false, assigned = false, assignments = [], binWord = bin) {
3587
3877
  for (const rule of COMMAND_RULES) {
3588
3878
  if (!rule.bins.includes(bin))
3589
3879
  continue;
3590
3880
  if (rule.probe === true && !isVersionProbe(args))
3591
3881
  continue;
3882
+ if (rule.shape !== undefined && (substituted || !isSystemBinWord(binWord) || !rule.shape(args, assignments)))
3883
+ continue;
3592
3884
  // APRV-502: see the doc comment above; any of the three declines the row.
3593
3885
  if (rule.skillScript === true &&
3594
3886
  (substituted || assigned || skillScriptArg(bin, args) === null)) {
@@ -3609,7 +3901,19 @@ function classifySegment(segment, protectedPaths, context) {
3609
3901
  // `$(…)` is classified recursively. A substitution that only reads is inert;
3610
3902
  // anything else taints the segment, because its effect happens before the
3611
3903
  // outer command even starts and the outer class would not describe it.
3612
- 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) {
3613
3917
  for (const inner of word.substitutions) {
3614
3918
  const nested = classifyCommand(inner, protectedPaths, context);
3615
3919
  if (!nested.ok) {
@@ -3635,6 +3939,8 @@ function classifySegment(segment, protectedPaths, context) {
3635
3939
  cursor += 1;
3636
3940
  // APRV-502: read by the skill-script shape only ({@link matchRule}).
3637
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);
3638
3944
  // APRV-193. A sandbox wrapper is not a command: it is a room, and what
3639
3945
  // matters is what runs inside it. `approval sandbox -- npm install` is
3640
3946
  // `deps.add`, and it has to be, in both directions.
@@ -3670,22 +3976,64 @@ function classifySegment(segment, protectedPaths, context) {
3670
3976
  // a write. `2>/dev/null` is the suffix an agent writes on half its reads,
3671
3977
  // and until this it turned every one of them into `files.write.workspace`.
3672
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);
3673
3986
  // A redirection onto a protected path is a write to that path, whatever the
3674
3987
  // command in front of it was going to do. The CLASS says which surface was
3675
3988
  // aimed at (APRV-198); the RULE stays `redirect-protected`, because the
3676
3989
  // mechanism is unchanged and the hook's tiers and the channel's protected-path
3677
3990
  // view are keyed on the rule.
3678
3991
  const redirected = strictestProtected(writeTargets, protectedPaths);
3992
+ const bin = words[cursor];
3679
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);
3680
4004
  return {
3681
4005
  ok: true,
3682
4006
  class: redirected.surface,
3683
4007
  rule: "redirect-protected",
3684
4008
  path: redirected.path,
4009
+ ...(touched === null ? {} : { also: touched }),
3685
4010
  };
3686
4011
  }
3687
- const bin = words[cursor];
3688
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
+ }
3689
4037
  // `VAR=value` alone, or a bare redirection. `> file` truncates, so it is a
3690
4038
  // write; an assignment on its own touches nothing.
3691
4039
  return writeTargets.length > 0
@@ -3711,18 +4059,86 @@ function classifySegment(segment, protectedPaths, context) {
3711
4059
  return { ok: false, code: "opaque", detail: `${basename} runs inline source` };
3712
4060
  }
3713
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
+ }
3714
4100
  // Credential material, below the opaque checks so `sudo cat .approval/env`
3715
4101
  // stays opaque (a refusal) rather than being softened into a request, and
3716
4102
  // above the binary table so a reader the table does not know (`base64`,
3717
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.
3718
4125
  const credential = credentialTouch(basename, args, positionals);
3719
- if (credential !== null)
3720
- 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;
3721
4137
  const sub = positionals[0] ?? null;
3722
4138
  const substituted = segment.words
3723
4139
  .slice(cursor + 1)
3724
4140
  .some((word) => word.substitutions.length > 0);
3725
- const rule = matchRule(basename, sub, args, substituted, assigned);
4141
+ const rule = matchRule(basename, sub, args, substituted, assigned, assignments, bin);
3726
4142
  if (rule === null) {
3727
4143
  return {
3728
4144
  ok: false,
@@ -3760,12 +4176,59 @@ function classifySegment(segment, protectedPaths, context) {
3760
4176
  // a directory path is a sentence rather than a file. Redirection targets
3761
4177
  // above, and the apply-patch and file-tool paths elsewhere, are paths by
3762
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.
3763
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
+ }
3764
4215
  const named = strictestProtected(positionals, protectedPaths, true);
3765
- if (named !== null) {
3766
- 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 };
3767
4222
  }
3768
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 };
3769
4232
  // A read command with a write redirection writes. `ls > out.txt` creates a
3770
4233
  // file, and the class has to say so.
3771
4234
  if (cls.startsWith("read.") && writeTargets.length > 0) {
@@ -3870,6 +4333,20 @@ export function classifyCommand(command, protectedPaths = [], context = {}) {
3870
4333
  });
3871
4334
  if (!classes.includes(outcome.class))
3872
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
+ }
3873
4350
  }
3874
4351
  return { ok: true, segments, classes };
3875
4352
  }