seisin 0.6.3 → 0.6.5

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.
Files changed (50) hide show
  1. package/README.md +143 -686
  2. package/package.json +2 -2
  3. package/src/canary.js +1 -1
  4. package/src/cli.js +47 -3
  5. package/src/commands/check.js +68 -12
  6. package/src/commands/hook.js +1 -1
  7. package/src/commands/log.js +21 -14
  8. package/src/commands/requests.js +10 -15
  9. package/src/commands/review.js +2 -1
  10. package/src/commands/run.js +331 -245
  11. package/src/commands/walls.js +12 -5
  12. package/src/config-fields.js +305 -0
  13. package/src/config.js +114 -534
  14. package/src/controls.js +1 -1
  15. package/src/decide-request.js +37 -0
  16. package/src/denial-attribution.js +338 -0
  17. package/src/grants.js +1 -1
  18. package/src/help.js +6 -4
  19. package/src/index.js +1 -1
  20. package/src/inspect/enforce.js +1 -1
  21. package/src/inspect/files.js +1 -1
  22. package/src/inspect/home.js +1 -1
  23. package/src/inspect/inject.js +1 -1
  24. package/src/intake.js +3 -2
  25. package/src/keys.js +2 -2
  26. package/src/kinds.js +19 -0
  27. package/src/layout.js +1 -1
  28. package/src/log.js +3 -2
  29. package/src/mcp.js +1 -1
  30. package/src/notify-config.js +58 -0
  31. package/src/notify.js +4 -52
  32. package/src/owners.js +2 -2
  33. package/src/requests.js +9 -4
  34. package/src/rundir.js +2 -2
  35. package/src/runs.js +29 -10
  36. package/src/sandboxes/command.js +0 -2
  37. package/src/sandboxes/index.js +15 -2
  38. package/src/sandboxes/srt.js +1 -1
  39. package/src/sandboxes/tartaros.js +34 -6
  40. package/src/serve.js +11 -22
  41. package/src/spool.js +1 -1
  42. package/src/{srt.js → srt-settings.js} +5 -2
  43. package/src/state.js +25 -0
  44. package/src/suggest.js +26 -1
  45. package/src/surface.js +3 -2
  46. package/src/territory.js +20 -1
  47. package/src/toml.js +250 -0
  48. package/src/violations.js +16 -315
  49. package/src/walls.js +4 -2
  50. package/ui/index.html +35 -9
@@ -12,7 +12,7 @@ import { finished } from "node:stream/promises";
12
12
  import { constants as osConstants } from "node:os";
13
13
  import { mkdirSync, writeFileSync, existsSync, readFileSync, statSync } from "node:fs";
14
14
  import { join, dirname, delimiter, resolve } from "node:path";
15
- import { settingsFor, roleHome, loopbackVia, agentOf, AGENTS, AGENT_HOMES } from "../srt.js";
15
+ import { settingsFor, roleHome, loopbackVia, agentOf, AGENTS, AGENT_HOMES } from "../srt-settings.js";
16
16
  import { buildEnv } from "../env.js";
17
17
  import { traceFields } from "../trace.js";
18
18
  import { resolveKeys } from "../keys.js";
@@ -73,6 +73,46 @@ export function agentFor(mine, cmd) {
73
73
  return given;
74
74
  }
75
75
 
76
+ /**
77
+ * The egress refusals in a sandbox's own JSON-lines log (tartaros `--log`:
78
+ * `{axis: "network", outcome: "denied", target: "host:port", action}`), one per
79
+ * target with how many times: a client retrying a refused host is one wall, not
80
+ * fifty lines. Missing or unreadable: none, the run's own record still stands.
81
+ */
82
+ export function brokerDenials(file) {
83
+ let text;
84
+ try { text = readFileSync(file, "utf8"); } catch { return []; }
85
+ const by = new Map();
86
+ for (const line of text.split("\n")) {
87
+ let e;
88
+ try { e = JSON.parse(line); } catch { continue; }
89
+ if (e?.axis !== "network" || e.outcome !== "denied" || typeof e.target !== "string") continue;
90
+ // A request refused on a terminated connection says `host:port (why)`: the
91
+ // host is the wall, the why (method and path) is its reason.
92
+ const m = /^(\S+) \((.*)\)$/.exec(e.target);
93
+ const target = (m ? m[1] : e.target).replace(/\/$/, "").slice(0, 300);
94
+ const reason = m ? m[2].slice(0, 300) : String(e.action ?? "denied");
95
+ const d = by.get(`${target}\u0000${reason}`) ?? { target, reason, times: 0 };
96
+ d.times++;
97
+ by.set(`${target}\u0000${reason}`, d);
98
+ }
99
+ return [...by.values()];
100
+ }
101
+
102
+ /**
103
+ * `--run-id <id>`: the launcher's own id for this run (an orchestrator's ULID),
104
+ * used instead of a random one wherever the run is named. Letters, digits, `-`
105
+ * and `_`, 64 at most: it becomes part of a directory name and of log lines.
106
+ */
107
+ export function runIdOf(mine) {
108
+ const at = mine.lastIndexOf("--run-id");
109
+ if (at === -1) return null;
110
+ const id = mine[at + 1];
111
+ if (!id || !/^[A-Za-z0-9_-]{1,64}$/.test(id))
112
+ throw new Error(`--run-id ${id ?? ""}: letters, digits, "-" and "_" only, 64 at most`);
113
+ return id;
114
+ }
115
+
76
116
  export async function run(config, argv) {
77
117
  /**
78
118
  * Ours before the `--`, theirs after it.
@@ -146,7 +186,11 @@ export async function run(config, argv) {
146
186
  const notify = notifier(config);
147
187
  // Everything that belongs to this run alone — socket, settings, run-file keys
148
188
  // — in one private directory, with one id. See rundir.js.
149
- const theRun = openRun();
189
+ const runId = runIdOf(mine);
190
+ const theRun = openRun(runId ? { id: runId } : undefined);
191
+ // How the log names this run: the launcher's id when it gave one, so its
192
+ // records and seisin's join on the same key; else the first 8 of seisin's.
193
+ const runLabel = runId ?? theRun.id.slice(0, 8);
150
194
  /**
151
195
  * From here on, every way out removes what this run made.
152
196
  *
@@ -185,7 +229,7 @@ export async function run(config, argv) {
185
229
  * The run's temp directory: one the role may write, never the runtime's.
186
230
  *
187
231
  * sandbox-runtime hands every child `/tmp/claude` as TMPDIR, and that
188
- * directory is now denied (srt.js, SHARED_TMP). It takes CLAUDE_CODE_TMPDIR
232
+ * directory is now denied (srt-settings.js, SHARED_TMP). It takes CLAUDE_CODE_TMPDIR
189
233
  * instead when set, so that is where the run's goes: the role's own tmp under
190
234
  * `isolate = "home"`, else the parent's TMPDIR when the role may already write
191
235
  * it, else one made for this run beside the runs root, granted to it alone. A caller's
@@ -214,7 +258,9 @@ export async function run(config, argv) {
214
258
  const box = chooseSandbox(config, generated, { role });
215
259
  if (box.error) throw box.error;
216
260
  const { sandbox, bin } = box;
217
- const settings = settingsOf(sandbox, generated, { machRegister });
261
+ const handoffExempt = (config.roles[role].handoffExempt ?? []).map((e) => join(config.root, e));
262
+ const requests = config.roles[role].requests ?? [];
263
+ const settings = settingsOf(sandbox, generated, { machRegister, handoffExempt, requests });
218
264
  // Per run, never per role: `.seisin/<role>.json` was one file for every run
219
265
  // of that role, and two at once wrote each other's socket path into it.
220
266
  const file = theRun.writeSettings(settings);
@@ -224,7 +270,7 @@ export async function run(config, argv) {
224
270
  // The launcher's W3C trace, read from the parent's environment once: every line
225
271
  // this run writes carries it, so the log joins the launcher's trace (trace.js).
226
272
  const trace = traceFields(process.env);
227
- const take = intake({ config, role, runId: theRun.id, observe, settings, notify, chain: handed?.chain ?? null, expectDenied, trace });
273
+ const take = intake({ config, role, runId: theRun.id, label: runLabel, observe, settings, notify, chain: handed?.chain ?? null, expectDenied, trace });
228
274
  // The channel is a unix socket on macOS, and a socket path is capped. Past
229
275
  // the cap the listen dies on `EINVAL` and a path, which says nothing about
230
276
  // TMPDIR — the one thing that decides how long it is.
@@ -240,141 +286,10 @@ export async function run(config, argv) {
240
286
  writeFileSync(join(dirname(file), MACH_FILE), JSON.stringify({ register: machRegister }), { mode: 0o600 });
241
287
  const wrap = (inner) => sandbox.wrap(bin, file, settings, inner, { role, machRegister });
242
288
 
243
- // The hook runs inside the child and has to know which role it is. These are
244
- // the only variables seisin injects, and none carries a secret.
245
- const { env, dropped } = buildEnv(process.env, config.roles[role]);
246
- env.SEISIN_ROLE = role;
247
- env.SEISIN_CONFIG = config.path;
248
- env[SOCK_ENV] = sockPath;
249
- // The handoff chain, for the hook's words only (handoff.js, CHAIN_ENV): set
250
- // from the decision checked above, and removed otherwise, so a value the
251
- // parent happened to have never reaches a run that was not handed work.
252
- delete env[CHAIN_ENV];
253
- if (handed) env[CHAIN_ENV] = JSON.stringify(handed.chain);
254
- /**
255
- * The declared toolchain goes first on PATH, under `read = "territory"`.
256
- *
257
- * Declaring `~/.venvs/api` and then having `python` resolve to whatever the
258
- * parent's PATH found first is not having the toolchain. Only in that mode:
259
- * under "all" the PATH is the parent's, as it has always been.
260
- */
261
- if (config.read === "territory") {
262
- const bins = toolchainBins(config, config.roles[role], isDir);
263
- if (bins.length) env.PATH = [...bins, ...(env.PATH ? [env.PATH] : [])].join(delimiter);
264
- }
265
-
266
- /**
267
- * Reference keys: resolved here, by the parent, before anything is spawned.
268
- *
269
- * The ordering is the security property, not a convenience. The provider
270
- * command holds the vault's own credential — the keychain prompt, the
271
- * 1Password session — and running it inside the sandbox would mean putting that
272
- * credential in there too, which is the thing this feature exists to avoid.
273
- * The confined side cannot reach the parent; the parent can reach the vault;
274
- * so the parent resolves and hands over the result.
275
- *
276
- * A failure here stops the run. Not an empty value, not the file of the same
277
- * name, not a skipped key: a role that starts without a credential it
278
- * declared fails later, somewhere else, looking like something it is not.
279
- */
280
- /**
281
- * Each resolution is a line in the log: which key, which provider, which
282
- * mode, whether it resolved. Never the value — the log is the record of what
283
- * the policy did, and a secret in it would be a second plaintext copy with
284
- * worse access control than the first. Shaped like the canary's line, a
285
- * run-level event beside the verdicts, so readers that count verdicts skip it.
286
- */
287
- const resolved = resolveKeys(config, config.roles[role], {
288
- record: ({ entry, mode, ok }) => append(logPath(config.root), {
289
- // The variable's name and the provider, never the reference: a reference
290
- // can carry what it points at (a provider that echoes it, a path that
291
- // names the secret), and the log is read by more people than the policy.
292
- event: "key", role, run: theRun.id.slice(0, 8), ...trace, key: entry.name, provider: entry.scheme, mode, ok,
293
- }),
294
- });
295
- const runFileKeys = theRun.keys;
296
- if (resolved.some((r) => r.mode === "run-file")) mkdirSync(runFileKeys, { recursive: true, mode: 0o700 });
297
- for (const { entry, mode, value } of resolved) {
298
- if (mode === "env") { env[entry.name] = value; continue; }
299
- /**
300
- * `inject`: the real value goes into the environment of `srt` — not the
301
- * agent's. The settings this run already generated declare it under
302
- * `credentials.envVars` with `mode: "mask"`, so the runtime reads the real
303
- * value from its own environment, hands the sandboxed child a sentinel, and
304
- * substitutes the real value back only on egress to the credential's hosts.
305
- * `srt` runs outside the sandbox, and the sandboxed child gets the sentinel.
306
- * This mode is Linux-only for now — see injectGate in keys.js. The value is
307
- * still masked on stdout/stderr below, so a sentinel the agent reflects into
308
- * its own output is covered too.
309
- */
310
- if (mode === "inject") { env[entry.name] = value; continue; }
311
- // `run-file`: the value lands in the per-run directory this process already
312
- // makes for the spool and already removes on exit, so its lifetime is the
313
- // run's by construction rather than by a cleanup somebody has to remember.
314
- // The variable carries the PATH, which is the convention every tool that
315
- // wants a secret from a file already reads (`_FILE`), and it means the
316
- // value itself is not in the environment of a process that may print it.
317
- const file = join(runFileKeys, entry.name);
318
- writeFileSync(file, value, { mode: 0o600 });
319
- /**
320
- * Both names, because the world has two conventions and neither is ours.
321
- *
322
- * `<NAME>_FILE` is the Docker-secrets shape, read by tools that normally
323
- * take the value in `<NAME>`. But a large family of tools already expects
324
- * a PATH in the plain variable — `KUBECONFIG`,
325
- * `GOOGLE_APPLICATION_CREDENTIALS`, `AWS_SHARED_CREDENTIALS_FILE` — and
326
- * for those, `KUBECONFIG_FILE` is a name nothing reads.
327
- *
328
- * Found by running `kubectl` against it rather than reasoning about it:
329
- * the file was there and correct, and the variable it was announced under
330
- * was one kubectl has never heard of.
331
- *
332
- * Setting both costs nothing and leaks nothing: in `run-file` mode the
333
- * value is deliberately not in the environment, so both variables hold a
334
- * path and neither holds a secret.
335
- */
336
- env[entry.name] = file;
337
- env[`${entry.name}_FILE`] = file;
338
- }
339
-
340
- /**
341
- * Isolated mode: point the toolchain at a home of this role's own.
342
- *
343
- * Granting `~/.claude` to every role is what made "its own folders" only
344
- * true of the repo. A CLI that keeps session state — or a token — under the
345
- * real home put it somewhere every other role could read and write.
346
- *
347
- * The XDG variables are set as well as HOME, because a tool that reads
348
- * `XDG_CACHE_HOME` directly would otherwise still land in the real one, and
349
- * that failure is silent: the run works and the isolation does not.
350
- */
351
- // Only the `home` level moves HOME. `credentials` closes the places
352
- // credentials live and leaves the home where it is — which is what keeps the
353
- // agent logged in, since on macOS its credential is in the login keychain and
354
- // the keychain is found through HOME.
355
- if (config.isolate === "home" || config.isolate === true) {
356
- const home = roleHome(config, role);
357
- for (const d of [home, join(home, ".config"), join(home, ".local", "share"),
358
- join(home, ".local", "state"), join(home, ".cache"), join(home, "tmp")])
359
- mkdirSync(d, { recursive: true });
360
- env.HOME = home;
361
- env.XDG_CONFIG_HOME = join(home, ".config");
362
- env.XDG_DATA_HOME = join(home, ".local", "share");
363
- env.XDG_STATE_HOME = join(home, ".local", "state");
364
- env.XDG_CACHE_HOME = join(home, ".cache");
365
- env.TMPDIR = join(home, "tmp");
366
- // zsh writes its heredocs to $TMPPREFIX, not $TMPDIR, and defaults it to
367
- // /tmp/zsh — which the role cannot write. Every heredoc in every zsh script
368
- // failed with the role's own tmp a directory away (measured by an orchestrator running zsh scripts).
369
- env.TMPPREFIX = join(home, "tmp", "zsh");
370
- }
371
- env.TMPDIR = runTmp;
372
- env.CLAUDE_CODE_TMPDIR = runTmp;
373
- // zsh writes heredocs to $TMPPREFIX (default /tmp/zsh), not $TMPDIR: see
374
- // the isolated-home note above. Same answer for every run now.
375
- env.TMPPREFIX = join(runTmp, "zsh");
376
- delete env.CLAUDE_TMPDIR;
377
- if (observe) env.SEISIN_OBSERVE = "1";
289
+ const { env, dropped } = prepareEnvironment({ config, role, sockPath, handed, isDir });
290
+ // Resolve in the parent, then deliver, before the canary or agent can start.
291
+ const resolved = deliverCredentials({ config, role, runLabel, trace, theRun, env });
292
+ prepareRunHome({ config, role, env, runTmp, observe });
378
293
  if (mine.includes("--debug-env")) err(`${C.dim}seisin: dropped ${dropped.join(" ")}${C.off}\n`);
379
294
 
380
295
  /**
@@ -493,7 +408,7 @@ export async function run(config, argv) {
493
408
  });
494
409
  if (!probe.ok) {
495
410
  append(logPath(config.root), {
496
- event: "canary", role, run: theRun.id.slice(0, 8), ...trace, agent: agent ?? null, confined: false, reasons: probe.failed,
411
+ event: "canary", role, run: runLabel, ...trace, agent: agent ?? null, confined: false, reasons: probe.failed,
497
412
  ...(sandbox.name === DEFAULT_SANDBOX ? {} : { sandbox: sandbox.name }),
498
413
  });
499
414
  if (probe.exposed) {
@@ -512,7 +427,7 @@ export async function run(config, argv) {
512
427
  process.exit(CANARY_EXIT);
513
428
  }
514
429
  append(logPath(config.root), {
515
- event: "canary", role, run: theRun.id.slice(0, 8), ...trace, agent: agent ?? null, confined: true, reasons: [],
430
+ event: "canary", role, run: runLabel, ...trace, agent: agent ?? null, confined: true, reasons: [],
516
431
  ...(sandbox.name === DEFAULT_SANDBOX ? {} : { sandbox: sandbox.name }),
517
432
  });
518
433
  // Said once, only when it is not the default: which sandbox holds this run
@@ -530,7 +445,7 @@ export async function run(config, argv) {
530
445
  `${handed.chain.depth} of ${MAX_DEPTH}${C.off}\n`);
531
446
  }
532
447
 
533
- const boxed = ["env", ...restoreTmp, `SEISIN_RUN_ID=${theRun.id}`, ...loopbackVia(config.roles[role], cmd, sandbox.name)];
448
+ const boxed = ["env", ...restoreTmp, `SEISIN_RUN_ID=${runLabel}`, ...loopbackVia(config.roles[role], cmd, sandbox.name)];
534
449
  // The tag is sandbox-runtime's: under any other sandbox there is none to
535
450
  // match, and attribution goes by the process tree alone (violations.js).
536
451
  denials = watchDenials(take.fromKernel, { argv: sandbox.tagsDenials ? boxed : null });
@@ -552,7 +467,12 @@ export async function run(config, argv) {
552
467
  const nested = nestedSandboxWarning(cmd);
553
468
  if (nested) err(`${C.yellow}seisin: ${nested}${C.off}\n`);
554
469
 
555
- const [runner, ...runnerArgs] = wrap(boxed);
470
+ // The sandbox's own record of what its egress broker refused (tartaros
471
+ // `--log`), in this run's private directory: read back into seisin's log when
472
+ // the run ends, so a denied host is a wall like any other instead of a line
473
+ // on stderr. Only the agent's run gets it, not the canary's.
474
+ const brokerLog = join(theRun.dir, "sandbox-log.jsonl");
475
+ const [runner, ...runnerArgs] = sandbox.wrap(bin, file, settings, boxed, { role, machRegister, log: brokerLog });
556
476
  const child = spawn(runner, runnerArgs, {
557
477
  stdio: outStream ? ["inherit", "pipe", "pipe"] : "inherit",
558
478
  env,
@@ -590,17 +510,6 @@ export async function run(config, argv) {
590
510
  process.on(sig, () => { stoppedBy ??= sig; try { child.kill(sig); } catch {} });
591
511
  if (outStream) { child.stdout.pipe(outStream); child.stderr.pipe(errStream); }
592
512
 
593
- // The exit code is the child's, and the output has to be all the way out
594
- // before we leave. Both halves were wrong once and neither was loud:
595
- //
596
- // - Calling exit() the moment the child exits discards whatever is still in
597
- // the redaction stream, so the command looks like it produced nothing.
598
- // - Waiting for `finish` but subscribing AFTER it fired left nothing to call
599
- // exit: first the process drifted out with status 0 (every failure
600
- // reported as a success), later the 2-second guard below caught it and
601
- // every keyed run paid two seconds. `finished()` has no such window.
602
- //
603
- // Keep a real timer (not unref'd, or it cannot save anything) as the ceiling.
604
513
  /**
605
514
  * The status is worked out when the run is about to leave, not when the
606
515
  * child exits. A Ctrl-C reaches the runtime and seisin at once; when the
@@ -617,96 +526,10 @@ export async function run(config, argv) {
617
526
  : interrupted && !code ? 130
618
527
  : code ?? 0;
619
528
 
620
- child.on("exit", async (code, signal) => {
621
- // The notice rides on what you are already looking at. A queue nobody opens
622
- // is not human-in-the-loop, and this is the terminal that just showed you
623
- // the denial — so it goes to stderr, beside it, not into the agent's stdout
624
- // where a pipeline would swallow it.
625
- // Close the spool before reading the queue: a line the hook sent on the
626
- // agent's last turn may still be in flight, and reporting a queue that is
627
- // one entry behind is how a request goes unnoticed for a day.
628
- audit.close();
629
- // Same reason, one layer further out: the kernel writes its line through a
630
- // separate process, so the last denial of a run can still be in flight
631
- // here. This is the only wait the mechanism adds and it is per run — it
632
- // returns as soon as the stream goes quiet, so a run that was denied
633
- // nothing pays a tick.
634
- await denials.close({ drain: DRAIN_MS });
635
- // The run made this directory, so the run removes it.
636
- cleanup();
637
-
638
- // Say what was dropped rather than only what was kept. A filter nobody can
639
- // see is indistinguishable from a monitor that is not working, and this one
640
- // drops the majority of what the kernel says on a busy run.
641
- // Only when something was actually recorded. A run where every denial was
642
- // filtered out has nothing to contextualise, and `0 recorded, 1 outside the
643
- // policy's paths` is a line that answers a question nobody asked — the
644
- // counts exist to explain a filter, not to announce that one ran.
645
- const { attributed, dropped } = denials.stats;
646
- // The one loss the filter above does not explain: the system log could not
647
- // keep up and threw lines away before seisin saw them. The boundary held;
648
- // the record did not. Said every time, and written down, because a denial
649
- // that never arrived files no request and nothing else would show it.
650
- if (dropped) {
651
- append(logPath(config.root), { event: "log-dropped", role, run: theRun.id.slice(0, 8), ...trace, batches: dropped });
652
- err(`${C.yellow}seisin: the system log dropped ${dropped} batch(es) of messages during this run: ` +
653
- `kernel denials may be missing from the log and the queue. ` +
654
- `\`seisin check --system-log\` says what is flooding it.${C.off}\n`);
655
- }
656
- const { offPolicy, walks, expected, probes, atomic, browsers } = take.stats;
657
- // A browser that died at start on its Mach rendezvous: the crash the agent
658
- // saw does not say why, and this is the line that does.
659
- for (const b of browsers)
660
- err(`${C.yellow}seisin: ${role} started ${b} and the sandbox denied the Mach service it registers at start, so it ` +
661
- `exited. Add browsers = ["${b}"] to [roles.${role}] in seisin.toml (docs/browsers.md).${C.off}\n`);
662
- // A save refused at its temporary file, beside a file this role may write:
663
- // the file was not saved, and nothing was queued because there is nothing
664
- // to grant (the temporary name changes every time). Said whether or not
665
- // anything else was recorded, because the agent's error said neither.
666
- if (atomic.length) {
667
- const files = [...new Set(atomic)];
668
- err(`${C.yellow}seisin: ${files.length} file(s) ${role} may write were not saved: the save's temporary file beside ` +
669
- `${files.slice(0, 3).join(", ")}${files.length > 3 ? ` and ${files.length - 3} more` : ""} was denied ` +
670
- `(Claude Code's Edit and Write). \`seisin check ${role}\` says whether a grant can fix it.${C.off}\n`);
671
- }
672
- const recorded = attributed - offPolicy - walks;
673
- if (recorded > 0)
674
- err(`${C.dim}seisin: ${recorded} kernel denial(s) recorded` +
675
- `${walks ? `, ${walks} directory scan(s)` : ""}` +
676
- `${offPolicy ? `, ${offPolicy} outside the policy's paths` : ""}${C.off}\n` +
677
- // Claude Code's plugin updater, refused correctly: said as expected so
678
- // it does not read as the run failing (kinds.js expectedOf).
679
- (expected ? `${C.dim}seisin: ${expected} of them expected: ${EXPECTED.plugins}${C.off}\n` : "") +
680
- // The caller's own probes: denied as it asked, and nothing to grant.
681
- (probes ? `${C.dim}seisin: ${probes} of them expected by the caller (--expect-denied) — nothing queued${C.off}\n` : ""));
682
-
683
- // Only what this run added, with the numbers `grant` takes, and one line
684
- // for the rest. The whole queue after every run buried the new request
685
- // under the ones already seen; nothing new, nothing said.
686
- const queue = queueOf(config);
687
- const added = queue.map((q, i) => ({ ...q, n: i + 1 })).filter((q) => !before.has(q.key));
688
- if (added.length) err(renderAdded(added, queue.length - added.length));
689
- const failed = await notify.settle();
690
- if (failed.length) err(`${C.yellow}seisin: could not notify about a new request (${failed[0]})${C.off}\n`);
691
- // One more turn of the loop, so a signal that arrived with the child's exit
692
- // has run its handler before the status is decided.
693
- await new Promise((r) => setImmediate(r));
694
- const status = statusOf(code, signal);
695
- if (!outStream) return process.exit(status);
696
- // `finished()` and not `once("finish")`, because the event is usually over
697
- // before this line runs: `child.stdout.pipe(outStream)` ends the redactor
698
- // itself when the child's stdout closes, so the listener was attached to an
699
- // event that had already fired and the 2-second guard was the only way out.
700
- // Every run with a key paid it — measured 3.0 s against 1.2 s without keys.
701
- // `finished()` settles at once for a stream that is already done.
702
- const guard = setTimeout(() => process.exit(status), 2000);
703
- // A child's exit is not its pipes' EOF. Let pipe() end the redactors
704
- // after the remaining stdout/stderr arrives; ending them here lost that
705
- // output. The guard still bounds a descendant that keeps a pipe open.
706
- await Promise.all([finished(outStream), finished(errStream)]).catch(() => {});
707
- clearTimeout(guard);
708
- process.exit(status);
709
- });
529
+ child.on("exit", (code, signal) => finishRun({
530
+ config, role, runLabel, trace, audit, denials, brokerLog, sandbox,
531
+ cleanup, take, before, notify, statusOf, outStream, errStream,
532
+ }, code, signal));
710
533
  // Not a `throw`: in a listener it is an uncaught exception — exit 1, a
711
534
  // stack trace, and none of the cleanup. Said the way cli.js says any other
712
535
  // failure, with its code; the `exit` handler above removes the run.
@@ -716,6 +539,269 @@ export async function run(config, argv) {
716
539
  });
717
540
  }
718
541
 
542
+ // Keep environment filtering ahead of credential resolution: providers run in
543
+ // the parent, and only the sandbox receives the resulting environment.
544
+ function prepareEnvironment({ config, role, sockPath, handed, isDir }) {
545
+ // The hook runs inside the child and has to know which role it is. These are
546
+ // the only variables seisin injects, and none carries a secret.
547
+ const { env, dropped } = buildEnv(process.env, config.roles[role]);
548
+ env.SEISIN_ROLE = role;
549
+ env.SEISIN_CONFIG = config.path;
550
+ // A policy read with --policy is rooted where the run started, not beside the
551
+ // file; the hook must agree, or it would judge paths against the wrong repo.
552
+ if (config.root !== dirname(config.path)) env.SEISIN_ROOT = config.root;
553
+ else delete env.SEISIN_ROOT;
554
+ env[SOCK_ENV] = sockPath;
555
+ // The handoff chain, for the hook's words only (handoff.js, CHAIN_ENV): set
556
+ // from the decision checked above, and removed otherwise, so a value the
557
+ // parent happened to have never reaches a run that was not handed work.
558
+ delete env[CHAIN_ENV];
559
+ if (handed) env[CHAIN_ENV] = JSON.stringify(handed.chain);
560
+ /**
561
+ * The declared toolchain goes first on PATH, under `read = "territory"`.
562
+ *
563
+ * Declaring `~/.venvs/api` and then having `python` resolve to whatever the
564
+ * parent's PATH found first is not having the toolchain. Only in that mode:
565
+ * under "all" the PATH is the parent's, as it has always been.
566
+ */
567
+ if (config.read === "territory") {
568
+ const bins = toolchainBins(config, config.roles[role], isDir);
569
+ if (bins.length) env.PATH = [...bins, ...(env.PATH ? [env.PATH] : [])].join(delimiter);
570
+ }
571
+ return { env, dropped };
572
+ }
573
+
574
+ function deliverCredentials({ config, role, runLabel, trace, theRun, env }) {
575
+ /**
576
+ * Reference keys: resolved here, by the parent, before anything is spawned.
577
+ *
578
+ * The ordering is the security property, not a convenience. The provider
579
+ * command holds the vault's own credential — the keychain prompt, the
580
+ * 1Password session — and running it inside the sandbox would mean putting that
581
+ * credential in there too, which is the thing this feature exists to avoid.
582
+ * The confined side cannot reach the parent; the parent can reach the vault;
583
+ * so the parent resolves and hands over the result.
584
+ *
585
+ * A failure here stops the run. Not an empty value, not the file of the same
586
+ * name, not a skipped key: a role that starts without a credential it
587
+ * declared fails later, somewhere else, looking like something it is not.
588
+ */
589
+ /**
590
+ * Each resolution is a line in the log: which key, which provider, which
591
+ * mode, whether it resolved. Never the value — the log is the record of what
592
+ * the policy did, and a secret in it would be a second plaintext copy with
593
+ * worse access control than the first. Shaped like the canary's line, a
594
+ * run-level event beside the verdicts, so readers that count verdicts skip it.
595
+ */
596
+ const resolved = resolveKeys(config, config.roles[role], {
597
+ record: ({ entry, mode, ok }) => append(logPath(config.root), {
598
+ // The variable's name and the provider, never the reference: a reference
599
+ // can carry what it points at (a provider that echoes it, a path that
600
+ // names the secret), and the log is read by more people than the policy.
601
+ event: "key", role, run: runLabel, ...trace, key: entry.name, provider: entry.scheme, mode, ok,
602
+ }),
603
+ });
604
+ const runFileKeys = theRun.keys;
605
+ if (resolved.some((r) => r.mode === "run-file")) mkdirSync(runFileKeys, { recursive: true, mode: 0o700 });
606
+ for (const { entry, mode, value } of resolved) {
607
+ if (mode === "env") { env[entry.name] = value; continue; }
608
+ /**
609
+ * `inject`: the real value goes into the environment of `srt` — not the
610
+ * agent's. The settings this run already generated declare it under
611
+ * `credentials.envVars` with `mode: "mask"`, so the runtime reads the real
612
+ * value from its own environment, hands the sandboxed child a sentinel, and
613
+ * substitutes the real value back only on egress to the credential's hosts.
614
+ * `srt` runs outside the sandbox, and the sandboxed child gets the sentinel.
615
+ * This mode is Linux-only for now — see injectGate in keys.js. The value is
616
+ * still masked on stdout/stderr below, so a sentinel the agent reflects into
617
+ * its own output is covered too.
618
+ */
619
+ if (mode === "inject") { env[entry.name] = value; continue; }
620
+ // `run-file`: the value lands in the per-run directory this process already
621
+ // makes for the spool and already removes on exit, so its lifetime is the
622
+ // run's by construction rather than by a cleanup somebody has to remember.
623
+ // The variable carries the PATH, which is the convention every tool that
624
+ // wants a secret from a file already reads (`_FILE`), and it means the
625
+ // value itself is not in the environment of a process that may print it.
626
+ const file = join(runFileKeys, entry.name);
627
+ writeFileSync(file, value, { mode: 0o600 });
628
+ /**
629
+ * Both names, because the world has two conventions and neither is ours.
630
+ *
631
+ * `<NAME>_FILE` is the Docker-secrets shape, read by tools that normally
632
+ * take the value in `<NAME>`. But a large family of tools already expects
633
+ * a PATH in the plain variable — `KUBECONFIG`,
634
+ * `GOOGLE_APPLICATION_CREDENTIALS`, `AWS_SHARED_CREDENTIALS_FILE` — and
635
+ * for those, `KUBECONFIG_FILE` is a name nothing reads.
636
+ *
637
+ * Found by running `kubectl` against it rather than reasoning about it:
638
+ * the file was there and correct, and the variable it was announced under
639
+ * was one kubectl has never heard of.
640
+ *
641
+ * Setting both costs nothing and leaks nothing: in `run-file` mode the
642
+ * value is deliberately not in the environment, so both variables hold a
643
+ * path and neither holds a secret.
644
+ */
645
+ env[entry.name] = file;
646
+ env[`${entry.name}_FILE`] = file;
647
+ }
648
+ return resolved;
649
+ }
650
+
651
+ // Home and tmp overrides follow key delivery, as they did in the inline setup.
652
+ function prepareRunHome({ config, role, env, runTmp, observe }) {
653
+ /**
654
+ * Isolated mode: point the toolchain at a home of this role's own.
655
+ *
656
+ * Granting `~/.claude` to every role is what made "its own folders" only
657
+ * true of the repo. A CLI that keeps session state — or a token — under the
658
+ * real home put it somewhere every other role could read and write.
659
+ *
660
+ * The XDG variables are set as well as HOME, because a tool that reads
661
+ * `XDG_CACHE_HOME` directly would otherwise still land in the real one, and
662
+ * that failure is silent: the run works and the isolation does not.
663
+ */
664
+ // Only the `home` level moves HOME. `credentials` closes the places
665
+ // credentials live and leaves the home where it is — which is what keeps the
666
+ // agent logged in, since on macOS its credential is in the login keychain and
667
+ // the keychain is found through HOME.
668
+ if (config.isolate === "home" || config.isolate === true) {
669
+ const home = roleHome(config, role);
670
+ for (const d of [home, join(home, ".config"), join(home, ".local", "share"),
671
+ join(home, ".local", "state"), join(home, ".cache"), join(home, "tmp")])
672
+ mkdirSync(d, { recursive: true });
673
+ env.HOME = home;
674
+ env.XDG_CONFIG_HOME = join(home, ".config");
675
+ env.XDG_DATA_HOME = join(home, ".local", "share");
676
+ env.XDG_STATE_HOME = join(home, ".local", "state");
677
+ env.XDG_CACHE_HOME = join(home, ".cache");
678
+ env.TMPDIR = join(home, "tmp");
679
+ // zsh writes its heredocs to $TMPPREFIX, not $TMPDIR, and defaults it to
680
+ // /tmp/zsh — which the role cannot write. Every heredoc in every zsh script
681
+ // failed with the role's own tmp a directory away (measured by an orchestrator running zsh scripts).
682
+ env.TMPPREFIX = join(home, "tmp", "zsh");
683
+ }
684
+ env.TMPDIR = runTmp;
685
+ env.CLAUDE_CODE_TMPDIR = runTmp;
686
+ // zsh writes heredocs to $TMPPREFIX (default /tmp/zsh), not $TMPDIR: see
687
+ // the isolated-home note above. Same answer for every run now.
688
+ env.TMPPREFIX = join(runTmp, "zsh");
689
+ delete env.CLAUDE_TMPDIR;
690
+ if (observe) env.SEISIN_OBSERVE = "1";
691
+ }
692
+
693
+ // Do not pass resolved keys or env to closing: it needs audit/output state.
694
+ // statusOf reads live signal state after the drains, not a snapshot at exit.
695
+ async function finishRun({
696
+ config, role, runLabel, trace, audit, denials, brokerLog, sandbox,
697
+ cleanup, take, before, notify, statusOf, outStream, errStream,
698
+ }, code, signal) {
699
+ // The exit code is the child's, and the output has to be all the way out
700
+ // before we leave. Both halves were wrong once and neither was loud:
701
+ //
702
+ // - Calling exit() the moment the child exits discards whatever is still in
703
+ // the redaction stream, so the command looks like it produced nothing.
704
+ // - Waiting for `finish` but subscribing AFTER it fired left nothing to call
705
+ // exit: first the process drifted out with status 0 (every failure
706
+ // reported as a success), later the 2-second guard below caught it and
707
+ // every keyed run paid two seconds. `finished()` has no such window.
708
+ //
709
+ // Keep a real timer (not unref'd, or it cannot save anything) as the ceiling.
710
+ // Drain and record before cleanup removes the run's broker log and keys.
711
+ // The notice rides on what you are already looking at. A queue nobody opens
712
+ // is not human-in-the-loop, and this is the terminal that just showed you
713
+ // the denial — so it goes to stderr, beside it, not into the agent's stdout
714
+ // where a pipeline would swallow it.
715
+ // Close the spool before reading the queue: a line the hook sent on the
716
+ // agent's last turn may still be in flight, and reporting a queue that is
717
+ // one entry behind is how a request goes unnoticed for a day.
718
+ audit.close();
719
+ // Same reason, one layer further out: the kernel writes its line through a
720
+ // separate process, so the last denial of a run can still be in flight
721
+ // here. This is the only wait the mechanism adds and it is per run — it
722
+ // returns as soon as the stream goes quiet, so a run that was denied
723
+ // nothing pays a tick.
724
+ await denials.close({ drain: DRAIN_MS });
725
+ // Before the directory goes: the hosts the sandbox's broker refused.
726
+ for (const d of brokerDenials(brokerLog))
727
+ append(logPath(config.root), { role, run: runLabel, ...trace, tool: "broker", source: sandbox.name, action: "connect",
728
+ kind: "network", target: d.target, verdict: "denied", owners: [], reason: d.reason, times: d.times });
729
+ // The run made this directory, so the run removes it.
730
+ cleanup();
731
+
732
+ // Say what was dropped rather than only what was kept. A filter nobody can
733
+ // see is indistinguishable from a monitor that is not working, and this one
734
+ // drops the majority of what the kernel says on a busy run.
735
+ // Only when something was actually recorded. A run where every denial was
736
+ // filtered out has nothing to contextualise, and `0 recorded, 1 outside the
737
+ // policy's paths` is a line that answers a question nobody asked — the
738
+ // counts exist to explain a filter, not to announce that one ran.
739
+ const { attributed, dropped } = denials.stats;
740
+ // The one loss the filter above does not explain: the system log could not
741
+ // keep up and threw lines away before seisin saw them. The boundary held;
742
+ // the record did not. Said every time, and written down, because a denial
743
+ // that never arrived files no request and nothing else would show it.
744
+ if (dropped) {
745
+ append(logPath(config.root), { event: "log-dropped", role, run: runLabel, ...trace, batches: dropped });
746
+ err(`${C.yellow}seisin: the system log dropped ${dropped} batch(es) of messages during this run: ` +
747
+ `kernel denials may be missing from the log and the queue. ` +
748
+ `\`seisin check --system-log\` says what is flooding it.${C.off}\n`);
749
+ }
750
+ const { offPolicy, walks, expected, probes, atomic, browsers } = take.stats;
751
+ // A browser that died at start on its Mach rendezvous: the crash the agent
752
+ // saw does not say why, and this is the line that does.
753
+ for (const b of browsers)
754
+ err(`${C.yellow}seisin: ${role} started ${b} and the sandbox denied the Mach service it registers at start, so it ` +
755
+ `exited. Add browsers = ["${b}"] to [roles.${role}] in seisin.toml (docs/browsers.md).${C.off}\n`);
756
+ // A save refused at its temporary file, beside a file this role may write:
757
+ // the file was not saved, and nothing was queued because there is nothing
758
+ // to grant (the temporary name changes every time). Said whether or not
759
+ // anything else was recorded, because the agent's error said neither.
760
+ if (atomic.length) {
761
+ const files = [...new Set(atomic)];
762
+ err(`${C.yellow}seisin: ${files.length} file(s) ${role} may write were not saved: the save's temporary file beside ` +
763
+ `${files.slice(0, 3).join(", ")}${files.length > 3 ? ` and ${files.length - 3} more` : ""} was denied ` +
764
+ `(Claude Code's Edit and Write). \`seisin check ${role}\` says whether a grant can fix it.${C.off}\n`);
765
+ }
766
+ const recorded = attributed - offPolicy - walks;
767
+ if (recorded > 0)
768
+ err(`${C.dim}seisin: ${recorded} kernel denial(s) recorded` +
769
+ `${walks ? `, ${walks} directory scan(s)` : ""}` +
770
+ `${offPolicy ? `, ${offPolicy} outside the policy's paths` : ""}${C.off}\n` +
771
+ // Claude Code's plugin updater, refused correctly: said as expected so
772
+ // it does not read as the run failing (kinds.js expectedOf).
773
+ (expected ? `${C.dim}seisin: ${expected} of them expected: ${EXPECTED.plugins}${C.off}\n` : "") +
774
+ // The caller's own probes: denied as it asked, and nothing to grant.
775
+ (probes ? `${C.dim}seisin: ${probes} of them expected by the caller (--expect-denied) — nothing queued${C.off}\n` : ""));
776
+
777
+ // Only what this run added, with the numbers `grant` takes, and one line
778
+ // for the rest. The whole queue after every run buried the new request
779
+ // under the ones already seen; nothing new, nothing said.
780
+ const queue = queueOf(config);
781
+ const added = queue.map((q, i) => ({ ...q, n: i + 1 })).filter((q) => !before.has(q.key));
782
+ if (added.length) err(renderAdded(added, queue.length - added.length));
783
+ const failed = await notify.settle();
784
+ if (failed.length) err(`${C.yellow}seisin: could not notify about a new request (${failed[0]})${C.off}\n`);
785
+ // One more turn of the loop, so a signal that arrived with the child's exit
786
+ // has run its handler before the status is decided.
787
+ await new Promise((r) => setImmediate(r));
788
+ const status = statusOf(code, signal);
789
+ if (!outStream) return process.exit(status);
790
+ // `finished()` and not `once("finish")`, because the event is usually over
791
+ // before this line runs: `child.stdout.pipe(outStream)` ends the redactor
792
+ // itself when the child's stdout closes, so the listener was attached to an
793
+ // event that had already fired and the 2-second guard was the only way out.
794
+ // Every run with a key paid it — measured 3.0 s against 1.2 s without keys.
795
+ // `finished()` settles at once for a stream that is already done.
796
+ const guard = setTimeout(() => process.exit(status), 2000);
797
+ // A child's exit is not its pipes' EOF. Let pipe() end the redactors
798
+ // after the remaining stdout/stderr arrives; ending them here lost that
799
+ // output. The guard still bounds a descendant that keeps a pipe open.
800
+ await Promise.all([finished(outStream), finished(errStream)]).catch(() => {});
801
+ clearTimeout(guard);
802
+ process.exit(status);
803
+ }
804
+
719
805
  /**
720
806
  * `--handoff <id>`: this run is the role a pending request was handed to.
721
807
  *