omp-conductor 0.15.10 → 0.15.12

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 (92) hide show
  1. package/README.md +273 -2544
  2. package/REFERENCE.md +2680 -0
  3. package/package.json +3 -2
  4. package/schema/config.schema.json +11 -23
  5. package/src/arm-challenge.ts +112 -0
  6. package/src/ask.ts +434 -0
  7. package/src/board.ts +81 -15
  8. package/src/brief-upgrade.ts +114 -8
  9. package/src/briefs/orchestrator.md +113 -34
  10. package/src/briefs/policy.md +33 -8
  11. package/src/briefs/worker.md +18 -9
  12. package/src/chain-check.ts +1 -1
  13. package/src/check-trailing-newlines.ts +82 -0
  14. package/src/cli.ts +225 -1406
  15. package/src/commands/arm.ts +21 -0
  16. package/src/commands/board.ts +23 -0
  17. package/src/commands/brief-upgrade.ts +186 -0
  18. package/src/commands/context.ts +150 -0
  19. package/src/commands/daemon.ts +71 -0
  20. package/src/commands/dashboard.ts +74 -0
  21. package/src/commands/decision.ts +103 -0
  22. package/src/commands/disarm.ts +21 -0
  23. package/src/commands/doctor.ts +100 -0
  24. package/src/commands/event.ts +62 -0
  25. package/src/commands/extend.ts +64 -0
  26. package/src/commands/friction.ts +56 -0
  27. package/src/commands/help.ts +9 -0
  28. package/src/commands/hold.ts +26 -0
  29. package/src/commands/intake.ts +155 -0
  30. package/src/commands/ledger.ts +69 -0
  31. package/src/commands/message.ts +96 -0
  32. package/src/commands/report.ts +206 -0
  33. package/src/commands/restart.ts +76 -0
  34. package/src/commands/resume.ts +58 -0
  35. package/src/commands/setup.ts +143 -0
  36. package/src/commands/start.ts +23 -0
  37. package/src/commands/stats.ts +131 -0
  38. package/src/commands/status.ts +48 -0
  39. package/src/commands/stop.ts +51 -0
  40. package/src/commands/tail.ts +109 -0
  41. package/src/commands/unblock.ts +39 -0
  42. package/src/commands/upgrade-install.ts +31 -0
  43. package/src/commands/upgrade-rollback.ts +32 -0
  44. package/src/commands/upgrade.ts +25 -0
  45. package/src/commands/verb.ts +83 -0
  46. package/src/commands/version.ts +30 -0
  47. package/src/commands/worker.ts +100 -0
  48. package/src/config-schema.ts +47 -1
  49. package/src/config.ts +45 -4
  50. package/src/daemon.ts +972 -101
  51. package/src/dashboard/app.js +459 -0
  52. package/src/dashboard/index.html +61 -0
  53. package/src/dashboard/server.ts +481 -0
  54. package/src/dashboard/style.css +348 -0
  55. package/src/decisions.ts +39 -14
  56. package/src/diff-flags.ts +131 -241
  57. package/src/doctor.ts +932 -0
  58. package/src/escalate.ts +2 -2
  59. package/src/failure-class.ts +66 -3
  60. package/src/fleet.ts +58 -1
  61. package/src/gitops.ts +157 -0
  62. package/src/graph-health.ts +1 -1
  63. package/src/label-projection.ts +1 -1
  64. package/src/lifecycle.ts +198 -2
  65. package/src/model-fallback.ts +177 -0
  66. package/src/notices.ts +9 -0
  67. package/src/omp.ts +93 -13
  68. package/src/orchestrator-tick.ts +414 -20
  69. package/src/orchestrator.ts +4 -4
  70. package/src/privileged.ts +10 -0
  71. package/src/release-policy.ts +342 -30
  72. package/src/reports.ts +19 -5
  73. package/src/session-host.ts +11 -5
  74. package/src/setup-host.ts +663 -25
  75. package/src/setup-install.ts +292 -28
  76. package/src/setup-wizard.ts +255 -74
  77. package/src/setup.ts +156 -35
  78. package/src/stats.ts +331 -0
  79. package/src/store.ts +219 -22
  80. package/src/tracker/github.ts +74 -4
  81. package/src/types.ts +215 -31
  82. package/src/unblock.ts +55 -11
  83. package/src/upgrade-journal.ts +220 -0
  84. package/src/upgrade-verify.ts +506 -0
  85. package/src/upgrade.ts +385 -58
  86. package/src/verbs/actions.ts +73 -1
  87. package/src/verbs/protocol.ts +29 -4
  88. package/src/verbs/server.ts +183 -20
  89. package/src/worker.ts +3 -3
  90. package/systemd/omp-conductor-recover.sh +433 -0
  91. package/systemd/omp-conductor.service.example +14 -3
  92. package/systemd/recover-unit-test.sh +428 -0
package/src/privileged.ts CHANGED
@@ -90,6 +90,14 @@ export interface RunPrivilegedOptions {
90
90
  title?: string;
91
91
  /** Extra lines shown above the step list — what this batch is for. */
92
92
  preamble?: readonly string[];
93
+ /**
94
+ * Runs after the confirm and the sudo priming, immediately before the first
95
+ * step. A caller that must do something between consent and execution — the
96
+ * pause/drain of a draining restart — does it here, under the same confirm,
97
+ * so the operator authorises the whole batch at once. A throw propagates to
98
+ * the caller with nothing restarted.
99
+ */
100
+ beforeRun?: () => Promise<void>;
93
101
  }
94
102
 
95
103
  /**
@@ -211,6 +219,8 @@ export async function runPrivileged(
211
219
  }
212
220
  }
213
221
 
222
+ await options.beforeRun?.();
223
+
214
224
  const softFailures: { step: PrivilegedStep; stderr: string }[] = [];
215
225
  for (const [index, step] of steps.entries()) {
216
226
  ui.notify(`[${index + 1}/${steps.length}] ${step.title}`, "info");
@@ -39,13 +39,26 @@ export const RELEASE_POLICY_AUDIT_FILE = "release-policy-blocks.jsonl";
39
39
  export interface ReleaseBlockContext {
40
40
  tool: string;
41
41
  reason: string;
42
+ /**
43
+ * What the shape matcher fired on: the offending command segment for a shell
44
+ * call, the deciding token for a tool name. Present on every record written
45
+ * from #491 on, so a fixture hit is visible as one; absent only on older
46
+ * lines, which the digest still renders.
47
+ */
48
+ matched?: string;
49
+ /**
50
+ * The blocked shell command itself, with anything credential-shaped redacted
51
+ * and the whole string bounded. Only a `bash` call sets it — every other
52
+ * tool's redacted {@link args} already name the invocation.
53
+ */
54
+ invocation?: string;
42
55
  args: Record<string, string>;
43
56
  }
44
57
 
45
58
  export interface ReleaseBlock extends Partial<ReleaseBlockContext> {
46
59
  project: string;
47
60
  source: "worker" | "orchestrator";
48
- shape: ReleaseShape;
61
+ shape: GateShape;
49
62
  at: string;
50
63
  issue?: number;
51
64
  runId?: string;
@@ -53,12 +66,108 @@ export interface ReleaseBlock extends Partial<ReleaseBlockContext> {
53
66
 
54
67
  export type ReleaseDecision = { block: true; reason: string };
55
68
 
69
+ /**
70
+ * The shared-host heavy-gate shape (#428): whole-package test runs and the
71
+ * shell suites a worker must be *unable* to run on the operator's shared host,
72
+ * rather than merely told not to.
73
+ *
74
+ * Deliberately **not** a {@link ReleaseShape}: no grant covers it. An operator
75
+ * is never refused — the operator owns the host, and CI owns the full suite —
76
+ * and a worker is always, whatever the config says. So it stays out of the
77
+ * configurable grant vocabulary (`releaseShapeEnum` is drawn straight from
78
+ * {@link RELEASE_SHAPES}); it only rides the release-policy classifier and the
79
+ * block ledger so one seam sees it and its refusals land where the next tick
80
+ * already reads drift.
81
+ */
82
+ export const SHARED_HOST_SHAPE = "shared-host-gate" as const;
83
+ export type SharedHostShape = typeof SHARED_HOST_SHAPE;
84
+
85
+ /** Every shape the classifier reports, grantable or not. */
86
+ export type GateShape = ReleaseShape | SharedHostShape;
87
+
56
88
  const GIT_PUSH_TAG_SHAPE =
57
89
  /(?:--tags\b|--follow-tags\b|refs\/tags\/|(?:^|\s)(?:tag\s+)?v\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?(?:\+[0-9A-Za-z.-]+)?(?=[:\s]|$))/;
58
90
 
91
+ /** The separators the original quote-blind splitter divides a command on. Kept
92
+ * for the unterminated-quote fallback, which must reproduce today's behaviour
93
+ * rather than silently pass a command through. */
94
+ const COMMAND_SEPARATORS = /(?:&&|\|\||[;\n|])/;
95
+
96
+ /**
97
+ * Split a shell command on its operators, ignoring ones inside single or
98
+ * double quotes. The old splitter was quote-blind, so a `|` inside a quoted
99
+ * argument (a grep alternation's `\|`, a `--jq` filter, a heredoc) shredded
100
+ * the command into phantom segments and a gated verb inside a string literal
101
+ * was refused as a release attempt (#526). A small scanner is enough: no shell
102
+ * parser, no dependency. An unterminated quote falls back to the original
103
+ * quote-blind split, because a gated command must not slip through merely
104
+ * because a quote never closed.
105
+ */
106
+ function splitCommandSegments(command: string): string[] {
107
+ const segments: string[] = [];
108
+ let current = "";
109
+ let quote: "'" | '"' | null = null;
110
+
111
+ const push = () => {
112
+ segments.push(current);
113
+ current = "";
114
+ };
115
+
116
+ for (let i = 0; i < command.length; i++) {
117
+ const ch = command[i];
118
+
119
+ // Inside a quote only its closing quote matters — separators are data. A
120
+ // `\"` inside double quotes is a literal quote and must not close it.
121
+ if (quote !== null) {
122
+ current += ch;
123
+ if (ch === "\\" && quote === '"') {
124
+ if (i + 1 < command.length) current += command[++i];
125
+ continue;
126
+ }
127
+ if (ch === quote) quote = null;
128
+ continue;
129
+ }
130
+
131
+ // Outside quotes a backslash escapes the next character, so a literal
132
+ // `\|`, `\;` or `\"` is data, never an operator or the start of a quote.
133
+ if (ch === "\\") {
134
+ current += ch;
135
+ if (i + 1 < command.length) current += command[++i];
136
+ continue;
137
+ }
138
+
139
+ if (ch === "'") {
140
+ quote = "'";
141
+ current += ch;
142
+ continue;
143
+ }
144
+ if (ch === '"') {
145
+ quote = '"';
146
+ current += ch;
147
+ continue;
148
+ }
149
+
150
+ if ((ch === "&" && command[i + 1] === "&") || (ch === "|" && command[i + 1] === "|")) {
151
+ push();
152
+ i++;
153
+ continue;
154
+ }
155
+ if (ch === "|" || ch === ";" || ch === "\n") {
156
+ push();
157
+ continue;
158
+ }
159
+
160
+ current += ch;
161
+ }
162
+
163
+ if (quote !== null) return command.split(COMMAND_SEPARATORS);
164
+
165
+ push();
166
+ return segments;
167
+ }
168
+
59
169
  function commandSegments(command: string): string[] {
60
- return command
61
- .split(/(?:&&|\|\||[;\n|])/)
170
+ return splitCommandSegments(command)
62
171
  .map((segment) => segment.trim())
63
172
  .filter((segment) => segment.length > 0);
64
173
  }
@@ -71,28 +180,107 @@ function stripCommandPrefix(segment: string): string {
71
180
  }
72
181
 
73
182
  /** Recognise the explicit release/deploy command shapes this policy promises to gate. */
74
- export function releaseShapeFromCommand(command: string): ReleaseShape | undefined {
183
+ export function releaseShapeFromCommand(command: string): GateShape | undefined {
184
+ return releaseCommandMatch(command)?.shape;
185
+ }
186
+
187
+ /**
188
+ * A whole-package `bun test`: `bun test` with no positional naming a specific
189
+ * test file. Flags alone — or nothing at all — still run the whole project,
190
+ * which is the gate (#428), and so do the path spellings of "everything":
191
+ * `bun test .`, `bun test ./`, `bun test src`. A command whose positional
192
+ * targets are test files — basename carrying the `*.test.*` / `*_test.*`
193
+ * marker bun itself keys on — is a focused run, a worker's proof path, and
194
+ * must stay available. A flag's own value is not a focused target: a
195
+ * `--preload x.ts` run with no file runs the whole package with a preload,
196
+ * while `--preload ./setup.ts src/daemon.test.ts` is still a test-file run.
197
+ */
198
+ function wholePackageBunTest(segment: string): boolean {
199
+ const head = /^bun\s+test\b/.exec(segment);
200
+ if (head === null) return false;
201
+ const rest = segment.slice(head[0].length);
202
+ return !rest
203
+ .split(/\s+/)
204
+ .filter((token) => token.length > 0)
205
+ .filter((token) => !token.startsWith("-"))
206
+ .some((token) => /\.test\.|_test\./.test(token.slice(token.lastIndexOf("/") + 1)));
207
+ }
208
+
209
+ /**
210
+ * The shared-host run/shell suites a worker must not execute on the operator's
211
+ * host (#428): `herdr/test/recover-test.sh`, `test/setup-test.sh` and
212
+ * `setup.sh`, each under the invocation prefixes a worker actually uses —
213
+ * plain `bash`/`sh`/`zsh`, POSIX `. ` and `source` (all so the script loads),
214
+ * and a bare or `./` path. Returns the script path that fired, for the
215
+ * matched-segment ledger record.
216
+ *
217
+ * `bash -n` is deliberately not a match: parsing a script never executes it,
218
+ * and the pre-push parse gate every worker brief runs is
219
+ * `bash -n herdr/bin/recover.sh herdr/test/recover-test.sh setup.sh
220
+ * test/setup-test.sh` — blocking that would make the gate unfollowable without
221
+ * buying a single byte of protection. A flag beyond `-n` (`bash -x`, `-e`) is
222
+ * a real execution and is not covered either: the incidents that overloaded
223
+ * the host were all plain invocations, and refusing the sanctioned parse check
224
+ * would teach workers to skip it rather than stop the load.
225
+ */
226
+ const SHARED_HOST_SCRIPTS = [
227
+ "herdr/test/recover-test.sh",
228
+ "test/setup-test.sh",
229
+ "setup.sh",
230
+ ];
231
+
232
+ const SHARED_HOST_PREFIX = /^(?:(?:bash|sh|zsh|source)\s+|\.\s+)?\.?\/?/;
233
+
234
+ function sharedHostScriptMatch(segment: string): string | undefined {
235
+ const path = segment.replace(SHARED_HOST_PREFIX, "");
236
+ for (const script of SHARED_HOST_SCRIPTS) {
237
+ if (path === script || path.startsWith(`${script} `) || path.startsWith(`${script}\t`)) {
238
+ return script;
239
+ }
240
+ }
241
+ return undefined;
242
+ }
243
+
244
+ /**
245
+ * Classify a shell command and the segment that fired. The segment is what a
246
+ * triager reads: `bun test src/foo.test.ts && npm publish` matches on its
247
+ * `npm publish` half, and the record says so instead of folding the whole
248
+ * command into the shape.
249
+ */
250
+ function releaseCommandMatch(command: string): { shape: GateShape; matched: string } | undefined {
75
251
  for (const raw of commandSegments(command)) {
76
252
  const segment = stripCommandPrefix(raw);
77
- if (/^git(?:\s+-[Cc]\s+\S+)*\s+tag(?:\s|$)/.test(segment)) return "git-tag";
253
+ // Patching the running conductor is gated like a release act: `upgrade`,
254
+ // the detached `upgrade-install`/`upgrade-rollback` executor entries and
255
+ // any future spelling under the upgrade family all need the install shape,
256
+ // and a session without that grant is refused before the shell runs.
257
+ if (/^omp-conductor\s+upgrade(?:\b|$)/.test(segment)) return { shape: "install", matched: segment };
258
+ if (/^git(?:\s+-[Cc]\s+\S+)*\s+tag(?:\s|$)/.test(segment)) return { shape: "git-tag", matched: segment };
78
259
  if (
79
260
  /^git(?:\s+-[Cc]\s+\S+)*\s+push\b/.test(segment) &&
80
261
  GIT_PUSH_TAG_SHAPE.test(segment)
81
262
  ) {
82
- return "git-push-tags";
263
+ return { shape: "git-push-tags", matched: segment };
83
264
  }
84
265
  if (/^(?:npm|pnpm|bun)\s+(?:publish|stage\s+publish)\b/.test(segment)) {
85
- return "package-publish";
266
+ return { shape: "package-publish", matched: segment };
86
267
  }
87
- if (/^yarn\s+npm\s+publish\b/.test(segment)) return "package-publish";
88
- if (/^gh\s+release\s+create\b/.test(segment)) return "github-release";
268
+ if (/^yarn\s+npm\s+publish\b/.test(segment)) return { shape: "package-publish", matched: segment };
269
+ if (/^gh\s+release\s+create\b/.test(segment)) return { shape: "github-release", matched: segment };
89
270
  if (
90
271
  /^(?:kubectl\s+(?:apply|create|replace)|helm\s+(?:install|upgrade)|terraform\s+apply|pulumi\s+up|(?:fly|vercel|wrangler)\s+deploy|docker\s+push)\b/.test(
91
272
  segment,
92
273
  )
93
274
  ) {
94
- return "deploy";
275
+ return { shape: "deploy", matched: segment };
95
276
  }
277
+ // The shared-host gate (#428). Disjoint from every release shape above, so
278
+ // it is checked last without disturbing any of them. `matched` names the
279
+ // script path for a suite and the offending segment for a whole-package
280
+ // `bun test`, so the ledger and the digest say exactly which one fired.
281
+ if (wholePackageBunTest(segment)) return { shape: SHARED_HOST_SHAPE, matched: segment };
282
+ const script = sharedHostScriptMatch(segment);
283
+ if (script !== undefined) return { shape: SHARED_HOST_SHAPE, matched: script };
96
284
  }
97
285
  return undefined;
98
286
  }
@@ -187,8 +375,11 @@ const RELEASE_ACTIONS: Record<string, true> = {
187
375
  * only asks a question — while a read verb *after* the action verb is not:
188
376
  * `DeployStackAndWatch` deploys.
189
377
  */
190
- function releaseShapeFromTokens(tokens: readonly string[]): ReleaseShape | undefined {
378
+ function releaseTokenMatch(
379
+ tokens: readonly string[],
380
+ ): { shape: ReleaseShape; matched: string } | undefined {
191
381
  if (tokens[tokens.length - 1] === "status") return undefined;
382
+ let action: string | undefined;
192
383
  for (const token of tokens) {
193
384
  if (READ_VERBS[token] === true) return undefined;
194
385
  if (
@@ -196,31 +387,39 @@ function releaseShapeFromTokens(tokens: readonly string[]): ReleaseShape | undef
196
387
  RELEASE_ACTIONS[token] === true ||
197
388
  PUBLISH_ACTIONS[token] === true
198
389
  ) {
390
+ action = token;
199
391
  break;
200
392
  }
201
393
  }
394
+ if (action === undefined) return undefined;
202
395
 
203
396
  // `release` first: `PublishRelease` cuts a GitHub release, it does not publish
204
- // a package, and both vocabularies claim `publish`.
397
+ // a package, and both vocabularies claim `publish`. `matched` names the token
398
+ // the shape was read off, so a name that only grazes a vocabulary shows it.
205
399
  if (
206
400
  tokens.some((t) => t === "release" || t === "releases") &&
207
401
  tokens.some((t) => RELEASE_ACTIONS[t] === true)
208
402
  ) {
209
- return "github-release";
403
+ return { shape: "github-release", matched: "release" };
210
404
  }
211
- if (tokens.some((t) => PUBLISH_ACTIONS[t] === true)) return "package-publish";
212
- if (tokens.some((t) => DEPLOY_ACTIONS[t] === true)) return "deploy";
405
+ if (tokens.some((t) => PUBLISH_ACTIONS[t] === true)) return { shape: "package-publish", matched: action };
406
+ if (tokens.some((t) => DEPLOY_ACTIONS[t] === true)) return { shape: "deploy", matched: action };
213
407
  return undefined;
214
408
  }
215
409
 
216
- /** Release-shaped device/tool invocations that do not pass through a shell. */
217
- export function releaseShapeFromTool(
410
+ /**
411
+ * Classify a release-shaped tool call and what matched, like
412
+ * {@link releaseShapeFromTool} plus the substring the shape was read off. One
413
+ * classifier sits behind decisions, audit records and tick prompts, so they
414
+ * cannot disagree about which call was blocked.
415
+ */
416
+ export function releaseToolMatch(
218
417
  toolName: string,
219
418
  input: Record<string, unknown> | undefined,
220
- ): ReleaseShape | undefined {
419
+ ): { shape: GateShape; matched: string } | undefined {
221
420
  if (input === undefined) return undefined;
222
421
  if (toolName === "bash") {
223
- return typeof input.command === "string" ? releaseShapeFromCommand(input.command) : undefined;
422
+ return typeof input.command === "string" ? releaseCommandMatch(input.command) : undefined;
224
423
  }
225
424
 
226
425
  // An MCP tool reached through the device path: the path *is* the tool name, so
@@ -228,11 +427,40 @@ export function releaseShapeFromTool(
228
427
  // `write` to `src/deploy.ts` is source code, not a deploy.
229
428
  const path = typeof input.path === "string" ? input.path : "";
230
429
  if (toolName === "write" && path.startsWith("xd://")) {
231
- const viaDevice = releaseShapeFromTokens(releaseTokens(path));
430
+ const viaDevice = releaseTokenMatch(releaseTokens(path));
232
431
  if (viaDevice !== undefined) return viaDevice;
233
432
  }
234
433
 
235
- return releaseShapeFromTokens(releaseTokens(toolName));
434
+ return releaseTokenMatch(releaseTokens(toolName));
435
+ }
436
+
437
+ /** Release-shaped device/tool invocations that do not pass through a shell. */
438
+ export function releaseShapeFromTool(
439
+ toolName: string,
440
+ input: Record<string, unknown> | undefined,
441
+ ): GateShape | undefined {
442
+ return releaseToolMatch(toolName, input)?.shape;
443
+ }
444
+
445
+ /**
446
+ * The refusal wording for the shared-host gate (#428). The whole-package
447
+ * `bun test` form names the focused alternative (a guard that only denies
448
+ * teaches nothing and gets worked around); the shell suites get their own
449
+ * because there is no focused form of a suite script.
450
+ */
451
+ function sharedHostRefusalReason(matched: string | undefined): string {
452
+ if (matched !== undefined && /^bun\s+test\b/.test(matched)) {
453
+ return (
454
+ "Blocked by sharedHostPolicy: the whole-package `bun test` is not a worker's proof path on this shared " +
455
+ "host (it overloads the 4-core VPS and, before #399, SIGTERMed the production daemon). " +
456
+ "Run a focused `bun test <file>.test.ts` instead."
457
+ );
458
+ }
459
+ return (
460
+ "Blocked by sharedHostPolicy: this shell suite is not a worker's proof path on this shared host " +
461
+ "(it overloads the 4-core VPS that also runs Langfuse and the fleet). " +
462
+ "Run focused `bun test <file>.test.ts` unit tests instead."
463
+ );
236
464
  }
237
465
 
238
466
  /**
@@ -249,8 +477,16 @@ export function releaseShapeFromTool(
249
477
  export function releaseRefusal(
250
478
  grants: ResolvedGrants,
251
479
  role: SessionRole,
252
- shape: ReleaseShape,
480
+ shape: GateShape,
253
481
  ): ReleaseDecision | undefined {
482
+ // The shared-host gate is not a grant anyone holds: a worker is always
483
+ // refused — the shared operator host cannot absorb a whole-package suite,
484
+ // which is the exact load that stopped it — and an operator session is
485
+ // never, because the operator owns the host and CI owns the full suite.
486
+ if (shape === SHARED_HOST_SHAPE) {
487
+ if (role !== "worker") return undefined;
488
+ return { block: true, reason: sharedHostRefusalReason(undefined) };
489
+ }
254
490
  const holder = grants[shape];
255
491
  if (holder === role) return undefined;
256
492
  return {
@@ -272,11 +508,22 @@ export function releaseDecision(
272
508
  role: SessionRole,
273
509
  toolName: string,
274
510
  input: Record<string, unknown>,
275
- ): { shape: ReleaseShape; decision: ReleaseDecision } | undefined {
276
- const shape = releaseShapeFromTool(toolName, input);
277
- if (shape === undefined) return undefined;
278
- const decision = releaseRefusal(grants, role, shape);
279
- return decision === undefined ? undefined : { shape, decision };
511
+ ): { shape: GateShape; matched: string; decision: ReleaseDecision } | undefined {
512
+ const match = releaseToolMatch(toolName, input);
513
+ if (match === undefined) return undefined;
514
+ const decision = releaseRefusal(grants, role, match.shape);
515
+ if (decision === undefined) return undefined;
516
+ // The shared-host gate's wording is matched-specific (name the focused form
517
+ // for a whole-package `bun test`, name the suite for a script), so it is
518
+ // read off the offending segment here rather than from `releaseRefusal`.
519
+ if (match.shape === SHARED_HOST_SHAPE) {
520
+ return {
521
+ shape: match.shape,
522
+ matched: match.matched,
523
+ decision: { block: true, reason: sharedHostRefusalReason(match.matched) },
524
+ };
525
+ }
526
+ return { shape: match.shape, matched: match.matched, decision };
280
527
  }
281
528
 
282
529
  const RELEASE_ARG_ALLOWLIST = new Set([
@@ -305,6 +552,64 @@ export function redactReleaseArgs(input: Record<string, unknown>): Record<string
305
552
  return redacted;
306
553
  }
307
554
 
555
+ /** Credential-shaped text a blocked invocation must not carry into the audit
556
+ * file, each with the replacement that takes its place. */
557
+ const CREDENTIAL_PATTERNS: Array<readonly [RegExp, string]> = [
558
+ // Armoured private keys, in full so a key cannot survive as its own body.
559
+ [/-----BEGIN [A-Z0-9 ]+PRIVATE KEY-----[\s\S]*?-----END [A-Z0-9 ]+PRIVATE KEY-----/g, "[redacted]"],
560
+ // GitHub classic and fine-grained PATs.
561
+ [/gh[pousr]_[A-Za-z0-9]{20,}/g, "[redacted]"],
562
+ [/github_pat_[A-Za-z0-9_]{20,}/g, "[redacted]"],
563
+ // Slack tokens.
564
+ [/xox[baprs]-[A-Za-z0-9-]{10,}/g, "[redacted]"],
565
+ // AWS access key ids. The secret half is caught by the long-run rule below.
566
+ [/AKIA[0-9A-Z]{16}/g, "[redacted]"],
567
+ // OpenAI-style `sk-` secrets.
568
+ [/sk-[A-Za-z0-9]{16,}/g, "[redacted]"],
569
+ // `--token x`, `--password=x` and friends. The token word must end at the
570
+ // separator, so `--author` stays a git flag rather than dying as a
571
+ // credential. The flag name survives so the record says what kind of
572
+ // credential rode on the call; only its value is replaced.
573
+ [
574
+ /--([a-z0-9-]*(?:token|secret|password|passwd|api-?key|auth(?:orization)?))(?=[\s=])(?:\s+|=)\S+/gi,
575
+ "--$1 [redacted]",
576
+ ],
577
+ // `TOKEN=…`, `NPM_CONFIG_TOKEN=…` style assignments.
578
+ [/[A-Z][A-Z0-9_]*(?:TOKEN|PASSWORD|PASSWD|SECRET|API_?KEY|CREDENTIAL)[A-Z0-9_]*=\S+/g, "[redacted]"],
579
+ // `Bearer …` / `Basic …` header values.
580
+ [/\b(?:Bearer|Basic)\s+\S+/g, "[redacted]"],
581
+ // Long unbroken runs are token-shaped whatever scheme they belong to. A
582
+ // commit sha is a stranger in a blocked command worth keeping; a leaked
583
+ // token is not worth the triage convenience.
584
+ [/[A-Za-z0-9_-]{32,}/g, "[redacted]"],
585
+ ];
586
+
587
+ /** The bound a recorded invocation is cut to. Long enough to read a heredoc,
588
+ * short enough that a fixture dump cannot bloat the audit file. */
589
+ const RELEASE_INVOCATION_MAX = 400;
590
+
591
+ /** Redact anything credential-shaped in a blocked command, then bound its
592
+ * length. Redaction runs before truncation so a cut cannot leak a token
593
+ * fragment past its own pattern. */
594
+ export function redactReleaseCommand(command: string): string {
595
+ let safe = command;
596
+ for (const [pattern, replacement] of CREDENTIAL_PATTERNS) {
597
+ safe = safe.replace(pattern, replacement);
598
+ }
599
+ return safe.length <= RELEASE_INVOCATION_MAX ? safe : `${safe.slice(0, RELEASE_INVOCATION_MAX)}…`;
600
+ }
601
+
602
+ /** The invocation a block records: the shell command for a `bash` call, and
603
+ * nothing for other tools — their redacted args already name the call. */
604
+ export function releaseBlockedInvocation(
605
+ toolName: string,
606
+ input: Record<string, unknown>,
607
+ ): string | undefined {
608
+ return toolName === "bash" && typeof input.command === "string"
609
+ ? redactReleaseCommand(input.command)
610
+ : undefined;
611
+ }
612
+
308
613
  interface ReleasePolicyPi {
309
614
  on(
310
615
  event: "tool_call",
@@ -319,16 +624,19 @@ interface ReleasePolicyPi {
319
624
  export function releasePolicyTripwire(
320
625
  grants: ResolvedGrants,
321
626
  role: SessionRole,
322
- onBlocked: (shape: ReleaseShape, context: ReleaseBlockContext) => void = () => {},
627
+ onBlocked: (shape: GateShape, context: ReleaseBlockContext) => void = () => {},
323
628
  ): (pi: ReleasePolicyPi) => void {
324
629
  return (pi) => {
325
630
  pi.on("tool_call", (event) => {
326
631
  const blocked = releaseDecision(grants, role, event.toolName, event.input);
327
632
  if (blocked === undefined) return undefined;
328
633
  try {
634
+ const invocation = releaseBlockedInvocation(event.toolName, event.input);
329
635
  onBlocked(blocked.shape, {
330
636
  tool: event.toolName,
331
637
  reason: blocked.decision.reason,
638
+ matched: blocked.matched,
639
+ ...(invocation === undefined ? {} : { invocation }),
332
640
  args: redactReleaseArgs(event.input),
333
641
  });
334
642
  } catch {
@@ -342,7 +650,7 @@ export function releasePolicyTripwire(
342
650
  export function recordReleaseBlock(
343
651
  project: string,
344
652
  source: ReleaseBlock["source"],
345
- shape: ReleaseShape,
653
+ shape: GateShape,
346
654
  details: Omit<ReleaseBlock, "project" | "source" | "shape" | "at"> = {},
347
655
  root = stateDir(),
348
656
  now = new Date(),
@@ -350,13 +658,15 @@ export function recordReleaseBlock(
350
658
  mkdirSync(root, { recursive: true });
351
659
  // Redact again at the durable boundary. Callers normally pass the tripwire's
352
660
  // already-safe context, but no alternate recorder path gets to rely on that.
353
- const { args, ...attribution } = details;
661
+ const { args, invocation, matched, ...attribution } = details;
354
662
  const record: ReleaseBlock = {
355
663
  project,
356
664
  source,
357
665
  shape,
358
666
  ...attribution,
359
667
  ...(args === undefined ? {} : { args: redactReleaseArgs(args) }),
668
+ ...(invocation === undefined ? {} : { invocation: redactReleaseCommand(invocation) }),
669
+ ...(matched === undefined ? {} : { matched: redactReleaseCommand(matched) }),
360
670
  at: now.toISOString(),
361
671
  };
362
672
  appendFileSync(join(root, RELEASE_POLICY_AUDIT_FILE), `${JSON.stringify(record)}\n`, { mode: 0o600 });
@@ -416,6 +726,8 @@ export function releaseDriftDigestLine(
416
726
  ...(latest.issue === undefined ? [] : [`issue #${latest.issue}`]),
417
727
  ...(latest.runId === undefined ? [] : [`run ${latest.runId}`]),
418
728
  ...(latest.tool === undefined ? [] : [`tool ${latest.tool}`]),
729
+ ...(latest.invocation === undefined ? [] : [`attempted "${latest.invocation}"`]),
730
+ ...(latest.matched === undefined ? [] : [`matched "${latest.matched}"`]),
419
731
  ].join(", ");
420
732
  return (
421
733
  `Release-policy drift today: ${drift.count} release/deploy tool call(s) were blocked ` +
package/src/reports.ts CHANGED
@@ -219,15 +219,22 @@ export function formatOpenReports(
219
219
 
220
220
  /** Durable rows still owed to a future digest. Always rendered in `status`: a
221
221
  * quiet line proves the accumulator is empty, while a non-zero line makes loss
222
- * or backlog visible without asking the session what it remembers. */
222
+ * or backlog visible without asking the session what it remembers. Held
223
+ * notices are split by what releases them: `releaseOnAvailable` rows are
224
+ * interrupt-worthy notices waiting only for the operator's working window,
225
+ * everything else is ordinary digest-bound backlog. One noun for both was how
226
+ * a routine material backlog read as stranded escalations (#492). */
223
227
  export function formatDigestBacklog(backlog: DigestBacklog, now: number = Date.now()): string[] {
224
228
  const age = (at: number | undefined): string =>
225
229
  at === undefined ? "" : ` (oldest ${humanAge(Math.max(0, now - at))})`;
230
+ const heldForAvailability = backlog.availabilityHeldNoticeCount ?? 0;
231
+ const heldForDigest = Math.max(0, backlog.heldNoticeCount - heldForAvailability);
226
232
  return [
227
233
  "",
228
234
  "digest backlog",
229
- ` material events ${backlog.materialCount}${age(backlog.materialOldestAt)}`,
230
- ` held escalations ${backlog.heldNoticeCount}${age(backlog.heldNoticeOldestAt)}`,
235
+ ` material events ${backlog.materialCount}${age(backlog.materialOldestAt)}`,
236
+ ` held for digest ${heldForDigest}${age(backlog.digestHeldNoticeOldestAt ?? backlog.heldNoticeOldestAt)}`,
237
+ ` held for availability ${heldForAvailability}${age(backlog.availabilityHeldNoticeOldestAt)}`,
231
238
  ];
232
239
  }
233
240
 
@@ -315,9 +322,16 @@ export function operatorMessageCategory(text: string): InterruptCategory {
315
322
  export async function deliverOperatorMessage(
316
323
  project: ProjectConfig,
317
324
  text: string,
318
- deps: { store: Store; at: number; noticeId: string; send?: ReportSend },
325
+ deps: {
326
+ store: Store;
327
+ at: number;
328
+ noticeId: string;
329
+ send?: ReportSend;
330
+ /** Declared escalation category; overrides the `QUESTION:`-prefix read. */
331
+ category?: InterruptCategory;
332
+ },
319
333
  ): Promise<OperatorMessageOutcome> {
320
- const category = operatorMessageCategory(text);
334
+ const category = deps.category ?? operatorMessageCategory(text);
321
335
  const disposition = interruptDisposition(project.reporting, category, deps.at);
322
336
  if (disposition !== "interrupt") {
323
337
  deps.store.addHeldNotice({
@@ -22,9 +22,8 @@
22
22
  import { connect } from "node:net";
23
23
 
24
24
  import { createLocalSession, disposeSession, type AgentSessionLike } from "./omp.ts";
25
- import type { ReleaseBlockContext } from "./release-policy.ts";
26
-
27
- import type { ReleaseShape, ResolvedGrants, SessionRole } from "./types.ts";
25
+ import type { GateShape, ReleaseBlockContext } from "./release-policy.ts";
26
+ import type { ResolvedGrants, SessionRole } from "./types.ts";
28
27
 
29
28
  /**
30
29
  * Everything the child needs to build the session. Plain JSON on purpose:
@@ -68,7 +67,7 @@ export type HostToParent =
68
67
  | { t: "session-file"; path: string }
69
68
  | { t: "prompt-result"; id: number; ok: boolean; error?: string }
70
69
  | { t: "park-result"; id: number; ok: boolean; error?: string }
71
- | ({ t: "release-blocked"; shape: ReleaseShape } & ReleaseBlockContext);
70
+ | ({ t: "release-blocked"; shape: GateShape } & ReleaseBlockContext);
72
71
 
73
72
  /**
74
73
  * Depth at which a harness event stops being copied for the wire.
@@ -196,7 +195,14 @@ export async function runSessionHost(
196
195
  });
197
196
  } catch (err) {
198
197
  send({ t: "start-error", message: err instanceof Error ? err.message : String(err) });
199
- socket.end();
198
+ // The frame must reach the parent before this process exits: `process.exit`
199
+ // does not flush the socket, and a parent that only observes the exit used
200
+ // to report a bare exit code instead of the child's own words.
201
+ await new Promise<void>((resolve) => {
202
+ socket.once("finish", () => resolve());
203
+ socket.once("error", () => resolve());
204
+ socket.end();
205
+ });
200
206
  return;
201
207
  }
202
208