omp-conductor 0.3.25 → 0.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -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 "./release-policy.ts";
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
- releasePolicy?: ReleasePolicy;
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
- releasePolicy?: ReleasePolicy;
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
- ...(o.releasePolicy === undefined ? {} : { releasePolicy: o.releasePolicy }),
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 ReleasePolicy,
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
- async function askReleasePolicy(
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: ReleasePolicy,
355
- ): Promise<ReleasePolicy> {
356
- const open = await ctx.ui.confirm(
357
- "Release tool gate",
358
- "Allow worker and orchestrator sessions to invoke release/deploy-shaped tools? Only enable this " +
359
- "when the operator brief contains the release procedure they must follow. Default: no, block " +
360
- "git tags, tag pushes, package publishing, GitHub release creation and deploy commands" +
361
- `${prior === "operator-brief" ? " currently enabled, answer no to close it" : ""}.`,
362
- );
363
- return open ? "operator-brief" : "none";
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
- /** Both grants, asked together because they are the two questions that decide
651
- * what an unattended fleet may do without asking anybody. */
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
- releasePolicy: await askReleasePolicy(ctx, a.releasePolicy),
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);
@@ -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 { ReleasePolicy } from "./types.ts";
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
- if (/create[_-]release/i.test(path)) return "github-release";
82
- if (/publish/i.test(path)) return "package-publish";
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
- const normalized = toolName.toLowerCase().replaceAll("-", "_");
87
- if (/create_?release|release_?create/.test(normalized)) return "github-release";
88
- if (/(?:^|_)publish(?:$|_)/.test(normalized)) return "package-publish";
89
- if (/(?:^|_)deploy(?:$|_)/.test(normalized)) return "deploy";
90
- return undefined;
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
- policy: ReleasePolicy,
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
- return {
102
- shape,
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
- policy: ReleasePolicy,
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(policy, event.toolName, event.input);
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);