seisin 0.5.0 → 0.6.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (76) hide show
  1. package/README.md +385 -129
  2. package/package.json +13 -5
  3. package/src/browsers.js +52 -0
  4. package/src/canary.js +149 -0
  5. package/src/cli.js +47 -23
  6. package/src/commands/check.js +154 -3
  7. package/src/commands/explain.js +2 -5
  8. package/src/commands/hook.js +7 -3
  9. package/src/commands/init.js +57 -50
  10. package/src/commands/requests.js +31 -56
  11. package/src/commands/review.js +33 -6
  12. package/src/commands/run.js +332 -110
  13. package/src/commands/walls.js +39 -7
  14. package/src/commands/whose.js +2 -1
  15. package/src/config.js +321 -15
  16. package/src/controls.js +5 -5
  17. package/src/diagnose.js +67 -16
  18. package/src/env.js +3 -0
  19. package/src/grants.js +107 -2
  20. package/src/handoff.js +285 -10
  21. package/src/help.js +39 -19
  22. package/src/hook.js +59 -30
  23. package/src/index.d.ts +217 -0
  24. package/src/index.js +20 -12
  25. package/src/inspect/codex.js +64 -0
  26. package/src/inspect/credentials.js +48 -0
  27. package/src/inspect/enforce.js +24 -0
  28. package/src/inspect/files.js +71 -0
  29. package/src/inspect/home.js +43 -0
  30. package/src/inspect/inject.js +68 -0
  31. package/src/inspect/keys.js +74 -0
  32. package/src/inspect/layout.js +258 -0
  33. package/src/inspect/limits.js +98 -0
  34. package/src/inspect/machine.js +107 -0
  35. package/src/inspect/network.js +37 -0
  36. package/src/inspect/providers.js +71 -0
  37. package/src/inspect/read-territory.js +105 -0
  38. package/src/inspect/role-keys.js +136 -0
  39. package/src/inspect/shared.js +37 -0
  40. package/src/inspect/warnings.js +58 -0
  41. package/src/inspect.js +13 -815
  42. package/src/intake.js +131 -21
  43. package/src/keys.js +223 -67
  44. package/src/kinds.js +42 -0
  45. package/src/lines.js +26 -0
  46. package/src/log.js +7 -2
  47. package/src/mcp.js +28 -43
  48. package/src/nested.js +12 -3
  49. package/src/notify.js +53 -12
  50. package/src/owners.js +165 -3
  51. package/src/paths.js +17 -1
  52. package/src/provider-run.js +81 -0
  53. package/src/redact.js +34 -15
  54. package/src/render.js +35 -9
  55. package/src/requests.js +363 -45
  56. package/src/review.js +21 -9
  57. package/src/rundir.js +29 -8
  58. package/src/sandboxes/command.js +93 -0
  59. package/src/sandboxes/index.js +113 -0
  60. package/src/sandboxes/mach-profile.js +31 -0
  61. package/src/sandboxes/srt-mach.mjs +43 -0
  62. package/src/sandboxes/srt.js +163 -0
  63. package/src/sandboxes/tartaros.js +80 -0
  64. package/src/scan.js +7 -3
  65. package/src/serve.js +35 -17
  66. package/src/spool.js +8 -17
  67. package/src/srt.js +263 -33
  68. package/src/surface.js +129 -16
  69. package/src/territory.js +261 -0
  70. package/src/trace.js +33 -0
  71. package/src/uilink.js +15 -6
  72. package/src/views.js +40 -14
  73. package/src/violations.js +143 -7
  74. package/src/walls.js +62 -9
  75. package/ui/index.html +85 -13
  76. package/ui/favicon.svg +0 -1
@@ -10,25 +10,35 @@
10
10
  import { spawn } from "node:child_process";
11
11
  import { finished } from "node:stream/promises";
12
12
  import { constants as osConstants } from "node:os";
13
- import { mkdirSync, writeFileSync, existsSync, readFileSync } from "node:fs";
14
- import { join, dirname, delimiter } from "node:path";
15
- import { fileURLToPath } from "node:url";
16
- import { settingsFor, roleHome, loopbackVia } from "../srt.js";
13
+ import { mkdirSync, writeFileSync, existsSync, readFileSync, statSync } from "node:fs";
14
+ import { join, dirname, delimiter, resolve } from "node:path";
15
+ import { settingsFor, roleHome, loopbackVia, agentOf, AGENTS, AGENT_HOMES } from "../srt.js";
17
16
  import { buildEnv } from "../env.js";
17
+ import { traceFields } from "../trace.js";
18
18
  import { resolveKeys } from "../keys.js";
19
19
  import { nestedSandboxWarning } from "../nested.js";
20
- import { spool, SOCK_ENV } from "../spool.js";
20
+ import { spool, SOCK_ENV, FIFO_SUFFIX } from "../spool.js";
21
21
  import { openRun } from "../rundir.js";
22
22
  import { secretsOf, redactor } from "../redact.js";
23
23
  import { C, err } from "../render.js";
24
- import { pending, requestsPath, withDeclarers } from "../requests.js";
24
+ import { pending, requestsPath, queueOf, recordHandoff } from "../requests.js";
25
+ import { chainFrom, describeHandoff, MAX_DEPTH, CHAIN_ENV } from "../handoff.js";
25
26
  import { notifier } from "../notify.js";
26
27
  import { renderAdded } from "./requests.js";
27
28
  import { unknownRole } from "../suggest.js";
28
29
  import { watchDenials } from "../violations.js";
29
30
  import { intake } from "../intake.js";
31
+ import { EXPECTED } from "../kinds.js";
32
+ import { toolchainBins } from "../territory.js";
33
+ import { writePathsOf, realOrSelf, SOCKET_MAX } from "../grants.js";
34
+ import { runCanary, CANARY_EXIT } from "../canary.js";
35
+ import { append, logPath } from "../log.js";
36
+ import { flagsOf } from "../help.js";
37
+ import { WILD } from "../paths.js";
38
+ import { chooseSandbox, settingsOf, DEFAULT_SANDBOX } from "../sandboxes/index.js";
39
+ import { machOf } from "../browsers.js";
40
+ import { MACH_FILE } from "../sandboxes/mach-profile.js";
30
41
 
31
- const HERE = dirname(fileURLToPath(import.meta.url));
32
42
 
33
43
  /**
34
44
  * The ceiling on waiting for the kernel's last word when a run ends.
@@ -40,71 +50,27 @@ const HERE = dirname(fileURLToPath(import.meta.url));
40
50
  */
41
51
  const DRAIN_MS = 250;
42
52
 
43
- /**
44
- * The runtime seisin depends on first, one on PATH second, and nothing third.
45
- *
46
- * "The one seisin depends on" is found the way Node finds a dependency — up
47
- * from this file, through every `node_modules` — not at one spelled-out path.
48
- * `<seisin>/node_modules/.bin/srt` is only where it lands in a checkout. In a
49
- * project that installs seisin as a dependency, npm hoists the runtime to the
50
- * project's own `node_modules`, and that path does not exist. Measured with
51
- * 0.5.0 packed and installed into a project: `npx seisin` still worked, because
52
- * npx puts the project's `.bin` on PATH; `./node_modules/.bin/seisin` and
53
- * `node node_modules/seisin/src/cli.js` did not — they said "not found", or,
54
- * with a global srt 0.0.75 installed, ran under THAT one instead of the 0.0.78
55
- * this version pins, with none of its fixes.
56
- *
57
- * There is deliberately no fallback to running unsandboxed. A permission tool
58
- * that quietly becomes a no-op when its enforcer is missing is worse than one
59
- * that refuses, because you keep trusting it.
60
- */
61
- export function resolveSrt({ from = HERE, path = process.env.PATH } = {}) {
62
- const local = dependencySrt(from);
63
- if (local) return local;
64
-
65
- // Walk PATH rather than asking a shell. `spawnSync(..., { shell: true })`
66
- // prints a deprecation warning on every single run — Node's DEP0190 — which
67
- // is a line of noise in front of every user for the sake of finding one file.
68
- // It is also the concatenation hazard the warning is about.
69
- for (const dir of (path ?? "").split(delimiter)) {
70
- if (!dir) continue;
71
- const candidate = join(dir, "srt");
72
- if (existsSync(candidate)) return candidate;
73
- }
74
- return null;
75
- }
76
-
77
- const RUNTIME = "@anthropic-ai/sandbox-runtime";
53
+ // Moved with the rest of what is particular to sandbox-runtime (sandboxes/srt.js);
54
+ // exported from here still, where callers and the tests have always found them.
55
+ export { resolveSrt, dependencySrt, installHint } from "../sandboxes/srt.js";
78
56
 
79
57
  /**
80
- * The `srt` of the sandbox-runtime that resolves from `from`, or null.
81
- *
82
- * The `.bin/srt` npm linked beside the package when there is one — that is the
83
- * file npm made executable — and otherwise the package's own `bin` entry.
84
- * The package's `package.json` is found by walking up rather than through
85
- * `require.resolve`, which a package's `exports` map is free to refuse.
58
+ * Which agent this run is: `--agent <name>` when given, else read off the
59
+ * command. It decides whose home is scratch and whose sign-in is closed (see
60
+ * AGENT_HOMES in grants.js). `--agent none` says "no agent", for a run that
61
+ * must get neither home whatever its command looks like.
86
62
  */
87
- export function dependencySrt(from = HERE) {
88
- let dir = from;
89
- for (;;) {
90
- const nm = join(dir, "node_modules");
91
- const pkg = join(nm, RUNTIME, "package.json");
92
- if (existsSync(pkg)) {
93
- const linked = join(nm, ".bin", "srt");
94
- if (existsSync(linked)) return linked;
95
- try {
96
- const bin = JSON.parse(readFileSync(pkg, "utf8")).bin;
97
- const rel = typeof bin === "string" ? bin : bin?.srt;
98
- if (rel) {
99
- const target = join(nm, RUNTIME, rel);
100
- if (existsSync(target)) return target;
101
- }
102
- } catch { /* an unreadable package.json is not a runtime */ }
103
- }
104
- const up = dirname(dir);
105
- if (up === dir) return null;
106
- dir = up;
107
- }
63
+ export function agentFor(mine, cmd) {
64
+ const at = mine.lastIndexOf("--agent");
65
+ if (at === -1) return agentOf(cmd);
66
+ const given = mine[at + 1];
67
+ if (given === "none") return null;
68
+ if (!AGENTS.includes(given))
69
+ throw new Error(
70
+ `--agent ${given ?? ""}: seisin knows ${AGENTS.join(", ")} and none.\n` +
71
+ ` It decides whose home a run may write (${AGENTS.map((a) => AGENT_HOMES[a]).join(", ")}) ` +
72
+ `and whose sign-in it cannot read.`);
73
+ return given;
108
74
  }
109
75
 
110
76
  export async function run(config, argv) {
@@ -129,12 +95,14 @@ export async function run(config, argv) {
129
95
  // `--observe` as seisin's and put the run in observe mode; now that flag
130
96
  // belongs to `echo`, and observe needs `seisin run x --observe -- echo hi`.
131
97
  let i = 1;
132
- while (i < argv.length && argv[i].startsWith("-")) i++;
98
+ const values = flagsOf("run").values;
99
+ while (i < argv.length && argv[i].startsWith("-")) i += values.includes(argv[i]) ? 2 : 1;
133
100
  mine = argv.slice(1, i);
134
101
  cmd = argv.slice(i);
135
102
  }
136
103
  if (!role || cmd.length === 0) throw new Error("usage: seisin run <role> -- <command...>");
137
104
  if (!config.roles[role]) throw unknownRole(config, role);
105
+ const agent = agentFor(mine, cmd);
138
106
 
139
107
  /**
140
108
  * seisin does not run inside seisin, and says so here rather than later.
@@ -161,6 +129,9 @@ export async function run(config, argv) {
161
129
  ` above them — asked by the role that wants the work done, not run inside it.`,
162
130
  );
163
131
 
132
+ const handed = handoffOf(config, role, mine);
133
+ const expectDenied = expectedDenials(mine);
134
+
164
135
  /**
165
136
  * The audit spool: this process holds the log and the queue, and the hook
166
137
  * inside the sandbox gets a socket instead of a directory. That is the whole
@@ -173,7 +144,7 @@ export async function run(config, argv) {
173
144
  // Built before the first request can arrive; the POST leaves from here, the
174
145
  // parent, never from inside the sandbox (notify.js).
175
146
  const notify = notifier(config);
176
- // Everything that belongs to this run alone — socket, settings, scratch keys
147
+ // Everything that belongs to this run alone — socket, settings, run-file keys
177
148
  // — in one private directory, with one id. See rundir.js.
178
149
  const theRun = openRun();
179
150
  /**
@@ -182,7 +153,7 @@ export async function run(config, argv) {
182
153
  * The happy path closed the spool, the kernel watcher and the directory; the
183
154
  * failures did not. A key provider that failed, a spool that would not bind,
184
155
  * a runtime that would not start — each left the directory behind with its
185
- * socket and, past the key step, its `scratch` keys on disk until some later
156
+ * socket and, past the key step, its `run-file` keys on disk until some later
186
157
  * run swept it, and the `log stream` process running. An `exit` handler,
187
158
  * because every one of those ends in `process.exit`, from cli.js or from here,
188
159
  * and everything it has to do is synchronous. Idempotent: the normal exit
@@ -202,18 +173,72 @@ export async function run(config, argv) {
202
173
  // What was already waiting, so the end of the run can say what it added.
203
174
  const before = new Set(pending(requestsPath(config.root)).map((q) => q.key));
204
175
  const sockPath = theRun.sock;
205
- const settings = settingsFor(config, role, sockPath, observe);
176
+ // The program, as the role's PATH will find it: `read = "territory"` keeps
177
+ // that one file readable (territory.js). Same order run uses below — the
178
+ // declared toolchain first, then the parent's PATH.
179
+ const isDir = (d) => { try { return statSync(d).isDirectory(); } catch { return false; } };
180
+ const searchPath = config.read === "territory"
181
+ ? [...toolchainBins(config, config.roles[role], isDir), process.env.PATH ?? ""].join(delimiter)
182
+ : process.env.PATH;
183
+ const program = whichOn(cmd[0], searchPath);
184
+ /**
185
+ * The run's temp directory: one the role may write, never the runtime's.
186
+ *
187
+ * 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
189
+ * instead when set, so that is where the run's goes: the role's own tmp under
190
+ * `isolate = "home"`, else the parent's TMPDIR when the role may already write
191
+ * it, else one made for this run beside the runs root, granted to it alone. A caller's
192
+ * own CLAUDE_CODE_TMPDIR is kept only if the role may write it: an
193
+ * environment variable is not a way to widen the policy.
194
+ */
195
+ const granted = writePathsOf(config, config.roles[role], { observe, agent });
196
+ const writable = (p) => !!p && [p, realOrSelf(p)].some((q) =>
197
+ granted.some((g) => q === g || q === realOrSelf(g) || q.startsWith(g + "/") || q.startsWith(realOrSelf(g) + "/")));
198
+ const isolatedHome = config.isolate === "home" || config.isolate === true;
199
+ let runTmp = [process.env.CLAUDE_CODE_TMPDIR,
200
+ isolatedHome ? join(roleHome(config, role), "tmp") : process.env.TMPDIR].find(writable);
201
+ if (!runTmp) runTmp = theRun.tmp();
202
+ const generated = settingsFor(config, role, sockPath, observe, { agent, program, tmp: runTmp, forRun: true });
203
+
204
+ /**
205
+ * The sandbox this role runs under: the first one `[sandbox] provider` names
206
+ * that is installed and enforces every part of these settings
207
+ * (sandboxes/index.js). None is an error that names what each one lacked —
208
+ * never a run under one that enforces less. Chosen before the settings are
209
+ * written, because the file is the chosen sandbox's version of them
210
+ * (settingsOf: tartaros takes a browser's Mach names in a field of its own).
211
+ */
212
+ // `browsers`: the Mach names to register (browsers.js).
213
+ const { register: machRegister } = machOf(config.roles[role]);
214
+ const box = chooseSandbox(config, generated, { role });
215
+ if (box.error) throw box.error;
216
+ const { sandbox, bin } = box;
217
+ const settings = settingsOf(sandbox, generated, { machRegister });
206
218
  // Per run, never per role: `.seisin/<role>.json` was one file for every run
207
219
  // of that role, and two at once wrote each other's socket path into it.
208
220
  const file = theRun.writeSettings(settings);
209
221
 
210
222
  // What arrives from the hook and from the kernel, checked against the policy
211
223
  // this run started with and written with the run's id on it. See intake.js.
212
- const take = intake({ config, role, runId: theRun.id, observe, settings, notify });
224
+ // The launcher's W3C trace, read from the parent's environment once: every line
225
+ // this run writes carries it, so the log joins the launcher's trace (trace.js).
226
+ const trace = traceFields(process.env);
227
+ const take = intake({ config, role, runId: theRun.id, observe, settings, notify, chain: handed?.chain ?? null, expectDenied, trace });
228
+ // The channel is a unix socket on macOS, and a socket path is capped. Past
229
+ // the cap the listen dies on `EINVAL` and a path, which says nothing about
230
+ // TMPDIR — the one thing that decides how long it is.
231
+ if (!sockPath.endsWith(FIFO_SUFFIX) && Buffer.byteLength(sockPath) >= SOCKET_MAX)
232
+ throw new Error(
233
+ `TMPDIR is too long for this run's socket: ${Buffer.byteLength(sockPath)} bytes, and a unix socket ` +
234
+ `path is capped near ${SOCKET_MAX}.\n Run seisin with a shorter TMPDIR (TMPDIR=/tmp/<short> seisin run …).`);
213
235
  audit = await spool(take.fromHook, sockPath);
214
236
 
215
- const srt = resolveSrt();
216
- if (!srt) throw new Error(`sandbox runtime not found. ${installHint()}`);
237
+ // srt's settings have no field for the Mach names to register: beside them,
238
+ // where srt-mach.mjs looks for them.
239
+ if (machRegister.length && sandbox.name === "srt")
240
+ writeFileSync(join(dirname(file), MACH_FILE), JSON.stringify({ register: machRegister }), { mode: 0o600 });
241
+ const wrap = (inner) => sandbox.wrap(bin, file, settings, inner, { role, machRegister });
217
242
 
218
243
  // The hook runs inside the child and has to know which role it is. These are
219
244
  // the only variables seisin injects, and none carries a secret.
@@ -221,6 +246,22 @@ export async function run(config, argv) {
221
246
  env.SEISIN_ROLE = role;
222
247
  env.SEISIN_CONFIG = config.path;
223
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
+ }
224
265
 
225
266
  /**
226
267
  * Reference keys: resolved here, by the parent, before anything is spawned.
@@ -236,18 +277,44 @@ export async function run(config, argv) {
236
277
  * name, not a skipped key: a role that starts without a credential it
237
278
  * declared fails later, somewhere else, looking like something it is not.
238
279
  */
239
- const resolved = resolveKeys(config, config.roles[role]);
240
- const scratchKeys = theRun.keys;
241
- if (resolved.some((r) => r.mode === "scratch")) mkdirSync(scratchKeys, { recursive: true, mode: 0o700 });
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 });
242
297
  for (const { entry, mode, value } of resolved) {
243
298
  if (mode === "env") { env[entry.name] = value; continue; }
244
- // `scratch`: the value lands in the per-run directory this process already
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
245
312
  // makes for the spool and already removes on exit, so its lifetime is the
246
313
  // run's by construction rather than by a cleanup somebody has to remember.
247
314
  // The variable carries the PATH, which is the convention every tool that
248
315
  // wants a secret from a file already reads (`_FILE`), and it means the
249
316
  // value itself is not in the environment of a process that may print it.
250
- const file = join(scratchKeys, entry.name);
317
+ const file = join(runFileKeys, entry.name);
251
318
  writeFileSync(file, value, { mode: 0o600 });
252
319
  /**
253
320
  * Both names, because the world has two conventions and neither is ours.
@@ -262,7 +329,7 @@ export async function run(config, argv) {
262
329
  * the file was there and correct, and the variable it was announced under
263
330
  * was one kubectl has never heard of.
264
331
  *
265
- * Setting both costs nothing and leaks nothing: in `scratch` mode the
332
+ * Setting both costs nothing and leaks nothing: in `run-file` mode the
266
333
  * value is deliberately not in the environment, so both variables hold a
267
334
  * path and neither holds a secret.
268
335
  */
@@ -296,7 +363,17 @@ export async function run(config, argv) {
296
363
  env.XDG_STATE_HOME = join(home, ".local", "state");
297
364
  env.XDG_CACHE_HOME = join(home, ".cache");
298
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");
299
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;
300
377
  if (observe) env.SEISIN_OBSERVE = "1";
301
378
  if (mine.includes("--debug-env")) err(`${C.dim}seisin: dropped ${dropped.join(" ")}${C.off}\n`);
302
379
 
@@ -321,7 +398,8 @@ export async function run(config, argv) {
321
398
  err(
322
399
  `${C.dim}seisin: ${role} · writes ${territory}${scratch > 0 ? ` (+${scratch} scratch)` : ""} · ` +
323
400
  `keys ${r.keys.length ? r.keys.join(" ") : "none"} · ` +
324
- `env ${Object.keys(env).length} kept, ${dropped.length} dropped` +
401
+ `${agent ? `runs ${agent}` : "runs no agent seisin knows"} · ` +
402
+ `env ${Object.keys(env).length} kept, ${dropped.length} dropped · read ${config.read}` +
325
403
  `${observe ? ` · ${C.yellow}OBSERVING — the repo is writable, the network is NOT${C.off}${C.dim}` : ""}${C.off}\n`
326
404
  );
327
405
 
@@ -381,8 +459,73 @@ export async function run(config, argv) {
381
459
  // one request per role, and a denial logged against the role that owns the
382
460
  // path (measured: 3 lines and 3 requests for one write by one of 3 roles).
383
461
  // The nonce makes each run's command unique, so the tag names one run.
384
- const boxed = ["env", `SEISIN_RUN_ID=${theRun.id}`, ...loopbackVia(config.roles[role], cmd)];
385
- denials = watchDenials(take.fromKernel, { argv: boxed });
462
+ /**
463
+ * Linux, `read = "territory"`: the runtime's own temp directory moves into
464
+ * this run's, and the agent gets its TMPDIR back.
465
+ *
466
+ * sandbox-runtime makes the sockets its in-sandbox bridge reaches the proxy
467
+ * through in `os.tmpdir()` — of the runtime's process. With /tmp a data root
468
+ * and denied, the bridge found nothing and the whole network died in
469
+ * silence: every allowed host and `local_ports` refused like the rest
470
+ * (measured in Docker, Debian 12, bwrap 0.8.0). The run's directory is
471
+ * already readable from inside, so the sockets go there; the `env` the
472
+ * command runs through puts the agent's TMPDIR back as it was.
473
+ */
474
+ const agentTmp = env.TMPDIR;
475
+ const restoreTmp = [];
476
+ if (sandbox.name === "srt" && process.platform === "linux" && config.read === "territory") {
477
+ const srtTmp = join(theRun.dir, "srt");
478
+ mkdirSync(srtTmp, { recursive: true, mode: 0o700 });
479
+ env.TMPDIR = srtTmp;
480
+ restoreTmp.push(...(agentTmp ? [`TMPDIR=${agentTmp}`] : ["-u", "TMPDIR"]));
481
+ }
482
+
483
+ /**
484
+ * The canary, before anything of the agent's starts — and before the kernel
485
+ * watcher below, so its own denials are not recorded as the agent's. See
486
+ * canary.js. A failure stops the run with its own exit code and a line in
487
+ * the log; there is no flag to skip it.
488
+ */
489
+ const probe = await runCanary({
490
+ wrap, settingsFile: file, settings, env, cwd: process.cwd(),
491
+ runDir: theRun.dir, runsRoot: theRun.root, role: config.roles[role],
492
+ granted: writePathsOf(config, config.roles[role], { observe, agent }),
493
+ });
494
+ if (!probe.ok) {
495
+ append(logPath(config.root), {
496
+ event: "canary", role, run: theRun.id.slice(0, 8), ...trace, agent: agent ?? null, confined: false, reasons: probe.failed,
497
+ ...(sandbox.name === DEFAULT_SANDBOX ? {} : { sandbox: sandbox.name }),
498
+ });
499
+ err(`${C.red}seisin: ${role} was NOT started — the sandbox failed its canary:${C.off}\n` +
500
+ probe.failed.map((f) => ` - ${f}\n`).join("") +
501
+ ` The policy as generated does not hold on this machine, so no agent runs under it. ` +
502
+ `This is recorded in the log (confined=false). Exit 86 means the boundary did not hold; ` +
503
+ `run seisin check, fix the reported policy or machine setup, and try again. There is no flag to skip it.\n`);
504
+ process.exit(CANARY_EXIT);
505
+ }
506
+ append(logPath(config.root), {
507
+ event: "canary", role, run: theRun.id.slice(0, 8), ...trace, agent: agent ?? null, confined: true, reasons: [],
508
+ ...(sandbox.name === DEFAULT_SANDBOX ? {} : { sandbox: sandbox.name }),
509
+ });
510
+ // Said once, only when it is not the default: which sandbox holds this run
511
+ // is a fact a reader of the terminal would otherwise have to guess.
512
+ if (sandbox.name !== DEFAULT_SANDBOX) err(`${C.dim}seisin: ${role} runs under ${sandbox.name}${C.off}\n`);
513
+ // Said at the start, not discovered from a log that stayed empty.
514
+ if (!sandbox.tagsDenials && process.platform === "darwin")
515
+ err(`${C.yellow}seisin: ${sandbox.name} does not tag its denials, so none of the kernel's denials in this run are recorded ` +
516
+ `(tags_denials in docs/sandboxes.md)${C.off}\n`);
517
+ // Recorded once the receiver is confined and about to start, not before: a
518
+ // run that failed its canary did not take the work.
519
+ if (handed) {
520
+ recordHandoff(requestsPath(config.root), handed.request.key, handed.request.handoff);
521
+ err(`${C.dim}seisin: ${role} took ${handed.request.key} — handoff chain ${handed.chain.visited.join(" → ")}, ` +
522
+ `${handed.chain.depth} of ${MAX_DEPTH}${C.off}\n`);
523
+ }
524
+
525
+ const boxed = ["env", ...restoreTmp, `SEISIN_RUN_ID=${theRun.id}`, ...loopbackVia(config.roles[role], cmd, sandbox.name)];
526
+ // The tag is sandbox-runtime's: under any other sandbox there is none to
527
+ // match, and attribution goes by the process tree alone (violations.js).
528
+ denials = watchDenials(take.fromKernel, { argv: sandbox.tagsDenials ? boxed : null });
386
529
 
387
530
  // Deliberately NOT announced here. On Linux this branch is taken every time,
388
531
  // so saying it per run puts a line the reader cannot act on in front of every
@@ -401,7 +544,8 @@ export async function run(config, argv) {
401
544
  const nested = nestedSandboxWarning(cmd);
402
545
  if (nested) err(`${C.yellow}seisin: ${nested}${C.off}\n`);
403
546
 
404
- const child = spawn(srt, ["--settings", file, "--", ...boxed], {
547
+ const [runner, ...runnerArgs] = wrap(boxed);
548
+ const child = spawn(runner, runnerArgs, {
405
549
  stdio: outStream ? ["inherit", "pipe", "pipe"] : "inherit",
406
550
  env,
407
551
  });
@@ -490,19 +634,48 @@ export async function run(config, argv) {
490
634
  // filtered out has nothing to contextualise, and `0 recorded, 1 outside the
491
635
  // policy's paths` is a line that answers a question nobody asked — the
492
636
  // counts exist to explain a filter, not to announce that one ran.
493
- const { attributed, foreign } = denials.stats;
494
- const { offPolicy, walks } = take.stats;
637
+ const { attributed, dropped } = denials.stats;
638
+ // The one loss the filter above does not explain: the system log could not
639
+ // keep up and threw lines away before seisin saw them. The boundary held;
640
+ // the record did not. Said every time, and written down, because a denial
641
+ // that never arrived files no request and nothing else would show it.
642
+ if (dropped) {
643
+ append(logPath(config.root), { event: "log-dropped", role, run: theRun.id.slice(0, 8), ...trace, batches: dropped });
644
+ err(`${C.yellow}seisin: the system log dropped ${dropped} batch(es) of messages during this run: ` +
645
+ `kernel denials may be missing from the log and the queue. ` +
646
+ `\`seisin check --system-log\` says what is flooding it.${C.off}\n`);
647
+ }
648
+ const { offPolicy, walks, expected, probes, atomic, browsers } = take.stats;
649
+ // A browser that died at start on its Mach rendezvous: the crash the agent
650
+ // saw does not say why, and this is the line that does.
651
+ for (const b of browsers)
652
+ err(`${C.yellow}seisin: ${role} started ${b} and the sandbox denied the Mach service it registers at start, so it ` +
653
+ `exited. Add browsers = ["${b}"] to [roles.${role}] in seisin.toml (docs/browsers.md).${C.off}\n`);
654
+ // A save refused at its temporary file, beside a file this role may write:
655
+ // the file was not saved, and nothing was queued because there is nothing
656
+ // to grant (the temporary name changes every time). Said whether or not
657
+ // anything else was recorded, because the agent's error said neither.
658
+ if (atomic.length) {
659
+ const files = [...new Set(atomic)];
660
+ err(`${C.yellow}seisin: ${files.length} file(s) ${role} may write were not saved: the save's temporary file beside ` +
661
+ `${files.slice(0, 3).join(", ")}${files.length > 3 ? ` and ${files.length - 3} more` : ""} was denied ` +
662
+ `(Claude Code's Edit and Write). \`seisin check ${role}\` says whether a grant can fix it.${C.off}\n`);
663
+ }
495
664
  const recorded = attributed - offPolicy - walks;
496
665
  if (recorded > 0)
497
666
  err(`${C.dim}seisin: ${recorded} kernel denial(s) recorded` +
498
667
  `${walks ? `, ${walks} directory scan(s)` : ""}` +
499
- `${offPolicy ? `, ${offPolicy} outside the policy's paths` : ""}` +
500
- `${foreign ? `, ${foreign} from other sandboxes` : ""}${C.off}\n`);
668
+ `${offPolicy ? `, ${offPolicy} outside the policy's paths` : ""}${C.off}\n` +
669
+ // Claude Code's plugin updater, refused correctly: said as expected so
670
+ // it does not read as the run failing (kinds.js expectedOf).
671
+ (expected ? `${C.dim}seisin: ${expected} of them expected: ${EXPECTED.plugins}${C.off}\n` : "") +
672
+ // The caller's own probes: denied as it asked, and nothing to grant.
673
+ (probes ? `${C.dim}seisin: ${probes} of them expected by the caller (--expect-denied) — nothing queued${C.off}\n` : ""));
501
674
 
502
675
  // Only what this run added, with the numbers `grant` takes, and one line
503
676
  // for the rest. The whole queue after every run buried the new request
504
677
  // under the ones already seen; nothing new, nothing said.
505
- const queue = withDeclarers(config, pending(requestsPath(config.root), { keyDirs: config.keyDirs }));
678
+ const queue = queueOf(config);
506
679
  const added = queue.map((q, i) => ({ ...q, n: i + 1 })).filter((q) => !before.has(q.key));
507
680
  if (added.length) err(renderAdded(added, queue.length - added.length));
508
681
  const failed = await notify.settle();
@@ -519,8 +692,9 @@ export async function run(config, argv) {
519
692
  // Every run with a key paid it — measured 3.0 s against 1.2 s without keys.
520
693
  // `finished()` settles at once for a stream that is already done.
521
694
  const guard = setTimeout(() => process.exit(status), 2000);
522
- outStream.end();
523
- errStream.end();
695
+ // A child's exit is not its pipes' EOF. Let pipe() end the redactors
696
+ // after the remaining stdout/stderr arrives; ending them here lost that
697
+ // output. The guard still bounds a descendant that keeps a pipe open.
524
698
  await Promise.all([finished(outStream), finished(errStream)]).catch(() => {});
525
699
  clearTimeout(guard);
526
700
  process.exit(status);
@@ -534,26 +708,74 @@ export async function run(config, argv) {
534
708
  });
535
709
  }
536
710
 
537
- /** Is `name` a program the role can start with this PATH? */
538
- function onPath(name, path) {
539
- if (!name) return false;
540
- if (name.includes("/")) return existsSync(name);
541
- return (path ?? "").split(delimiter).some((d) => d && existsSync(join(d, name)));
711
+ /**
712
+ * `--handoff <id>`: this run is the role a pending request was handed to.
713
+ *
714
+ * The flag names a request, never a role. Which role takes it is the decision
715
+ * handoff.js makes from the policy as it stands, and a run of any other role
716
+ * is refused — so whatever starts roles can act on `handoff.role` but cannot
717
+ * substitute its own choice, and a model upstream of it choosing "backend"
718
+ * changes nothing. What the run carries forward is the chain the decision
719
+ * extends; every request it files is recorded in that chain, which is how a
720
+ * cycle back to a role the work came from, or a chain past its depth, is
721
+ * caught instead of routed.
722
+ */
723
+ export function handoffOf(config, role, mine) {
724
+ const at = mine.indexOf("--handoff");
725
+ if (at === -1) return null;
726
+ // Said before the request is looked up: whatever the id, this role is not
727
+ // one the policy hands work to, and that is the answer.
728
+ if (config.roles[role]?.handoff === false)
729
+ throw new Error(`${role} does not take handed work (handoff = false in its role): no request is handed to it. Nothing was started.`);
730
+ const id = mine[at + 1];
731
+ if (!id || id.startsWith("-")) throw new Error("usage: seisin run <role> --handoff <id> -- <command...> (the id from seisin requests)");
732
+ const request = queueOf(config).find((r) => r.key === id);
733
+ if (!request) throw new Error(`no pending request has the id "${id}" — seisin requests lists them`);
734
+ const d = request.handoff;
735
+ if (d.type !== "route")
736
+ throw new Error(`${id} is not handed to anybody: ${describeHandoff(d, request.attempt)}. Nothing was started.`);
737
+ if (d.role !== role)
738
+ throw new Error(`${id} is handed to ${d.role}, not ${role}: the policy decides who takes it. Nothing was started.`);
739
+ return { request, chain: chainFrom(d) };
542
740
  }
543
741
 
544
742
  /**
545
- * How to put the runtime back, for the way this seisin was installed.
743
+ * `--expect-denied <path>`, as many times as given: the paths whose denial the
744
+ * caller is waiting for (intake.js). Read from this process's argv and nowhere
745
+ * else — not the environment, not a file in the repo — because everything
746
+ * else is something the sandboxed side could reach, and a list it could add
747
+ * to would be a way to keep its own denials out of the queue.
546
748
  *
547
- * It is a dependency, so it is missing only from a broken install — and the
548
- * fix differs: a project's own node_modules, a global install, or a checkout.
549
- * `npm i -g` was suggested to everyone, which for a project-local seisin
550
- * installs a second runtime somewhere seisin does not look first.
749
+ * A path, exactly. A glob is refused rather than read literally: `*` meaning
750
+ * "this one file" is not what anyone who typed it meant, and meaning "every
751
+ * file" would make a probe a way to silence a whole directory.
551
752
  */
552
- export function installHint(from = HERE) {
553
- const at = from.split("/node_modules/seisin/")[0];
554
- if (at === from) return `Install it in this checkout with: npm install (in ${dirname(dirname(from))})`;
555
- // npm's global layout is <prefix>/lib/node_modules on unix, …/npm/node_modules on Windows.
556
- if (/\/lib$/.test(at) || /[\\/]npm$/.test(at))
557
- return "seisin is installed globally; reinstall it with: npm install -g seisin";
558
- return `seisin is installed in ${at}; reinstall it there with: npm install seisin`;
753
+ export function expectedDenials(mine) {
754
+ const out = [];
755
+ for (let i = 0; i < mine.length; i++) {
756
+ if (mine[i] !== "--expect-denied") continue;
757
+ const p = mine[++i];
758
+ if (!p || p.startsWith("-") || !p.trim())
759
+ throw new Error("usage: seisin run <role> --expect-denied <path> -- <command...> (a path, relative to the repo or absolute)");
760
+ if (WILD.test(p))
761
+ throw new Error(`--expect-denied ${p}: a path, not a pattern. Only a denial of exactly that path is expected; ` +
762
+ `name each one with its own --expect-denied.`);
763
+ out.push(p);
764
+ }
765
+ return out;
766
+ }
767
+
768
+ /** Is `name` a program the role can start with this PATH? */
769
+ /** Where `name` resolves on `path`, absolute, or null. */
770
+ function whichOn(name, path) {
771
+ if (!name) return null;
772
+ if (name.includes("/")) return existsSync(name) ? resolve(name) : null;
773
+ for (const d of (path ?? "").split(delimiter)) if (d && existsSync(join(d, name))) return join(d, name);
774
+ return null;
775
+ }
776
+
777
+ function onPath(name, path) {
778
+ if (!name) return false;
779
+ if (name.includes("/")) return existsSync(name);
780
+ return (path ?? "").split(delimiter).some((d) => d && existsSync(join(d, name)));
559
781
  }