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.
- package/README.md +273 -2544
- package/REFERENCE.md +2680 -0
- package/package.json +3 -2
- package/schema/config.schema.json +11 -23
- package/src/arm-challenge.ts +112 -0
- package/src/ask.ts +434 -0
- package/src/board.ts +81 -15
- package/src/brief-upgrade.ts +114 -8
- package/src/briefs/orchestrator.md +113 -34
- package/src/briefs/policy.md +33 -8
- package/src/briefs/worker.md +18 -9
- package/src/chain-check.ts +1 -1
- package/src/check-trailing-newlines.ts +82 -0
- package/src/cli.ts +225 -1406
- package/src/commands/arm.ts +21 -0
- package/src/commands/board.ts +23 -0
- package/src/commands/brief-upgrade.ts +186 -0
- package/src/commands/context.ts +150 -0
- package/src/commands/daemon.ts +71 -0
- package/src/commands/dashboard.ts +74 -0
- package/src/commands/decision.ts +103 -0
- package/src/commands/disarm.ts +21 -0
- package/src/commands/doctor.ts +100 -0
- package/src/commands/event.ts +62 -0
- package/src/commands/extend.ts +64 -0
- package/src/commands/friction.ts +56 -0
- package/src/commands/help.ts +9 -0
- package/src/commands/hold.ts +26 -0
- package/src/commands/intake.ts +155 -0
- package/src/commands/ledger.ts +69 -0
- package/src/commands/message.ts +96 -0
- package/src/commands/report.ts +206 -0
- package/src/commands/restart.ts +76 -0
- package/src/commands/resume.ts +58 -0
- package/src/commands/setup.ts +143 -0
- package/src/commands/start.ts +23 -0
- package/src/commands/stats.ts +131 -0
- package/src/commands/status.ts +48 -0
- package/src/commands/stop.ts +51 -0
- package/src/commands/tail.ts +109 -0
- package/src/commands/unblock.ts +39 -0
- package/src/commands/upgrade-install.ts +31 -0
- package/src/commands/upgrade-rollback.ts +32 -0
- package/src/commands/upgrade.ts +25 -0
- package/src/commands/verb.ts +83 -0
- package/src/commands/version.ts +30 -0
- package/src/commands/worker.ts +100 -0
- package/src/config-schema.ts +47 -1
- package/src/config.ts +45 -4
- package/src/daemon.ts +972 -101
- package/src/dashboard/app.js +459 -0
- package/src/dashboard/index.html +61 -0
- package/src/dashboard/server.ts +481 -0
- package/src/dashboard/style.css +348 -0
- package/src/decisions.ts +39 -14
- package/src/diff-flags.ts +131 -241
- package/src/doctor.ts +932 -0
- package/src/escalate.ts +2 -2
- package/src/failure-class.ts +66 -3
- package/src/fleet.ts +58 -1
- package/src/gitops.ts +157 -0
- package/src/graph-health.ts +1 -1
- package/src/label-projection.ts +1 -1
- package/src/lifecycle.ts +198 -2
- package/src/model-fallback.ts +177 -0
- package/src/notices.ts +9 -0
- package/src/omp.ts +93 -13
- package/src/orchestrator-tick.ts +414 -20
- package/src/orchestrator.ts +4 -4
- package/src/privileged.ts +10 -0
- package/src/release-policy.ts +342 -30
- package/src/reports.ts +19 -5
- package/src/session-host.ts +11 -5
- package/src/setup-host.ts +663 -25
- package/src/setup-install.ts +292 -28
- package/src/setup-wizard.ts +255 -74
- package/src/setup.ts +156 -35
- package/src/stats.ts +331 -0
- package/src/store.ts +219 -22
- package/src/tracker/github.ts +74 -4
- package/src/types.ts +215 -31
- package/src/unblock.ts +55 -11
- package/src/upgrade-journal.ts +220 -0
- package/src/upgrade-verify.ts +506 -0
- package/src/upgrade.ts +385 -58
- package/src/verbs/actions.ts +73 -1
- package/src/verbs/protocol.ts +29 -4
- package/src/verbs/server.ts +183 -20
- package/src/worker.ts +3 -3
- package/systemd/omp-conductor-recover.sh +433 -0
- package/systemd/omp-conductor.service.example +14 -3
- 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");
|
package/src/release-policy.ts
CHANGED
|
@@ -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:
|
|
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):
|
|
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
|
-
|
|
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
|
|
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
|
-
/**
|
|
217
|
-
|
|
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
|
-
):
|
|
419
|
+
): { shape: GateShape; matched: string } | undefined {
|
|
221
420
|
if (input === undefined) return undefined;
|
|
222
421
|
if (toolName === "bash") {
|
|
223
|
-
return typeof input.command === "string" ?
|
|
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 =
|
|
430
|
+
const viaDevice = releaseTokenMatch(releaseTokens(path));
|
|
232
431
|
if (viaDevice !== undefined) return viaDevice;
|
|
233
432
|
}
|
|
234
433
|
|
|
235
|
-
return
|
|
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:
|
|
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:
|
|
276
|
-
const
|
|
277
|
-
if (
|
|
278
|
-
const decision = releaseRefusal(grants, role, shape);
|
|
279
|
-
|
|
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:
|
|
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:
|
|
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
|
|
230
|
-
` held
|
|
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: {
|
|
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({
|
package/src/session-host.ts
CHANGED
|
@@ -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:
|
|
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
|
-
|
|
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
|
|