omp-conductor 0.4.2 → 0.4.4
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 +189 -50
- package/package.json +1 -1
- package/src/board.ts +6 -1
- package/src/brief-upgrade.ts +42 -105
- package/src/briefs/orchestrator.md +51 -13
- package/src/briefs/policy.md +6 -2
- package/src/cli.ts +147 -30
- package/src/config.ts +23 -52
- package/src/confinement.ts +22 -472
- package/src/daemon.ts +391 -9
- package/src/decisions.ts +164 -0
- package/src/failure-class.ts +172 -0
- package/src/fleet.ts +192 -51
- package/src/omp.ts +8 -54
- package/src/orchestrator-tick.ts +33 -0
- package/src/plugin.ts +9 -20
- package/src/session-host.ts +10 -22
- package/src/setup.ts +22 -4
- package/src/store.ts +230 -4
- package/src/tracker/github.ts +188 -5
- package/src/types.ts +183 -13
- package/src/upgrade.ts +27 -3
package/src/confinement.ts
CHANGED
|
@@ -1,48 +1,37 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* Mechanical filesystem confinement for the sessions this package
|
|
2
|
+
* Mechanical filesystem confinement for the worker sessions this package
|
|
3
|
+
* starts — and only those.
|
|
3
4
|
*
|
|
4
5
|
* The harness has no first-class fs-policy field, but `createAgentSession`
|
|
5
6
|
* accepts inline `extensions` that subscribe to `tool_call` and can return
|
|
6
7
|
* `{ block: true }` before a tool runs (see the harness `protected-paths`
|
|
7
|
-
* example).
|
|
8
|
-
*
|
|
8
|
+
* example). {@link worktreeConfinement} rides on that seam: a worker may touch
|
|
9
|
+
* its own checkout and nothing else.
|
|
9
10
|
*
|
|
10
|
-
*
|
|
11
|
-
*
|
|
12
|
-
*
|
|
13
|
-
*
|
|
14
|
-
*
|
|
15
|
-
*
|
|
16
|
-
*
|
|
11
|
+
* The orchestrator is deliberately **unconfined** (operator ruling, #143).
|
|
12
|
+
* An earlier release jailed it to an allowlist, which was never the boundary it
|
|
13
|
+
* appeared to be: the gate only existed inside sessions this daemon spawns, so
|
|
14
|
+
* every externally started orchestrator — the supported shape — ran without it
|
|
15
|
+
* while the brief claimed the refusal was absolute. Rather than widen a jail
|
|
16
|
+
* nobody can install everywhere, the orchestrator's boundaries are the brief it
|
|
17
|
+
* is handed and the verb ledger every `conductor_*` call writes, and the
|
|
18
|
+
* operator reads both. Unconfined means auditable, not licensed.
|
|
17
19
|
*
|
|
18
|
-
* `bash` is deliberately not confined here: its input is an opaque shell
|
|
19
|
-
* string, and parsing it is a false sense of security.
|
|
20
|
-
* determined session, and no comment or doc here should claim
|
|
21
|
-
* closing that gap is a least-privilege OS principal (
|
|
22
|
-
*
|
|
20
|
+
* `bash` is deliberately not confined here either: its input is an opaque shell
|
|
21
|
+
* string, and parsing it is a false sense of security. This gate does not
|
|
22
|
+
* contain a determined session, and no comment or doc here should claim
|
|
23
|
+
* otherwise — closing that gap is a least-privilege OS principal (a worker uid
|
|
24
|
+
* under `credentials.isolation: "per-run"`), documented beside this module's
|
|
23
25
|
* README section, not a regex over `rm -rf`.
|
|
24
26
|
*
|
|
25
|
-
* What
|
|
26
|
-
*
|
|
27
|
-
* comes back naming the worktree
|
|
28
|
-
*
|
|
29
|
-
* transient error and retry.
|
|
27
|
+
* What it does buy is a *legible* refusal that holds on hosts where no uid
|
|
28
|
+
* split will ever be deployed: a structured `edit` outside the assigned
|
|
29
|
+
* worktree comes back naming the worktree, which the model can act on in one
|
|
30
|
+
* turn, instead of an `EACCES` it may read as transient and retry.
|
|
30
31
|
*/
|
|
31
32
|
|
|
32
|
-
import {
|
|
33
|
+
import { existsSync, realpathSync, statSync } from "node:fs";
|
|
33
34
|
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";
|
|
46
35
|
|
|
47
36
|
/** Tools whose structured `path` (or path-like) field we can gate. */
|
|
48
37
|
const GATED = new Set(["write", "edit", "read", "grep", "glob"]);
|
|
@@ -163,442 +152,3 @@ export function worktreeConfinement(root: string): (pi: ConfinementPi) => void {
|
|
|
163
152
|
};
|
|
164
153
|
}
|
|
165
154
|
|
|
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
|
-
}
|