omp-conductor 0.3.25 → 0.4.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.
- package/README.md +733 -30
- package/package.json +1 -1
- package/skills/conductor-onboarding/SKILL.md +56 -0
- package/skills/conductor-update/SKILL.md +27 -0
- package/src/board.ts +24 -5
- package/src/briefs/orchestrator.md +106 -20
- package/src/briefs/policy.md +48 -27
- package/src/briefs/worker.md +82 -31
- package/src/cli.ts +155 -2
- package/src/config.ts +669 -16
- package/src/confinement.ts +506 -25
- package/src/credentials.ts +2029 -0
- package/src/daemon.ts +1021 -32
- package/src/diff-flags.ts +696 -0
- package/src/escalate.ts +136 -9
- package/src/fleet.ts +110 -3
- package/src/omp.ts +471 -15
- package/src/orchestrator-tick.ts +42 -19
- package/src/orchestrator.ts +32 -5
- package/src/plugin.ts +267 -15
- package/src/release-policy.ts +191 -29
- package/src/reports.ts +440 -0
- package/src/session-host.ts +307 -0
- package/src/setup-host.ts +162 -1
- package/src/setup.ts +207 -18
- package/src/store.ts +506 -3
- package/src/tracker/github.ts +45 -0
- package/src/types.ts +847 -10
- package/src/upgrade.ts +96 -3
- package/src/usage.ts +726 -0
- package/src/verbs/actions.ts +142 -0
- package/src/verbs/client.ts +207 -0
- package/src/verbs/ledger.ts +77 -0
- package/src/verbs/protocol.ts +465 -0
- package/src/verbs/server.ts +1098 -0
- package/src/verbs/socket.ts +446 -0
- package/src/worker.ts +36 -7
- package/src/worktree.ts +202 -109
- package/systemd/omp-conductor.service.example +96 -8
package/src/orchestrator.ts
CHANGED
|
@@ -31,10 +31,10 @@ import { join } from "node:path";
|
|
|
31
31
|
|
|
32
32
|
import { stateDir } from "./config.ts";
|
|
33
33
|
import { formatEscalation } from "./escalate.ts";
|
|
34
|
+
import type { SessionBoundary } from "./credentials.ts";
|
|
34
35
|
import { createSession, disposeSession } from "./omp.ts";
|
|
35
36
|
import type { AgentSessionLike } from "./omp.ts";
|
|
36
|
-
import type { ReleaseShape } from "./
|
|
37
|
-
import type { Escalation, ReleasePolicy } from "./types.ts";
|
|
37
|
+
import type { Escalation, ReleaseShape, ResolvedGrants, SessionRole } from "./types.ts";
|
|
38
38
|
|
|
39
39
|
/**
|
|
40
40
|
* The session factory {@link startOrchestrator} uses. Named so the test seam
|
|
@@ -45,8 +45,13 @@ export type CreateSessionFn = (opts: {
|
|
|
45
45
|
sessionDir?: string;
|
|
46
46
|
model?: string;
|
|
47
47
|
resume?: boolean;
|
|
48
|
-
|
|
48
|
+
role: SessionRole;
|
|
49
|
+
releaseGrants?: ResolvedGrants;
|
|
49
50
|
onReleaseBlocked?: (shape: ReleaseShape) => void;
|
|
51
|
+
boundary?: SessionBoundary;
|
|
52
|
+
socketPath?: string;
|
|
53
|
+
verbSocketPath?: string;
|
|
54
|
+
onChildLog?: (line: string) => void;
|
|
50
55
|
}) => Promise<AgentSessionLike>;
|
|
51
56
|
|
|
52
57
|
/**
|
|
@@ -79,8 +84,25 @@ export interface OrchestratorOpts {
|
|
|
79
84
|
cwd: string;
|
|
80
85
|
sessionDir?: string;
|
|
81
86
|
model?: string;
|
|
82
|
-
|
|
87
|
+
releaseGrants?: ResolvedGrants;
|
|
83
88
|
onReleaseBlocked?: (shape: ReleaseShape) => void;
|
|
89
|
+
/**
|
|
90
|
+
* The OS principal this session runs as (#125). Its own, distinct from every
|
|
91
|
+
* worker slot: the orchestrator reads the state directory and its briefs, and
|
|
92
|
+
* must have no read or write access to any run checkout — a property the
|
|
93
|
+
* adversarial probe asserts rather than assumes.
|
|
94
|
+
*/
|
|
95
|
+
boundary?: SessionBoundary;
|
|
96
|
+
/** Control socket for the session child. See {@link OrchestratorOpts.boundary}. */
|
|
97
|
+
socketPath?: string;
|
|
98
|
+
/**
|
|
99
|
+
* The orchestrator's own verb socket (#126) — a third, distinct one, never
|
|
100
|
+
* shared with a run. It is what makes "merge authority is the orchestrator's"
|
|
101
|
+
* a fact about the channel rather than a claim in a prompt.
|
|
102
|
+
*/
|
|
103
|
+
verbSocketPath?: string;
|
|
104
|
+
/** Where the session child's stdout/stderr go. */
|
|
105
|
+
onChildLog?: (line: string) => void;
|
|
84
106
|
/**
|
|
85
107
|
* Standing orders — which repo, which labels, what the fleet is. Prepended to
|
|
86
108
|
* the *first* injection rather than sent as its own prompt on startup: a
|
|
@@ -164,8 +186,13 @@ export async function startOrchestrator(o: OrchestratorOpts): Promise<Orchestrat
|
|
|
164
186
|
cwd: o.cwd,
|
|
165
187
|
sessionDir,
|
|
166
188
|
...(o.model === undefined ? {} : { model: o.model }),
|
|
167
|
-
|
|
189
|
+
role: "orchestrator",
|
|
190
|
+
...(o.releaseGrants === undefined ? {} : { releaseGrants: o.releaseGrants }),
|
|
168
191
|
...(o.onReleaseBlocked === undefined ? {} : { onReleaseBlocked: o.onReleaseBlocked }),
|
|
192
|
+
...(o.boundary === undefined ? {} : { boundary: o.boundary }),
|
|
193
|
+
...(o.socketPath === undefined ? {} : { socketPath: o.socketPath }),
|
|
194
|
+
...(o.verbSocketPath === undefined ? {} : { verbSocketPath: o.verbSocketPath }),
|
|
195
|
+
...(o.onChildLog === undefined ? {} : { onChildLog: o.onChildLog }),
|
|
169
196
|
// The whole point of a persistent orchestrator: a daemon restart must not
|
|
170
197
|
// reset what it knows it has already escalated, or the first tick after a
|
|
171
198
|
// deploy re-litigates every parked issue from scratch.
|
package/src/plugin.ts
CHANGED
|
@@ -22,6 +22,7 @@ import {
|
|
|
22
22
|
writeMergedBrief,
|
|
23
23
|
} from "./brief-upgrade.ts";
|
|
24
24
|
import { configPath, expandHome, findProject, loadConfig, resolveCaps, saveConfig } from "./config.ts";
|
|
25
|
+
import { mechanismSatisfies, probeHost } from "./credentials.ts";
|
|
25
26
|
import { hostRamBytes, recommendedMaxWorkers } from "./host.ts";
|
|
26
27
|
import {
|
|
27
28
|
isPaused,
|
|
@@ -51,8 +52,12 @@ import {
|
|
|
51
52
|
} from "./setup-host.ts";
|
|
52
53
|
import {
|
|
53
54
|
AMEND_AREAS,
|
|
55
|
+
BASE_FRESHNESS_CHOICES,
|
|
56
|
+
BEHIND_BASE_CHOICES,
|
|
57
|
+
DRAFT_POLICY_CHOICES,
|
|
54
58
|
ORCHESTRATOR_BRIEF_NAME,
|
|
55
59
|
POLICY_BRIEF_NAME,
|
|
60
|
+
RELEASE_REQUIREMENT_CHOICES,
|
|
56
61
|
REPORT_SCOPE_CHOICES,
|
|
57
62
|
SETUP_DEFAULTS,
|
|
58
63
|
amendChoices,
|
|
@@ -76,13 +81,22 @@ import {
|
|
|
76
81
|
type SetupAnswers,
|
|
77
82
|
} from "./setup.ts";
|
|
78
83
|
import {
|
|
84
|
+
BASE_FRESHNESS,
|
|
85
|
+
BEHIND_BASE_ACTIONS,
|
|
86
|
+
CREDENTIAL_ISOLATIONS,
|
|
79
87
|
DEFAULT_CAPS,
|
|
88
|
+
DRAFT_POLICIES,
|
|
89
|
+
RELEASE_REQUIREMENTS,
|
|
90
|
+
RELEASE_SHAPES,
|
|
80
91
|
type Caps,
|
|
92
|
+
type CredentialIsolation,
|
|
81
93
|
type ConductorConfig,
|
|
82
94
|
type OrchestratorMode,
|
|
83
95
|
type ProjectConfig,
|
|
84
|
-
type
|
|
96
|
+
type ProjectPolicy,
|
|
97
|
+
type ReleaseRequirement,
|
|
85
98
|
type ReportScope,
|
|
99
|
+
type ResolvedGrants,
|
|
86
100
|
} from "./types.ts";
|
|
87
101
|
|
|
88
102
|
/**
|
|
@@ -319,6 +333,164 @@ async function askReportScope(ctx: CommandContext, current: ReportScope): Promis
|
|
|
319
333
|
return choice.scope;
|
|
320
334
|
}
|
|
321
335
|
|
|
336
|
+
/**
|
|
337
|
+
* One value out of a closed vocabulary, described in the operator's words.
|
|
338
|
+
*
|
|
339
|
+
* A select rather than a confirm, and the cursor starts on the configured value
|
|
340
|
+
* so Enter re-affirms it — the contract every prompt here has. It cannot be a
|
|
341
|
+
* confirm: a precondition's safe answer is "keep requiring it", and this
|
|
342
|
+
* harness's confirms always start on no, so a re-run that Entered through them
|
|
343
|
+
* would quietly relax the gate it was meant to leave alone.
|
|
344
|
+
*
|
|
345
|
+
* The label *is* the config value, so the answer the harness hands back needs no
|
|
346
|
+
* lookup table that could disagree with the vocabulary it was built from.
|
|
347
|
+
*/
|
|
348
|
+
async function askLiteral<T extends string>(
|
|
349
|
+
ctx: CommandContext,
|
|
350
|
+
title: string,
|
|
351
|
+
values: readonly T[],
|
|
352
|
+
described: { readonly [K in T]: string },
|
|
353
|
+
current: T,
|
|
354
|
+
): Promise<T> {
|
|
355
|
+
const at = values.findIndex((v) => v === current);
|
|
356
|
+
const picked = await ctx.ui.select(
|
|
357
|
+
title,
|
|
358
|
+
values.map((v) => ({ label: v, description: described[v] })),
|
|
359
|
+
{ initialIndex: at === -1 ? 0 : at },
|
|
360
|
+
);
|
|
361
|
+
if (picked === undefined) throw new Cancelled();
|
|
362
|
+
|
|
363
|
+
const hit = values.find((v) => v === picked);
|
|
364
|
+
if (hit === undefined) {
|
|
365
|
+
// The harness answered with a label we never offered, which only happens if
|
|
366
|
+
// the dialog contract changed under us. Keeping the current value is the
|
|
367
|
+
// answer that changes nothing, and it is said out loud rather than assumed.
|
|
368
|
+
ctx.ui.notify(`Unrecognised choice "${picked}" — keeping "${current}".`, "warning");
|
|
369
|
+
return current;
|
|
370
|
+
}
|
|
371
|
+
return hit;
|
|
372
|
+
}
|
|
373
|
+
|
|
374
|
+
/** How an empty list is both shown and typed. A word, because a blank line in
|
|
375
|
+
* this wizard means "accept what you see", not "clear it". */
|
|
376
|
+
const EMPTY_LIST = "none";
|
|
377
|
+
|
|
378
|
+
/** A name list as the prompt shows it and reads it back — one spelling, so the
|
|
379
|
+
* pre-filled default and the value it round-trips to cannot drift. */
|
|
380
|
+
function formatNameList(names: readonly string[]): string {
|
|
381
|
+
return names.length === 0 ? EMPTY_LIST : names.join(", ");
|
|
382
|
+
}
|
|
383
|
+
|
|
384
|
+
function parseNameList(answer: string): string[] {
|
|
385
|
+
if (answer.trim().toLowerCase() === EMPTY_LIST) return [];
|
|
386
|
+
return answer
|
|
387
|
+
.split(",")
|
|
388
|
+
.map((name) => name.trim())
|
|
389
|
+
.filter((name) => name.length > 0);
|
|
390
|
+
}
|
|
391
|
+
|
|
392
|
+
/** Check names, artefacts, environments: open-ended lists this package cannot
|
|
393
|
+
* enumerate, so the only validation is the shape. */
|
|
394
|
+
async function askNameList(ctx: CommandContext, title: string, seed: readonly string[]): Promise<string[]> {
|
|
395
|
+
return parseNameList(await ask(ctx, title, formatNameList(seed)));
|
|
396
|
+
}
|
|
397
|
+
|
|
398
|
+
/**
|
|
399
|
+
* The `requires` set, typed rather than picked one confirm at a time.
|
|
400
|
+
*
|
|
401
|
+
* Validated in the dialog against the same array the loader validates against,
|
|
402
|
+
* and the complaint names every accepted value — an operator who mistyped a
|
|
403
|
+
* requirement they believed they had set would otherwise find out from a release
|
|
404
|
+
* that went ahead without it.
|
|
405
|
+
*/
|
|
406
|
+
async function askReleaseRequirements(
|
|
407
|
+
ctx: CommandContext,
|
|
408
|
+
prior: readonly ReleaseRequirement[],
|
|
409
|
+
): Promise<ReleaseRequirement[]> {
|
|
410
|
+
const accepted = RELEASE_REQUIREMENTS.join(", ");
|
|
411
|
+
// The vocabulary, spelled out where it is being asked for. Built from the same
|
|
412
|
+
// data the validator reads, so a fifth requirement is offered here the moment
|
|
413
|
+
// it exists rather than staying invisible to everyone who did not read #129.
|
|
414
|
+
ctx.ui.notify(
|
|
415
|
+
RELEASE_REQUIREMENTS.map((r) => `${r} — ${RELEASE_REQUIREMENT_CHOICES[r]}`).join("\n"),
|
|
416
|
+
"info",
|
|
417
|
+
);
|
|
418
|
+
const answered = await askValid(
|
|
419
|
+
ctx,
|
|
420
|
+
`Release — what must have landed first (any of ${accepted}, comma separated, or "${EMPTY_LIST}")`,
|
|
421
|
+
formatNameList(prior),
|
|
422
|
+
(value) => {
|
|
423
|
+
const unknown = parseNameList(value).filter((name) => !RELEASE_REQUIREMENTS.some((r) => r === name));
|
|
424
|
+
return unknown.length === 0 ? undefined : `Not a release requirement: ${unknown.join(", ")}. Accepted: ${accepted}.`;
|
|
425
|
+
},
|
|
426
|
+
);
|
|
427
|
+
|
|
428
|
+
const chosen = new Set(parseNameList(answered));
|
|
429
|
+
// The vocabulary's order, not the operator's: two fleets that require the same
|
|
430
|
+
// three things must read identically in the plan and in a refusal.
|
|
431
|
+
return RELEASE_REQUIREMENTS.filter((r) => chosen.has(r));
|
|
432
|
+
}
|
|
433
|
+
|
|
434
|
+
/**
|
|
435
|
+
* The gating conditions #126's verbs read (#129).
|
|
436
|
+
*
|
|
437
|
+
* Asked here rather than left to a hand-edit because the whole point of moving
|
|
438
|
+
* them out of POLICY.md is that they are config: a condition an operator can
|
|
439
|
+
* only reach by opening `config.json` is one that stays at its default while
|
|
440
|
+
* their prose says something else, which is the drift this key ended.
|
|
441
|
+
*/
|
|
442
|
+
async function askPolicyPreconditions(ctx: CommandContext, prior: ProjectPolicy): Promise<ProjectPolicy> {
|
|
443
|
+
const merge = {
|
|
444
|
+
requiredChecks: await askNameList(
|
|
445
|
+
ctx,
|
|
446
|
+
`Merge — required checks (comma separated, "${EMPTY_LIST}" = every check the PR reports)`,
|
|
447
|
+
prior.merge.requiredChecks,
|
|
448
|
+
),
|
|
449
|
+
baseFreshness: await askLiteral(
|
|
450
|
+
ctx,
|
|
451
|
+
"Merge — must the PR be level with its base?",
|
|
452
|
+
BASE_FRESHNESS,
|
|
453
|
+
BASE_FRESHNESS_CHOICES,
|
|
454
|
+
prior.merge.baseFreshness,
|
|
455
|
+
),
|
|
456
|
+
drafts: await askLiteral(
|
|
457
|
+
ctx,
|
|
458
|
+
"Merge — draft pull requests",
|
|
459
|
+
DRAFT_POLICIES,
|
|
460
|
+
DRAFT_POLICY_CHOICES,
|
|
461
|
+
prior.merge.drafts,
|
|
462
|
+
),
|
|
463
|
+
whenBehindBase: await askLiteral(
|
|
464
|
+
ctx,
|
|
465
|
+
"Merge — a green PR that fell behind its base",
|
|
466
|
+
BEHIND_BASE_ACTIONS,
|
|
467
|
+
BEHIND_BASE_CHOICES,
|
|
468
|
+
prior.merge.whenBehindBase,
|
|
469
|
+
),
|
|
470
|
+
};
|
|
471
|
+
|
|
472
|
+
const release = {
|
|
473
|
+
requires: await askReleaseRequirements(ctx, prior.release.requires),
|
|
474
|
+
requiredChecks: await askNameList(
|
|
475
|
+
ctx,
|
|
476
|
+
`Release — required checks (comma separated, "${EMPTY_LIST}" = every check the branch reports)`,
|
|
477
|
+
prior.release.requiredChecks,
|
|
478
|
+
),
|
|
479
|
+
artefacts: await askNameList(
|
|
480
|
+
ctx,
|
|
481
|
+
`Release — artefacts this project ships (comma separated, or "${EMPTY_LIST}")`,
|
|
482
|
+
prior.release.artefacts,
|
|
483
|
+
),
|
|
484
|
+
environments: await askNameList(
|
|
485
|
+
ctx,
|
|
486
|
+
`Release — environments a deploy may target (comma separated, or "${EMPTY_LIST}")`,
|
|
487
|
+
prior.release.environments,
|
|
488
|
+
),
|
|
489
|
+
};
|
|
490
|
+
|
|
491
|
+
return { merge, release };
|
|
492
|
+
}
|
|
493
|
+
|
|
322
494
|
/**
|
|
323
495
|
* Who merges and who releases. Two confirms rather than one four-way list:
|
|
324
496
|
* these are independent grants — delegating merges is routine, delegating
|
|
@@ -349,18 +521,53 @@ async function askAuthority(
|
|
|
349
521
|
return { merge: merge ? "orchestrator" : "human", release: release ? "orchestrator" : "human" };
|
|
350
522
|
}
|
|
351
523
|
|
|
352
|
-
|
|
524
|
+
/**
|
|
525
|
+
* What each shape means to the operator being asked about it, in their words
|
|
526
|
+
* rather than the classifier's. Declared as data over the closed enum so a sixth
|
|
527
|
+
* shape cannot be added without a question to ask about it — an unasked shape
|
|
528
|
+
* would silently take the deny default and read as a decision afterwards.
|
|
529
|
+
*
|
|
530
|
+
* No fleet vocabulary here on purpose (#122): every one of these is an act the
|
|
531
|
+
* package can recognise anywhere, not a step in one project's release topology.
|
|
532
|
+
*/
|
|
533
|
+
const RELEASE_SHAPE_QUESTIONS: { readonly [K in (typeof RELEASE_SHAPES)[number]]: string } = {
|
|
534
|
+
"git-tag": "create git tags (`git tag v1.2.3`)",
|
|
535
|
+
"git-push-tags": "push tags to the remote (`git push --follow-tags`)",
|
|
536
|
+
"package-publish": "publish packages (`npm publish` and equivalents)",
|
|
537
|
+
"github-release": "create GitHub releases (`gh release create`)",
|
|
538
|
+
deploy:
|
|
539
|
+
"deploy — change what is running: kubectl/helm/terraform, a deploy device call, a rollout. " +
|
|
540
|
+
"This is the one grant that mutates a live environment rather than producing an artifact",
|
|
541
|
+
};
|
|
542
|
+
|
|
543
|
+
/**
|
|
544
|
+
* The mechanical tool gate, one confirm per shape.
|
|
545
|
+
*
|
|
546
|
+
* One binary question used to cover all five, which is how #122 happened: an
|
|
547
|
+
* operator who meant "it may cut a release" also granted "it may deploy to
|
|
548
|
+
* production", because there was one switch for both. Asking five times is the
|
|
549
|
+
* point — each answer is a different blast radius.
|
|
550
|
+
*
|
|
551
|
+
* No confirm can start on "yes", so a re-run that Enters through the wizard
|
|
552
|
+
* revokes rather than renews. The current grant is named in the question, so
|
|
553
|
+
* that revoke is never a surprise.
|
|
554
|
+
*/
|
|
555
|
+
async function askReleaseGrants(
|
|
353
556
|
ctx: CommandContext,
|
|
354
|
-
prior:
|
|
355
|
-
): Promise<
|
|
356
|
-
const
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
557
|
+
prior: ResolvedGrants,
|
|
558
|
+
): Promise<ResolvedGrants> {
|
|
559
|
+
const grants = { ...prior };
|
|
560
|
+
for (const shape of RELEASE_SHAPES) {
|
|
561
|
+
const open = await ctx.ui.confirm(
|
|
562
|
+
`Release tool gate — ${shape}`,
|
|
563
|
+
`Allow the orchestrator session to ${RELEASE_SHAPE_QUESTIONS[shape]}? Grant this only when the ` +
|
|
564
|
+
"operator brief carries the procedure it must follow. A worker session is refused this " +
|
|
565
|
+
"whatever you answer. Default: no" +
|
|
566
|
+
`${prior[shape] === "orchestrator" ? " — currently granted, answer no to take it back" : ""}.`,
|
|
567
|
+
);
|
|
568
|
+
grants[shape] = open ? "orchestrator" : "human";
|
|
569
|
+
}
|
|
570
|
+
return grants;
|
|
364
571
|
}
|
|
365
572
|
|
|
366
573
|
/**
|
|
@@ -647,14 +854,55 @@ const askWorkerModel: AreaAsker = async (ctx, a) => {
|
|
|
647
854
|
return next;
|
|
648
855
|
};
|
|
649
856
|
|
|
650
|
-
/**
|
|
651
|
-
* what an unattended fleet may do
|
|
857
|
+
/** The two ownership questions, then the mechanical gate one shape at a time:
|
|
858
|
+
* together they are what decides what an unattended fleet may do unasked. */
|
|
652
859
|
const askAuthorityArea: AreaAsker = async (ctx, a) => ({
|
|
653
860
|
...a,
|
|
654
861
|
authority: await askAuthority(ctx, a.authority),
|
|
655
|
-
|
|
862
|
+
releaseGrants: await askReleaseGrants(ctx, a.releaseGrants),
|
|
656
863
|
});
|
|
657
864
|
|
|
865
|
+
/** What a merge and a release must satisfy. Asked straight after the grants:
|
|
866
|
+
* who may act, then under what conditions (#129). */
|
|
867
|
+
const askPolicy: AreaAsker = async (ctx, a) => ({ ...a, policy: await askPolicyPreconditions(ctx, a.policy) });
|
|
868
|
+
|
|
869
|
+
/**
|
|
870
|
+
* Whether model-executed code gets its own OS principal (#125).
|
|
871
|
+
*
|
|
872
|
+
* The host is probed before the question is asked, and the answer the box can
|
|
873
|
+
* actually honour is named in the option itself. That matters because the two
|
|
874
|
+
* failure modes are asymmetric: choosing `none` gets a fleet that dispatches
|
|
875
|
+
* and is unprotected, while choosing `per-run` on a host with no mechanism gets
|
|
876
|
+
* a fleet that refuses every issue. An operator should not have to discover
|
|
877
|
+
* which one they picked by watching the queue stall.
|
|
878
|
+
*/
|
|
879
|
+
const askCredentials: AreaAsker = async (ctx, a) => {
|
|
880
|
+
const probe = await probeHost({ slots: a.caps.maxConcurrentWorkers ?? DEFAULT_CAPS.maxConcurrentWorkers });
|
|
881
|
+
// Each option says whether THIS host can honour it, asked of the same
|
|
882
|
+
// predicate the dispatch gate uses, so the wizard cannot promise a boundary
|
|
883
|
+
// the daemon will then refuse to build.
|
|
884
|
+
const offer = (isolation: CredentialIsolation, claim: string): string =>
|
|
885
|
+
mechanismSatisfies(isolation, probe.mechanism)
|
|
886
|
+
? `${claim} — this host can build it with ${probe.mechanism}`
|
|
887
|
+
: `${claim} — UNAVAILABLE here (${probe.reasons.join("; ") || "no mechanism found"}); dispatch would refuse every issue`;
|
|
888
|
+
const described: { readonly [K in CredentialIsolation]: string } = {
|
|
889
|
+
"per-run": offer("per-run", "each session runs under its own OS principal; it cannot reach the daemon's credentials"),
|
|
890
|
+
"group-mode": offer(
|
|
891
|
+
"group-mode",
|
|
892
|
+
"same uid as the daemon, cross-run separation by group and mode only; bounds accidents, does NOT contain a bash escape",
|
|
893
|
+
),
|
|
894
|
+
none: "sessions run as the daemon's user; env scrubbing only, which same-uid code defeats in one line",
|
|
895
|
+
};
|
|
896
|
+
const isolation = await askLiteral(
|
|
897
|
+
ctx,
|
|
898
|
+
"Credential isolation for worker and orchestrator sessions",
|
|
899
|
+
CREDENTIAL_ISOLATIONS,
|
|
900
|
+
described,
|
|
901
|
+
a.credentials.isolation,
|
|
902
|
+
);
|
|
903
|
+
return { ...a, credentials: { ...a.credentials, isolation } };
|
|
904
|
+
};
|
|
905
|
+
|
|
658
906
|
/** How a stuck run reaches a human, and who triages it when it does. */
|
|
659
907
|
const askEscalation: AreaAsker = async (ctx, a) => {
|
|
660
908
|
const telegram = detectTelegram();
|
|
@@ -710,6 +958,8 @@ const AREA_ASKERS: { readonly [K in AmendAreaId]: AreaAsker } = {
|
|
|
710
958
|
caps: async (ctx, a) => await askWorkerModel(ctx, await askCaps(ctx, a)),
|
|
711
959
|
graph: askGraph,
|
|
712
960
|
authority: askAuthorityArea,
|
|
961
|
+
policy: askPolicy,
|
|
962
|
+
credentials: askCredentials,
|
|
713
963
|
escalation: askEscalation,
|
|
714
964
|
reporting: askReporting,
|
|
715
965
|
brief: askBrief,
|
|
@@ -799,6 +1049,8 @@ async function collectAnswers(
|
|
|
799
1049
|
a = await askGraph(ctx, a);
|
|
800
1050
|
a = await askCaps(ctx, a);
|
|
801
1051
|
a = await askAuthorityArea(ctx, a);
|
|
1052
|
+
a = await askPolicy(ctx, a);
|
|
1053
|
+
a = await askCredentials(ctx, a);
|
|
802
1054
|
a = await askWorkerModel(ctx, a);
|
|
803
1055
|
a = await askEscalation(ctx, a);
|
|
804
1056
|
a = await askReporting(ctx, a);
|
package/src/release-policy.ts
CHANGED
|
@@ -1,18 +1,40 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The release/deploy tool-call tripwire.
|
|
3
|
+
*
|
|
4
|
+
* **Defence in depth, and no longer load-bearing (#126).** This module works by
|
|
5
|
+
* pattern-matching a tool name or a shell string inside the session, which is
|
|
6
|
+
* the weakest place a rule can live: the string is the model's, the matching is
|
|
7
|
+
* a regex, and `bash` hands it an unbounded alphabet to spell `npm publish` in.
|
|
8
|
+
* It was the only mechanism there was, and it was never the one anybody should
|
|
9
|
+
* have had to rely on.
|
|
10
|
+
*
|
|
11
|
+
* What actually stops a release now is that the session cannot perform one. It
|
|
12
|
+
* holds no credential (#125) and its only route to a mutation is the mediated
|
|
13
|
+
* verbs, whose checks run in the daemon against the configured holder
|
|
14
|
+
* (`verbs/server.ts`). A worker that talks its way past every regex below still
|
|
15
|
+
* has nothing to push with.
|
|
16
|
+
*
|
|
17
|
+
* It stays installed anyway, for the two things a tripwire is good at and a
|
|
18
|
+
* boundary is not: it refuses *early*, in the session, with an explanation the
|
|
19
|
+
* model can act on in the same turn instead of an `EACCES` it may read as
|
|
20
|
+
* transient; and it leaves a durable record that something tried
|
|
21
|
+
* ({@link recordReleaseBlock}), which is how drift shows up in a digest rather
|
|
22
|
+
* than in an incident. Treat a block here as evidence about a session's
|
|
23
|
+
* intentions, never as proof that the release was prevented — the daemon is
|
|
24
|
+
* what prevented it.
|
|
25
|
+
*
|
|
26
|
+
* {@link releaseRefusal} is the exception to all of the above: it is the shared
|
|
27
|
+
* grant comparison, and `conductor_release` calls exactly this function, so the
|
|
28
|
+
* refusal an operator reads is worded once.
|
|
29
|
+
*/
|
|
1
30
|
import { appendFileSync, mkdirSync, readFileSync } from "node:fs";
|
|
2
31
|
import { join } from "node:path";
|
|
3
32
|
|
|
4
33
|
import { stateDir } from "./config.ts";
|
|
5
|
-
import type {
|
|
34
|
+
import type { ReleaseShape, ResolvedGrants, SessionRole } from "./types.ts";
|
|
6
35
|
|
|
7
36
|
export const RELEASE_POLICY_AUDIT_FILE = "release-policy-blocks.jsonl";
|
|
8
37
|
|
|
9
|
-
export type ReleaseShape =
|
|
10
|
-
| "git-tag"
|
|
11
|
-
| "git-push-tags"
|
|
12
|
-
| "package-publish"
|
|
13
|
-
| "github-release"
|
|
14
|
-
| "deploy";
|
|
15
|
-
|
|
16
38
|
export interface ReleaseBlock {
|
|
17
39
|
project: string;
|
|
18
40
|
source: "worker" | "orchestrator";
|
|
@@ -66,6 +88,122 @@ export function releaseShapeFromCommand(command: string): ReleaseShape | undefin
|
|
|
66
88
|
return undefined;
|
|
67
89
|
}
|
|
68
90
|
|
|
91
|
+
/**
|
|
92
|
+
* The tokens a tool name or device path is matched on: split on every separator
|
|
93
|
+
* (`_`, `-`, `.`, `/`, `:`) and on CamelCase boundaries, then lowercased.
|
|
94
|
+
* `DeployStack` → `[deploy, stack]`; `GetDeploymentStatus` → `[get, deployment,
|
|
95
|
+
* status]`; `xd://mcp__komodo_DeployStack` → `[xd, mcp, komodo, deploy, stack]`.
|
|
96
|
+
*
|
|
97
|
+
* One tokeniser for both branches of {@link releaseShapeFromTool}, because #130
|
|
98
|
+
* was two matchers disagreeing about the same tool. The device path tested the
|
|
99
|
+
* whole string with an unbounded `/deploy/i`, so `xd://mcp__komodo_GetDeploymentStatus`
|
|
100
|
+
* — a read this fleet's orchestrator makes routinely — was *blocked* as a
|
|
101
|
+
* release action; the native tool name required a delimiter, so
|
|
102
|
+
* `mcp__komodo_DeployStack`, the real deploy, matched nothing at all. Wrong in
|
|
103
|
+
* opposite directions, about the same call.
|
|
104
|
+
*
|
|
105
|
+
* Exported for the tests: the negative cases are the whole point of this file,
|
|
106
|
+
* and they are cheaper to state against tokens than against a decision.
|
|
107
|
+
*/
|
|
108
|
+
export function releaseTokens(name: string): string[] {
|
|
109
|
+
return (
|
|
110
|
+
name
|
|
111
|
+
// `DeployStack` → `Deploy Stack`, then `HTTPServer` → `HTTP Server` so an
|
|
112
|
+
// acronym prefix cannot swallow the verb that follows it.
|
|
113
|
+
.replace(/([a-z0-9])([A-Z])/g, "$1 $2")
|
|
114
|
+
.replace(/([A-Z]+)([A-Z][a-z])/g, "$1 $2")
|
|
115
|
+
.toLowerCase()
|
|
116
|
+
.split(/[^a-z0-9]+/)
|
|
117
|
+
.filter((token) => token.length > 0)
|
|
118
|
+
);
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
/**
|
|
122
|
+
* Verbs that make a name a query, whatever nouns follow. `GetDeploymentStatus`,
|
|
123
|
+
* `ListDeployments` and `describeDeployment` are how an orchestrator reads a
|
|
124
|
+
* deployment's state, and refusing those as release actions is the live block
|
|
125
|
+
* #130 was filed for.
|
|
126
|
+
*/
|
|
127
|
+
const READ_VERBS: Record<string, true> = {
|
|
128
|
+
get: true,
|
|
129
|
+
list: true,
|
|
130
|
+
describe: true,
|
|
131
|
+
inspect: true,
|
|
132
|
+
read: true,
|
|
133
|
+
watch: true,
|
|
134
|
+
show: true,
|
|
135
|
+
search: true,
|
|
136
|
+
status: true,
|
|
137
|
+
};
|
|
138
|
+
|
|
139
|
+
/**
|
|
140
|
+
* Mutating deploy verbs. The noun `deployment` is deliberately absent: it is
|
|
141
|
+
* what a read is *about*, never what a call does.
|
|
142
|
+
*
|
|
143
|
+
* `apply` and `destroy` are generic enough to catch an unrelated tool
|
|
144
|
+
* (`ApplyPatch`), and that is the direction this errs on purpose — an
|
|
145
|
+
* over-refused edit tool is an escalation, a missed IaC call is an incident.
|
|
146
|
+
*/
|
|
147
|
+
const DEPLOY_ACTIONS: Record<string, true> = {
|
|
148
|
+
deploy: true,
|
|
149
|
+
redeploy: true,
|
|
150
|
+
rollout: true,
|
|
151
|
+
promote: true,
|
|
152
|
+
apply: true,
|
|
153
|
+
destroy: true,
|
|
154
|
+
teardown: true,
|
|
155
|
+
};
|
|
156
|
+
|
|
157
|
+
/** Publishing an artifact. `published` is a noun and excluded: it is why
|
|
158
|
+
* `xd://mcp__x_ListPublishedArtifacts` classified as `package-publish` (#130). */
|
|
159
|
+
const PUBLISH_ACTIONS: Record<string, true> = { publish: true, republish: true };
|
|
160
|
+
|
|
161
|
+
/** Cutting a GitHub release takes an action *and* the `release` noun, so
|
|
162
|
+
* `create_repository` and `list_releases` are each missing half of it. */
|
|
163
|
+
const RELEASE_ACTIONS: Record<string, true> = {
|
|
164
|
+
create: true,
|
|
165
|
+
draft: true,
|
|
166
|
+
cut: true,
|
|
167
|
+
publish: true,
|
|
168
|
+
republish: true,
|
|
169
|
+
};
|
|
170
|
+
|
|
171
|
+
/**
|
|
172
|
+
* The shape a tokenised tool name or device path names, if any.
|
|
173
|
+
*
|
|
174
|
+
* A query name yields nothing whatever its nouns say. The first token drawn from
|
|
175
|
+
* either vocabulary decides that, because tool names are verb-first
|
|
176
|
+
* (`ListDeployments`, `DeployStack`) and the leading verb is the act. A trailing
|
|
177
|
+
* `status` is a query too — `DeployStatus` opens with a mutating verb and still
|
|
178
|
+
* only asks a question — while a read verb *after* the action verb is not:
|
|
179
|
+
* `DeployStackAndWatch` deploys.
|
|
180
|
+
*/
|
|
181
|
+
function releaseShapeFromTokens(tokens: readonly string[]): ReleaseShape | undefined {
|
|
182
|
+
if (tokens[tokens.length - 1] === "status") return undefined;
|
|
183
|
+
for (const token of tokens) {
|
|
184
|
+
if (READ_VERBS[token] === true) return undefined;
|
|
185
|
+
if (
|
|
186
|
+
DEPLOY_ACTIONS[token] === true ||
|
|
187
|
+
RELEASE_ACTIONS[token] === true ||
|
|
188
|
+
PUBLISH_ACTIONS[token] === true
|
|
189
|
+
) {
|
|
190
|
+
break;
|
|
191
|
+
}
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
// `release` first: `PublishRelease` cuts a GitHub release, it does not publish
|
|
195
|
+
// a package, and both vocabularies claim `publish`.
|
|
196
|
+
if (
|
|
197
|
+
tokens.some((t) => t === "release" || t === "releases") &&
|
|
198
|
+
tokens.some((t) => RELEASE_ACTIONS[t] === true)
|
|
199
|
+
) {
|
|
200
|
+
return "github-release";
|
|
201
|
+
}
|
|
202
|
+
if (tokens.some((t) => PUBLISH_ACTIONS[t] === true)) return "package-publish";
|
|
203
|
+
if (tokens.some((t) => DEPLOY_ACTIONS[t] === true)) return "deploy";
|
|
204
|
+
return undefined;
|
|
205
|
+
}
|
|
206
|
+
|
|
69
207
|
/** Release-shaped device/tool invocations that do not pass through a shell. */
|
|
70
208
|
export function releaseShapeFromTool(
|
|
71
209
|
toolName: string,
|
|
@@ -76,37 +214,60 @@ export function releaseShapeFromTool(
|
|
|
76
214
|
return typeof input.command === "string" ? releaseShapeFromCommand(input.command) : undefined;
|
|
77
215
|
}
|
|
78
216
|
|
|
217
|
+
// An MCP tool reached through the device path: the path *is* the tool name, so
|
|
218
|
+
// it goes through the same tokeniser as a native one. Only `xd://` paths — a
|
|
219
|
+
// `write` to `src/deploy.ts` is source code, not a deploy.
|
|
79
220
|
const path = typeof input.path === "string" ? input.path : "";
|
|
80
221
|
if (toolName === "write" && path.startsWith("xd://")) {
|
|
81
|
-
|
|
82
|
-
if (
|
|
83
|
-
if (/deploy/i.test(path)) return "deploy";
|
|
222
|
+
const viaDevice = releaseShapeFromTokens(releaseTokens(path));
|
|
223
|
+
if (viaDevice !== undefined) return viaDevice;
|
|
84
224
|
}
|
|
85
225
|
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
226
|
+
return releaseShapeFromTokens(releaseTokens(toolName));
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
/**
|
|
230
|
+
* The refusal a `role` session gets for `shape` under `grants`, or `undefined`
|
|
231
|
+
* when the grant covers it.
|
|
232
|
+
*
|
|
233
|
+
* Separate from classification so the external-orchestrator tick can classify
|
|
234
|
+
* once, consult its own ownership state, and still word the refusal identically.
|
|
235
|
+
*
|
|
236
|
+
* The comparison is the whole gate: a grant is an `AuthorityHolder`, so
|
|
237
|
+
* `"human"` equals no role and refuses every session, and `"worker"` equals no
|
|
238
|
+
* grant and is refused every shape however permissive the config is (#126).
|
|
239
|
+
*/
|
|
240
|
+
export function releaseRefusal(
|
|
241
|
+
grants: ResolvedGrants,
|
|
242
|
+
role: SessionRole,
|
|
243
|
+
shape: ReleaseShape,
|
|
244
|
+
): ReleaseDecision | undefined {
|
|
245
|
+
const holder = grants[shape];
|
|
246
|
+
if (holder === role) return undefined;
|
|
247
|
+
return {
|
|
248
|
+
block: true,
|
|
249
|
+
reason:
|
|
250
|
+
`Blocked by releasePolicy: ${shape} is granted to "${holder}", and this is a ${role} session. ` +
|
|
251
|
+
(role === "worker"
|
|
252
|
+
? "A worker session never holds a release grant, whatever the config says — report the release " +
|
|
253
|
+
"as the next step and stop at a green PR."
|
|
254
|
+
: holder === "human"
|
|
255
|
+
? `Only a human may do this. Set releasePolicy.${shape} to "orchestrator" for this project ` +
|
|
256
|
+
"first, and only once the operator brief carries the procedure to follow."
|
|
257
|
+
: "Ask the operator which session is meant to hold this grant."),
|
|
258
|
+
};
|
|
91
259
|
}
|
|
92
260
|
|
|
93
261
|
export function releaseDecision(
|
|
94
|
-
|
|
262
|
+
grants: ResolvedGrants,
|
|
263
|
+
role: SessionRole,
|
|
95
264
|
toolName: string,
|
|
96
265
|
input: Record<string, unknown>,
|
|
97
266
|
): { shape: ReleaseShape; decision: ReleaseDecision } | undefined {
|
|
98
|
-
if (policy !== "none") return undefined;
|
|
99
267
|
const shape = releaseShapeFromTool(toolName, input);
|
|
100
268
|
if (shape === undefined) return undefined;
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
decision: {
|
|
104
|
-
block: true,
|
|
105
|
-
reason:
|
|
106
|
-
`Blocked by releasePolicy=none (${shape}). ` +
|
|
107
|
-
"Only a human may change the project to operator-brief before release or deploy tools can run.",
|
|
108
|
-
},
|
|
109
|
-
};
|
|
269
|
+
const decision = releaseRefusal(grants, role, shape);
|
|
270
|
+
return decision === undefined ? undefined : { shape, decision };
|
|
110
271
|
}
|
|
111
272
|
|
|
112
273
|
interface ReleasePolicyPi {
|
|
@@ -121,12 +282,13 @@ interface ReleasePolicyPi {
|
|
|
121
282
|
|
|
122
283
|
/** Inline session extension used by workers and the embedded orchestrator. */
|
|
123
284
|
export function releasePolicyTripwire(
|
|
124
|
-
|
|
285
|
+
grants: ResolvedGrants,
|
|
286
|
+
role: SessionRole,
|
|
125
287
|
onBlocked: (shape: ReleaseShape) => void = () => {},
|
|
126
288
|
): (pi: ReleasePolicyPi) => void {
|
|
127
289
|
return (pi) => {
|
|
128
290
|
pi.on("tool_call", (event) => {
|
|
129
|
-
const blocked = releaseDecision(
|
|
291
|
+
const blocked = releaseDecision(grants, role, event.toolName, event.input);
|
|
130
292
|
if (blocked === undefined) return undefined;
|
|
131
293
|
try {
|
|
132
294
|
onBlocked(blocked.shape);
|