omp-conductor 0.4.2 → 0.4.3

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,48 +1,37 @@
1
1
  /**
2
- * Mechanical filesystem confinement for the sessions this package starts.
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). Two gates ride on that seam, and they are shaped differently
8
- * because the two sessions need different things:
8
+ * example). {@link worktreeConfinement} rides on that seam: a worker may touch
9
+ * its own checkout and nothing else.
9
10
  *
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).
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. 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
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 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.
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 { appendFileSync, existsSync, mkdirSync, readFileSync, realpathSync, statSync } from "node:fs";
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
- }