seisin 0.4.2 → 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 (82) hide show
  1. package/README.md +506 -162
  2. package/package.json +14 -6
  3. package/src/browsers.js +52 -0
  4. package/src/canary.js +149 -0
  5. package/src/cli.js +175 -52
  6. package/src/commands/check.js +157 -5
  7. package/src/commands/explain.js +31 -13
  8. package/src/commands/hook.js +20 -6
  9. package/src/commands/init.js +183 -88
  10. package/src/commands/log.js +48 -9
  11. package/src/commands/requests.js +122 -70
  12. package/src/commands/review.js +73 -21
  13. package/src/commands/run.js +422 -78
  14. package/src/commands/scan.js +18 -3
  15. package/src/commands/ui.js +18 -5
  16. package/src/commands/walls.js +62 -11
  17. package/src/commands/whose.js +9 -7
  18. package/src/commands/wire.js +156 -17
  19. package/src/config.js +505 -37
  20. package/src/controls.js +244 -0
  21. package/src/diagnose.js +102 -49
  22. package/src/env.js +14 -8
  23. package/src/grants.js +122 -9
  24. package/src/handoff.js +285 -10
  25. package/src/help.js +179 -0
  26. package/src/hook.js +101 -42
  27. package/src/index.d.ts +217 -0
  28. package/src/index.js +24 -16
  29. package/src/inspect/codex.js +64 -0
  30. package/src/inspect/credentials.js +48 -0
  31. package/src/inspect/enforce.js +24 -0
  32. package/src/inspect/files.js +71 -0
  33. package/src/inspect/home.js +43 -0
  34. package/src/inspect/inject.js +68 -0
  35. package/src/inspect/keys.js +74 -0
  36. package/src/inspect/layout.js +258 -0
  37. package/src/inspect/limits.js +98 -0
  38. package/src/inspect/machine.js +107 -0
  39. package/src/inspect/network.js +37 -0
  40. package/src/inspect/providers.js +71 -0
  41. package/src/inspect/read-territory.js +105 -0
  42. package/src/inspect/role-keys.js +136 -0
  43. package/src/inspect/shared.js +37 -0
  44. package/src/inspect/warnings.js +58 -0
  45. package/src/inspect.js +20 -756
  46. package/src/intake.js +150 -35
  47. package/src/keys.js +231 -73
  48. package/src/kinds.js +249 -0
  49. package/src/lines.js +26 -0
  50. package/src/log.js +242 -52
  51. package/src/mcp.js +57 -58
  52. package/src/nested.js +12 -3
  53. package/src/notify.js +66 -20
  54. package/src/owners.js +358 -39
  55. package/src/paths.js +54 -2
  56. package/src/provider-run.js +81 -0
  57. package/src/redact.js +46 -13
  58. package/src/render.js +101 -17
  59. package/src/requests.js +506 -87
  60. package/src/review.js +50 -25
  61. package/src/rundir.js +33 -14
  62. package/src/sandboxes/command.js +93 -0
  63. package/src/sandboxes/index.js +113 -0
  64. package/src/sandboxes/mach-profile.js +31 -0
  65. package/src/sandboxes/srt-mach.mjs +43 -0
  66. package/src/sandboxes/srt.js +163 -0
  67. package/src/sandboxes/tartaros.js +80 -0
  68. package/src/scan.js +181 -19
  69. package/src/serve.js +396 -230
  70. package/src/spool.js +16 -45
  71. package/src/srt.js +296 -49
  72. package/src/suggest.js +58 -0
  73. package/src/surface.js +314 -54
  74. package/src/territory.js +261 -0
  75. package/src/trace.js +33 -0
  76. package/src/uilink.js +24 -9
  77. package/src/views.js +315 -0
  78. package/src/violations.js +247 -23
  79. package/src/walls.js +76 -46
  80. package/src/worktree.js +3 -11
  81. package/ui/index.html +1429 -538
  82. 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, markStale } 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
- import { logPath, read } from "../log.js";
27
- import { renderQueue } from "./requests.js";
27
+ import { renderAdded } from "./requests.js";
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,30 +50,29 @@ const HERE = dirname(fileURLToPath(import.meta.url));
40
50
  */
41
51
  const DRAIN_MS = 250;
42
52
 
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";
56
+
43
57
  /**
44
- * The bundled runtime first, a global one second, and nothing third.
45
- *
46
- * There is deliberately no fallback to running unsandboxed. A permission tool
47
- * that quietly becomes a no-op when its enforcer is missing is worse than one
48
- * that refuses, because you keep trusting it.
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.
49
62
  */
50
- export function resolveSrt() {
51
- const bundled = join(HERE, "..", "..", "node_modules", ".bin", "srt");
52
- if (existsSync(bundled)) return bundled;
53
-
54
- // Walk PATH rather than asking a shell. `spawnSync(..., { shell: true })`
55
- // prints a deprecation warning on every single run — Node's DEP0190 — which
56
- // is a line of noise in front of every user for the sake of finding one file.
57
- // It is also the concatenation hazard the warning is about.
58
- for (const dir of (process.env.PATH ?? "").split(delimiter)) {
59
- if (!dir) continue;
60
- const candidate = join(dir, "srt");
61
- if (existsSync(candidate)) return candidate;
62
- }
63
- return null;
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;
64
74
  }
65
75
 
66
-
67
76
  export async function run(config, argv) {
68
77
  /**
69
78
  * Ours before the `--`, theirs after it.
@@ -86,13 +95,14 @@ export async function run(config, argv) {
86
95
  // `--observe` as seisin's and put the run in observe mode; now that flag
87
96
  // belongs to `echo`, and observe needs `seisin run x --observe -- echo hi`.
88
97
  let i = 1;
89
- 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;
90
100
  mine = argv.slice(1, i);
91
101
  cmd = argv.slice(i);
92
102
  }
93
103
  if (!role || cmd.length === 0) throw new Error("usage: seisin run <role> -- <command...>");
94
- if (!config.roles[role])
95
- throw new Error(`unknown role "${role}". Known: ${Object.keys(config.roles).join(", ")}`);
104
+ if (!config.roles[role]) throw unknownRole(config, role);
105
+ const agent = agentFor(mine, cmd);
96
106
 
97
107
  /**
98
108
  * seisin does not run inside seisin, and says so here rather than later.
@@ -101,27 +111,30 @@ export async function run(config, argv) {
101
111
  * than the outer one: the inner run dies in the runtime with rc=13 and a Node
102
112
  * warning about an unsettled await — no role named, no boundary named, and it
103
113
  * reads as seisin crashing rather than as a refusal. Before that it failed
104
- * even earlier, on a $TMPDIR the outer box had already reshaped.
114
+ * even earlier, on a $TMPDIR the outer sandbox had already reshaped.
105
115
  *
106
116
  * This is the refusal, not a fix, because the shape it is usually reached for
107
- * is wrong anyway: a dispatcher that starts roles from inside one role's box
108
- * makes every worker a descendant of the box that should hold the least. Such
117
+ * is wrong anyway: a dispatcher that starts roles from inside one role's sandbox
118
+ * makes every worker a descendant of the sandbox that should hold the least. Such
109
119
  * a dispatcher belongs above the roles and gets asked, rather than running
110
120
  * inside one of them.
111
121
  */
112
122
  const outer = process.env.SEISIN_ROLE;
113
123
  if (outer)
114
124
  throw new Error(
115
- `already inside the box as "${outer}" — seisin does not nest.\n` +
125
+ `already inside a seisin sandbox as "${outer}" — seisin does not nest.\n` +
116
126
  ` Starting "${role}" from in here does not give it its own territory: the run dies in the\n` +
117
- ` sandbox runtime, and what territory a second box would even hold is undefined.\n` +
127
+ ` sandbox runtime, and what territory a second sandbox would even hold is undefined.\n` +
118
128
  ` Run roles as siblings from outside instead. Something that needs to start roles belongs\n` +
119
129
  ` above them — asked by the role that wants the work done, not run inside it.`,
120
130
  );
121
131
 
132
+ const handed = handoffOf(config, role, mine);
133
+ const expectDenied = expectedDenials(mine);
134
+
122
135
  /**
123
136
  * The audit spool: this process holds the log and the queue, and the hook
124
- * inside the box gets a socket instead of a directory. That is the whole
137
+ * inside the sandbox gets a socket instead of a directory. That is the whole
125
138
  * reason `.seisin/` is no longer in anybody's allowWrite — see spool.js.
126
139
  *
127
140
  * Entries arrive with their own `at` already set by the sender, so what lands
@@ -129,24 +142,103 @@ export async function run(config, argv) {
129
142
  */
130
143
  const observe = mine.includes("--observe");
131
144
  // Built before the first request can arrive; the POST leaves from here, the
132
- // parent, never from inside the box (notify.js).
145
+ // parent, never from inside the sandbox (notify.js).
133
146
  const notify = notifier(config);
134
- // Everything that belongs to this run alone — socket, settings, scratch keys
147
+ // Everything that belongs to this run alone — socket, settings, run-file keys
135
148
  // — in one private directory, with one id. See rundir.js.
136
149
  const theRun = openRun();
150
+ /**
151
+ * From here on, every way out removes what this run made.
152
+ *
153
+ * The happy path closed the spool, the kernel watcher and the directory; the
154
+ * failures did not. A key provider that failed, a spool that would not bind,
155
+ * a runtime that would not start — each left the directory behind with its
156
+ * socket and, past the key step, its `run-file` keys on disk until some later
157
+ * run swept it, and the `log stream` process running. An `exit` handler,
158
+ * because every one of those ends in `process.exit`, from cli.js or from here,
159
+ * and everything it has to do is synchronous. Idempotent: the normal exit
160
+ * below runs it first and the handler finds nothing left.
161
+ */
162
+ let audit = null;
163
+ let denials = null;
164
+ let closed = false;
165
+ const cleanup = () => {
166
+ if (closed) return;
167
+ closed = true;
168
+ try { audit?.close(); } catch {}
169
+ try { denials?.close(); } catch {}
170
+ theRun.close();
171
+ };
172
+ process.once("exit", cleanup);
173
+ // What was already waiting, so the end of the run can say what it added.
174
+ const before = new Set(pending(requestsPath(config.root)).map((q) => q.key));
137
175
  const sockPath = theRun.sock;
138
- 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 });
139
218
  // Per run, never per role: `.seisin/<role>.json` was one file for every run
140
219
  // of that role, and two at once wrote each other's socket path into it.
141
220
  const file = theRun.writeSettings(settings);
142
221
 
143
222
  // What arrives from the hook and from the kernel, checked against the policy
144
223
  // this run started with and written with the run's id on it. See intake.js.
145
- const take = intake({ config, role, runId: theRun.id, observe, settings, notify });
146
- const audit = await spool(take.fromHook, sockPath);
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 …).`);
235
+ audit = await spool(take.fromHook, sockPath);
147
236
 
148
- const srt = resolveSrt();
149
- if (!srt) throw new Error("sandbox runtime not found. Install it with: npm i -g @anthropic-ai/sandbox-runtime");
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 });
150
242
 
151
243
  // The hook runs inside the child and has to know which role it is. These are
152
244
  // the only variables seisin injects, and none carries a secret.
@@ -154,13 +246,29 @@ export async function run(config, argv) {
154
246
  env.SEISIN_ROLE = role;
155
247
  env.SEISIN_CONFIG = config.path;
156
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
+ }
157
265
 
158
266
  /**
159
267
  * Reference keys: resolved here, by the parent, before anything is spawned.
160
268
  *
161
269
  * The ordering is the security property, not a convenience. The provider
162
270
  * command holds the vault's own credential — the keychain prompt, the
163
- * 1Password session — and running it inside the box would mean putting that
271
+ * 1Password session — and running it inside the sandbox would mean putting that
164
272
  * credential in there too, which is the thing this feature exists to avoid.
165
273
  * The confined side cannot reach the parent; the parent can reach the vault;
166
274
  * so the parent resolves and hands over the result.
@@ -169,18 +277,44 @@ export async function run(config, argv) {
169
277
  * name, not a skipped key: a role that starts without a credential it
170
278
  * declared fails later, somewhere else, looking like something it is not.
171
279
  */
172
- const resolved = resolveKeys(config, config.roles[role]);
173
- const scratchKeys = theRun.keys;
174
- 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 });
175
297
  for (const { entry, mode, value } of resolved) {
176
298
  if (mode === "env") { env[entry.name] = value; continue; }
177
- // `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
178
312
  // makes for the spool and already removes on exit, so its lifetime is the
179
- // turn's by construction rather than by a cleanup somebody has to remember.
313
+ // run's by construction rather than by a cleanup somebody has to remember.
180
314
  // The variable carries the PATH, which is the convention every tool that
181
315
  // wants a secret from a file already reads (`_FILE`), and it means the
182
316
  // value itself is not in the environment of a process that may print it.
183
- const file = join(scratchKeys, entry.name);
317
+ const file = join(runFileKeys, entry.name);
184
318
  writeFileSync(file, value, { mode: 0o600 });
185
319
  /**
186
320
  * Both names, because the world has two conventions and neither is ours.
@@ -195,7 +329,7 @@ export async function run(config, argv) {
195
329
  * the file was there and correct, and the variable it was announced under
196
330
  * was one kubectl has never heard of.
197
331
  *
198
- * Setting both costs nothing and leaks nothing: in `scratch` mode the
332
+ * Setting both costs nothing and leaks nothing: in `run-file` mode the
199
333
  * value is deliberately not in the environment, so both variables hold a
200
334
  * path and neither holds a secret.
201
335
  */
@@ -229,14 +363,43 @@ export async function run(config, argv) {
229
363
  env.XDG_STATE_HOME = join(home, ".local", "state");
230
364
  env.XDG_CACHE_HOME = join(home, ".cache");
231
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");
232
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;
233
377
  if (observe) env.SEISIN_OBSERVE = "1";
234
378
  if (mine.includes("--debug-env")) err(`${C.dim}seisin: dropped ${dropped.join(" ")}${C.off}\n`);
235
379
 
380
+ /**
381
+ * A command the role cannot find, said as that — before the sandbox starts.
382
+ *
383
+ * It used to reach the runtime, which runs it through `env`, and the only
384
+ * word on screen was `env: nosuchcmd: No such file or directory`: seisin's
385
+ * own wrapper named as the thing that failed. 127, the shell's code for it.
386
+ */
387
+ if (!onPath(cmd[0], env.PATH)) {
388
+ const e = new Error(`"${cmd[0]}" not found on the role's PATH`);
389
+ e.exitCode = 127;
390
+ throw e;
391
+ }
392
+
393
+ // What the role gets, in the policy's own words: `writes 8 path(s)` counted
394
+ // the runtime's scratch directories as territory and named none of it.
395
+ const r = config.roles[role];
396
+ const territory = observe ? "the whole repo" : r.writes.length ? r.writes.join(" ") : "nothing";
397
+ const scratch = settings.filesystem.allowWrite.length - (observe ? 1 : r.writes.length);
236
398
  err(
237
- `${C.dim}seisin: ${role} · writes ${settings.filesystem.allowWrite.length} path(s) · ` +
238
- `reads ${settings.filesystem.allowRead.length} key(s) · ` +
239
- `env ${Object.keys(env).length} kept, ${dropped.length} dropped` +
399
+ `${C.dim}seisin: ${role} · writes ${territory}${scratch > 0 ? ` (+${scratch} scratch)` : ""} · ` +
400
+ `keys ${r.keys.length ? r.keys.join(" ") : "none"} · ` +
401
+ `${agent ? `runs ${agent}` : "runs no agent seisin knows"} · ` +
402
+ `env ${Object.keys(env).length} kept, ${dropped.length} dropped · read ${config.read}` +
240
403
  `${observe ? ` · ${C.yellow}OBSERVING — the repo is writable, the network is NOT${C.off}${C.dim}` : ""}${C.off}\n`
241
404
  );
242
405
 
@@ -267,11 +430,11 @@ export async function run(config, argv) {
267
430
  // `claude -c`, `claude --debug`. Found by passing --settings to claude and
268
431
  // watching the sandbox reject it as its own malformed config.
269
432
  /**
270
- * The kernel's own refusals, alongside the hook's.
433
+ * The kernel's own denials, alongside the hook's.
271
434
  *
272
435
  * Started BEFORE the spawn, and that ordering is the whole difference between
273
436
  * this working and this recording nothing. `log stream` is a separate process
274
- * with its own startup; a `sh -c` that gets refused is over in single-digit
437
+ * with its own startup; a `sh -c` that gets denied is over in single-digit
275
438
  * milliseconds. Started after the spawn, the first version of this caught
276
439
  * zero denials on exactly the short commands an agent runs most.
277
440
  *
@@ -281,12 +444,12 @@ export async function run(config, argv) {
281
444
  *
282
445
  * What each denial becomes — a log line, a request, or a count — is decided
283
446
  * in intake.js, beside the hook's lines: the kernel is the thing that
284
- * actually refused, so when the two disagree, it is the one that is right.
447
+ * actually denied, so when the two disagree, it is the one that is right.
285
448
  */
286
- // What actually runs inside the box, computed once and used by both ends. The
449
+ // What actually runs inside the sandbox, computed once and used by both ends. The
287
450
  // watcher recognises a denial by comparing the runtime's command tag with this
288
451
  // argv; handed `cmd` while `srt` was handed `env NO_PROXY=… cmd`, it recognised
289
- // nothing for a role with `local_ports`, and every short refusal of those roles
452
+ // nothing for a role with `local_ports`, and every short denial of those roles
290
453
  // — writes included — left no line (measured: 0 of 5, and 5 of 5 without the key).
291
454
  //
292
455
  // And a nonce in front, because the tag alone does not tell runs apart: an
@@ -296,8 +459,73 @@ export async function run(config, argv) {
296
459
  // one request per role, and a denial logged against the role that owns the
297
460
  // path (measured: 3 lines and 3 requests for one write by one of 3 roles).
298
461
  // The nonce makes each run's command unique, so the tag names one run.
299
- const boxed = ["env", `SEISIN_RUN_ID=${theRun.id}`, ...loopbackVia(config.roles[role], cmd)];
300
- const 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 });
301
529
 
302
530
  // Deliberately NOT announced here. On Linux this branch is taken every time,
303
531
  // so saying it per run puts a line the reader cannot act on in front of every
@@ -316,7 +544,8 @@ export async function run(config, argv) {
316
544
  const nested = nestedSandboxWarning(cmd);
317
545
  if (nested) err(`${C.yellow}seisin: ${nested}${C.off}\n`);
318
546
 
319
- const child = spawn(srt, ["--settings", file, "--", ...boxed], {
547
+ const [runner, ...runnerArgs] = wrap(boxed);
548
+ const child = spawn(runner, runnerArgs, {
320
549
  stdio: outStream ? ["inherit", "pipe", "pipe"] : "inherit",
321
550
  env,
322
551
  });
@@ -372,8 +601,11 @@ export async function run(config, argv) {
372
601
  * Seen once in a full suite run under load; by the end of the drains below
373
602
  * the pending signal has been handled.
374
603
  */
604
+ // A runtime that itself dies of a signal (SIGKILL, the OOM killer) exits
605
+ // the shell's way, 128 + n, the same as a run seisin was told to stop — it
606
+ // was reported as 1, a plain failure, which hides that it was killed.
375
607
  const statusOf = (code, signal) => stoppedBy ? 128 + osConstants.signals[stoppedBy]
376
- : signal ? 1
608
+ : signal ? 128 + (osConstants.signals[signal] ?? 0)
377
609
  : interrupted && !code ? 130
378
610
  : code ?? 0;
379
611
 
@@ -387,32 +619,65 @@ export async function run(config, argv) {
387
619
  // one entry behind is how a request goes unnoticed for a day.
388
620
  audit.close();
389
621
  // Same reason, one layer further out: the kernel writes its line through a
390
- // separate process, so the last refusal of a run can still be in flight
622
+ // separate process, so the last denial of a run can still be in flight
391
623
  // here. This is the only wait the mechanism adds and it is per run — it
392
- // returns as soon as the stream goes quiet, so a run that was refused
624
+ // returns as soon as the stream goes quiet, so a run that was denied
393
625
  // nothing pays a tick.
394
626
  await denials.close({ drain: DRAIN_MS });
395
627
  // The run made this directory, so the run removes it.
396
- theRun.close();
628
+ cleanup();
397
629
 
398
630
  // Say what was dropped rather than only what was kept. A filter nobody can
399
631
  // see is indistinguishable from a monitor that is not working, and this one
400
632
  // drops the majority of what the kernel says on a busy run.
401
- // Only when something was actually recorded. A run where every refusal was
633
+ // Only when something was actually recorded. A run where every denial was
402
634
  // filtered out has nothing to contextualise, and `0 recorded, 1 outside the
403
635
  // policy's paths` is a line that answers a question nobody asked — the
404
636
  // counts exist to explain a filter, not to announce that one ran.
405
- const { attributed, foreign } = denials.stats;
406
- 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
+ }
407
664
  const recorded = attributed - offPolicy - walks;
408
665
  if (recorded > 0)
409
666
  err(`${C.dim}seisin: ${recorded} kernel denial(s) recorded` +
410
667
  `${walks ? `, ${walks} directory scan(s)` : ""}` +
411
- `${offPolicy ? `, ${offPolicy} outside the policy's paths` : ""}` +
412
- `${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` : ""));
413
674
 
414
- const queue = pending(requestsPath(config.root));
415
- if (queue.length) err(renderQueue(markStale(queue, read(logPath(config.root)))));
675
+ // Only what this run added, with the numbers `grant` takes, and one line
676
+ // for the rest. The whole queue after every run buried the new request
677
+ // under the ones already seen; nothing new, nothing said.
678
+ const queue = queueOf(config);
679
+ const added = queue.map((q, i) => ({ ...q, n: i + 1 })).filter((q) => !before.has(q.key));
680
+ if (added.length) err(renderAdded(added, queue.length - added.length));
416
681
  const failed = await notify.settle();
417
682
  if (failed.length) err(`${C.yellow}seisin: could not notify about a new request (${failed[0]})${C.off}\n`);
418
683
  // One more turn of the loop, so a signal that arrived with the child's exit
@@ -427,11 +692,90 @@ export async function run(config, argv) {
427
692
  // Every run with a key paid it — measured 3.0 s against 1.2 s without keys.
428
693
  // `finished()` settles at once for a stream that is already done.
429
694
  const guard = setTimeout(() => process.exit(status), 2000);
430
- outStream.end();
431
- 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.
432
698
  await Promise.all([finished(outStream), finished(errStream)]).catch(() => {});
433
699
  clearTimeout(guard);
434
700
  process.exit(status);
435
701
  });
436
- child.on("error", (e) => { throw new Error(`could not start the sandbox: ${e.message}`); });
702
+ // Not a `throw`: in a listener it is an uncaught exception — exit 1, a
703
+ // stack trace, and none of the cleanup. Said the way cli.js says any other
704
+ // failure, with its code; the `exit` handler above removes the run.
705
+ child.on("error", (e) => {
706
+ err(`${C.red}seisin:${C.off} could not start the sandbox: ${e.message}\n`);
707
+ process.exit(2);
708
+ });
709
+ }
710
+
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) };
740
+ }
741
+
742
+ /**
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.
748
+ *
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.
752
+ */
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)));
437
781
  }