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.
- 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 +19 -0
- package/dist/src/core/command-class.js +492 -15
- 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/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 (;;) {
|
|
@@ -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
|
-
|
|
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
|
-
|
|
3720
|
-
|
|
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
|
-
|
|
3766
|
-
|
|
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
|
}
|