seisin 0.6.4 → 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.
@@ -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";
@@ -229,7 +229,7 @@ export async function run(config, argv) {
229
229
  * The run's temp directory: one the role may write, never the runtime's.
230
230
  *
231
231
  * sandbox-runtime hands every child `/tmp/claude` as TMPDIR, and that
232
- * 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
233
233
  * instead when set, so that is where the run's goes: the role's own tmp under
234
234
  * `isolate = "home"`, else the parent's TMPDIR when the role may already write
235
235
  * it, else one made for this run beside the runs root, granted to it alone. A caller's
@@ -270,7 +270,7 @@ export async function run(config, argv) {
270
270
  // The launcher's W3C trace, read from the parent's environment once: every line
271
271
  // this run writes carries it, so the log joins the launcher's trace (trace.js).
272
272
  const trace = traceFields(process.env);
273
- 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 });
274
274
  // The channel is a unix socket on macOS, and a socket path is capped. Past
275
275
  // the cap the listen dies on `EINVAL` and a path, which says nothing about
276
276
  // TMPDIR — the one thing that decides how long it is.
@@ -286,145 +286,10 @@ export async function run(config, argv) {
286
286
  writeFileSync(join(dirname(file), MACH_FILE), JSON.stringify({ register: machRegister }), { mode: 0o600 });
287
287
  const wrap = (inner) => sandbox.wrap(bin, file, settings, inner, { role, machRegister });
288
288
 
289
- // The hook runs inside the child and has to know which role it is. These are
290
- // the only variables seisin injects, and none carries a secret.
291
- const { env, dropped } = buildEnv(process.env, config.roles[role]);
292
- env.SEISIN_ROLE = role;
293
- env.SEISIN_CONFIG = config.path;
294
- // A policy read with --policy is rooted where the run started, not beside the
295
- // file; the hook must agree, or it would judge paths against the wrong repo.
296
- if (config.root !== dirname(config.path)) env.SEISIN_ROOT = config.root;
297
- else delete env.SEISIN_ROOT;
298
- env[SOCK_ENV] = sockPath;
299
- // The handoff chain, for the hook's words only (handoff.js, CHAIN_ENV): set
300
- // from the decision checked above, and removed otherwise, so a value the
301
- // parent happened to have never reaches a run that was not handed work.
302
- delete env[CHAIN_ENV];
303
- if (handed) env[CHAIN_ENV] = JSON.stringify(handed.chain);
304
- /**
305
- * The declared toolchain goes first on PATH, under `read = "territory"`.
306
- *
307
- * Declaring `~/.venvs/api` and then having `python` resolve to whatever the
308
- * parent's PATH found first is not having the toolchain. Only in that mode:
309
- * under "all" the PATH is the parent's, as it has always been.
310
- */
311
- if (config.read === "territory") {
312
- const bins = toolchainBins(config, config.roles[role], isDir);
313
- if (bins.length) env.PATH = [...bins, ...(env.PATH ? [env.PATH] : [])].join(delimiter);
314
- }
315
-
316
- /**
317
- * Reference keys: resolved here, by the parent, before anything is spawned.
318
- *
319
- * The ordering is the security property, not a convenience. The provider
320
- * command holds the vault's own credential — the keychain prompt, the
321
- * 1Password session — and running it inside the sandbox would mean putting that
322
- * credential in there too, which is the thing this feature exists to avoid.
323
- * The confined side cannot reach the parent; the parent can reach the vault;
324
- * so the parent resolves and hands over the result.
325
- *
326
- * A failure here stops the run. Not an empty value, not the file of the same
327
- * name, not a skipped key: a role that starts without a credential it
328
- * declared fails later, somewhere else, looking like something it is not.
329
- */
330
- /**
331
- * Each resolution is a line in the log: which key, which provider, which
332
- * mode, whether it resolved. Never the value — the log is the record of what
333
- * the policy did, and a secret in it would be a second plaintext copy with
334
- * worse access control than the first. Shaped like the canary's line, a
335
- * run-level event beside the verdicts, so readers that count verdicts skip it.
336
- */
337
- const resolved = resolveKeys(config, config.roles[role], {
338
- record: ({ entry, mode, ok }) => append(logPath(config.root), {
339
- // The variable's name and the provider, never the reference: a reference
340
- // can carry what it points at (a provider that echoes it, a path that
341
- // names the secret), and the log is read by more people than the policy.
342
- event: "key", role, run: runLabel, ...trace, key: entry.name, provider: entry.scheme, mode, ok,
343
- }),
344
- });
345
- const runFileKeys = theRun.keys;
346
- if (resolved.some((r) => r.mode === "run-file")) mkdirSync(runFileKeys, { recursive: true, mode: 0o700 });
347
- for (const { entry, mode, value } of resolved) {
348
- if (mode === "env") { env[entry.name] = value; continue; }
349
- /**
350
- * `inject`: the real value goes into the environment of `srt` — not the
351
- * agent's. The settings this run already generated declare it under
352
- * `credentials.envVars` with `mode: "mask"`, so the runtime reads the real
353
- * value from its own environment, hands the sandboxed child a sentinel, and
354
- * substitutes the real value back only on egress to the credential's hosts.
355
- * `srt` runs outside the sandbox, and the sandboxed child gets the sentinel.
356
- * This mode is Linux-only for now — see injectGate in keys.js. The value is
357
- * still masked on stdout/stderr below, so a sentinel the agent reflects into
358
- * its own output is covered too.
359
- */
360
- if (mode === "inject") { env[entry.name] = value; continue; }
361
- // `run-file`: the value lands in the per-run directory this process already
362
- // makes for the spool and already removes on exit, so its lifetime is the
363
- // run's by construction rather than by a cleanup somebody has to remember.
364
- // The variable carries the PATH, which is the convention every tool that
365
- // wants a secret from a file already reads (`_FILE`), and it means the
366
- // value itself is not in the environment of a process that may print it.
367
- const file = join(runFileKeys, entry.name);
368
- writeFileSync(file, value, { mode: 0o600 });
369
- /**
370
- * Both names, because the world has two conventions and neither is ours.
371
- *
372
- * `<NAME>_FILE` is the Docker-secrets shape, read by tools that normally
373
- * take the value in `<NAME>`. But a large family of tools already expects
374
- * a PATH in the plain variable — `KUBECONFIG`,
375
- * `GOOGLE_APPLICATION_CREDENTIALS`, `AWS_SHARED_CREDENTIALS_FILE` — and
376
- * for those, `KUBECONFIG_FILE` is a name nothing reads.
377
- *
378
- * Found by running `kubectl` against it rather than reasoning about it:
379
- * the file was there and correct, and the variable it was announced under
380
- * was one kubectl has never heard of.
381
- *
382
- * Setting both costs nothing and leaks nothing: in `run-file` mode the
383
- * value is deliberately not in the environment, so both variables hold a
384
- * path and neither holds a secret.
385
- */
386
- env[entry.name] = file;
387
- env[`${entry.name}_FILE`] = file;
388
- }
389
-
390
- /**
391
- * Isolated mode: point the toolchain at a home of this role's own.
392
- *
393
- * Granting `~/.claude` to every role is what made "its own folders" only
394
- * true of the repo. A CLI that keeps session state — or a token — under the
395
- * real home put it somewhere every other role could read and write.
396
- *
397
- * The XDG variables are set as well as HOME, because a tool that reads
398
- * `XDG_CACHE_HOME` directly would otherwise still land in the real one, and
399
- * that failure is silent: the run works and the isolation does not.
400
- */
401
- // Only the `home` level moves HOME. `credentials` closes the places
402
- // credentials live and leaves the home where it is — which is what keeps the
403
- // agent logged in, since on macOS its credential is in the login keychain and
404
- // the keychain is found through HOME.
405
- if (config.isolate === "home" || config.isolate === true) {
406
- const home = roleHome(config, role);
407
- for (const d of [home, join(home, ".config"), join(home, ".local", "share"),
408
- join(home, ".local", "state"), join(home, ".cache"), join(home, "tmp")])
409
- mkdirSync(d, { recursive: true });
410
- env.HOME = home;
411
- env.XDG_CONFIG_HOME = join(home, ".config");
412
- env.XDG_DATA_HOME = join(home, ".local", "share");
413
- env.XDG_STATE_HOME = join(home, ".local", "state");
414
- env.XDG_CACHE_HOME = join(home, ".cache");
415
- env.TMPDIR = join(home, "tmp");
416
- // zsh writes its heredocs to $TMPPREFIX, not $TMPDIR, and defaults it to
417
- // /tmp/zsh — which the role cannot write. Every heredoc in every zsh script
418
- // failed with the role's own tmp a directory away (measured by an orchestrator running zsh scripts).
419
- env.TMPPREFIX = join(home, "tmp", "zsh");
420
- }
421
- env.TMPDIR = runTmp;
422
- env.CLAUDE_CODE_TMPDIR = runTmp;
423
- // zsh writes heredocs to $TMPPREFIX (default /tmp/zsh), not $TMPDIR: see
424
- // the isolated-home note above. Same answer for every run now.
425
- env.TMPPREFIX = join(runTmp, "zsh");
426
- delete env.CLAUDE_TMPDIR;
427
- 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 });
428
293
  if (mine.includes("--debug-env")) err(`${C.dim}seisin: dropped ${dropped.join(" ")}${C.off}\n`);
429
294
 
430
295
  /**
@@ -580,7 +445,7 @@ export async function run(config, argv) {
580
445
  `${handed.chain.depth} of ${MAX_DEPTH}${C.off}\n`);
581
446
  }
582
447
 
583
- 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)];
584
449
  // The tag is sandbox-runtime's: under any other sandbox there is none to
585
450
  // match, and attribution goes by the process tree alone (violations.js).
586
451
  denials = watchDenials(take.fromKernel, { argv: sandbox.tagsDenials ? boxed : null });
@@ -645,17 +510,6 @@ export async function run(config, argv) {
645
510
  process.on(sig, () => { stoppedBy ??= sig; try { child.kill(sig); } catch {} });
646
511
  if (outStream) { child.stdout.pipe(outStream); child.stderr.pipe(errStream); }
647
512
 
648
- // The exit code is the child's, and the output has to be all the way out
649
- // before we leave. Both halves were wrong once and neither was loud:
650
- //
651
- // - Calling exit() the moment the child exits discards whatever is still in
652
- // the redaction stream, so the command looks like it produced nothing.
653
- // - Waiting for `finish` but subscribing AFTER it fired left nothing to call
654
- // exit: first the process drifted out with status 0 (every failure
655
- // reported as a success), later the 2-second guard below caught it and
656
- // every keyed run paid two seconds. `finished()` has no such window.
657
- //
658
- // Keep a real timer (not unref'd, or it cannot save anything) as the ceiling.
659
513
  /**
660
514
  * The status is worked out when the run is about to leave, not when the
661
515
  * child exits. A Ctrl-C reaches the runtime and seisin at once; when the
@@ -672,100 +526,10 @@ export async function run(config, argv) {
672
526
  : interrupted && !code ? 130
673
527
  : code ?? 0;
674
528
 
675
- child.on("exit", async (code, signal) => {
676
- // The notice rides on what you are already looking at. A queue nobody opens
677
- // is not human-in-the-loop, and this is the terminal that just showed you
678
- // the denial — so it goes to stderr, beside it, not into the agent's stdout
679
- // where a pipeline would swallow it.
680
- // Close the spool before reading the queue: a line the hook sent on the
681
- // agent's last turn may still be in flight, and reporting a queue that is
682
- // one entry behind is how a request goes unnoticed for a day.
683
- audit.close();
684
- // Same reason, one layer further out: the kernel writes its line through a
685
- // separate process, so the last denial of a run can still be in flight
686
- // here. This is the only wait the mechanism adds and it is per run — it
687
- // returns as soon as the stream goes quiet, so a run that was denied
688
- // nothing pays a tick.
689
- await denials.close({ drain: DRAIN_MS });
690
- // Before the directory goes: the hosts the sandbox's broker refused.
691
- for (const d of brokerDenials(brokerLog))
692
- append(logPath(config.root), { role, run: runLabel, ...trace, tool: "broker", source: sandbox.name, action: "connect",
693
- kind: "network", target: d.target, verdict: "denied", owners: [], reason: d.reason, times: d.times });
694
- // The run made this directory, so the run removes it.
695
- cleanup();
696
-
697
- // Say what was dropped rather than only what was kept. A filter nobody can
698
- // see is indistinguishable from a monitor that is not working, and this one
699
- // drops the majority of what the kernel says on a busy run.
700
- // Only when something was actually recorded. A run where every denial was
701
- // filtered out has nothing to contextualise, and `0 recorded, 1 outside the
702
- // policy's paths` is a line that answers a question nobody asked — the
703
- // counts exist to explain a filter, not to announce that one ran.
704
- const { attributed, dropped } = denials.stats;
705
- // The one loss the filter above does not explain: the system log could not
706
- // keep up and threw lines away before seisin saw them. The boundary held;
707
- // the record did not. Said every time, and written down, because a denial
708
- // that never arrived files no request and nothing else would show it.
709
- if (dropped) {
710
- append(logPath(config.root), { event: "log-dropped", role, run: runLabel, ...trace, batches: dropped });
711
- err(`${C.yellow}seisin: the system log dropped ${dropped} batch(es) of messages during this run: ` +
712
- `kernel denials may be missing from the log and the queue. ` +
713
- `\`seisin check --system-log\` says what is flooding it.${C.off}\n`);
714
- }
715
- const { offPolicy, walks, expected, probes, atomic, browsers } = take.stats;
716
- // A browser that died at start on its Mach rendezvous: the crash the agent
717
- // saw does not say why, and this is the line that does.
718
- for (const b of browsers)
719
- err(`${C.yellow}seisin: ${role} started ${b} and the sandbox denied the Mach service it registers at start, so it ` +
720
- `exited. Add browsers = ["${b}"] to [roles.${role}] in seisin.toml (docs/browsers.md).${C.off}\n`);
721
- // A save refused at its temporary file, beside a file this role may write:
722
- // the file was not saved, and nothing was queued because there is nothing
723
- // to grant (the temporary name changes every time). Said whether or not
724
- // anything else was recorded, because the agent's error said neither.
725
- if (atomic.length) {
726
- const files = [...new Set(atomic)];
727
- err(`${C.yellow}seisin: ${files.length} file(s) ${role} may write were not saved: the save's temporary file beside ` +
728
- `${files.slice(0, 3).join(", ")}${files.length > 3 ? ` and ${files.length - 3} more` : ""} was denied ` +
729
- `(Claude Code's Edit and Write). \`seisin check ${role}\` says whether a grant can fix it.${C.off}\n`);
730
- }
731
- const recorded = attributed - offPolicy - walks;
732
- if (recorded > 0)
733
- err(`${C.dim}seisin: ${recorded} kernel denial(s) recorded` +
734
- `${walks ? `, ${walks} directory scan(s)` : ""}` +
735
- `${offPolicy ? `, ${offPolicy} outside the policy's paths` : ""}${C.off}\n` +
736
- // Claude Code's plugin updater, refused correctly: said as expected so
737
- // it does not read as the run failing (kinds.js expectedOf).
738
- (expected ? `${C.dim}seisin: ${expected} of them expected: ${EXPECTED.plugins}${C.off}\n` : "") +
739
- // The caller's own probes: denied as it asked, and nothing to grant.
740
- (probes ? `${C.dim}seisin: ${probes} of them expected by the caller (--expect-denied) — nothing queued${C.off}\n` : ""));
741
-
742
- // Only what this run added, with the numbers `grant` takes, and one line
743
- // for the rest. The whole queue after every run buried the new request
744
- // under the ones already seen; nothing new, nothing said.
745
- const queue = queueOf(config);
746
- const added = queue.map((q, i) => ({ ...q, n: i + 1 })).filter((q) => !before.has(q.key));
747
- if (added.length) err(renderAdded(added, queue.length - added.length));
748
- const failed = await notify.settle();
749
- if (failed.length) err(`${C.yellow}seisin: could not notify about a new request (${failed[0]})${C.off}\n`);
750
- // One more turn of the loop, so a signal that arrived with the child's exit
751
- // has run its handler before the status is decided.
752
- await new Promise((r) => setImmediate(r));
753
- const status = statusOf(code, signal);
754
- if (!outStream) return process.exit(status);
755
- // `finished()` and not `once("finish")`, because the event is usually over
756
- // before this line runs: `child.stdout.pipe(outStream)` ends the redactor
757
- // itself when the child's stdout closes, so the listener was attached to an
758
- // event that had already fired and the 2-second guard was the only way out.
759
- // Every run with a key paid it — measured 3.0 s against 1.2 s without keys.
760
- // `finished()` settles at once for a stream that is already done.
761
- const guard = setTimeout(() => process.exit(status), 2000);
762
- // A child's exit is not its pipes' EOF. Let pipe() end the redactors
763
- // after the remaining stdout/stderr arrives; ending them here lost that
764
- // output. The guard still bounds a descendant that keeps a pipe open.
765
- await Promise.all([finished(outStream), finished(errStream)]).catch(() => {});
766
- clearTimeout(guard);
767
- process.exit(status);
768
- });
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));
769
533
  // Not a `throw`: in a listener it is an uncaught exception — exit 1, a
770
534
  // stack trace, and none of the cleanup. Said the way cli.js says any other
771
535
  // failure, with its code; the `exit` handler above removes the run.
@@ -775,6 +539,269 @@ export async function run(config, argv) {
775
539
  });
776
540
  }
777
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
+
778
805
  /**
779
806
  * `--handoff <id>`: this run is the role a pending request was handed to.
780
807
  *
@@ -1,5 +1,5 @@
1
1
  /** `seisin walls <role>` — what that role keeps being denied, and what it cost. */
2
- import { unknownRole } from "../suggest.js";
2
+ import { unknownRole, positiveInt } from "../suggest.js";
3
3
  import { walls, wasted } from "../walls.js";
4
4
  import { STALE_RUNS } from "../requests.js";
5
5
  import { logPath } from "../log.js";
@@ -8,6 +8,9 @@ import { kindsOf } from "../kinds.js";
8
8
  import { parseSince } from "../views.js";
9
9
  import { C, out, safe } from "../render.js";
10
10
 
11
+ /** How many walls a terminal shows without --all. */
12
+ const SHOWN = 6;
13
+
11
14
  export function wallsCommand(config, argv = []) {
12
15
  const role = argv[0];
13
16
  if (!role) throw new Error("usage: seisin walls <role> [--since <date|duration>] [--min <n>] [--all]");
@@ -22,21 +25,25 @@ export function wallsCommand(config, argv = []) {
22
25
  throw new Error(`--since takes a date (2026-10-01) or a duration back from now (7d, 12h, 2w), not "${safe(at("--since", "") ?? "")}"`);
23
26
  const every = walls(config, role, {
24
27
  file: logPath(config.root),
25
- min: Number(at("--min", 2)),
28
+ min: positiveInt("--min", at("--min", "2")),
26
29
  since,
27
30
  limit: 0,
28
31
  });
29
32
  const all = argv.includes("--all");
30
- const list = (all ? every : every.filter((w) => !w.stale)).slice(0, 6);
33
+ // Six at a terminal unless --all, and the rest counted: a cut nobody is told
34
+ // about reads as the whole list. The exit status is over everything selected.
35
+ const selected = withKinds(config, all ? every : every.filter((w) => !w.stale));
36
+ const shown = all ? selected : selected.slice(0, SHOWN);
31
37
  const old = every.filter((w) => w.stale).length;
32
- const shown = withKinds(config, list);
33
38
  if (shown.length) out(renderForPerson(role, shown));
39
+ if (selected.length > shown.length)
40
+ out(` (${selected.length - shown.length} more wall(s): seisin walls ${safe(role)} --all)\n`);
34
41
  else if (!every.length)
35
42
  out(`\n ${C.dim}${safe(role)} has no repeated denials in the recorded runs. A path becomes a wall after ${at("--min", 2)} denials.${C.off}\n\n`);
36
43
  else if (!all)
37
44
  out(`\n ${C.dim}${safe(role)} has no recent walls; older ones are hidden. Use seisin walls ${safe(role)} --all.${C.off}\n\n`);
38
45
  if (old && !all) out(` (${old} older wall(s) not hit in the last ${STALE_RUNS}+ runs: seisin walls ${role} --all)\n`);
39
- return shown;
46
+ return selected;
40
47
  }
41
48
 
42
49
  /**