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
package/src/spool.js CHANGED
@@ -3,7 +3,7 @@
3
3
  *
4
4
  * ── Why this exists ──
5
5
  * `.seisin/` used to be in every role's allowWrite, because the hook runs
6
- * inside the box and has to record what it decided. That made the log and the
6
+ * inside the sandbox and has to record what it decided. That made the log and the
7
7
  * request queue writable by the process they are a record of: an agent could
8
8
  * truncate `log.jsonl`, rewrite a decision, or invent a request that had never
9
9
  * been refused. The files said "append-only" in their own comments and nothing
@@ -27,34 +27,15 @@
27
27
  * else did.
28
28
  */
29
29
  import { createServer, createConnection, Socket } from "node:net";
30
- import { unlinkSync, existsSync, mkdtempSync, chmodSync, openSync, writeSync, closeSync, constants } from "node:fs";
30
+ import { unlinkSync, existsSync, openSync, writeSync, closeSync, constants } from "node:fs";
31
31
  import { execFileSync } from "node:child_process";
32
- import { tmpdir } from "node:os";
33
- import { join, dirname } from "node:path";
32
+ import { lineReader as readLines } from "./lines.js";
34
33
 
35
34
  /** The env var a confined hook looks for. Absent means "write the file". */
36
35
  export const SOCK_ENV = "SEISIN_SPOOL";
37
36
 
38
37
  /**
39
- * Where the socket lives.
40
- *
41
- * Not under the repo: the point is a path the role's territory does not cover.
42
- * Kept short because a unix socket path is capped near 104 bytes on macOS and
43
- * the failure when it is too long is an unhelpful EINVAL.
44
- */
45
- export function spoolPath() {
46
- // A random directory, 0700, rather than a name anyone can guess from the pid.
47
- // The socket itself is already denyWrite so the agent cannot unlink it, but
48
- // any other process running as this user could otherwise connect and add
49
- // lines. That does not let them erase anything — forgery is the part that
50
- // stays open — but a predictable path invites it for free.
51
- const dir = mkdtempSync(join(tmpdir(), "seisin-"));
52
- chmodSync(dir, 0o700);
53
- return join(dir, "spool.sock");
54
- }
55
-
56
- /**
57
- * Listens for lines from inside the box and hands each to `sink`.
38
+ * Listens for lines from inside the sandbox and hands each to `sink`.
58
39
  *
59
40
  * One line is one JSON object with a `to` field naming which file it belongs
60
41
  * in. The parent decides what that means; the sender does not get to pick a
@@ -63,7 +44,7 @@ export function spoolPath() {
63
44
  /**
64
45
  * Which channel this platform can use: a unix socket, or a FIFO.
65
46
  *
66
- * On Linux the runtime blocks creating ANY unix socket inside the box with a
47
+ * On Linux the runtime blocks creating ANY unix socket inside the sandbox with a
67
48
  * seccomp filter — it cannot filter by path, so `allowUnixSockets` is ignored
68
49
  * there (its README says so). The socket this file was built on therefore
69
50
  * never carried a line on Linux: measured in Docker on 2026-09-23, with the
@@ -82,24 +63,14 @@ const PIPE_BUF = 4096;
82
63
 
83
64
  /** Parses newline-separated JSON messages and hands each to `sink`. */
84
65
  function lineReader(sink, onTooLong) {
85
- let buffer = "";
86
- return (chunk) => {
87
- buffer += chunk;
88
- // A 1 MB line is not a log entry, it is something trying to fill a disk.
89
- if (buffer.length > 1_000_000) { buffer = ""; return onTooLong?.(); }
90
- let nl;
91
- while ((nl = buffer.indexOf("\n")) !== -1) {
92
- const line = buffer.slice(0, nl);
93
- buffer = buffer.slice(nl + 1);
94
- if (!line.trim()) continue;
95
- try {
96
- const msg = JSON.parse(line);
97
- if (msg && (msg.to === "log" || msg.to === "requests")) sink(msg.to, msg.entry);
98
- } catch {
99
- // Garbage from inside the box is not the parent's emergency.
100
- }
66
+ return readLines((line) => {
67
+ try {
68
+ const msg = JSON.parse(line);
69
+ if (msg && (msg.to === "log" || msg.to === "requests")) sink(msg.to, msg.entry);
70
+ } catch {
71
+ // Garbage from inside the sandbox is not the parent's emergency.
101
72
  }
102
- };
73
+ }, { onTooLong });
103
74
  }
104
75
 
105
76
  /**
@@ -125,7 +96,7 @@ function fifoSpool(sink, path) {
125
96
  });
126
97
  }
127
98
 
128
- export function spool(sink, path = spoolPath()) {
99
+ export function spool(sink, path) {
129
100
  if (path.endsWith(FIFO_SUFFIX)) return fifoSpool(sink, path);
130
101
  if (existsSync(path)) unlinkSync(path);
131
102
 
@@ -143,7 +114,7 @@ export function spool(sink, path = spoolPath()) {
143
114
  * too long for a unix socket, a directory that is not there — left an awaited
144
115
  * promise pending forever. What the user saw was Node's "Detected unsettled
145
116
  * top-level await" and rc=13: no error, no path, nothing naming the spool.
146
- * Measured while running seisin inside seisin, where the outer box refuses
117
+ * Measured while running seisin inside seisin, where the outer sandbox denies
147
118
  * the inner bind; but nothing about it is particular to that case, which is
148
119
  * the reason it is fixed here rather than beside the nesting check.
149
120
  *
@@ -161,11 +132,11 @@ export function spool(sink, path = spoolPath()) {
161
132
  close() {
162
133
  server.close();
163
134
  // Only the socket. The first version removed `dirname(path)` too,
164
- // reasoning that spoolPath() had just made that directory — but a
135
+ // reasoning that the spool had just made that directory — but a
165
136
  // caller passing its own path makes dirname the system temp directory,
166
137
  // and closing the spool deleted it. That is a caller's whole scratch
167
138
  // space gone, for a cleanup. Whoever created the directory removes it:
168
- // see `run`, which owns the one spoolPath() makes.
139
+ // see `run`, whose run directory (rundir.js) holds the socket.
169
140
  try { unlinkSync(path); } catch {}
170
141
  },
171
142
  });
package/src/srt.js CHANGED
@@ -13,23 +13,31 @@
13
13
  * back to its defaults. Emitting the whole object removes that class of
14
14
  * error, which is most of what this file is for.
15
15
  */
16
+ import { unknownRole } from "./suggest.js";
17
+ import { privateDir } from "./config.js";
18
+ import { existsSync, mkdirSync } from "node:fs";
16
19
  import { join, dirname } from "node:path";
20
+ import { homedir } from "node:os";
17
21
  import { STATE_DIR, CONFIG_NAME } from "./layout.js";
18
- import { entriesOf } from "./keys.js";
22
+ import { entriesOf, injectKeysOf } from "./keys.js";
19
23
  import { enforcedNeverWrites } from "./owners.js";
20
24
  import { realAncestor } from "./paths.js";
21
25
  import { denyFor } from "./surface.js";
22
- import { runsRootOf } from "./rundir.js";
26
+ import { runsRootOf, SETTINGS_NAME, TMP_NAME } from "./rundir.js";
23
27
  import { FIFO_SUFFIX } from "./spool.js";
28
+ import { machOf } from "./browsers.js";
29
+ import { territoryReads, outlastAllows, dataRoots, keptFor } from "./territory.js";
30
+ import { seccompDir } from "./sandboxes/srt.js";
24
31
  import {
25
32
  SOCKET_MAX, isLink, realOrSelf, expand, homeFits, roleHome, roleHomeRoot,
26
- CREDENTIAL_HOMES, toWritePath, writePathsOf,
33
+ CREDENTIAL_HOMES, toWritePath, writePathsOf, otherAgentsCredentials,
27
34
  } from "./grants.js";
28
35
 
29
36
  // Re-exported: these were srt.js's exports before grants.js existed, and
30
37
  // callers (the CLI, the tests, anyone embedding seisin) import them from here.
31
38
  export {
32
39
  expand, homeFits, roleHome, roleHomeRoot, CREDENTIAL_HOMES, RUNTIME_WRITES,
40
+ AGENTS, AGENT_HOMES, AGENT_CREDENTIALS, agentOf,
33
41
  } from "./grants.js";
34
42
 
35
43
 
@@ -75,28 +83,49 @@ export const NO_PROXY_WITHOUT_LOOPBACK = "169.254.0.0/16,10.0.0.0/8,172.16.0.0/1
75
83
 
76
84
  /**
77
85
  * The command a role runs, with loopback routed through the proxy when it
78
- * has `local_ports`.
86
+ * has `local_ports` and the sandbox is sandbox-runtime.
79
87
  *
80
88
  * An `env` in front and not a variable in the environment seisin passes: the
81
89
  * runtime sets NO_PROXY itself, after seisin's environment, so the only place
82
- * that wins is inside the box. Clients that ignore HTTP_PROXY — a MySQL driver,
90
+ * that wins is inside the sandbox. Clients that ignore HTTP_PROXY — a MySQL driver,
83
91
  * Chromium, which exempts loopback on its own — still get refused. They need
84
92
  * to be pointed at the proxy, or the port needs `local_binding`, which opens
85
93
  * every port.
86
94
  */
87
- export function loopbackVia(role, cmd) {
88
- if (!role?.localPorts?.length) return cmd;
95
+ export function loopbackVia(role, cmd, sandbox = "srt") {
96
+ // tartaros holds the port list in the kernel's profile itself: a listed port
97
+ // is reached directly, any other refused, and its own NO_PROXY already exempts
98
+ // loopback. Its egress broker, made to carry loopback as a forward proxy, cut
99
+ // POST bodies (measured on 0.1.0-alpha.7: curl got no answer, and Claude Code
100
+ // retried until ECONNRESET), so under it the route is left as it sets it.
101
+ if (!role?.localPorts?.length || sandbox === "tartaros") return cmd;
89
102
  return ["env", `NO_PROXY=${NO_PROXY_WITHOUT_LOOPBACK}`, `no_proxy=${NO_PROXY_WITHOUT_LOOPBACK}`, ...cmd];
90
103
  }
91
104
 
92
- export function settingsFor(config, roleName, spool = null, observe = false) {
105
+ /** The temp directory sandbox-runtime grants every sandbox it starts. See settingsFor. */
106
+ export const SHARED_TMP = { darwin: ["/tmp/claude", "/private/tmp/claude"], linux: ["/tmp/claude"] };
107
+
108
+ /**
109
+ * `agent` is what the run's command runs — `agentOf(cmd)`, or `--agent`. It
110
+ * decides which agent's home is scratch for this run and whose sign-in files
111
+ * are closed to it. Left out (check, console, explain: no command), the
112
+ * settings are the most the role can get, which is every agent's home.
113
+ */
114
+ export function settingsFor(config, roleName, spool = null, observe = false, { agent, program, tmp, forRun = false } = {}) {
93
115
  const role = config.roles[roleName];
94
- if (!role) throw new Error(`unknown role "${roleName}". Known: ${Object.keys(config.roles).join(", ")}`);
116
+ if (!role) throw unknownRole(config, roleName);
117
+ const claudeGrant = role.writes.find((g) => /^(?:\.\/)?\.claude(?:\/|$)/.test(g));
118
+ if (claudeGrant)
119
+ throw new Error(
120
+ `roles.${roleName}.writes includes "${claudeGrant}", but a project's .claude cannot be granted.\n` +
121
+ " Claude Code reads its settings and hooks outside the sandbox. Remove this territory; " +
122
+ "a broad territory such as ** keeps working with .claude protected.",
123
+ );
95
124
 
96
125
  const abs = (p) => (p.startsWith("/") ? p : join(config.root, p));
97
126
  // "credentials" closes the places credentials live; "home" does that and
98
127
  // gives the role a home of its own. Only the second one needs a new HOME,
99
- // and only the second one signs every CLI in the box out. See config.js.
128
+ // and only the second one signs every CLI in the sandbox out. See config.js.
100
129
  //
101
130
  // `true` is normalised here as well as in the config reader, because a caller
102
131
  // embedding the library builds this object itself and `isolate: true` has
@@ -104,6 +133,45 @@ export function settingsFor(config, roleName, spool = null, observe = false) {
104
133
  const level = config.isolate === true ? "home" : config.isolate;
105
134
  const shielded = level === "credentials" || level === "home";
106
135
  const isolated = level === "home";
136
+ /**
137
+ * `sandbox-runtime` silently makes these writable as conveniences even when
138
+ * they are absent from `allowWrite`. That gives every role a shared host
139
+ * directory outside its territory. A denyRead makes the runtime drop its
140
+ * implicit grant; denyWrite also wins over Seisin's explicit `~/.claude`
141
+ * runtime scratch grant for the nested debug directory.
142
+ *
143
+ * On macOS both are named whether they exist or not: the profile is a set of
144
+ * patterns, and a missing directory costs nothing. On Linux only the ones
145
+ * that exist are. bubblewrap denies a missing path by placing something on
146
+ * the host for the length of the run, in the user's home, where other tools
147
+ * see it — and a run that is killed leaves it behind. So a missing one is
148
+ * either not needed or made first:
149
+ * - its parent is not one a role may write (`~/.npm`): nothing can create
150
+ * it inside the sandbox, and there is nothing to deny;
151
+ * - its parent is (`~/.claude`, the runtime scratch every role gets): a role
152
+ * could create it, so it is created here, outside the sandbox, and denied like
153
+ * any directory that exists. It is the directory Claude Code itself logs
154
+ * to; making it empty changes nothing for anyone.
155
+ */
156
+ const scratch = writePathsOf(config, config.roles[roleName] ?? { name: roleName, writes: [] }, { agent });
157
+ const runtimeConvenience = ["~/.npm/_logs", "~/.claude/debug"]
158
+ .map(expand)
159
+ .filter((p) => process.platform !== "linux" || existsSync(p) || madeFirst(p, scratch));
160
+ /**
161
+ * The runtime's own temp directory, shared by every sandbox on the machine.
162
+ *
163
+ * sandbox-runtime grants `/tmp/claude` to every sandbox it starts, outside
164
+ * any allowWrite and "never left out", and hands it to the child as TMPDIR.
165
+ * Every Claude Code session on the machine keeps its own temp files there
166
+ * too, so a confined role could write beside them — caches, locks — with
167
+ * nothing in the policy saying so (measured: `echo x > /tmp/claude/…` from
168
+ * a role with `read = "territory"` and no scratch, rc=0). Only a denyWrite
169
+ * takes it back. The run gets a temp directory of its own instead
170
+ * (`tmp`, passed in, through CLAUDE_CODE_TMPDIR in commands/run.js), so
171
+ * nothing the runtime does needs the shared one.
172
+ */
173
+ const sharedTmp = SHARED_TMP[process.platform === "linux" ? "linux" : "darwin"]
174
+ .filter((p) => process.platform !== "linux" || existsSync(p) || madeFirst(p, scratch));
107
175
 
108
176
  /**
109
177
  * Refuse before the runtime does, because the runtime's refusal says nothing.
@@ -127,6 +195,7 @@ export function settingsFor(config, roleName, spool = null, observe = false) {
127
195
  const home = isolated ? roleHome(config, roleName) : null;
128
196
  const denyRead = [];
129
197
  const allowRead = [];
198
+ denyRead.push(...runtimeConvenience);
130
199
 
131
200
  // Every declared directory is denied, then each key the role names is
132
201
  // re-allowed. A key written without a directory resolves against the first
@@ -168,9 +237,29 @@ export function settingsFor(config, roleName, spool = null, observe = false) {
168
237
  * touches, gets one wrong, and fails as an unexplainable crash inside the
169
238
  * agent. Naming the places credentials actually live is narrower than the
170
239
  * ideal and it is a boundary that holds up in practice.
240
+ *
241
+ * For agents you would not trust there is `[runtime] read = "territory"`,
242
+ * below: not that allowlist, but its useful half — the places data lives are
243
+ * denied, the system stays readable, and what a toolchain needs from a home
244
+ * is declared and checked before the run. decisions.md has both halves.
171
245
  */
172
246
  if (shielded) denyRead.push(...CREDENTIAL_HOMES.map(expand));
173
247
 
248
+ /**
249
+ * The other agents' sign-in, closed to this run both ways. See AGENT_HOMES
250
+ * in grants.js. Under `isolate = "home"` the agent signs in inside the role's
251
+ * own home, so that is where the files are looked for too.
252
+ *
253
+ * On Linux only the ones that exist: bubblewrap would otherwise put a file
254
+ * where the agent will later want to write its own. The directory around a
255
+ * missing one is not this run's to write (it is another agent's home), so
256
+ * nothing in the run can create it either — except under `isolate = "home"`,
257
+ * where the whole role home is writable and a missing one stays creatable.
258
+ */
259
+ const notMine = otherAgentsCredentials(agent, isolated ? roleHome(config, roleName) : homedir())
260
+ .filter((p) => process.platform !== "linux" || existsSync(p));
261
+ denyRead.push(...notMine);
262
+
174
263
  /**
175
264
  * `~/.claude` and `~/.codex` are closed only at the `home` level, and the
176
265
  * difference is the whole reason the two levels exist.
@@ -199,7 +288,7 @@ export function settingsFor(config, roleName, spool = null, observe = false) {
199
288
  *
200
289
  * A reference has no path, so there is nothing here to allow: it is resolved
201
290
  * by the parent before the sandbox starts and handed over as an environment
202
- * variable or as a scratch file. See keys.js. Filtering here rather than
291
+ * variable or as a file in the run directory. See keys.js. Filtering here rather than
203
292
  * upstream keeps the two kinds from having to know about each other — the
204
293
  * settings this function emits are still, entirely, about the filesystem.
205
294
  */
@@ -235,9 +324,9 @@ export function settingsFor(config, roleName, spool = null, observe = false) {
235
324
  /**
236
325
  * Other runs' directories are unreadable; this run's is readable.
237
326
  *
238
- * Every run keeps its socket and `scratch` keys under one root (rundir.js),
327
+ * Every run keeps its socket and `run-file` keys under one root (rundir.js),
239
328
  * and every role runs as the same user, so the directory mode protects
240
- * nothing between roles. Measured: one role read another's scratch key while
329
+ * nothing between roles. Measured: one role read another's run-file key while
241
330
  * both ran. Same shape as the key directories: deny the parent, allow your own.
242
331
  */
243
332
  const runs = runsRootOf(spool);
@@ -245,46 +334,137 @@ export function settingsFor(config, roleName, spool = null, observe = false) {
245
334
  if (runs) {
246
335
  denyRead.push(runs);
247
336
  allowRead.push(dirname(spool));
337
+ /**
338
+ * And the temp directories runs are given when their role may write no
339
+ * other (rundir.js, TMP_NAME): denied as a whole, this run's own allowed
340
+ * back. Same shape again, and for the same reason — every run is the same
341
+ * user, so a run could otherwise read what another left in its temp.
342
+ */
343
+ const tmps = join(dirname(runs), TMP_NAME);
344
+ denyRead.push(tmps);
345
+ if (tmp && (tmp === tmps || tmp.startsWith(tmps + "/"))) allowRead.push(tmp);
346
+ /**
347
+ * This run's settings are not this run's to read.
348
+ *
349
+ * They sit in the run's own directory, which the run reads for its run-file
350
+ * keys, so they were readable from inside: a map of every path left open
351
+ * and every one shut, handed to the process it confines. Not a defence on
352
+ * its own — the policy has to be right either way — but there is no reason
353
+ * to give an agent the list of doors to try. Named here, by the path
354
+ * rundir.js writes them to, because they do not exist yet.
355
+ */
356
+ denyRead.push(join(dirname(spool), SETTINGS_NAME));
248
357
  }
249
358
 
359
+ /**
360
+ * `[runtime] read = "territory"`: the data roots are denied whole and what
361
+ * this role needs is allowed back inside them. See territory.js for why
362
+ * allowed back and no longer carved.
363
+ *
364
+ * Allowed back: the repo (key directories inside it stay denied by name,
365
+ * above), what the role writes, and what it declares in `reads` and
366
+ * `toolchain` — except where a deny above already covers it. A `reads`
367
+ * entry under `~/.ssh` stays shut, as it did when the mode only denied.
368
+ *
369
+ * Not allowed back twice: what is already re-allowed above — this run's
370
+ * directory, the role's own home, the key files.
371
+ */
372
+ /**
373
+ * Other roles' private folders: denied to read and to write, whatever this
374
+ * role's territory says and in either read mode.
375
+ *
376
+ * Deny-only, and never an allow for the owner: the owner reaches its folder
377
+ * the way it reaches the rest of its territory, and nobody else gets an
378
+ * allow anywhere near it. That is the shape that holds on both platforms —
379
+ * an allow around a deny is re-emitted after it on macOS, and a read allow
380
+ * is mounted over the writable mounts on Linux (territory.js).
381
+ *
382
+ * On Linux the folder is made first when it is missing: bubblewrap denies a
383
+ * missing path by putting something on the host in its place, and the owner
384
+ * would find a file where its folder should be. It is the owner's territory,
385
+ * so an empty folder there is what the policy already says. Only for a run
386
+ * (`forRun`, passed by `seisin run`): check, explain and the console ask for
387
+ * these settings too, and they write nothing to disk. For them a missing
388
+ * folder on Linux is left out — there is no run for it to be denied in.
389
+ */
390
+ const shut = othersPrivate(config, roleName, abs, { make: forRun });
391
+ denyRead.push(...shut);
392
+
393
+ const allowWrite = [
394
+ // Observing means the kernel stops refusing, because otherwise there is
395
+ // nothing to observe. `--observe` used to relax only the hook, so the
396
+ // sandbox denied the write anyway and the banner said "nothing denied"
397
+ // over a transcript of denials — and `init --from-observations` builds
398
+ // a policy out of that transcript. It would read "the agent needs
399
+ // nothing outside its territory", which is the opposite of the truth.
400
+ //
401
+ // The repo, and only the repo. Everything in denyWrite below still
402
+ // holds: the config, the state directory and the key directories stay
403
+ // shut even here, because observing is not a reason to hand over the
404
+ // paperwork of the confinement.
405
+ // One function answers "what may this role write" for the settings and
406
+ // for surface.js, which needs the union over every role. Two copies of
407
+ // that arithmetic would be two answers the day one of them changes.
408
+ ...writePathsOf(config, role, { observe, agent }),
409
+ ...(isFifo ? [spool] : []),
410
+ ...(tmp ? [tmp] : []),
411
+ ];
412
+
413
+ if (config.read === "territory") {
414
+ const reopened = allowRead.map(realOrSelf);
415
+ const inReopened = (p) => { const r = realAncestor(p); return reopened.some((a) => r === a || r.startsWith(a + "/")); };
416
+ // On Linux the runtime's apply-seccomp runs inside: see seccompDir.
417
+ const seccomp = process.platform === "linux" ? seccompDir() : null;
418
+ const keep = [...keptFor(config, role, { observe, agent, program }), ...(seccomp ? [seccomp] : [])]
419
+ .filter((p) => !inReopened(p));
420
+ const { deny, allow } = territoryReads(dataRoots(), keep, { writes: allowWrite, denied: [...denyRead] });
421
+ denyRead.push(...deny);
422
+ allowRead.push(...allow);
423
+ }
424
+ // The key directory inside a repo that is now an allow: see outlastAllows.
425
+ const spelt = config.read === "territory" && process.platform === "darwin"
426
+ ? outlastAllows(denyRead, allowRead, realAncestor) : denyRead;
427
+
428
+ const allowedDomains = role.localPorts?.length
429
+ ? [...(role.network ?? config.allowedDomains ?? []), ...localPortDomains(role.localPorts)]
430
+ : role.network ?? config.allowedDomains;
431
+ const { credentials, tlsTerminate } = injectSettings(config, role, allowedDomains);
432
+
250
433
  return {
251
434
  network: {
252
435
  // `local_ports` rides the same allowlist as the domains: the runtime's
253
436
  // proxy is the one place that sees the destination PORT. See loopbackVia.
254
437
  // Without ports the value is passed through untouched, `undefined` included: that
255
438
  // is how a config with no network list has always read, and a spread would throw.
256
- allowedDomains: role.localPorts?.length
257
- ? [...(role.network ?? config.allowedDomains ?? []), ...localPortDomains(role.localPorts)]
258
- : role.network ?? config.allowedDomains,
439
+ allowedDomains,
259
440
  deniedDomains: [],
441
+ // `[network] deny_addresses`: what an allowed name may not resolve to, on
442
+ // top of the runtime's own loopback / link-local / metadata set. Left out
443
+ // when empty, so a policy without it emits the settings it always did.
444
+ ...(config.deniedAddresses?.length ? { deniedResolvedAddresses: config.deniedAddresses } : {}),
445
+ /**
446
+ * `inject` keys terminate TLS, because substituting a header needs reading
447
+ * it. Only the hosts that carry a masked credential are terminated; every
448
+ * other allowed domain is excluded, so the MITM is as narrow as the policy
449
+ * lets it be. A wildcard allow that covers an inject host is terminated
450
+ * whole — srt cannot terminate one host out of `*.x` — and `check` says so.
451
+ */
452
+ ...(tlsTerminate ? { tlsTerminate } : {}),
260
453
  // Exactly one socket: the parent's audit spool, when there is a parent.
261
454
  // Granted by path, not by turning unix sockets on.
262
455
  allowUnixSockets: spool && !isFifo ? [spool] : [],
263
456
  // Per role, `local_binding = true`. Off by default: listening is a
264
457
  // capability, and most roles never need it.
265
458
  allowLocalBinding: role.localBinding === true,
459
+ // `browsers = ["chromium"]`: the Mach services the browser's own
460
+ // processes look up to find each other, by prefix (browsers.js). The
461
+ // register half has no field here; srt-mach.mjs adds it.
462
+ ...(machOf(role).lookup.length ? { allowMachLookup: machOf(role).lookup.map((p) => `${p}*`) } : {}),
266
463
  },
267
464
  filesystem: {
268
- denyRead,
465
+ denyRead: spelt,
269
466
  allowRead,
270
- allowWrite: [
271
- // Observing means the kernel stops refusing, because otherwise there is
272
- // nothing to observe. `--observe` used to relax only the hook, so the
273
- // sandbox denied the write anyway and the banner said "nothing denied"
274
- // over a transcript of denials — and `init --from-observations` builds
275
- // a policy out of that transcript. It would read "the agent needs
276
- // nothing outside its territory", which is the opposite of the truth.
277
- //
278
- // The repo, and only the repo. Everything in denyWrite below still
279
- // holds: the config, the state directory and the key directories stay
280
- // shut even here, because observing is not a reason to hand over the
281
- // paperwork of the confinement.
282
- // One function answers "what may this role write" for the settings and
283
- // for surface.js, which needs the union over every role. Two copies of
284
- // that arithmetic would be two answers the day one of them changes.
285
- ...writePathsOf(config, role, { observe }),
286
- ...(isFifo ? [spool] : []),
287
- ],
467
+ allowWrite,
288
468
  /**
289
469
  * The confinement's own paperwork, never writable — not even by a role
290
470
  * that was granted the whole repo.
@@ -301,6 +481,9 @@ export function settingsFor(config, roleName, spool = null, observe = false) {
301
481
  * relying on someone else's implementation detail staying put.
302
482
  */
303
483
  denyWrite: [
484
+ ...runtimeConvenience,
485
+ ...sharedTmp,
486
+ ...notMine,
304
487
  /**
305
488
  * The role's own subtractions, `never_writes`.
306
489
  *
@@ -318,13 +501,14 @@ export function settingsFor(config, roleName, spool = null, observe = false) {
318
501
  .flatMap((p) => { const r = realAncestor(p); return r === p ? [p] : [p, r]; }),
319
502
  /**
320
503
  * Everything the parent reads or executes, and every file that tells a
321
- * program outside the box what to run — the policy, `.seisin/`, the key
504
+ * program outside the sandbox what to run — the policy, `.seisin/`, the key
322
505
  * directories, the key providers, `file://` targets, programs on PATH
323
506
  * installed where a role writes, and each project's hooks and settings.
324
507
  * One list with a reason per entry, built in surface.js, which says why
325
508
  * each member of the family is on it. `seisin check` prints it.
326
509
  */
327
510
  ...denyFor(config, role, { observe }).map((e) => e.path),
511
+ ...shut,
328
512
  // The audit socket, and the directory holding it. Denying only the
329
513
  // socket left `mv /tmp/seisin-xxxx /tmp/gone` as a way to take the
330
514
  // channel out without ever touching the file that was protected.
@@ -343,27 +527,90 @@ export function settingsFor(config, roleName, spool = null, observe = false) {
343
527
  * verify through the Security framework — Go before 1.27 (gh, kubectl,
344
528
  * terraform) and every Dart/Flutter. The runtime ships it as
345
529
  * `enableWeakerNetworkIsolation` and warns it is an exfiltration path:
346
- * trustd fetches, outside the box, the URLs a certificate carries. Per role
530
+ * trustd fetches, outside the sandbox, the URLs a certificate carries. Per role
347
531
  * and off by default, so the answer is written next to the role that needs it.
348
532
  */
349
533
  ...(role.trustd === true ? { enableWeakerNetworkIsolation: true } : {}),
534
+ /**
535
+ * Masked credentials: the real value reaches the runtime's own environment
536
+ * (run.js), never the sandbox, which sees `fake_value_…`. `mode: "mask"`
537
+ * and the per-credential `injectHosts` are all that is emitted — no
538
+ * `extract`, `decode`, files or SigV4, and never `allowPlaintextInject`,
539
+ * which over HTTPS carries the sentinel and so is deny with extra steps.
540
+ */
541
+ ...(credentials ? { credentials } : {}),
350
542
  };
351
543
  }
352
544
 
353
545
  /**
354
- * The provider executables that live at a path, absolute.
355
- *
356
- * A command with no separator (`security`, `op`, `gpg`) is resolved by the OS
357
- * from PATH and is deliberately left alone: denying "wherever gpg happens to
358
- * be" would mean writing a rule about a machine rather than about a repo.
546
+ * A domain pattern covers a host — srt's rule, narrowed to what seisin needs.
547
+ * `*` covers all; `*.base` covers any `x.base`; otherwise an exact, case-folded
548
+ * match. Mirrors `matchesDomainPattern` in the runtime's domain-pattern.js;
549
+ * written here rather than imported because seisin generates settings and does
550
+ * not embed the library, and exported so `inspect/inject.js` shares the one
551
+ * rule. Inject hosts are exact (config.js rejects a wildcard), so
552
+ * this is only ever asked "does this allowedDomains pattern cover this host".
553
+ */
554
+ export function coversHost(pattern, host) {
555
+ const p = String(pattern).toLowerCase(), h = host.toLowerCase();
556
+ if (p === "*") return true;
557
+ if (p.startsWith("*.")) return h.endsWith("." + p.slice(2));
558
+ return h === p;
559
+ }
560
+
561
+ /**
562
+ * The `credentials` block and `tlsTerminate.excludeDomains` for a role's
563
+ * `inject` keys, or `{}` when it has none. One `envVars` entry per credential,
564
+ * `mode: "mask"` with its own `injectHosts`; TLS is terminated only for the
565
+ * domains that carry one, every other allowed domain excluded. Throws when an
566
+ * inject host is not reachable through the role's `allowedDomains` — a masked
567
+ * credential aimed at a host the role cannot connect to is a policy mistake,
568
+ * not a thing to emit silently.
569
+ */
570
+ export function injectSettings(config, role, allowedDomains) {
571
+ const inject = injectKeysOf(config, role);
572
+ if (inject.length === 0) return {};
573
+ const allowed = allowedDomains ?? [];
574
+ for (const c of inject)
575
+ for (const h of c.hosts)
576
+ if (!allowed.some((p) => coversHost(p, h)))
577
+ throw new Error(
578
+ `roles.${role.name}: inject host ${h} (for ${c.name}) is not reachable.\n` +
579
+ ` A masked credential is substituted only on egress to its host, and this role's ` +
580
+ `network does not allow ${h}. Add it: [roles.${role.name}] network = ["${h}", …], ` +
581
+ `or correct the host in inject_hosts.`);
582
+ const credentials = {
583
+ envVars: inject.map((c) => ({ name: c.name, mode: "mask", injectHosts: c.hosts })),
584
+ };
585
+ // Terminate a domain only if it carries an inject host; exclude the rest.
586
+ const targets = inject.flatMap((c) => c.hosts);
587
+ const excludeDomains = allowed.filter((d) => !targets.some((h) => coversHost(d, h)));
588
+ return { credentials, tlsTerminate: { excludeDomains } };
589
+ }
590
+
591
+ /**
592
+ * The private folders of every role but `roleName`, absolute, in both
593
+ * spellings (as written and as the kernel meets it through a symlink).
359
594
  */
360
- export function providerPaths(config) {
595
+ export function othersPrivate(config, roleName, abs = (p) => (p.startsWith("/") ? p : join(config.root, p)), { make = false } = {}) {
361
596
  const out = [];
362
- for (const p of Object.values(config.keyProviders ?? {})) {
363
- const cmd = p.command?.[0];
364
- if (!cmd || !cmd.includes("/")) continue;
365
- out.push(cmd.startsWith("/") ? cmd : join(config.root ?? ".", cmd));
366
- }
367
- return [...new Set(out)];
597
+ for (const r of Object.values(config.roles))
598
+ if (r.name !== roleName)
599
+ for (const entry of r.private ?? []) {
600
+ const p = abs(privateDir(entry));
601
+ if (process.platform === "linux" && !existsSync(p)) {
602
+ if (!make) continue;
603
+ try { mkdirSync(p, { recursive: true }); } catch { continue; }
604
+ }
605
+ const real = realAncestor(p);
606
+ out.push(...(real === p ? [p] : [p, real]));
607
+ }
608
+ return out;
368
609
  }
369
610
 
611
+ /** Creates `p` when its parent exists and a role may write there; says whether it now exists. */
612
+ function madeFirst(p, writable) {
613
+ const parent = dirname(p);
614
+ if (!existsSync(parent) || !writable.some((w) => parent === w || parent.startsWith(w + "/"))) return false;
615
+ try { mkdirSync(p, { recursive: true }); return true; } catch { return false; }
616
+ }
package/src/suggest.js ADDED
@@ -0,0 +1,58 @@
1
+ /**
2
+ * "Did you mean" — for a command, a role, a flag.
3
+ *
4
+ * One place, so every command answers a typo the same way: the name that was
5
+ * not found, what exists, and the nearest one when it is near enough to be a
6
+ * typo rather than a different word.
7
+ */
8
+
9
+ /** Edit distance, small strings only. */
10
+ function distance(a, b) {
11
+ const d = Array.from({ length: a.length + 1 }, (_, i) => [i, ...Array(b.length).fill(0)]);
12
+ for (let j = 1; j <= b.length; j++) d[0][j] = j;
13
+ for (let i = 1; i <= a.length; i++)
14
+ for (let j = 1; j <= b.length; j++)
15
+ d[i][j] = Math.min(d[i - 1][j] + 1, d[i][j - 1] + 1, d[i - 1][j - 1] + (a[i - 1] === b[j - 1] ? 0 : 1));
16
+ return d[a.length][b.length];
17
+ }
18
+
19
+ /** The nearest of `known` to `word`, or null when nothing is close. */
20
+ export function nearest(word, known) {
21
+ let best = null, score = Infinity;
22
+ for (const k of known) {
23
+ const n = distance(String(word).toLowerCase(), k.toLowerCase());
24
+ if (n < score) { best = k; score = n; }
25
+ }
26
+ const room = Math.max(1, Math.floor(String(word).length / 3));
27
+ return score <= room || (best && best.startsWith(word) && word.length >= 2) ? best : null;
28
+ }
29
+
30
+ /** The error for a role the policy does not have, always with the list. */
31
+ export function unknownRole(config, role) {
32
+ const known = Object.keys(config.roles ?? {});
33
+ const near = nearest(role, known);
34
+ return new Error(
35
+ `unknown role "${role}"${near ? ` — did you mean "${near}"?` : ""}` +
36
+ `\n known roles: ${known.join(", ") || "none — add a [roles.<name>] section"}`);
37
+ }
38
+
39
+ /**
40
+ * Flags a command does not take are refused, never ignored. An ignored
41
+ * `--verbsoe` is a question answered as if it had not been asked.
42
+ * `values` are the flags that take the next word as their value.
43
+ */
44
+ export function checkFlags(command, argv, known, values = []) {
45
+ const all = [...known, ...values];
46
+ for (let i = 0; i < argv.length; i++) {
47
+ const a = argv[i];
48
+ if (a === "--") break;
49
+ if (values.includes(a)) { i++; continue; }
50
+ if (!a.startsWith("-") || a === "-") continue;
51
+ if (all.includes(a)) continue;
52
+ const near = nearest(a, all);
53
+ const e = new Error(`unknown flag "${a}" for ${command}${near ? ` — did you mean "${near}"?` : ""}` +
54
+ `\n seisin ${command} --help lists its flags`);
55
+ e.usage = true;
56
+ throw e;
57
+ }
58
+ }