@lannguyensi/harness 0.53.0 → 0.55.0

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.
Files changed (63) hide show
  1. package/CHANGELOG.md +70 -1
  2. package/dist/cli/delegate/index.d.ts +15 -2
  3. package/dist/cli/delegate/index.js +128 -4
  4. package/dist/cli/delegate/index.js.map +1 -1
  5. package/dist/cli/doctor/bypass-without-auto-approve.js +10 -4
  6. package/dist/cli/doctor/bypass-without-auto-approve.js.map +1 -1
  7. package/dist/cli/dry-run.js +32 -1
  8. package/dist/cli/dry-run.js.map +1 -1
  9. package/dist/cli/index.js +13 -5
  10. package/dist/cli/index.js.map +1 -1
  11. package/dist/cli/init/profiles.js +89 -8
  12. package/dist/cli/init/profiles.js.map +1 -1
  13. package/dist/cli/init/templates.js +163 -3
  14. package/dist/cli/init/templates.js.map +1 -1
  15. package/dist/cli/pack/hook-pre-tool-use.js +217 -95
  16. package/dist/cli/pack/hook-pre-tool-use.js.map +1 -1
  17. package/dist/cli/record/index.d.ts +8 -0
  18. package/dist/cli/record/index.js +55 -7
  19. package/dist/cli/record/index.js.map +1 -1
  20. package/dist/cli/validate/checks.js +61 -9
  21. package/dist/cli/validate/checks.js.map +1 -1
  22. package/dist/io/extract.d.ts +29 -0
  23. package/dist/io/extract.js +36 -0
  24. package/dist/io/extract.js.map +1 -1
  25. package/dist/io/read-regular-file.d.ts +33 -2
  26. package/dist/io/read-regular-file.js +41 -8
  27. package/dist/io/read-regular-file.js.map +1 -1
  28. package/dist/policies/index.d.ts +1 -1
  29. package/dist/policies/index.js +1 -1
  30. package/dist/policies/index.js.map +1 -1
  31. package/dist/policy-packs/builtin/understanding-before-execution/delegation-markers.d.ts +71 -4
  32. package/dist/policy-packs/builtin/understanding-before-execution/delegation-markers.js +75 -4
  33. package/dist/policy-packs/builtin/understanding-before-execution/delegation-markers.js.map +1 -1
  34. package/dist/policy-packs/builtin/understanding-before-execution/index.d.ts +1 -1
  35. package/dist/policy-packs/builtin/understanding-before-execution/index.js +1 -1
  36. package/dist/policy-packs/builtin/understanding-before-execution/index.js.map +1 -1
  37. package/dist/policy-packs/builtin/understanding-before-execution/permission-mode-observations.js +9 -3
  38. package/dist/policy-packs/builtin/understanding-before-execution/permission-mode-observations.js.map +1 -1
  39. package/dist/runtime/destructive-shell-floor.d.ts +19 -0
  40. package/dist/runtime/destructive-shell-floor.js +597 -0
  41. package/dist/runtime/destructive-shell-floor.js.map +1 -0
  42. package/dist/runtime/intercept.d.ts +10 -7
  43. package/dist/runtime/intercept.js +108 -9
  44. package/dist/runtime/intercept.js.map +1 -1
  45. package/dist/runtime/read-only-bash.d.ts +54 -0
  46. package/dist/runtime/read-only-bash.js +775 -7
  47. package/dist/runtime/read-only-bash.js.map +1 -1
  48. package/dist/runtime/risk-classifier.js +72 -13
  49. package/dist/runtime/risk-classifier.js.map +1 -1
  50. package/dist/runtime/when-eval.d.ts +18 -5
  51. package/dist/runtime/when-eval.js +64 -11
  52. package/dist/runtime/when-eval.js.map +1 -1
  53. package/dist/runtime/workflow-policies.d.ts +43 -1
  54. package/dist/runtime/workflow-policies.js +158 -1
  55. package/dist/runtime/workflow-policies.js.map +1 -1
  56. package/dist/schema/extract.d.ts +4 -0
  57. package/dist/schema/extract.js +64 -1
  58. package/dist/schema/extract.js.map +1 -1
  59. package/dist/schema/index.d.ts +13 -0
  60. package/dist/schema/policies.d.ts +19 -0
  61. package/dist/schema/policies.js +17 -1
  62. package/dist/schema/policies.js.map +1 -1
  63. package/package.json +1 -1
@@ -617,17 +617,25 @@ const VERSION_OR_HELP_FLAGS = new Set([
617
617
  ]);
618
618
  /**
619
619
  * True when `trimmed` contains a shell chaining (`;`, `&`, `|`),
620
- * redirection (`<`, `>`), or command-substitution (backtick, `$(`)
621
- * metacharacter that could hide a write behind an otherwise-provable
622
- * read. Shared by `isReadOnlyBashCommand` and `isReadOnlyKubectlCommand`
623
- * below (extracted, task da823721, to keep their identical preamble
624
- * from being flagged as new copy-paste by the duplication gate). NOT
625
- * reused by `isReadOnlyBashPipeline`, which deliberately admits a bare
626
- * `|` (see its own doc comment) and so needs a narrower reject set.
620
+ * redirection (`<`, `>`), command-substitution (backtick, `$(`), or a
621
+ * bare carriage return that could hide a write (or, for `curl`'s `-H`
622
+ * values, a header-injection newline a lenient parser folds on) behind
623
+ * an otherwise-provable read. Shared by `isReadOnlyBashCommand` and
624
+ * `isReadOnlyKubectlCommand` below (extracted, task da823721, to keep
625
+ * their identical preamble from being flagged as new copy-paste by the
626
+ * duplication gate), by `floorArgv` (`isReadOnlySedCommand`'s own
627
+ * preamble -- `floorArgv`'s only caller since D-013 removed the curl
628
+ * floor it used to also serve), and directly by `isReadOnlyCurlCommand`
629
+ * (task fdaad781, round 2:
630
+ * added `\r` after a measured bare-CR header-injection reaching curl's
631
+ * wire, see docs/risk-gate.md's curl section). NOT reused by
632
+ * `isReadOnlyBashPipeline`, which deliberately admits a bare `|` (see
633
+ * its own doc comment) and so needs a narrower reject set.
627
634
  */
628
635
  function hasUnsafeShellMetachar(trimmed) {
629
636
  return (/[;&|<>]/.test(trimmed) ||
630
637
  trimmed.includes("\n") ||
638
+ trimmed.includes("\r") ||
631
639
  trimmed.includes("`") ||
632
640
  trimmed.includes("$("));
633
641
  }
@@ -974,6 +982,766 @@ export function isReadOnlyKubectlCommand(command) {
974
982
  }
975
983
  return true;
976
984
  }
985
+ // ---------------------------------------------------------------------------
986
+ // `sed` read-only floor, Risk-Classifier ONLY (task 2929c5b7).
987
+ //
988
+ // Placed here beside `isReadOnlyKubectlCommand` above, and wired ONLY at
989
+ // `risk-classifier.ts`'s built-in floor, for the same reason that floor is:
990
+ // `isReadOnlyBashCommand` is consumed DIRECTLY by two other gates, and both
991
+ // short-circuit on it before running their own checks. The
992
+ // solution-acceptance write-guard (`src/cli/pack/hook-solution-acceptance-
993
+ // writeguard.ts`) returns `blocked: false` for anything
994
+ // `isReadOnlyBashCommand` accepts BEFORE it ever looks at whether the
995
+ // command references the protected verdict directory. Teaching the shared
996
+ // predicate about `sed` therefore widens that guard: review round 2 of this
997
+ // task measured `sed 's/a/b/w <verdict-dir>/marker.json' f` and
998
+ // `sed -n 'w <verdict-dir>/marker.json' f` going BLOCKED -> ALLOWED. This
999
+ // predicate must therefore stay OUT of `classifyTokens`; the write-guard
1000
+ // pins in `tests/cli/pack-hook-solution-acceptance-writeguard.test.ts`
1001
+ // fail if that is ever undone.
1002
+ //
1003
+ // NO `curl` FLOOR EXISTS, deliberately (decision D-013, this run). Rounds
1004
+ // 2 and 3 both shipped one and both leaked: round 2's write-flag denylist
1005
+ // missed `-o`/`-O`/`-D`/`-c`/`-K`, and round 3's flag ALLOWLIST still
1006
+ // admitted `-w`/`--write-out`, whose `%output{FILE}` directive writes a
1007
+ // local file since curl 8.3.0 (measured on curl 8.7.1). The recurring
1008
+ // class is the premise, not the list: deciding a curl invocation is inert
1009
+ // requires knowing every curl flag's write capability across curl
1010
+ // versions, and `-H @file` shows the value side has the same problem.
1011
+ // `curl` therefore stays UNCLASSIFIED, like `ssh` and `node -e`, and rides
1012
+ // the `when:` evaluator's fallback: approval-gated in a production
1013
+ // context, never hard-blocked, never floored. Its write-capable spellings
1014
+ // are raised to `high` by `destructive-shell-floor.ts` instead, which only
1015
+ // has to recognise capability it CAN name. See docs/risk-gate.md's
1016
+ // "Unclassified actions and the fail-close rule".
1017
+ //
1018
+ // Design: ALLOWLIST, fail closed (decision D-012, this run). Every token
1019
+ // after the head must be recognised: a known read-only flag, a known
1020
+ // flag's value, or a plain operand. An unknown flag, an unknown short-flag
1021
+ // cluster character, a missing flag value, or anything this module cannot
1022
+ // name forfeits the floor. That is the opposite of the round-2 shape
1023
+ // (a denylist of write flags, where every flag nobody had enumerated
1024
+ // silently passed).
1025
+ //
1026
+ // Forfeiting is cheap: the command simply stays unclassified, which the
1027
+ // `when:` evaluator treats as risk-bearing at the "high" rung (approval-
1028
+ // gated, never hard-blocked at `severity_at_least: critical`). Under-
1029
+ // blocking is not cheap. Over-block a read, never under-block a write.
1030
+ //
1031
+ // `decodeShellWord` is used here on the PERMISSIVE side (to EXEMPT a
1032
+ // command), which `shell-word.ts`'s module header explicitly calls out as
1033
+ // outside its `raw || decoded` direction rule. That is safe here BY
1034
+ // CONSTRUCTION rather than by argument: (1) the decoder returns the RAW
1035
+ // token unchanged for anything it cannot resolve, and a raw token with
1036
+ // quoting still in it does not match any allowlist entry, so an incomplete
1037
+ // decode forfeits the floor; (2) a token whose RAW form is not a flag but
1038
+ // whose DECODED form is (`"-o"`), or vice versa, is rejected outright
1039
+ // rather than classified under either reading.
1040
+ /**
1041
+ * Characters whose UNQUOTED appearance in a token means the shell rewrites
1042
+ * that token into text this classifier never sees, and, critically, can
1043
+ * rewrite ONE token into SEVERAL argv words, which is how a write flag
1044
+ * gets past a token-shape allowlist: `sed $FLAGS f` with
1045
+ * `FLAGS='-i'`, `sed -n 1p {-i,x}` (brace expansion), or a glob whose
1046
+ * match starts with `-`. `$` and a backtick also cover command
1047
+ * substitution that the `hasUnsafeShellMetachar` preamble does not already
1048
+ * refuse (`$(`, a backtick and every chaining metacharacter are refused
1049
+ * there first).
1050
+ *
1051
+ * Quoting is honoured: inside `'single quotes'` every one of these is
1052
+ * literal, and inside `"double quotes"` the glob/brace characters are
1053
+ * literal while `$` and a backtick stay live. That is what keeps
1054
+ * `sed -n '$p' f` on the floor while `sed -n "/$X/p" f` forfeits it.
1055
+ * Quote your script to keep the floor.
1056
+ */
1057
+ const LIVE_EXPANSION_CHARS = new Set([
1058
+ "$", "`", "{", "}", "*", "?", "[", "]",
1059
+ ]);
1060
+ /** Glob/brace characters, literal inside double quotes; `$`/backtick are not. */
1061
+ const DOUBLE_QUOTE_INERT_EXPANSION_CHARS = new Set([
1062
+ "{", "}", "*", "?", "[", "]",
1063
+ ]);
1064
+ /**
1065
+ * True when `token` contains a shell expansion (see `LIVE_EXPANSION_CHARS`)
1066
+ * that is NOT quoted away. An unterminated quote run (the `'A:` / `b'` pair
1067
+ * a whitespace tokenizer produces for `-H 'A: b'`) is treated as quoted to
1068
+ * its end, which is what bash does across the whitespace this tokenizer
1069
+ * split on.
1070
+ */
1071
+ function hasLiveShellExpansion(token) {
1072
+ let quote = null;
1073
+ for (let i = 0; i < token.length; i += 1) {
1074
+ const ch = token[i];
1075
+ if (quote !== "'" && ch === "\\") {
1076
+ i += 1; // backslash escapes the next character outside single quotes
1077
+ continue;
1078
+ }
1079
+ if (quote === null && (ch === "'" || ch === '"')) {
1080
+ quote = ch;
1081
+ continue;
1082
+ }
1083
+ if (quote !== null && ch === quote) {
1084
+ quote = null;
1085
+ continue;
1086
+ }
1087
+ if (quote === "'")
1088
+ continue;
1089
+ if (quote === '"' && DOUBLE_QUOTE_INERT_EXPANSION_CHARS.has(ch))
1090
+ continue;
1091
+ if (LIVE_EXPANSION_CHARS.has(ch))
1092
+ return true;
1093
+ }
1094
+ return false;
1095
+ }
1096
+ /**
1097
+ * Splits a metachar-cleared command into `[head, ...rest]` and applies the
1098
+ * two preconditions the floor below needs: the head must be the exact
1099
+ * binary name (no path prefix, the same tightness as
1100
+ * `isReadOnlyKubectlCommand`'s `tokens[0] !== "kubectl"`), and no token may
1101
+ * carry a live shell expansion. Returns `null` when the command is not a
1102
+ * floorable invocation of `bin`.
1103
+ *
1104
+ * Parameterised by `bin` rather than hard-coded to `sed`: it was shared
1105
+ * with the `curl` floor D-013 removed, and the parameter keeps the head
1106
+ * check honest (an exact match, not a prefix) for whichever binary a
1107
+ * future floor names.
1108
+ */
1109
+ function floorArgv(command, bin) {
1110
+ const trimmed = command.trim();
1111
+ if (trimmed === "")
1112
+ return null;
1113
+ if (hasUnsafeShellMetachar(trimmed))
1114
+ return null;
1115
+ const tokens = trimmed.split(/\s+/);
1116
+ if (tokens[0] !== bin)
1117
+ return null;
1118
+ const rest = tokens.slice(1);
1119
+ if (rest.length === 0)
1120
+ return null;
1121
+ if (rest.some(hasLiveShellExpansion))
1122
+ return null;
1123
+ return rest;
1124
+ }
1125
+ const CLUSTER_FORFEIT = { kind: "forfeit" };
1126
+ /**
1127
+ * Walk a single-dash short-flag cluster CHARACTER BY CHARACTER, which is
1128
+ * the whole point of the floor's short-flag handling: `-ni f` must forfeit
1129
+ * on the `i` even though it starts with an allowlisted `n`.
1130
+ *
1131
+ * Every character must be in `noValueChars` until the first character
1132
+ * `isValueChar` claims. That character consumes the REST of the cluster as
1133
+ * its glued value, or `nextToken` when nothing is glued, and `onValue`
1134
+ * decides whether the value keeps the floor. `onValue` returning `false`,
1135
+ * an empty cluster, or an unrecognised character all forfeit.
1136
+ *
1137
+ * `isReadOnlySedCommand` is its only caller since D-013 removed the `curl`
1138
+ * floor. Kept as its own function rather than inlined: it is the
1139
+ * security-relevant half of the sed floor and is exercised directly by the
1140
+ * cluster cases in tests/runtime/read-only-floors.test.ts.
1141
+ */
1142
+ function walkShortFlagCluster(chars, noValueChars, isValueChar, onValue, nextToken) {
1143
+ if (chars.length === 0)
1144
+ return CLUSTER_FORFEIT;
1145
+ for (let j = 0; j < chars.length; j += 1) {
1146
+ const c = chars[j];
1147
+ if (isValueChar(c)) {
1148
+ const glued = chars.slice(j + 1);
1149
+ if (!onValue(c, glued, nextToken))
1150
+ return CLUSTER_FORFEIT;
1151
+ return { kind: "ok", consumedNext: glued.length === 0 };
1152
+ }
1153
+ if (!noValueChars.has(c))
1154
+ return CLUSTER_FORFEIT;
1155
+ }
1156
+ return { kind: "ok", consumedNext: false };
1157
+ }
1158
+ // --- sed -------------------------------------------------------------------
1159
+ /** `sed` flags that take no value and cannot write. */
1160
+ const SED_READ_ONLY_LONG_FLAGS = new Set([
1161
+ "--quiet", "--silent", "--regexp-extended", "--separate",
1162
+ "--null-data", "--unbuffered", "--posix",
1163
+ ]);
1164
+ /** `sed` flags whose value is a SCRIPT (scanned by `sedScriptIsReadOnly`). */
1165
+ const SED_SCRIPT_LONG_FLAGS = new Set(["--expression"]);
1166
+ /** Short-flag cluster characters that take no value and cannot write. */
1167
+ const SED_READ_ONLY_SHORT_CHARS = new Set([
1168
+ "n", "E", "r", "s", "z", "u",
1169
+ ]);
1170
+ /** Short-flag cluster characters whose value is a SCRIPT. */
1171
+ const SED_SCRIPT_SHORT_CHARS = new Set(["e"]);
1172
+ /**
1173
+ * `sed` read-only floor for the Risk Classifier ONLY.
1174
+ *
1175
+ * CORRECTS A FALSE PREMISE this task shipped in round 1 ("without `-i`,
1176
+ * sed only writes to stdout"). It does not: sed's `w FILE` command and the
1177
+ * `s///w FILE` substitution flag write an operator-named file with no
1178
+ * `-i`, no redirection and no shell metacharacter, and `-f SCRIPTFILE`
1179
+ * executes a script this classifier never reads. GNU sed's `e` command and
1180
+ * `s///e` flag additionally execute a shell command. All four are refused
1181
+ * here.
1182
+ *
1183
+ * `true` requires ALL of: the head is exactly `sed`; no token carries a
1184
+ * live shell expansion; every flag is drawn from the read-only allowlist
1185
+ * above (so `-i` in every spelling: `-i`, `-i.bak`, `-ni`,
1186
+ * `--in-place`, `--in-place=SUFFIX`, and `-f`/`--file` forfeit by never
1187
+ * being ON it, not by being enumerated as write flags); and every script
1188
+ * (each `-e`/`--expression` value, or the first positional operand when no
1189
+ * `-e` was given) parses as a read-only sed program under
1190
+ * `sedScriptIsReadOnly`.
1191
+ */
1192
+ export function isReadOnlySedCommand(command) {
1193
+ const rest = floorArgv(command, "sed");
1194
+ if (rest === null)
1195
+ return false;
1196
+ const scripts = [];
1197
+ const positionals = [];
1198
+ let optionsEnded = false;
1199
+ for (let i = 0; i < rest.length; i += 1) {
1200
+ const raw = rest[i];
1201
+ const decoded = decodeShellWord(raw);
1202
+ if (optionsEnded) {
1203
+ positionals.push(decoded);
1204
+ continue;
1205
+ }
1206
+ const rawIsFlag = raw.startsWith("-") && raw !== "-";
1207
+ const decodedIsFlag = decoded.startsWith("-") && decoded !== "-";
1208
+ // A token that is a flag under exactly one of the two readings is
1209
+ // unclassifiable: `"-i"` reaches sed as `-i` while looking like an
1210
+ // operand. Fail closed rather than pick a reading.
1211
+ if (rawIsFlag !== decodedIsFlag)
1212
+ return false;
1213
+ if (!rawIsFlag) {
1214
+ positionals.push(decoded);
1215
+ continue;
1216
+ }
1217
+ if (decoded === "--") {
1218
+ optionsEnded = true;
1219
+ continue;
1220
+ }
1221
+ if (decoded.startsWith("--")) {
1222
+ const eq = decoded.indexOf("=");
1223
+ const name = eq === -1 ? decoded : decoded.slice(0, eq);
1224
+ if (SED_SCRIPT_LONG_FLAGS.has(name)) {
1225
+ if (eq !== -1) {
1226
+ scripts.push(decoded.slice(eq + 1));
1227
+ continue;
1228
+ }
1229
+ i += 1;
1230
+ if (i >= rest.length)
1231
+ return false; // missing value: fail closed
1232
+ scripts.push(decodeShellWord(rest[i]));
1233
+ continue;
1234
+ }
1235
+ if (eq === -1 && SED_READ_ONLY_LONG_FLAGS.has(name))
1236
+ continue;
1237
+ return false;
1238
+ }
1239
+ // Short-flag cluster, decomposed CHARACTER BY CHARACTER: every
1240
+ // character must be allowlisted, not just the first.
1241
+ const walk = walkShortFlagCluster(decoded.slice(1), SED_READ_ONLY_SHORT_CHARS, (c) => SED_SCRIPT_SHORT_CHARS.has(c), (_c, glued, nextToken) => {
1242
+ if (glued.length > 0) {
1243
+ scripts.push(glued);
1244
+ return true;
1245
+ }
1246
+ if (nextToken === undefined)
1247
+ return false; // missing value: fail closed
1248
+ scripts.push(decodeShellWord(nextToken));
1249
+ return true;
1250
+ }, rest[i + 1]);
1251
+ if (walk.kind === "forfeit")
1252
+ return false;
1253
+ if (walk.consumedNext)
1254
+ i += 1;
1255
+ }
1256
+ // With no `-e`, sed's FIRST positional operand is the script; the rest
1257
+ // are input files (read-only by construction: an operand that looked
1258
+ // like a flag under either reading was already refused above).
1259
+ if (scripts.length === 0) {
1260
+ const first = positionals[0];
1261
+ if (first === undefined)
1262
+ return false; // bare `sed`: nothing to prove
1263
+ scripts.push(first);
1264
+ }
1265
+ return scripts.every(sedScriptIsReadOnly);
1266
+ }
1267
+ /**
1268
+ * sed script commands that consume no argument and cannot write: print,
1269
+ * delete, hold/pattern-space moves, line number, quit, zap, filename.
1270
+ * `l`, `q` and `Q` accept an optional numeric argument, handled below.
1271
+ *
1272
+ * Everything NOT in this set (plus `s`, `y`, and the branch/label
1273
+ * commands handled explicitly) forfeits the floor: `w` and `W` (write a
1274
+ * file), `e` (execute a shell command), `r`/`R` (read a file), `a`/`i`/`c`
1275
+ * (text whose syntax swallows the rest of the script), `#` (comment), and
1276
+ * any command a future sed adds.
1277
+ */
1278
+ const SED_SAFE_NO_ARG_COMMANDS = new Set([
1279
+ "p", "P", "d", "D", "n", "N", "h", "H", "g", "G", "x", "l", "=", "q", "Q", "z", "F",
1280
+ ]);
1281
+ /** Branch/label commands: control flow only, no I/O. */
1282
+ const SED_LABEL_COMMANDS = new Set(["b", "t", "T", ":"]);
1283
+ /** `s///` flags that cannot write or execute. `w` and `e` are NOT here. */
1284
+ const SED_SAFE_SUBSTITUTION_FLAGS = new Set([
1285
+ "g", "p", "i", "I", "m", "M",
1286
+ ]);
1287
+ /**
1288
+ * `true` when `script` parses, end to end, as a sed program built only
1289
+ * from the safe command set above. A deliberately small recursive-descent
1290
+ * scan rather than a substring search for `w`: a "forfeit on any letter
1291
+ * `w`" rule would also forfeit `/warning/p`, and a substring search cannot
1292
+ * see the `e` command at all. Anything the scan does not fully understand
1293
+ * (an unterminated regex, a custom `\cREGEXc` address delimiter, a
1294
+ * command not in the sets above, a trailing argument where a separator
1295
+ * must be) returns `false`, so the failure mode is a forfeited floor,
1296
+ * never an accepted write.
1297
+ */
1298
+ function sedScriptIsReadOnly(script) {
1299
+ const n = script.length;
1300
+ let i = 0;
1301
+ let statements = 0;
1302
+ while (i < n) {
1303
+ const lead = script[i];
1304
+ if (lead === ";" || lead === " " || lead === "\t" || lead === "\n" || lead === "}") {
1305
+ i += 1;
1306
+ continue;
1307
+ }
1308
+ const afterFirst = readSedAddress(script, i);
1309
+ if (afterFirst === -1)
1310
+ return false;
1311
+ i = afterFirst;
1312
+ if (script[i] === ",") {
1313
+ i += 1;
1314
+ const afterSecond = readSedAddress(script, i);
1315
+ if (afterSecond === -1 || afterSecond === i)
1316
+ return false; // second address required
1317
+ i = afterSecond;
1318
+ }
1319
+ while (script[i] === "!")
1320
+ i += 1;
1321
+ while (script[i] === " " || script[i] === "\t")
1322
+ i += 1;
1323
+ const cmd = script[i];
1324
+ if (cmd === undefined)
1325
+ return false; // address with no command
1326
+ i += 1;
1327
+ if (cmd === "{")
1328
+ continue; // block open; the block's statements follow
1329
+ if (SED_SAFE_NO_ARG_COMMANDS.has(cmd)) {
1330
+ while (i < n && script[i] === " ")
1331
+ i += 1;
1332
+ while (i < n && script[i] >= "0" && script[i] <= "9")
1333
+ i += 1; // `l N`, `q N`
1334
+ }
1335
+ else if (cmd === "s" || cmd === "y") {
1336
+ const after = readSedTwoPartCommand(script, i, cmd === "s");
1337
+ if (after === -1)
1338
+ return false;
1339
+ i = after;
1340
+ }
1341
+ else if (SED_LABEL_COMMANDS.has(cmd)) {
1342
+ while (i < n && script[i] === " ")
1343
+ i += 1;
1344
+ while (i < n && /[A-Za-z0-9_]/.test(script[i]))
1345
+ i += 1;
1346
+ }
1347
+ else {
1348
+ return false; // w, W, e, r, R, a, i, c, #, v, and anything unknown
1349
+ }
1350
+ statements += 1;
1351
+ while (i < n && (script[i] === " " || script[i] === "\t"))
1352
+ i += 1;
1353
+ if (i < n) {
1354
+ const sep = script[i];
1355
+ // A command must be followed by a separator. `s/a/b/w file` lands
1356
+ // HERE, on the `w`, because `w` is not an accepted `s///` flag.
1357
+ if (sep !== ";" && sep !== "}" && sep !== "{" && sep !== "\n")
1358
+ return false;
1359
+ }
1360
+ }
1361
+ return statements > 0;
1362
+ }
1363
+ /**
1364
+ * Reads one sed ADDRESS starting at `start`. Returns the index after it,
1365
+ * `start` itself when no address is present (an address is optional), or
1366
+ * `-1` when the address is malformed or uses a form this scan refuses
1367
+ * (`\cREGEXc`, an unterminated `/regex/`).
1368
+ */
1369
+ function readSedAddress(s, start) {
1370
+ let i = start;
1371
+ const c = s[i];
1372
+ if (c === undefined)
1373
+ return i;
1374
+ if (c === "$")
1375
+ return i + 1;
1376
+ if (c === "\\")
1377
+ return -1; // custom address delimiter: fail closed
1378
+ if (c === "+" || c === "~") {
1379
+ i += 1;
1380
+ const digitsFrom = i;
1381
+ while (i < s.length && s[i] >= "0" && s[i] <= "9")
1382
+ i += 1;
1383
+ return i > digitsFrom ? i : -1;
1384
+ }
1385
+ if (c >= "0" && c <= "9") {
1386
+ while (i < s.length && s[i] >= "0" && s[i] <= "9")
1387
+ i += 1;
1388
+ if (s[i] === "~") {
1389
+ i += 1;
1390
+ const digitsFrom = i;
1391
+ while (i < s.length && s[i] >= "0" && s[i] <= "9")
1392
+ i += 1;
1393
+ if (i === digitsFrom)
1394
+ return -1;
1395
+ }
1396
+ return i;
1397
+ }
1398
+ if (c === "/") {
1399
+ i += 1;
1400
+ let closed = false;
1401
+ while (i < s.length) {
1402
+ const ch = s[i];
1403
+ if (ch === "\\") {
1404
+ i += 2;
1405
+ continue;
1406
+ }
1407
+ if (ch === "/") {
1408
+ i += 1;
1409
+ closed = true;
1410
+ break;
1411
+ }
1412
+ i += 1;
1413
+ }
1414
+ if (!closed)
1415
+ return -1;
1416
+ while (s[i] === "I" || s[i] === "M")
1417
+ i += 1;
1418
+ return i;
1419
+ }
1420
+ return i; // no address
1421
+ }
1422
+ /**
1423
+ * Reads an `s<D>regex<D>replacement<D>[flags]` or `y<D>a<D>b<D>` body
1424
+ * starting at the delimiter. Returns the index after it, or `-1` when the
1425
+ * body is malformed or (for `s`) carries a flag outside
1426
+ * `SED_SAFE_SUBSTITUTION_FLAGS`, which is exactly how `s/a/b/w FILE` and
1427
+ * `s/a/b/e` forfeit the floor.
1428
+ */
1429
+ function readSedTwoPartCommand(s, start, isSubstitution) {
1430
+ let i = start;
1431
+ const delim = s[i];
1432
+ if (delim === undefined || delim === "\\" || delim === "\n" || delim === " ")
1433
+ return -1;
1434
+ i += 1;
1435
+ for (let part = 0; part < 2; part += 1) {
1436
+ let closed = false;
1437
+ while (i < s.length) {
1438
+ const ch = s[i];
1439
+ if (ch === "\\") {
1440
+ i += 2;
1441
+ continue;
1442
+ }
1443
+ if (ch === delim) {
1444
+ i += 1;
1445
+ closed = true;
1446
+ break;
1447
+ }
1448
+ i += 1;
1449
+ }
1450
+ if (!closed)
1451
+ return -1;
1452
+ }
1453
+ if (!isSubstitution)
1454
+ return i;
1455
+ while (i < s.length) {
1456
+ const ch = s[i];
1457
+ if (ch >= "0" && ch <= "9") {
1458
+ i += 1;
1459
+ continue;
1460
+ }
1461
+ if (SED_SAFE_SUBSTITUTION_FLAGS.has(ch)) {
1462
+ i += 1;
1463
+ continue;
1464
+ }
1465
+ break;
1466
+ }
1467
+ return i;
1468
+ }
1469
+ /**
1470
+ * Word separator `splitCurlWords` honors: space and tab only, not JS
1471
+ * `\s` (which also matches NBSP, vertical tab, form feed, ideographic
1472
+ * space, and line terminators). Round 2 (task fdaad781) narrowed this
1473
+ * from `\s`: a non-ASCII or exotic whitespace character bash does not
1474
+ * treat as IFS -- an NBSP between `curl` and `-q`, say -- must not glue
1475
+ * two argv words into one here the way bash never would. `\n`/`\r`
1476
+ * never reach this splitter: `isReadOnlyCurlCommand`'s
1477
+ * `hasUnsafeShellMetachar` guard refuses both for the whole command
1478
+ * first.
1479
+ */
1480
+ const CURL_WORD_SEPARATOR_RE = /[ \t]/;
1481
+ /**
1482
+ * Quote-aware word splitter built ONLY for `isReadOnlyCurlCommand`. Unlike
1483
+ * every other floor in this file, which treats `trimmed.split(/\s+/)` as
1484
+ * "the" tokenizer, this floor cannot: that split tears a quoted value
1485
+ * containing a space (`-H 'Accept: application/json'`) into the two raw
1486
+ * fragments `'Accept:` and `application/json'`, which this floor's own
1487
+ * positive fixtures require to parse as ONE quoted header value.
1488
+ *
1489
+ * Supports single and double quotes only; no backslash-escape or
1490
+ * expansion handling of any kind. Fails closed (`null`) on anything it
1491
+ * cannot cleanly resolve: an unterminated quote, or a bare unquoted
1492
+ * backslash outside any quote (this splitter has no escape model for it,
1493
+ * so refusing the whole command is the safe answer, not guessing one).
1494
+ * A word built from more than one quoted/unquoted run glued together with
1495
+ * no separating whitespace comes back `composite: true` rather than being
1496
+ * rejected outright here: it is a real, distinct shell word (bash would
1497
+ * concatenate the runs into one argv entry), just one this floor's shape
1498
+ * never allows, so the caller forfeits on it explicitly.
1499
+ */
1500
+ function splitCurlWords(s) {
1501
+ const words = [];
1502
+ const n = s.length;
1503
+ let i = 0;
1504
+ while (i < n) {
1505
+ while (i < n && CURL_WORD_SEPARATOR_RE.test(s[i]))
1506
+ i += 1;
1507
+ if (i >= n)
1508
+ break;
1509
+ const runs = [];
1510
+ while (i < n && !CURL_WORD_SEPARATOR_RE.test(s[i])) {
1511
+ const ch = s[i];
1512
+ if (ch === "'" || ch === '"') {
1513
+ const close = s.indexOf(ch, i + 1);
1514
+ if (close === -1)
1515
+ return null; // unterminated quote: fail closed
1516
+ runs.push({ quote: ch === "'" ? "single" : "double", text: s.slice(i + 1, close) });
1517
+ i = close + 1;
1518
+ continue;
1519
+ }
1520
+ if (ch === "\\")
1521
+ return null; // unmodelled escape: fail closed
1522
+ const start = i;
1523
+ while (i < n &&
1524
+ !CURL_WORD_SEPARATOR_RE.test(s[i]) &&
1525
+ s[i] !== "'" &&
1526
+ s[i] !== '"' &&
1527
+ s[i] !== "\\") {
1528
+ i += 1;
1529
+ }
1530
+ runs.push({ quote: "none", text: s.slice(start, i) });
1531
+ }
1532
+ if (runs.length === 0)
1533
+ continue;
1534
+ if (runs.length === 1) {
1535
+ words.push({ content: runs[0].text, quote: runs[0].quote, composite: false });
1536
+ }
1537
+ else {
1538
+ words.push({ content: "", quote: "none", composite: true });
1539
+ }
1540
+ }
1541
+ return words;
1542
+ }
1543
+ /**
1544
+ * curl short-flag cluster characters that take no value: no flag on
1545
+ * this list names a file, and the only method any of them selects is
1546
+ * HEAD (`-I`), which is read-only (`-s -S -f -I`, in any combination).
1547
+ * Round 2 (task fdaad781) dropped `-L` (`--location`) and `-k`
1548
+ * (`--insecure`) from this set: `-L` follows a redirect and forwards a
1549
+ * custom `-H` header cross-host over plaintext; `-k` is a deliberate
1550
+ * TLS-verification bypass that should stay approval-gated. Both now
1551
+ * forfeit the shape. See docs/risk-gate.md's curl section for the
1552
+ * measurement behind the `-L` finding.
1553
+ */
1554
+ const CURL_NO_VALUE_SHORT_CHARS = new Set(["s", "S", "f", "I"]);
1555
+ /** curl long flags that take no value: no flag on this list names a file, and the only method any of them selects is HEAD (`--head`), which is read-only. See `CURL_NO_VALUE_SHORT_CHARS` for why `--location` and `--insecure` are not on it. */
1556
+ const CURL_NO_VALUE_LONG_FLAGS = new Set([
1557
+ "--silent", "--show-error", "--fail", "--head",
1558
+ ]);
1559
+ /**
1560
+ * curl long flags whose value is admitted ONLY in the separate-token
1561
+ * form (`--header VALUE`, never `--header=VALUE`, checked and rejected
1562
+ * before this map is consulted). Value maps to the same key as the
1563
+ * matching short flag below, so a repeat count is shared across spellings.
1564
+ */
1565
+ const CURL_VALUE_LONG_FLAGS = new Map([
1566
+ ["--header", "header"],
1567
+ ["--max-time", "maxtime"],
1568
+ ["--connect-timeout", "connecttimeout"],
1569
+ ]);
1570
+ /**
1571
+ * Shape for the ONE operand this floor admits: `https://`, a host made
1572
+ * of letters, digits, `.` and `-` only, an optional `:PORT`, and an
1573
+ * optional path with none of curl's globbing characters (`{}[]`), no
1574
+ * whitespace, backslash, backtick or `$`. No userinfo (`user:pw@`) is
1575
+ * reachable through this shape: the host class has no `:`/`@`, so
1576
+ * `https://user:pw@x` cannot match end-to-end. Round 2 (task fdaad781)
1577
+ * admits `?` and `*` in the path (previously excluded): the operand is
1578
+ * single-quoted, so the shell cannot glob on either character, and
1579
+ * curl's own URL-globbing syntax uses only `{}`/`[]`, never `?`/`*`,
1580
+ * so admitting them widens no shape gap and closes the most common
1581
+ * single-parameter read-only query-string spelling this floor used to
1582
+ * forfeit (a MULTI-parameter query string joined by `&` still forfeits
1583
+ * even single-quoted -- see docs/risk-gate.md's Residuals for why that
1584
+ * stays out of scope). See docs/risk-gate.md's curl section for the
1585
+ * measurement behind admitting `?`/`*`. Do not "clean up" further
1586
+ * without re-deriving it.
1587
+ */
1588
+ const CURL_SHAPE_URL_RE = /^https:\/\/[A-Za-z0-9.-]+(:[0-9]+)?(\/[^'\s{}[\]\\`$]*)?$/;
1589
+ /**
1590
+ * `true` when `value` is an admissible VALUE for the value-taking flag
1591
+ * `key` (`"header"` or one of the two timeout keys). `composite` always
1592
+ * forfeits (a glued flag+value pair like `-H'X: y'` never reaches a
1593
+ * legitimate value this way). A header value must be quoted (either
1594
+ * quote character), must not start with `@` after quote-stripping and
1595
+ * leading-whitespace trimming (a local-file read; the trim keeps the
1596
+ * ALLOW decision on the shape rather than on curl's own header parser,
1597
+ * round 4), and must carry no `$` or backtick. A bare `\n` or
1598
+ * `\r` can never reach here: `isReadOnlyCurlCommand`'s whole-command
1599
+ * `hasUnsafeShellMetachar` guard already refuses both for the entire
1600
+ * command before any word is split out, so this function forfeits on
1601
+ * neither character itself (round 3, task fdaad781: they were dropped
1602
+ * from this regex as dead, unreachable code; see docs/risk-gate.md's
1603
+ * curl section for the measurement that motivated the `\r` guard in
1604
+ * the first place). A timeout value must be a bare non-negative
1605
+ * integer, `^[0-9]+$` exactly.
1606
+ */
1607
+ function isAllowedCurlFlagValue(key, value) {
1608
+ if (value.composite)
1609
+ return false;
1610
+ if (key === "header") {
1611
+ if (value.quote === "none")
1612
+ return false;
1613
+ if (value.content.trimStart().startsWith("@"))
1614
+ return false;
1615
+ return !/[\x60$]/.test(value.content);
1616
+ }
1617
+ return /^[0-9]+$/.test(value.content);
1618
+ }
1619
+ /**
1620
+ * Consume a value-taking flag's value at `words[i + 1]` (the shape
1621
+ * requires the separate-token form): validates it and enforces "never
1622
+ * repeated" for `key`, updating `valueFlagCounts` in place. Returns the
1623
+ * loop index to resume at (`i + 1`, the value's own index, since the
1624
+ * `for` loop's own `i += 1` then advances past it), or `null` to forfeit
1625
+ * the whole command. Shared by both value-taking-flag call sites in
1626
+ * `isReadOnlyCurlCommand` (the long-flag branch and the standalone
1627
+ * `-H`/`-m` branch) so the repeat-count bookkeeping has one home.
1628
+ */
1629
+ function consumeCurlFlagValue(words, i, key, valueFlagCounts) {
1630
+ const value = words[i + 1];
1631
+ if (value === undefined || !isAllowedCurlFlagValue(key, value))
1632
+ return null;
1633
+ const count = (valueFlagCounts.get(key) ?? 0) + 1;
1634
+ if (count > 1)
1635
+ return null; // repeated value-taking flag: forfeit
1636
+ valueFlagCounts.set(key, count);
1637
+ return i + 1;
1638
+ }
1639
+ /**
1640
+ * curl read-only SHAPE floor for the Risk Classifier ONLY (task fdaad781,
1641
+ * decision D-026, round 2 hardening). Composes exactly like the sed and
1642
+ * kubectl floors above: it never sinks below an operator classifier,
1643
+ * and only the DESTRUCTIVE floor (evaluated first in
1644
+ * `risk-classifier.ts`) can raise above it. This comment states only
1645
+ * the grammar; see docs/risk-gate.md's "curl read-only SHAPE floor"
1646
+ * section for the full history (D-013's two leaked flag lists), the
1647
+ * round-2 changes (mandatory `-q`, dropped `-L`/`-k`, the `\r`/query-
1648
+ * string fixes), the residuals, and the operator escape hatch.
1649
+ *
1650
+ * `true` requires ALL of: no shell chaining, redirection, substitution,
1651
+ * or background `&` anywhere in the whole command (the same whole-string
1652
+ * guard every other floor in this file uses); the head word is exactly
1653
+ * `curl` (unquoted, not composite -- no path prefix, no `env`/`command`/
1654
+ * `sudo`/other wrapper, those are other heads and forfeit here); the
1655
+ * SECOND word is exactly `-q` or `--disable` (unquoted, not composite,
1656
+ * not inside a cluster, not merely present later -- curl only honors it
1657
+ * as the first argument, and skipping it leaves `$CURL_HOME/.curlrc` /
1658
+ * `$XDG_CONFIG_HOME/curlrc` / `~/.curlrc` free to set `output`,
1659
+ * `request`, `data`, or `upload-file` underneath every other spelling
1660
+ * this shape would otherwise accept -- see docs/risk-gate.md's curl
1661
+ * section for the measurement); every remaining word is either a flag
1662
+ * drawn from the closed allowlists above (no flag on this list names a
1663
+ * file, and the only method any of them selects is HEAD, which is
1664
+ * read-only; curl's config surface is closed off by the mandatory
1665
+ * `-q`; a value-taking flag consumed only in its separate-token form,
1666
+ * and never repeated) or the SINGLE operand, which must be a single-quoted,
1667
+ * unbroken word matching `CURL_SHAPE_URL_RE`. Any composite word, any
1668
+ * unknown flag, any `=`-glued flag, any second operand, or a missing
1669
+ * operand forfeits the whole command.
1670
+ */
1671
+ export function isReadOnlyCurlCommand(command) {
1672
+ const trimmed = command.trim();
1673
+ if (trimmed === "")
1674
+ return false;
1675
+ if (hasUnsafeShellMetachar(trimmed))
1676
+ return false;
1677
+ const words = splitCurlWords(trimmed);
1678
+ if (words === null || words.length === 0)
1679
+ return false;
1680
+ const head = words[0];
1681
+ if (head.composite || head.quote !== "none" || head.content !== "curl")
1682
+ return false;
1683
+ // Mandatory `-q`/`--disable` as the SECOND word (curl only honors it
1684
+ // as the first argument): closes the curlrc auto-load residual, see
1685
+ // this function's docstring above.
1686
+ const disableWord = words[1];
1687
+ if (disableWord === undefined ||
1688
+ disableWord.composite ||
1689
+ disableWord.quote !== "none" ||
1690
+ (disableWord.content !== "-q" && disableWord.content !== "--disable")) {
1691
+ return false;
1692
+ }
1693
+ let urlWord = null;
1694
+ const valueFlagCounts = new Map();
1695
+ for (let i = 2; i < words.length; i += 1) {
1696
+ const w = words[i];
1697
+ if (w.composite)
1698
+ return false;
1699
+ if (w.quote === "none" && w.content.startsWith("-") && w.content !== "-") {
1700
+ const t = w.content;
1701
+ if (t.startsWith("--")) {
1702
+ if (t.includes("="))
1703
+ return false; // `--flag=value` glued form: forfeit
1704
+ if (CURL_NO_VALUE_LONG_FLAGS.has(t))
1705
+ continue;
1706
+ const key = CURL_VALUE_LONG_FLAGS.get(t);
1707
+ if (key === undefined)
1708
+ return false; // not on the closed list
1709
+ const next = consumeCurlFlagValue(words, i, key, valueFlagCounts);
1710
+ if (next === null)
1711
+ return false;
1712
+ i = next;
1713
+ continue;
1714
+ }
1715
+ if (t === "-H" || t === "-m") {
1716
+ const key = t === "-H" ? "header" : "maxtime";
1717
+ const next = consumeCurlFlagValue(words, i, key, valueFlagCounts);
1718
+ if (next === null)
1719
+ return false;
1720
+ i = next;
1721
+ continue;
1722
+ }
1723
+ // Any other short flag or cluster: every character must be a
1724
+ // no-value char. `-H`/`-m` are recognised only as a WHOLE token
1725
+ // above, never as a cluster member, so `-sH` and `-mS` forfeit here.
1726
+ const clusterChars = t.slice(1);
1727
+ if (clusterChars.length === 0)
1728
+ return false;
1729
+ if (![...clusterChars].every((c) => CURL_NO_VALUE_SHORT_CHARS.has(c)))
1730
+ return false;
1731
+ continue;
1732
+ }
1733
+ // Not a recognised flag token: the operand. A second operand (or a
1734
+ // quoted/mis-spelled flag that fell through to here) forfeits.
1735
+ if (urlWord !== null)
1736
+ return false;
1737
+ urlWord = w;
1738
+ }
1739
+ if (urlWord === null)
1740
+ return false;
1741
+ if (urlWord.composite || urlWord.quote !== "single")
1742
+ return false;
1743
+ return CURL_SHAPE_URL_RE.test(urlWord.content);
1744
+ }
977
1745
  /**
978
1746
  * Returns true when `t` (with any trailing `=VALUE` glue stripped) is a
979
1747
  * GNU/BSD `getopt_long` ABBREVIATION of `fullFlag`: a prefix (including