omp-conductor 0.18.0 → 0.18.1

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 (61) hide show
  1. package/README.md +34 -0
  2. package/REFERENCE.md +60 -10
  3. package/agents/to-spec.md +90 -0
  4. package/package.json +2 -1
  5. package/schema/config.schema.json +29 -0
  6. package/src/admission.ts +204 -75
  7. package/src/ask.ts +268 -7
  8. package/src/board.ts +17 -3
  9. package/src/briefs/orchestrator.md +42 -14
  10. package/src/briefs/to-spec.md +84 -0
  11. package/src/briefs/worker.md +2 -1
  12. package/src/cli.ts +2 -0
  13. package/src/command-help.ts +11 -0
  14. package/src/command-manifest.ts +22 -0
  15. package/src/commands/context.ts +1 -0
  16. package/src/commands/drain.ts +176 -0
  17. package/src/commands/extend.ts +6 -10
  18. package/src/commands/status.ts +5 -1
  19. package/src/commands/watch.ts +50 -2
  20. package/src/commands/worker.ts +9 -10
  21. package/src/config-schema.ts +24 -0
  22. package/src/config.ts +42 -1
  23. package/src/daemon.ts +965 -36
  24. package/src/dashboard/app.js +4 -1
  25. package/src/dashboard/server.ts +5 -2
  26. package/src/decisions.ts +235 -17
  27. package/src/diff-flags.ts +75 -1
  28. package/src/doctor.ts +52 -0
  29. package/src/escalate.ts +9 -3
  30. package/src/failure-class.ts +28 -2
  31. package/src/fleet.ts +146 -22
  32. package/src/gitops.ts +188 -81
  33. package/src/graph-health.ts +35 -1
  34. package/src/graph.ts +66 -1
  35. package/src/harness-loader.ts +59 -0
  36. package/src/host.ts +567 -2
  37. package/src/lifecycle.ts +122 -1
  38. package/src/omp.ts +227 -20
  39. package/src/orchestrator-tick.ts +1386 -15
  40. package/src/orchestrator.ts +12 -0
  41. package/src/privileged.ts +1 -4
  42. package/src/release-policy.ts +503 -9
  43. package/src/session-host.ts +99 -5
  44. package/src/settlement.ts +69 -17
  45. package/src/setup-host.ts +1205 -6
  46. package/src/setup-install.ts +28 -0
  47. package/src/setup-wizard.ts +13 -2
  48. package/src/setup.ts +29 -13
  49. package/src/shell.ts +15 -0
  50. package/src/status-render.ts +78 -11
  51. package/src/store.ts +443 -42
  52. package/src/to-spec.ts +387 -0
  53. package/src/tracker/github.ts +104 -14
  54. package/src/types.ts +343 -13
  55. package/src/upgrade-verify.ts +209 -2
  56. package/src/upgrade.ts +175 -1
  57. package/src/verbs/protocol.ts +39 -0
  58. package/src/verbs/server.ts +730 -56
  59. package/src/verbs/socket.ts +24 -5
  60. package/src/worker.ts +25 -2
  61. package/src/worktree.ts +29 -12
@@ -82,6 +82,12 @@ export interface OrchestratorHandle {
82
82
  * reads alive here — that is the stall watchdog's job, not liveness. */
83
83
  alive(): boolean;
84
84
  sessionFile(): string | undefined;
85
+ /**
86
+ * The omp-conductor version the live orchestrator process loaded (#832):
87
+ * the session's own attestation at start, exposed to the upgrade's
88
+ * session-reload verification. `undefined` when the session never attested.
89
+ */
90
+ extensionVersion(): string | undefined;
85
91
  dispose(): Promise<void>;
86
92
  }
87
93
 
@@ -290,6 +296,12 @@ export async function startOrchestrator(o: OrchestratorOpts): Promise<Orchestrat
290
296
  // The path the harness actually opened, read live: the transcript is how a
291
297
  // human audits what the orchestrator decided on their behalf.
292
298
  sessionFile: () => session.sessionFile,
299
+ // The loaded-module attestation (#832): the extension version this
300
+ // orchestrator PROCESS loaded at session start — the live fact the
301
+ // upgrade's session-reload verification compares against the target
302
+ // release. `undefined` when the session never attested (an old build, a
303
+ // hand-written fake) — absence is "not attested", never "the newest".
304
+ extensionVersion: () => session.extensionVersion,
293
305
  async dispose(): Promise<void> {
294
306
  if (disposed) return;
295
307
  disposed = true;
package/src/privileged.ts CHANGED
@@ -25,6 +25,7 @@
25
25
  */
26
26
 
27
27
  import type { WizardUi } from "./wizard-ui.ts";
28
+ import { shellQuote } from "./shell.ts";
28
29
 
29
30
  export interface PrivilegedStep {
30
31
  /** One line naming what this step accomplishes, shown in the plan. */
@@ -113,10 +114,6 @@ export function formatStep(step: PrivilegedStep, asRoot = false): string {
113
114
  return [...prefix, ...step.argv].map(shellQuote).join(" ");
114
115
  }
115
116
 
116
- function shellQuote(value: string): string {
117
- return /^[A-Za-z0-9_@%+=:,./-]+$/.test(value) ? value : `'${value.replaceAll("'", "'\\''")}'`;
118
- }
119
-
120
117
  /**
121
118
  * Refuses to build a step that re-runs conductor itself.
122
119
  *
@@ -175,17 +175,17 @@ function commandSegments(command: string): string[] {
175
175
  }
176
176
 
177
177
  /** A leading wrapper command, matched and consumed in a chain (#558). `env`
178
- * was already stripped; `timeout`, `nice`, `stdbuf` and a shell `-c` were
179
- * not, so a whole-package run became allowed the moment it picked up a
180
- * wrapper the incident ran `timeout 300 bun test` and the guard never
181
- * fired. Each alternative owns the arguments that belong to it (the
178
+ * has its own dedicated stripper below its option forms need their own
179
+ * handling while `timeout`, `nice`, `stdbuf` and a shell `-c` were not
180
+ * stripped at all, so a whole-package run became allowed the moment it
181
+ * picked up a wrapper: the incident ran `timeout 300 bun test` and the guard
182
+ * never fired. Each alternative owns the arguments that belong to it (the
182
183
  * duration, the niceness, the option run), so stripping never swallows the
183
184
  * command's own words. Ordered longest-first so `timeout` is consumed
184
- * before `time` and `env` before a bare `VAR=…` chain. */
185
+ * before `time` and a `VAR=…` chain after `sudo`. */
185
186
  const COMMAND_WRAPPER_PREFIX = new RegExp(
186
187
  [
187
188
  "^sudo(?:\\s+-[a-z][a-z0-9-]*)*\\s+",
188
- "^env\\s+",
189
189
  "^(?:[A-Za-z_][A-Za-z0-9_]*=\\S+\\s+)+",
190
190
  "^timeout(?:\\s+--?[a-z][a-z0-9-]*(?:=\\S+|\\s+\\S+)?)*\\s+(?:inf|infinity|\\d+(?:\\.\\d+)?[smhd]?)\\s+",
191
191
  "^nice(?:\\s+-n\\s+-?\\d+|\\s+-\\d+|\\s+--adjustment\\s*=\\s*-?\\d+)?\\s+",
@@ -196,6 +196,478 @@ const COMMAND_WRAPPER_PREFIX = new RegExp(
196
196
  ].join("|"),
197
197
  );
198
198
 
199
+ /**
200
+ * Consume `env` together with every argument that belongs to it — its
201
+ * environment assignments and its own option forms — leaving the command the
202
+ * wrapper would run. The wrapper table's bare `env ` strip read one word and
203
+ * stopped, so `env -u NAME bun test` survived classification as an
204
+ * unclassified `-u NAME bun test` while `env FOO=1 bun test` was consumed
205
+ * through the assignment chain (#789). Every option form below exists to
206
+ * mutate or choose the *environment*, and that mutation is the wrapper; the
207
+ * command sits after it, so the whole argument list must go before the
208
+ * command is classified.
209
+ *
210
+ * The parsing mirrors how `env` itself stops: `--` ends env's own argument
211
+ * list and the rest is the command; a token that is neither an assignment
212
+ * nor an env option is the command, not a wrapper argument. A short cluster
213
+ * may end in an operand-taking flag (`-u NAME`, `-iu NAME` — the operand is
214
+ * the next token) or carry the operand mid-token (`-ui` is `-u i`, `-uNAME`
215
+ * is `-u NAME` — the rest of the token is the operand), exactly as compiled
216
+ * option parsing does; a lone `-` is env's implied `-i`. A cluster containing
217
+ * an unrecognised flag is left behind because `env` itself rejects the whole
218
+ * command on it — there is no command to gate.
219
+ *
220
+ * `-S`/`--split-string` is handled, never unwrapped piecemeal. env *re-splits*
221
+ * the option's operand as its own command line — assignments, options, then
222
+ * the command — and then keeps the rest of the line as further arguments to
223
+ * it (verified against coreutils 9.4, where `env -S --unset=FOO cmd` applies
224
+ * the unset and runs `cmd` via the remaining argv). The operand is decoded
225
+ * exactly as the shell and env's split-string parser together leave it and is
226
+ * re-entered into the argument loop below, so the command env would actually
227
+ * execute meets ordinary classification — a whole-suite `bun test` behind any
228
+ * `-S` spelling is refused, a focused `bun test src/x.test.ts` still passes.
229
+ * The only case a static command line cannot resolve is an operand that
230
+ * expands a runtime variable (`${VAR}`): it is refused via
231
+ * {@link UNCLASSIFIABLE_ENV_SPLIT} rather than guessed at.
232
+ *
233
+ * Long options are the same, and GNU accepts any unambiguous abbreviation of
234
+ * one (`--u=NAME` is `--unset=NAME`, `--split 'bun test'` is
235
+ * `--split-string 'bun test'`), so they are resolved against env's option
236
+ * table by prefix; a token too ambiguous to be one option is what env itself
237
+ * rejects — nothing runs — and is left behind, exactly like an unrecognised
238
+ * short flag.
239
+ *
240
+ * Adjacent wrappers compose: env's own command may itself be an env
241
+ * invocation (`env -i env bun test` runs the inner env on the suite), so the
242
+ * strip reaches a strict shortening fixpoint rather than stopping at the
243
+ * first `env`.
244
+ */
245
+ /** The sentinel a strip returns for a segment it must not let through but
246
+ * cannot classify: an `env -S` operand that expands a runtime variable
247
+ * (`${VAR}`), which env resolves against its live environment and a static
248
+ * command line cannot. `releaseCommandMatch` reads it and refuses the whole
249
+ * segment as `shared-host-gate` rather than risk a whole-package run
250
+ * slipping through, unclassified, as itself. */
251
+ const UNCLASSIFIABLE_ENV_SPLIT = "\u0000env-split-unclassifiable";
252
+
253
+ /**
254
+ * Read one shell word at `input`'s head to the first unquoted whitespace (or
255
+ * end), quoted regions and concatenated fragments included — `'bun test'` and
256
+ * `'bun'test` are each one word, so the `env -S` operand survives as a unit
257
+ * while the rest of the segment stays behind. The raw command line still
258
+ * carries the quotes, because the shell would only remove them as env runs;
259
+ * they are preserved in `word` for {@link decodeEnvSplitOperand} to resolve
260
+ * against the split-string parser's own quoting. `undefined` when the head
261
+ * holds no word at all.
262
+ */
263
+ function readShellWord(input: string): { word: string; rest: string } | undefined {
264
+ let end = 0;
265
+ const n = input.length;
266
+ while (end < n) {
267
+ const c = input[end]!;
268
+ if (c === "\\") {
269
+ end += end + 1 < n ? 2 : 1;
270
+ continue;
271
+ }
272
+ if (c === "'" || c === '"') {
273
+ const quote = c;
274
+ end += 1;
275
+ while (end < n && input[end] !== quote) {
276
+ end += input[end] === "\\" && end + 1 < n ? 2 : 1;
277
+ }
278
+ if (end < n) end += 1; // the matching close
279
+ continue;
280
+ }
281
+ if (/\s/.test(c)) break;
282
+ end += 1;
283
+ }
284
+ if (end === 0) return undefined;
285
+ return { word: input.slice(0, end), rest: input.slice(end) };
286
+ }
287
+
288
+ /**
289
+ * One backslash escape env's split-string parser accepts, as its character.
290
+ * `\_` is a word-separating space exactly like a literal one — `bun\_test`
291
+ * splits into `bun` and `test` — so it cannot decode to an underscore
292
+ * (verified against coreutils 9.4). `undefined` for anything else: env
293
+ * rejects `\a`, `\b`, `\e`, `\0` (no octal), `\x`, `\q`, a backslash before
294
+ * whitespace and any other undeclared escape with "invalid sequence",
295
+ * running nothing — the caller fails closed rather than guess.
296
+ */
297
+ function splitEscapeChar(e: string): string | undefined {
298
+ switch (e) {
299
+ case "n": return "\n";
300
+ case "r": return "\r";
301
+ case "f": return "\f";
302
+ case "t": return "\t";
303
+ case "v": return "\v";
304
+ case "_": return " ";
305
+ case "\\": return "\\";
306
+ case '"': return '"';
307
+ case "'": return "'";
308
+ case "$": return "$";
309
+ case "#": return "#";
310
+ default: return undefined;
311
+ }
312
+ }
313
+
314
+ /**
315
+ * Decode one `'…'`/`"…"` split-string quoting group. env removes the quotes
316
+ * and the group stands as a single token, so its content is decoded exactly
317
+ * like the rest of the string. A `\c` inside a group is rejected by env
318
+ * ("'\c' must not appear in … -S string") and so is an unterminated group
319
+ * ("no terminating quote in -S string"): both fail closed.
320
+ */
321
+ function decodeSplitGroup(
322
+ text: string,
323
+ start: number,
324
+ quote: string,
325
+ ): { content: string; end: number } | undefined {
326
+ let content = "";
327
+ let i = start;
328
+ const n = text.length;
329
+ while (i < n && text[i] !== quote) {
330
+ const c = text[i]!;
331
+ if (c === "\\") {
332
+ i += 1;
333
+ if (i >= n) return undefined;
334
+ if (text[i] === "c") return undefined; // env rejects `\c` in a group
335
+ const decoded = splitEscapeChar(text[i]!);
336
+ if (decoded === undefined) return undefined;
337
+ content += decoded;
338
+ i += 1;
339
+ continue;
340
+ }
341
+ if (c === "$" || c === "`") return undefined;
342
+ content += c;
343
+ i += 1;
344
+ }
345
+ if (i >= n) return undefined; // no terminating quote
346
+ return { content, end: i + 1 };
347
+ }
348
+
349
+ /**
350
+ * Resolve env's split-string decoding of `text`, so the operand re-enters
351
+ * classification the way env would execute it (verified against coreutils
352
+ * 9.4). Escapes become their characters (`\_` a word-separating space),
353
+ * whitespace runs collapse to single spaces, and a `'…'`/`"…"` quoting group
354
+ * becomes one token with its quotes removed: `'bun' test` reads `bun test`,
355
+ * exactly as env runs it. A grouped token whose content carries whitespace
356
+ * keeps quotes around it in the decoded line — that is what a
357
+ * `bash -c "bun test"` command string looks like to the downstream shell-`-c`
358
+ * resolver, and the only shape whose token boundary classification would
359
+ * otherwise lose. An unquoted, unescaped `#` at the start of an argument — a
360
+ * `#` coreutils' `ss.sep` flag finds at the string head, after whitespace, or
361
+ * after an outer `\_` — is a comment that ends the split string: coreutils
362
+ * `goto eos` drops the `#` and everything after it (`env -S '#' bun test`
363
+ * executes the untouched `bun test` argv via the remaining command line), so
364
+ * the decode stops there and the caller re-enters only what preceded it.
365
+ * Escaped (`\#`) or quoted (`'#'`, `"#"`) hashes are that same literal `#`
366
+ * argument — `\#` passes as-is, and quotes group — never a comment, and a
367
+ * mid-argument `#` (`bun#test`, `'a'#b`) is the ordinary character it is to
368
+ * env. A construct env rejects (a `\c` in a group, an unterminated group, an
369
+ * undeclared escape, a trailing backslash) or cannot resolve statically (a
370
+ * `${VAR}`/`$VAR`/backtick expansion) is `undefined`: the caller must refuse
371
+ * the whole segment rather than guess.
372
+ */
373
+ function decodeSplitString(text: string): string | undefined {
374
+ let out = "";
375
+ const n = text.length;
376
+ let i = 0;
377
+ // Mirror coreutils' `ss.sep`: true while the next character opens a new
378
+ // argument — the string head, after a whitespace run, or after an outer
379
+ // `\_` separator. A `#` seen there is the comment that ends the split;
380
+ // every other position keeps `#` the literal character it is to env.
381
+ let sep = true;
382
+ while (i < n) {
383
+ const c = text[i]!;
384
+ if (c === "$" || c === "`") return undefined;
385
+ if (c === "\\") {
386
+ i += 1;
387
+ if (i >= n) return undefined; // a trailing backslash: env rejects it
388
+ const e = text[i]!;
389
+ if (e === "c") break; // `\c` stops processing the rest of the string
390
+ const decoded = splitEscapeChar(e);
391
+ if (decoded === undefined) return undefined;
392
+ out += decoded;
393
+ // `\_` is an argument separator outside quotes, exactly like a space;
394
+ // any other escape is a character inside the current argument.
395
+ sep = decoded === " " ? true : false;
396
+ i += 1;
397
+ continue;
398
+ }
399
+ if (c === "#" && sep) break; // comment: an arg-initial `#` ends the split
400
+ if (c === "'" || c === '"') {
401
+ const group = decodeSplitGroup(text, i + 1, c);
402
+ if (group === undefined) return undefined;
403
+ const content = group.content;
404
+ if (/\s/.test(content)) {
405
+ // One token with an interior space; re-quote it so the shell-`-c`
406
+ // resolver below still sees a quoted command string. Pick the quote
407
+ // style the content does not already carry; content with both is not
408
+ // representable and fails closed.
409
+ if (!content.includes(c)) {
410
+ out += c + content + c;
411
+ } else if (c === "'" && !content.includes('"')) {
412
+ out += `"${content}"`;
413
+ } else if (c === '"' && !content.includes("'")) {
414
+ out += `'${content}'`;
415
+ } else {
416
+ return undefined;
417
+ }
418
+ } else {
419
+ out += content; // quotes removed: `'bun' test` is `bun test`
420
+ }
421
+ // The group opened an argument, so what follows it — a `#` included —
422
+ // continues that same argument (`'a'#b` is `a#b`).
423
+ sep = false;
424
+ i = group.end;
425
+ continue;
426
+ }
427
+ if (/\s/.test(c)) {
428
+ out += " ";
429
+ sep = true;
430
+ while (i < n && /\s/.test(text[i]!)) i += 1;
431
+ continue;
432
+ }
433
+ out += c;
434
+ sep = false;
435
+ i += 1;
436
+ }
437
+ return out.replace(/\s+/g, " ").trim();
438
+ }
439
+
440
+ /**
441
+ * Decode the `env -S` operand word for re-entry, the way the shell and env's
442
+ * split-string parser between them leave it. The operand's own shell quoting
443
+ * — the `'…'`/`"…"` wrapping the whole word on the raw command line — is the
444
+ * printable form of the argument; stripping that one level is what the shell
445
+ * does before env sees it. The quotes *inside* the content are env's own
446
+ * grouping and are preserved for {@link decodeSplitString}. `undefined` when
447
+ * the decoded text carries a runtime expansion.
448
+ */
449
+ function decodeEnvSplitOperand(word: string): string | undefined {
450
+ let content = word;
451
+ if (word.length >= 2) {
452
+ const first = word[0]!;
453
+ const last = word[word.length - 1]!;
454
+ if (first === "'" && last === "'" && !word.slice(1, -1).includes("'")) {
455
+ content = word.slice(1, -1);
456
+ } else if (first === '"' && last === '"') {
457
+ content = word.slice(1, -1);
458
+ }
459
+ }
460
+ return decodeSplitString(content);
461
+ }
462
+
463
+ /**
464
+ * Consume a short `-S` option from the current head, returning its operand
465
+ * word (raw, quotes included) and the rest of the segment. `operand: null`
466
+ * means the option had no operand at all — env itself rejects that
467
+ * invocation, so there is no command to gate. The accepted spellings match
468
+ * compiled option parsing: `-S`, `-iS`, `-0S`, an operand attached to `S` in
469
+ * the same token (`-Sbun`, `-S'cmd'`), or the next word (`-S bun`). The long
470
+ * `--split-string` spelling — exact or an unambiguous abbreviation — is
471
+ * resolved by {@link parseEnvLongOption} instead.
472
+ */
473
+ function parseEnvSplitOption(rest: string): { operand: string | null; rest: string } | undefined {
474
+ const short = /^-[i0v]*S/.exec(rest);
475
+ if (short === null) return undefined;
476
+ // The operand is attached to S in the same token — `-Sbun`, `-S'cmd'` —
477
+ // or, after whitespace, the next word: `-S bun`.
478
+ const after = rest.slice(short[0].length).replace(/^\s+/, "");
479
+ const word = readShellWord(after);
480
+ if (word === undefined) return { operand: null, rest: "" };
481
+ return { operand: word.word, rest: word.rest };
482
+ }
483
+
484
+ /** env's long options with the operand shape each one declares. */
485
+ const ENV_LONG_OPTIONS: ReadonlyArray<{
486
+ name: string;
487
+ /** `required`: an operand via `=` or the next word (empty `=` rejected);
488
+ * `eq-only`: an optional operand only via `=` (`--block-signal[=SIG]`);
489
+ * `none`: no operand, and a trailing `=` is rejected. */
490
+ operand: "required" | "eq-only" | "none";
491
+ }> = [
492
+ { name: "split-string", operand: "required" },
493
+ { name: "unset", operand: "required" },
494
+ { name: "chdir", operand: "required" },
495
+ { name: "block-signal", operand: "eq-only" },
496
+ { name: "default-signal", operand: "eq-only" },
497
+ { name: "ignore-signal", operand: "eq-only" },
498
+ { name: "ignore-environment", operand: "none" },
499
+ { name: "null", operand: "none" },
500
+ { name: "list-signal-handling", operand: "none" },
501
+ { name: "debug", operand: "none" },
502
+ ];
503
+
504
+ /**
505
+ * Consume a `--…` env long option from the current head, exact or an
506
+ * unambiguous GNU abbreviation. `/usr/bin/env --u=NAME cmd` applies the unset
507
+ * and runs `cmd`, and `--split 'bun test'` re-splits — verified against
508
+ * coreutils 9.4 — so a parser that only knew the full spellings left a
509
+ * leading `--u=…` behind and a whole-package run bypassed classification.
510
+ * The resolution mirrors getopt_long: the token is matched against env's own
511
+ * option table as a prefix; exactly one match is the option, consumed with
512
+ * the operand shape it declares (split-string's operand is returned for the
513
+ * caller to decode and re-enter, exactly like `-S`). Zero matches (unknown)
514
+ * or several (ambiguous) are what env itself rejects — verified:
515
+ * `--frobnicate …` and `--i=…` both error out and run nothing — so the token
516
+ * is left behind for the caller to return untouched; there is no command to
517
+ * gate. `undefined` when the head is not a long option at all.
518
+ */
519
+ function parseEnvLongOption(
520
+ rest: string,
521
+ ): { next: "leave" } | { next: "continue"; rest: string } | { next: "split"; operand: string | null; rest: string } | undefined {
522
+ const head = /^--([A-Za-z][A-Za-z0-9-]*)/.exec(rest);
523
+ if (head === null) return undefined;
524
+ const token = head[1]!;
525
+ const matches = ENV_LONG_OPTIONS.filter((option) => option.name.startsWith(token));
526
+ if (matches.length !== 1) return { next: "leave" };
527
+ const option = matches[0]!;
528
+ const after = rest.slice(head[0].length);
529
+
530
+ if (option.operand === "none") {
531
+ if (after.startsWith("=")) return { next: "leave" }; // env rejects `--debug=…`
532
+ return { next: "continue", rest: after.replace(/^\s+/, "") };
533
+ }
534
+
535
+ if (option.operand === "eq-only") {
536
+ // `--block-signal[=SIG]`: the signal rides only on the `=` and may be
537
+ // empty (a no-op); without `=`, the token is consumed bare and the next
538
+ // word is env's command, matching env's own parsing.
539
+ if (after.startsWith("=")) {
540
+ const value = readShellWord(after.slice(1));
541
+ if (value === undefined) {
542
+ return { next: "continue", rest: after.slice(1).replace(/^\s+/, "") };
543
+ }
544
+ return { next: "continue", rest: value.rest.replace(/^\s+/, "") };
545
+ }
546
+ return { next: "continue", rest: after.replace(/^\s+/, "") };
547
+ }
548
+
549
+ if (option.name === "split-string") {
550
+ // The operand is split and re-entered as a command line by the caller;
551
+ // an empty `=` value still splits to nothing, after which the rest of
552
+ // the line is the command.
553
+ if (after.startsWith("=")) {
554
+ const value = readShellWord(after.slice(1));
555
+ if (value === undefined) return { next: "split", operand: "", rest: after.slice(1) };
556
+ return { next: "split", operand: value.word, rest: value.rest };
557
+ }
558
+ const spaced = after.replace(/^\s+/, "");
559
+ if (spaced.length === 0) {
560
+ // `--split-string` with no operand: env rejects the invocation.
561
+ return { next: "split", operand: null, rest: "" };
562
+ }
563
+ const word = readShellWord(spaced);
564
+ if (word === undefined) return { next: "split", operand: null, rest: "" };
565
+ return { next: "split", operand: word.word, rest: word.rest };
566
+ }
567
+
568
+ // `--unset NAME` / `--chdir DIR` — a required operand via `=` (empty is
569
+ // rejected by env: "cannot unset ''") or as the next word.
570
+ if (after.startsWith("=")) {
571
+ const value = readShellWord(after.slice(1));
572
+ if (value === undefined || value.word === "") return { next: "leave" };
573
+ return { next: "continue", rest: value.rest.replace(/^\s+/, "") };
574
+ }
575
+ const spaced = after.replace(/^\s+/, "");
576
+ const word = readShellWord(spaced);
577
+ if (word === undefined) {
578
+ // `--unset` with no operand: env rejects the invocation.
579
+ return { next: "continue", rest: "" };
580
+ }
581
+ return { next: "continue", rest: word.rest.replace(/^\s+/, "") };
582
+ }
583
+
584
+ function stripEnvPrefix(segment: string): string {
585
+ // env's own command may itself be an env invocation — `env env bun test`
586
+ // runs the inner env, which runs the whole suite (same for `env -i env …`
587
+ // and, after a freed wrapper, `sudo env -u NAME env …`) — and the `--`
588
+ // separator hands env's command to the same strip. So the strip reaches a
589
+ // strict fixpoint: loop until the remainder no longer opens with `env `.
590
+ // Each pass consumes at least the `env ` head, so this terminates.
591
+ let current = segment;
592
+ while (true) {
593
+ const head = /^env\s+/.exec(current);
594
+ if (head === null) return current;
595
+ let rest = current.slice(head[0].length);
596
+ const consume = (pattern: RegExp): boolean => {
597
+ const match = pattern.exec(rest);
598
+ if (match === null) return false;
599
+ rest = rest.slice(match[0].length);
600
+ return true;
601
+ };
602
+ while (true) {
603
+ // `-S`/`--split-string`: env splits its operand as its own command line
604
+ // and keeps the rest of the segment as further arguments to it — so the
605
+ // whole line after the option is still one command env makes the
606
+ // environment for. Decode the operand and let the argument loop below
607
+ // consume the line, so the command env would actually run meets ordinary
608
+ // classification. An operand that expands a runtime variable cannot be
609
+ // classified statically: refuse the whole segment instead of guessing.
610
+ const split = parseEnvSplitOption(rest);
611
+ if (split !== undefined) {
612
+ if (split.operand === null) {
613
+ // No operand at all: env itself rejects the invocation, so there is
614
+ // no command to gate — consume the bare option and move on.
615
+ rest = split.rest;
616
+ continue;
617
+ }
618
+ const decoded = decodeEnvSplitOperand(split.operand);
619
+ if (decoded === undefined) return UNCLASSIFIABLE_ENV_SPLIT;
620
+ rest = `${decoded} ${split.rest}`.trimStart();
621
+ continue;
622
+ }
623
+ // `--…` env long options, exact or an unambiguous GNU abbreviation:
624
+ // consumed with the operand shape the option declares. split-string's
625
+ // operand is decoded and re-entered here, like `-S`; unknown or ambiguous
626
+ // long options are what env itself rejects (nothing runs), so they are
627
+ // left behind and the loop stops — there is no command to classify.
628
+ const long = parseEnvLongOption(rest);
629
+ if (long !== undefined) {
630
+ if (long.next === "leave") return rest;
631
+ if (long.next === "split") {
632
+ if (long.operand === null) {
633
+ // `--split-string` with no operand: env rejects the invocation.
634
+ rest = "";
635
+ continue;
636
+ }
637
+ const decoded = decodeEnvSplitOperand(long.operand);
638
+ if (decoded === undefined) return UNCLASSIFIABLE_ENV_SPLIT;
639
+ rest = `${decoded} ${long.rest}`.trimStart();
640
+ continue;
641
+ }
642
+ rest = long.rest;
643
+ continue;
644
+ }
645
+ // `env -- cmd`: the separator ends env's argument list; the command
646
+ // follows — command or not, the outer fixpoint loop re-checks it.
647
+ if (consume(/^--\s+/)) {
648
+ current = rest;
649
+ break;
650
+ }
651
+ // An assignment: `env FOO=1 cmd` (`FOO= cmd` — the empty value env also
652
+ // honours — is an assignment too, because the `=` makes it one).
653
+ if (consume(/^(?:[A-Za-z_][A-Za-z0-9_]*=\S*)\s+/)) continue;
654
+ // Short-option cluster: operand-taking flag terminal in the token (the
655
+ // operand is the next token), operand-taking flag mid-token (the rest of
656
+ // the token is its operand), a bare cluster of no-operand flags, or the
657
+ // lone `-` that means `-i` (the cluster is then empty).
658
+ if (
659
+ consume(
660
+ /^-(?:[i0v]*[uC]\s+\S+|[i0v]*[uC]\S+|[i0v]+)?\s+/,
661
+ )
662
+ ) {
663
+ continue;
664
+ }
665
+ current = rest;
666
+ break;
667
+ }
668
+ }
669
+ }
670
+
199
671
  /** `bash -c`, `sh -c`, `zsh -c` with optional preceding flags — `-lc` and
200
672
  * friends parse as combined short options, so the `c` may ride in the same
201
673
  * token as the flags before it. A `bash -n` parse check is deliberately not
@@ -224,14 +696,18 @@ function shellCommandInner(segment: string): string | undefined {
224
696
  * command (#558). The old stripper recognised `env` and `sudo` and nothing
225
697
  * else, so a whole-package `bun test` became allowed the moment a worker
226
698
  * wrapped it in `timeout`/`nice`/`bash -c`. Chains matter — `timeout 300
227
- * nice bun test` is one command with two wrappers so the table is matched
228
- * repeatedly until the leading word belongs to the command itself.
699
+ * nice bun test` is one command with two wrappers, and an `env` with its own
700
+ * options may sit anywhere in the chain (`sudo env -u NAME timeout 300 bun
701
+ * test`) — so env is stripped before the table and again after each match,
702
+ * and the table is matched repeatedly until the leading word belongs to the
703
+ * command itself.
229
704
  */
230
705
  function stripCommandPrefix(segment: string): string {
231
- let stripped = segment;
706
+ let stripped = stripEnvPrefix(segment);
232
707
  let match: RegExpExecArray | null;
233
708
  while ((match = COMMAND_WRAPPER_PREFIX.exec(stripped)) !== null) {
234
709
  stripped = stripped.slice(match[0].length);
710
+ stripped = stripEnvPrefix(stripped);
235
711
  }
236
712
  return stripped;
237
713
  }
@@ -448,6 +924,12 @@ function gitTagIsWrite(segment: string): boolean {
448
924
  function releaseCommandMatch(command: string): { shape: GateShape; matched: string } | undefined {
449
925
  for (const raw of commandSegments(command)) {
450
926
  const segment = stripCommandPrefix(raw);
927
+ // A segment whose `env -S` operand expands a runtime variable cannot be
928
+ // classified from a static command line; refuse the whole command rather
929
+ // than let an unclassified whole-package run past.
930
+ if (segment === UNCLASSIFIABLE_ENV_SPLIT) {
931
+ return { shape: SHARED_HOST_SHAPE, matched: raw.trim() };
932
+ }
451
933
  // A `bash -c '<cmd>'` / `sh -c "<cmd>"` wrapper executes the quoted
452
934
  // string, so classify that string as a command of its own: a whole
453
935
  // package inside the quotes is refused (#558), a focused run inside
@@ -667,6 +1149,18 @@ function sharedHostRefusalReason(matched: string | undefined): string {
667
1149
  "Run a focused `bun test <file>.test.ts` instead."
668
1150
  );
669
1151
  }
1152
+ // The only shared-host matches that still open with `env` are the -S
1153
+ // operands this policy refuses rather than classify (a runtime `${…}`
1154
+ // expansion, or an escape env's split-string parser rejects). Name that,
1155
+ // not the generic suite wording, so a refused command reads like what it is.
1156
+ if (matched !== undefined && /^env\s+/.test(matched)) {
1157
+ return (
1158
+ "Blocked by sharedHostPolicy: an `env -S`/`--split-string` operand cannot be classified from a static " +
1159
+ "command line — it carries a `${…}`/`$` runtime expansion or an escape env's split-string parser does " +
1160
+ "not accept. Refusing rather than guessing whether it runs a whole-package `bun test`; spell the " +
1161
+ "command literally instead."
1162
+ );
1163
+ }
670
1164
  if (matched !== undefined) {
671
1165
  return (
672
1166
  `Blocked by sharedHostPolicy: \`${matched}\` is a shared-host shell suite and not a worker's proof ` +