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.
- package/dist/src/cli/hook.d.ts +25 -2
- package/dist/src/cli/hook.js +258 -6
- package/dist/src/cli/hook.js.map +1 -1
- package/dist/src/cli/wordmark.d.ts +2 -2
- package/dist/src/cli/wordmark.js +2 -2
- package/dist/src/core/command-class.d.ts +76 -0
- package/dist/src/core/command-class.js +678 -23
- package/dist/src/core/command-class.js.map +1 -1
- package/dist/src/core/date-argv.d.ts +83 -0
- package/dist/src/core/date-argv.js +212 -0
- package/dist/src/core/date-argv.js.map +1 -0
- package/dist/src/core/live-draw.d.ts +12 -2
- package/dist/src/core/live-draw.js +12 -2
- package/dist/src/core/live-draw.js.map +1 -1
- package/dist/src/core/read-scope.js +1 -0
- package/dist/src/core/read-scope.js.map +1 -1
- package/dist/src/core/sysinfo-argv.d.ts +48 -0
- package/dist/src/core/sysinfo-argv.js +340 -0
- package/dist/src/core/sysinfo-argv.js.map +1 -0
- package/dist/src/daemon/git-evidence.d.ts +2 -2
- package/dist/src/daemon/git-evidence.js +1 -1
- package/package.json +2 -2
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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`
|
|
896
|
-
|
|
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,
|
|
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
|
|
3413
|
-
*
|
|
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
|
-
|
|
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
|
-
|
|
3542
|
-
|
|
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
|
|
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
|
|
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
|
-
|
|
3588
|
-
|
|
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
|
}
|