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.
- package/README.md +775 -49
- package/package.json +1 -1
- package/src/approval-surface.ts +36 -1
- package/src/board.ts +30 -6
- package/src/briefs/orchestrator.md +114 -20
- package/src/briefs/policy.md +48 -27
- package/src/briefs/worker.md +82 -31
- package/src/cli.ts +164 -4
- package/src/config.ts +669 -16
- package/src/confinement.ts +506 -25
- package/src/credentials.ts +2029 -0
- package/src/daemon.ts +1213 -78
- package/src/diff-flags.ts +696 -0
- package/src/escalate.ts +136 -9
- package/src/fleet.ts +80 -4
- package/src/omp.ts +471 -15
- package/src/orchestrator-tick.ts +98 -25
- 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 +19 -1
- package/src/setup.ts +207 -18
- package/src/store.ts +555 -3
- package/src/tracker/github.ts +45 -0
- package/src/types.ts +863 -10
- package/src/unblock.ts +53 -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 +227 -120
- package/systemd/omp-conductor.service.example +96 -8
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);
|