omp-conductor 0.3.24 → 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.
@@ -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);