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.
- package/README.md +733 -30
- package/package.json +1 -1
- 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 +1000 -32
- package/src/diff-flags.ts +696 -0
- package/src/escalate.ts +136 -9
- package/src/fleet.ts +71 -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 +19 -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/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/confinement.ts
CHANGED
|
@@ -1,44 +1,71 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* Mechanical
|
|
2
|
+
* Mechanical filesystem confinement for the sessions this package starts.
|
|
3
3
|
*
|
|
4
4
|
* The harness has no first-class fs-policy field, but `createAgentSession`
|
|
5
5
|
* accepts inline `extensions` that subscribe to `tool_call` and can return
|
|
6
6
|
* `{ block: true }` before a tool runs (see the harness `protected-paths`
|
|
7
|
-
* example).
|
|
8
|
-
*
|
|
7
|
+
* example). Two gates ride on that seam, and they are shaped differently
|
|
8
|
+
* because the two sessions need different things:
|
|
9
|
+
*
|
|
10
|
+
* - {@link worktreeConfinement} — a worker may touch its own checkout and
|
|
11
|
+
* nothing else.
|
|
12
|
+
* - {@link orchestratorConfinement} — the orchestrator has to read the state
|
|
13
|
+
* directory and its briefs, so "jail everything to cwd" does not transfer.
|
|
14
|
+
* It gets an allowlist instead, default refusal, with every worker
|
|
15
|
+
* checkout, the mirror cache and this package's own install denied
|
|
16
|
+
* outright (#127).
|
|
9
17
|
*
|
|
10
18
|
* `bash` is deliberately not confined here: its input is an opaque shell
|
|
11
|
-
* string, and parsing it is a false
|
|
12
|
-
*
|
|
13
|
-
*
|
|
19
|
+
* string, and parsing it is a false sense of security. Neither gate contains a
|
|
20
|
+
* determined session, and no comment or doc here should claim otherwise —
|
|
21
|
+
* closing that gap is a least-privilege OS principal (#125 for the
|
|
22
|
+
* orchestrator, a worker uid for the deploy), documented beside this module's
|
|
23
|
+
* README section, not a regex over `rm -rf`.
|
|
24
|
+
*
|
|
25
|
+
* What these gates do buy is a *legible* refusal that holds on hosts where no
|
|
26
|
+
* uid split will ever be deployed: a structured `edit` into a live worktree
|
|
27
|
+
* comes back naming the worktree and the roots that are allowed, which the
|
|
28
|
+
* model can act on in one turn, instead of an `EACCES` it may read as a
|
|
29
|
+
* transient error and retry.
|
|
14
30
|
*/
|
|
15
31
|
|
|
16
|
-
import { existsSync, realpathSync } from "node:fs";
|
|
32
|
+
import { appendFileSync, existsSync, mkdirSync, readFileSync, realpathSync, statSync } from "node:fs";
|
|
17
33
|
import { basename, dirname, isAbsolute, join, relative, resolve, sep } from "node:path";
|
|
34
|
+
import { fileURLToPath } from "node:url";
|
|
35
|
+
|
|
36
|
+
import { ORCHESTRATOR_BRIEF_NAME, POLICY_BRIEF_NAME } from "./brief-upgrade.ts";
|
|
37
|
+
import {
|
|
38
|
+
configPath,
|
|
39
|
+
defaultMirrorRoot,
|
|
40
|
+
defaultWorkspaceRoot,
|
|
41
|
+
loadConfig,
|
|
42
|
+
stateDir,
|
|
43
|
+
} from "./config.ts";
|
|
44
|
+
import { TICK_CONFIG_FILE, TICK_OWNER_FILE, TICK_STATUS_FILE } from "./orchestrator-tick.ts";
|
|
45
|
+
import { briefPathForProject, policyPathForProject } from "./setup.ts";
|
|
18
46
|
|
|
19
47
|
/** Tools whose structured `path` (or path-like) field we can gate. */
|
|
20
48
|
const GATED = new Set(["write", "edit", "read", "grep", "glob"]);
|
|
21
49
|
|
|
50
|
+
/** Real path of a confinement root, or the lexical one when it does not exist. */
|
|
51
|
+
function realRoot(root: string): string {
|
|
52
|
+
try {
|
|
53
|
+
return realpathSync(resolve(root));
|
|
54
|
+
} catch {
|
|
55
|
+
return resolve(root);
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
|
|
22
59
|
/**
|
|
23
|
-
* Resolve `candidate`
|
|
24
|
-
*
|
|
25
|
-
* planted inside the worktree cannot escape by string-prefix tricks.
|
|
60
|
+
* Resolve `candidate` against an already-realpath'd `base`, following symlinks
|
|
61
|
+
* through every component that exists.
|
|
26
62
|
*
|
|
27
63
|
* A path that does not exist yet (a new write) realpaths the deepest existing
|
|
28
64
|
* ancestor and appends the rest — the same TOCTOU posture as the harness's
|
|
29
65
|
* own workspace confinement helper.
|
|
30
66
|
*/
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
let rootReal: string;
|
|
35
|
-
try {
|
|
36
|
-
rootReal = realpathSync(resolve(root));
|
|
37
|
-
} catch {
|
|
38
|
-
rootReal = resolve(root);
|
|
39
|
-
}
|
|
40
|
-
|
|
41
|
-
const abs = resolve(rootReal, candidate);
|
|
67
|
+
function realResolve(base: string, candidate: string): string {
|
|
68
|
+
const abs = resolve(base, candidate);
|
|
42
69
|
|
|
43
70
|
const missing: string[] = [];
|
|
44
71
|
let probe = abs;
|
|
@@ -49,20 +76,34 @@ export function isInsideWorktree(root: string, candidate: string): boolean {
|
|
|
49
76
|
probe = parent;
|
|
50
77
|
}
|
|
51
78
|
|
|
52
|
-
let
|
|
79
|
+
let head: string;
|
|
53
80
|
try {
|
|
54
|
-
|
|
81
|
+
head = realpathSync(probe);
|
|
55
82
|
} catch {
|
|
56
|
-
|
|
83
|
+
head = probe;
|
|
57
84
|
}
|
|
58
|
-
|
|
85
|
+
return missing.length === 0 ? head : join(head, ...missing);
|
|
86
|
+
}
|
|
59
87
|
|
|
88
|
+
/** Whether an already-resolved path stays under an already-resolved root. */
|
|
89
|
+
function contains(rootReal: string, resolved: string): boolean {
|
|
60
90
|
const rel = relative(rootReal, resolved);
|
|
61
91
|
// Inside ⇒ "" or a relative path that does not climb out. Absolute `rel` is
|
|
62
92
|
// a Windows drive mismatch; anything starting with `..` has left the root.
|
|
63
93
|
return rel === "" || (!isAbsolute(rel) && rel !== ".." && !rel.startsWith(`..${sep}`));
|
|
64
94
|
}
|
|
65
95
|
|
|
96
|
+
/**
|
|
97
|
+
* Resolve `candidate` as a worker would, then ask whether it stays under
|
|
98
|
+
* `root`. Symlink-aware: existing path components are realpath'd so a link
|
|
99
|
+
* planted inside the worktree cannot escape by string-prefix tricks.
|
|
100
|
+
*/
|
|
101
|
+
export function isInsideWorktree(root: string, candidate: string): boolean {
|
|
102
|
+
if (candidate.length === 0 || candidate.includes("\0")) return false;
|
|
103
|
+
const rootReal = realRoot(root);
|
|
104
|
+
return contains(rootReal, realResolve(rootReal, candidate));
|
|
105
|
+
}
|
|
106
|
+
|
|
66
107
|
/** Pull the path-like field a gated tool carries, if any. */
|
|
67
108
|
export function pathFromToolInput(toolName: string, input: Record<string, unknown>): string | undefined {
|
|
68
109
|
if (!GATED.has(toolName)) return undefined;
|
|
@@ -121,3 +162,443 @@ export function worktreeConfinement(root: string): (pi: ConfinementPi) => void {
|
|
|
121
162
|
pi.on("tool_call", (event) => confineToolCall(rootAbs, event.toolName, event.input));
|
|
122
163
|
};
|
|
123
164
|
}
|
|
165
|
+
|
|
166
|
+
// ---------------------------------------------------------------------------
|
|
167
|
+
// orchestrator confinement (#127)
|
|
168
|
+
// ---------------------------------------------------------------------------
|
|
169
|
+
|
|
170
|
+
/**
|
|
171
|
+
* The installed package's own root — `src/`'s parent, so the briefs under
|
|
172
|
+
* `src/briefs/`, the skills and `package.json` are all inside it. Read from
|
|
173
|
+
* `import.meta.dir` for the same reason the integrity tripwire does
|
|
174
|
+
* (`daemon.ts`): it is a self-portrait of the code executing right now, not
|
|
175
|
+
* whichever checkout happens to be on disk.
|
|
176
|
+
*/
|
|
177
|
+
const PACKAGE_ROOT = dirname(import.meta.dir);
|
|
178
|
+
|
|
179
|
+
/**
|
|
180
|
+
* `<scheme>://` prefixes the harness resolves itself. Reading `issue://`,
|
|
181
|
+
* `skill://` and `memory://` is a large part of what the orchestrator is
|
|
182
|
+
* *for*, and none of them names a file, so the gate has no opinion on them.
|
|
183
|
+
* `file://` is the exception — it does name a file, so it is converted back to
|
|
184
|
+
* a path and judged as one.
|
|
185
|
+
*/
|
|
186
|
+
const URI_SCHEME = /^([A-Za-z][A-Za-z0-9+.-]*):\/\//;
|
|
187
|
+
|
|
188
|
+
/**
|
|
189
|
+
* The filesystem path a tool argument denotes, or `undefined` when it denotes
|
|
190
|
+
* something the filesystem gate cannot and should not judge.
|
|
191
|
+
*/
|
|
192
|
+
function filesystemPath(raw: string): string | undefined {
|
|
193
|
+
const scheme = URI_SCHEME.exec(raw);
|
|
194
|
+
if (scheme === null) return raw;
|
|
195
|
+
if (scheme[1]?.toLowerCase() !== "file") return undefined;
|
|
196
|
+
try {
|
|
197
|
+
return fileURLToPath(raw);
|
|
198
|
+
} catch {
|
|
199
|
+
// A malformed `file://` URL names nothing resolvable. Hand back the raw
|
|
200
|
+
// string so it fails the allowlist rather than slipping through as "not a
|
|
201
|
+
// path at all" — this gate never guesses in the permissive direction.
|
|
202
|
+
return raw;
|
|
203
|
+
}
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
/**
|
|
207
|
+
* Roots the orchestrator is refused whatever the allowlist says. Declared as
|
|
208
|
+
* data so the refusal text, the README and the tests read from one list
|
|
209
|
+
* instead of three copies that drift (same pattern as `REPORT_SCOPES`).
|
|
210
|
+
*
|
|
211
|
+
* `config` is the odd one: it is a single file, and it is *readable* through
|
|
212
|
+
* the pinhole below. Denying it here is what stops the session writing it, and
|
|
213
|
+
* that matters because the config is where its own jail, its release grants
|
|
214
|
+
* and its merge authority are declared. A session that can edit the file that
|
|
215
|
+
* bounds it is not bounded.
|
|
216
|
+
*/
|
|
217
|
+
export const DENIED_ROOT_KINDS = ["worker-checkouts", "mirror", "package-source", "config"] as const;
|
|
218
|
+
|
|
219
|
+
export type DeniedRootKind = (typeof DENIED_ROOT_KINDS)[number];
|
|
220
|
+
|
|
221
|
+
/**
|
|
222
|
+
* Why each root is denied, phrased for the session that just hit it — the
|
|
223
|
+
* refusal has to be actionable in one turn, so it says what to do instead.
|
|
224
|
+
*/
|
|
225
|
+
export const DENIED_ROOT_REASON: Record<DeniedRootKind, string> = {
|
|
226
|
+
"worker-checkouts":
|
|
227
|
+
"a live worker may be mid-run in it, and its uncommitted work has provenance you cannot see — read the run's PR instead",
|
|
228
|
+
mirror: "the bare mirror cache is git plumbing shared by every run, not somewhere to read code from",
|
|
229
|
+
"package-source": "nobody patches the running conductor, and that includes you",
|
|
230
|
+
config:
|
|
231
|
+
"it declares your own jail, grants and authority — it is an operator decision, changed by re-running setup, never by the session it governs. Read it freely; ask your operator to change it",
|
|
232
|
+
};
|
|
233
|
+
|
|
234
|
+
export interface DeniedRoot {
|
|
235
|
+
kind: DeniedRootKind;
|
|
236
|
+
root: string;
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
/**
|
|
240
|
+
* What an orchestrator session may touch. **Default is refusal**: a path that
|
|
241
|
+
* matches nothing here is blocked. A deny-list would only stop what somebody
|
|
242
|
+
* thought to name, and would leave the structured tools pointed at the whole
|
|
243
|
+
* filesystem.
|
|
244
|
+
*/
|
|
245
|
+
export interface OrchestratorJail {
|
|
246
|
+
/** Directory roots readable in full. */
|
|
247
|
+
readRoots: string[];
|
|
248
|
+
/** Directory roots writable in full — a subset of {@link readRoots}. */
|
|
249
|
+
writeRoots: string[];
|
|
250
|
+
/**
|
|
251
|
+
* Exact files readable even though they sit inside a denied root. Derived
|
|
252
|
+
* from config only, never operator-extensible: the briefs live *in*
|
|
253
|
+
* `workspaceRoot`, and denying that root wholesale would otherwise blind the
|
|
254
|
+
* session to its own standing orders.
|
|
255
|
+
*/
|
|
256
|
+
readFiles: string[];
|
|
257
|
+
/**
|
|
258
|
+
* Exact files writable on the same terms — a subset of {@link readFiles}.
|
|
259
|
+
* `POLICY.md` is here because the Learning loop's whole job is amending it
|
|
260
|
+
* after an operator says yes.
|
|
261
|
+
*/
|
|
262
|
+
writeFiles: string[];
|
|
263
|
+
/** Refused outright. Outranks every root and every operator addition. */
|
|
264
|
+
denied: DeniedRoot[];
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
/** One project's contribution to the jail. */
|
|
268
|
+
export interface OrchestratorJailProject {
|
|
269
|
+
workspaceRoot: string;
|
|
270
|
+
mirrorRoot: string;
|
|
271
|
+
briefPath: string;
|
|
272
|
+
policyPath: string;
|
|
273
|
+
/**
|
|
274
|
+
* Read-only pinholes inside `workspaceRoot` that are not product code and
|
|
275
|
+
* not a run's work: the heartbeat's own config, status and owner files. A
|
|
276
|
+
* fleet points its heartbeat at the workspace that holds the briefs, so
|
|
277
|
+
* these sit beside them inside the denied root — and denial outranks
|
|
278
|
+
* `orchestratorReadPaths`, which would leave a session unable to read why
|
|
279
|
+
* its own tick did not fire with no way for an operator to grant it.
|
|
280
|
+
*/
|
|
281
|
+
fleetFiles?: readonly string[];
|
|
282
|
+
/** `orchestratorReadPaths`, already validated absolute by `config.ts`. */
|
|
283
|
+
readPaths?: readonly string[];
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
/**
|
|
287
|
+
* Build the jail from already-resolved inputs. Pure, so the precedence rules
|
|
288
|
+
* are testable without a config file or a live state directory.
|
|
289
|
+
*/
|
|
290
|
+
export function orchestratorJail(input: {
|
|
291
|
+
stateDir: string;
|
|
292
|
+
configPath: string;
|
|
293
|
+
packageRoot: string;
|
|
294
|
+
projects: readonly OrchestratorJailProject[];
|
|
295
|
+
}): OrchestratorJail {
|
|
296
|
+
const state = resolve(input.stateDir);
|
|
297
|
+
const config = resolve(input.configPath);
|
|
298
|
+
const readRoots = [state];
|
|
299
|
+
// Readable through the pinhole, unwritable through the denial below.
|
|
300
|
+
const readFiles: string[] = [config];
|
|
301
|
+
const writeFiles: string[] = [];
|
|
302
|
+
const denied: DeniedRoot[] = [
|
|
303
|
+
{ kind: "package-source", root: resolve(input.packageRoot) },
|
|
304
|
+
{ kind: "config", root: config },
|
|
305
|
+
];
|
|
306
|
+
|
|
307
|
+
for (const p of input.projects) {
|
|
308
|
+
denied.push({ kind: "worker-checkouts", root: resolve(p.workspaceRoot) });
|
|
309
|
+
denied.push({ kind: "mirror", root: resolve(p.mirrorRoot) });
|
|
310
|
+
readFiles.push(resolve(p.briefPath), resolve(p.policyPath));
|
|
311
|
+
for (const file of p.fleetFiles ?? []) readFiles.push(resolve(file));
|
|
312
|
+
writeFiles.push(resolve(p.policyPath));
|
|
313
|
+
// Operator additions are read roots and only read roots: they are checked
|
|
314
|
+
// *after* the denied list, so `orchestratorReadPaths: ["<workspaceRoot>"]`
|
|
315
|
+
// buys nothing. That case is the one this issue exists for.
|
|
316
|
+
for (const extra of p.readPaths ?? []) readRoots.push(resolve(extra));
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
return {
|
|
320
|
+
readRoots: [...new Set(readRoots)],
|
|
321
|
+
writeRoots: [state],
|
|
322
|
+
readFiles: [...new Set(readFiles)],
|
|
323
|
+
writeFiles: [...new Set(writeFiles)],
|
|
324
|
+
denied,
|
|
325
|
+
};
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
/**
|
|
329
|
+
* The live jail, computed from the config on disk rather than from hardcoded
|
|
330
|
+
* paths (#127). An operator who moves `$OMP_CONDUCTOR_HOME` moves the state
|
|
331
|
+
* directory with it, and a jail pinned to `~/.omp/conductor` would then be
|
|
332
|
+
* guarding an empty directory while the real one sat open.
|
|
333
|
+
*
|
|
334
|
+
* Every configured project contributes its denied roots, not just one the
|
|
335
|
+
* caller might have named: a single orchestrator serves the whole fleet, so a
|
|
336
|
+
* second project's checkouts are exactly as off-limits as the first's.
|
|
337
|
+
*
|
|
338
|
+
* A config too broken to load still produces a jail, and it is the fail-closed
|
|
339
|
+
* one: the state directory plus the *default* workspace and mirror roots
|
|
340
|
+
* denied. The session keeps its own scratch and loses everything else, rather
|
|
341
|
+
* than running unconfined because one key was malformed.
|
|
342
|
+
*/
|
|
343
|
+
export function orchestratorJailFromConfig(): OrchestratorJail {
|
|
344
|
+
const heartbeatFiles = (workspaceRoot: string): string[] =>
|
|
345
|
+
[TICK_CONFIG_FILE, TICK_STATUS_FILE, TICK_OWNER_FILE].map((name) => join(workspaceRoot, name));
|
|
346
|
+
|
|
347
|
+
let projects: OrchestratorJailProject[] = [];
|
|
348
|
+
try {
|
|
349
|
+
projects = loadConfig().projects.map((p) => ({
|
|
350
|
+
workspaceRoot: p.workspaceRoot,
|
|
351
|
+
mirrorRoot: p.mirrorRoot,
|
|
352
|
+
briefPath: briefPathForProject(p),
|
|
353
|
+
policyPath: policyPathForProject(p),
|
|
354
|
+
fleetFiles: heartbeatFiles(p.workspaceRoot),
|
|
355
|
+
...(p.orchestratorReadPaths === undefined ? {} : { readPaths: p.orchestratorReadPaths }),
|
|
356
|
+
}));
|
|
357
|
+
} catch {
|
|
358
|
+
// Unreadable config: fall through to the defaults below.
|
|
359
|
+
}
|
|
360
|
+
if (projects.length === 0) {
|
|
361
|
+
const workspaceRoot = defaultWorkspaceRoot();
|
|
362
|
+
projects = [
|
|
363
|
+
{
|
|
364
|
+
workspaceRoot,
|
|
365
|
+
mirrorRoot: defaultMirrorRoot(),
|
|
366
|
+
briefPath: join(workspaceRoot, ORCHESTRATOR_BRIEF_NAME),
|
|
367
|
+
policyPath: join(workspaceRoot, POLICY_BRIEF_NAME),
|
|
368
|
+
fleetFiles: heartbeatFiles(workspaceRoot),
|
|
369
|
+
},
|
|
370
|
+
];
|
|
371
|
+
}
|
|
372
|
+
return orchestratorJail({
|
|
373
|
+
stateDir: stateDir(),
|
|
374
|
+
configPath: configPath(),
|
|
375
|
+
packageRoot: PACKAGE_ROOT,
|
|
376
|
+
projects,
|
|
377
|
+
});
|
|
378
|
+
}
|
|
379
|
+
|
|
380
|
+
/** Why one orchestrator tool call was refused, for the audit and the count. */
|
|
381
|
+
export type OrchestratorRefusalKind = DeniedRootKind | "outside-allowlist" | "read-only";
|
|
382
|
+
|
|
383
|
+
export interface OrchestratorRefusal {
|
|
384
|
+
tool: string;
|
|
385
|
+
/** The path exactly as the tool asked for it, so the audit reads like the transcript. */
|
|
386
|
+
path: string;
|
|
387
|
+
kind: OrchestratorRefusalKind;
|
|
388
|
+
}
|
|
389
|
+
|
|
390
|
+
/** A refusal and the block the harness gets, kept together so counting the one
|
|
391
|
+
* never means re-deriving the other. */
|
|
392
|
+
export interface OrchestratorRefusalResult {
|
|
393
|
+
decision: ConfineDecision;
|
|
394
|
+
refusal: OrchestratorRefusal;
|
|
395
|
+
}
|
|
396
|
+
|
|
397
|
+
/**
|
|
398
|
+
* Exact-file match against an already-resolved candidate. Resolved the same
|
|
399
|
+
* way the candidate was, so a `POLICY.md` that does not exist yet and a state
|
|
400
|
+
* directory reached through a symlink both still compare equal.
|
|
401
|
+
*/
|
|
402
|
+
function samePath(file: string, resolved: string): boolean {
|
|
403
|
+
const abs = resolve(file);
|
|
404
|
+
return realResolve(realRoot(dirname(abs)), basename(abs)) === resolved;
|
|
405
|
+
}
|
|
406
|
+
|
|
407
|
+
/**
|
|
408
|
+
* The denied thing to name in the refusal. For worker checkouts that is the
|
|
409
|
+
* individual worktree — `<workspaceRoot>/349`, the directory a human would
|
|
410
|
+
* recognise — not the parent the config happens to call `workspaceRoot`.
|
|
411
|
+
*/
|
|
412
|
+
function deniedTarget(kind: DeniedRootKind, rootReal: string, resolved: string): string {
|
|
413
|
+
if (kind !== "worker-checkouts") return rootReal;
|
|
414
|
+
const first = relative(rootReal, resolved).split(sep)[0];
|
|
415
|
+
if (first === undefined || first.length === 0) return rootReal;
|
|
416
|
+
const checkout = join(rootReal, first);
|
|
417
|
+
// A *file* directly inside `workspaceRoot` — the composed brief, say — is
|
|
418
|
+
// not a checkout, and naming it as one would send the reader looking for a
|
|
419
|
+
// run that does not exist.
|
|
420
|
+
try {
|
|
421
|
+
return statSync(checkout).isDirectory() ? checkout : rootReal;
|
|
422
|
+
} catch {
|
|
423
|
+
return rootReal;
|
|
424
|
+
}
|
|
425
|
+
}
|
|
426
|
+
|
|
427
|
+
function listing(roots: string[], files: string[]): string {
|
|
428
|
+
const rootPart = roots.length === 0 ? "nothing" : roots.join(", ");
|
|
429
|
+
return files.length === 0 ? rootPart : `${rootPart}, plus the files ${files.join(", ")}`;
|
|
430
|
+
}
|
|
431
|
+
|
|
432
|
+
/**
|
|
433
|
+
* Decide whether one orchestrator tool_call may run. Undefined means "no
|
|
434
|
+
* opinion" (allow). Pure, so every precedence rule below is pinned by a test
|
|
435
|
+
* without a harness session.
|
|
436
|
+
*
|
|
437
|
+
* Precedence, in this order and for a reason:
|
|
438
|
+
* 1. the derived exact-file pinholes — the briefs live inside a denied root;
|
|
439
|
+
* 2. the denied roots — so a mis-specified `orchestratorReadPaths` cannot
|
|
440
|
+
* re-open the two roots this gate exists for;
|
|
441
|
+
* 3. the allowlist, write roots for `write`/`edit` and read roots otherwise;
|
|
442
|
+
* 4. refusal.
|
|
443
|
+
*/
|
|
444
|
+
export function confineOrchestratorToolCall(
|
|
445
|
+
jail: OrchestratorJail,
|
|
446
|
+
cwd: string,
|
|
447
|
+
toolName: string,
|
|
448
|
+
input: Record<string, unknown>,
|
|
449
|
+
): OrchestratorRefusalResult | undefined {
|
|
450
|
+
const raw = pathFromToolInput(toolName, input);
|
|
451
|
+
if (raw === undefined) return undefined;
|
|
452
|
+
const candidate = filesystemPath(raw);
|
|
453
|
+
if (candidate === undefined) return undefined;
|
|
454
|
+
|
|
455
|
+
const mutating = toolName === "write" || toolName === "edit";
|
|
456
|
+
if (candidate.includes("\0")) {
|
|
457
|
+
return {
|
|
458
|
+
decision: {
|
|
459
|
+
block: true,
|
|
460
|
+
reason: `Blocked: ${toolName} path "${raw}" contains a NUL byte and names no file.`,
|
|
461
|
+
},
|
|
462
|
+
refusal: { tool: toolName, path: raw, kind: "outside-allowlist" },
|
|
463
|
+
};
|
|
464
|
+
}
|
|
465
|
+
|
|
466
|
+
const resolved = realResolve(realRoot(cwd), candidate);
|
|
467
|
+
|
|
468
|
+
for (const file of mutating ? jail.writeFiles : jail.readFiles) {
|
|
469
|
+
if (samePath(file, resolved)) return undefined;
|
|
470
|
+
}
|
|
471
|
+
|
|
472
|
+
for (const { kind, root } of jail.denied) {
|
|
473
|
+
const rootReal = realRoot(root);
|
|
474
|
+
if (!contains(rootReal, resolved)) continue;
|
|
475
|
+
return {
|
|
476
|
+
decision: {
|
|
477
|
+
block: true,
|
|
478
|
+
reason:
|
|
479
|
+
`Blocked: ${toolName} path "${raw}" resolves into ${deniedTarget(kind, rootReal, resolved)}, ` +
|
|
480
|
+
`which the orchestrator may never touch — ${DENIED_ROOT_REASON[kind]}. ` +
|
|
481
|
+
`The denial covers all of ${rootReal} and outranks orchestratorReadPaths.`,
|
|
482
|
+
},
|
|
483
|
+
refusal: { tool: toolName, path: raw, kind },
|
|
484
|
+
};
|
|
485
|
+
}
|
|
486
|
+
|
|
487
|
+
for (const root of mutating ? jail.writeRoots : jail.readRoots) {
|
|
488
|
+
if (contains(realRoot(root), resolved)) return undefined;
|
|
489
|
+
}
|
|
490
|
+
|
|
491
|
+
const readable = jail.readRoots.some((root) => contains(realRoot(root), resolved));
|
|
492
|
+
if (mutating && readable) {
|
|
493
|
+
return {
|
|
494
|
+
decision: {
|
|
495
|
+
block: true,
|
|
496
|
+
reason:
|
|
497
|
+
`Blocked: ${toolName} path "${raw}" is readable but not writable by the orchestrator. ` +
|
|
498
|
+
`Writable: ${listing(jail.writeRoots, jail.writeFiles)}. ` +
|
|
499
|
+
"You re-brief workers and amend POLICY.md; editing anything else is a worker's job.",
|
|
500
|
+
},
|
|
501
|
+
refusal: { tool: toolName, path: raw, kind: "read-only" },
|
|
502
|
+
};
|
|
503
|
+
}
|
|
504
|
+
|
|
505
|
+
return {
|
|
506
|
+
decision: {
|
|
507
|
+
block: true,
|
|
508
|
+
reason:
|
|
509
|
+
`Blocked: ${toolName} path "${raw}" is outside the orchestrator's allowlist, and the default is refusal. ` +
|
|
510
|
+
`Readable: ${listing(jail.readRoots, jail.readFiles)}. ` +
|
|
511
|
+
'Add a root to "orchestratorReadPaths" in the project config if this session genuinely needs it.',
|
|
512
|
+
},
|
|
513
|
+
refusal: { tool: toolName, path: raw, kind: "outside-allowlist" },
|
|
514
|
+
};
|
|
515
|
+
}
|
|
516
|
+
|
|
517
|
+
/**
|
|
518
|
+
* Append-only audit of refusals, beside the release-policy one and for the
|
|
519
|
+
* same reason: a gate nobody can count is a gate nobody notices. An
|
|
520
|
+
* orchestrator that is refused thirty times an hour is misbriefed, and that
|
|
521
|
+
* shows up in `omp-conductor status` rather than only in a transcript.
|
|
522
|
+
*/
|
|
523
|
+
export const CONFINEMENT_AUDIT_FILE = "orchestrator-confinement-refusals.jsonl";
|
|
524
|
+
|
|
525
|
+
export interface ConfinementRefusalRecord extends OrchestratorRefusal {
|
|
526
|
+
at: string;
|
|
527
|
+
}
|
|
528
|
+
|
|
529
|
+
export function recordConfinementRefusal(
|
|
530
|
+
refusal: OrchestratorRefusal,
|
|
531
|
+
root = stateDir(),
|
|
532
|
+
now = new Date(),
|
|
533
|
+
): void {
|
|
534
|
+
mkdirSync(root, { recursive: true });
|
|
535
|
+
const record: ConfinementRefusalRecord = { ...refusal, at: now.toISOString() };
|
|
536
|
+
appendFileSync(join(root, CONFINEMENT_AUDIT_FILE), `${JSON.stringify(record)}\n`, { mode: 0o600 });
|
|
537
|
+
}
|
|
538
|
+
|
|
539
|
+
export interface ConfinementRefusalSummary {
|
|
540
|
+
count: number;
|
|
541
|
+
latest: ConfinementRefusalRecord;
|
|
542
|
+
}
|
|
543
|
+
|
|
544
|
+
/** Today's refusals, for the status surface. Undefined when there were none. */
|
|
545
|
+
export function confinementRefusalsToday(
|
|
546
|
+
root = stateDir(),
|
|
547
|
+
now = new Date(),
|
|
548
|
+
): ConfinementRefusalSummary | undefined {
|
|
549
|
+
let text: string;
|
|
550
|
+
try {
|
|
551
|
+
text = readFileSync(join(root, CONFINEMENT_AUDIT_FILE), "utf8");
|
|
552
|
+
} catch {
|
|
553
|
+
return undefined;
|
|
554
|
+
}
|
|
555
|
+
const day = now.toISOString().slice(0, 10);
|
|
556
|
+
let count = 0;
|
|
557
|
+
let latest: ConfinementRefusalRecord | undefined;
|
|
558
|
+
for (const line of text.split("\n")) {
|
|
559
|
+
if (line.length === 0) continue;
|
|
560
|
+
try {
|
|
561
|
+
const value = JSON.parse(line) as Partial<ConfinementRefusalRecord>;
|
|
562
|
+
if (
|
|
563
|
+
typeof value.at === "string" &&
|
|
564
|
+
value.at.startsWith(day) &&
|
|
565
|
+
typeof value.tool === "string" &&
|
|
566
|
+
typeof value.path === "string" &&
|
|
567
|
+
typeof value.kind === "string"
|
|
568
|
+
) {
|
|
569
|
+
count += 1;
|
|
570
|
+
latest = value as ConfinementRefusalRecord;
|
|
571
|
+
}
|
|
572
|
+
} catch {
|
|
573
|
+
// One torn line must not hide the valid records written after it.
|
|
574
|
+
}
|
|
575
|
+
}
|
|
576
|
+
return latest === undefined ? undefined : { count, latest };
|
|
577
|
+
}
|
|
578
|
+
|
|
579
|
+
/**
|
|
580
|
+
* Inline extension factory for an orchestrator session. Refusals are counted
|
|
581
|
+
* through `onRefusal`, which defaults to the durable audit above — a refusal
|
|
582
|
+
* the operator never learns about is indistinguishable from a session that
|
|
583
|
+
* behaved.
|
|
584
|
+
*/
|
|
585
|
+
export function orchestratorConfinement(
|
|
586
|
+
jail: OrchestratorJail,
|
|
587
|
+
cwd: string,
|
|
588
|
+
onRefusal: (refusal: OrchestratorRefusal) => void = recordConfinementRefusal,
|
|
589
|
+
): (pi: ConfinementPi) => void {
|
|
590
|
+
const cwdAbs = resolve(cwd);
|
|
591
|
+
return (pi) => {
|
|
592
|
+
pi.on("tool_call", (event) => {
|
|
593
|
+
const refused = confineOrchestratorToolCall(jail, cwdAbs, event.toolName, event.input);
|
|
594
|
+
if (refused === undefined) return undefined;
|
|
595
|
+
try {
|
|
596
|
+
onRefusal(refused.refusal);
|
|
597
|
+
} catch {
|
|
598
|
+
// Audit is evidence, not the gate. A full disk must not turn a deny
|
|
599
|
+
// into an allow.
|
|
600
|
+
}
|
|
601
|
+
return refused.decision;
|
|
602
|
+
});
|
|
603
|
+
};
|
|
604
|
+
}
|