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
package/src/serve.js CHANGED
@@ -18,9 +18,11 @@ import { fileURLToPath } from "node:url";
18
18
  import { loadConfig } from "./config.js";
19
19
  import { read, logPath, logSegments } from "./log.js";
20
20
  import { settingsFor } from "./srt.js";
21
- import { pending, requestsPath, settle, applyGrant, refuseIfBarred, editPolicy, cleanReason } from "./requests.js";
21
+ import { describeHandoff } from "./handoff.js";
22
+ import { pending, requestsPath, settle, applyGrant, refuseIfBarred, editPolicy, cleanReason, asFileGrant, lastAgentOf, fileOnlyNote } from "./requests.js";
22
23
  import { causesOf, describeWalls, parseSince, queue, verdicts, wallsByRole } from "./views.js";
23
24
  import { planEdit, hashOf } from "./controls.js";
25
+ import { inspect } from "./inspect.js";
24
26
 
25
27
  // Moved to views.js, which the MCP server shares; kept importable from here.
26
28
  export { causesOf } from "./views.js";
@@ -135,11 +137,16 @@ export function state(configPath, { since = null, snap = snapshot(configPath) }
135
137
  keys: r.keys,
136
138
  env: r.env,
137
139
  neverWrites: r.neverWritesDeclared ?? r.neverWrites ?? [],
140
+ private: r.private ?? [],
138
141
  localBinding: r.localBinding === true,
139
142
  localPorts: r.localPorts ?? [],
140
143
  mcp: r.mcp ?? null,
141
144
  trustd: r.trustd === true,
142
145
  controlFiles: r.controlFiles ?? [],
146
+ reads: r.reads ?? [],
147
+ toolchain: r.toolchain ?? [],
148
+ verify: r.verify,
149
+ canary: [...all].reverse().find((e) => e.event === "canary" && e.role === r.name) ?? null,
143
150
  // Counted over the same window as the causes, so the KPI and the screen it
144
151
  // links to agree.
145
152
  blocks: derived.blocks.get(r.name) ?? 0,
@@ -152,7 +159,16 @@ export function state(configPath, { since = null, snap = snapshot(configPath) }
152
159
  // is not old just because the filter hides the runs that made it old. The
153
160
  // whole log, every verdict, as `seisin requests` and the MCP read it — this
154
161
  // counted runs over denials only, and marked differently from both.
155
- requests: queue(cfg.root, all, cfg.keyDirs),
162
+ // A write keyed by a folder can be granted as its one file instead; the
163
+ // line that choice carries is said here, once, so the page and the CLI
164
+ // say the same thing about Claude Code.
165
+ requests: queue(cfg, all).map((r) => ({
166
+ ...r,
167
+ // Said by the server in the same words as the CLI, so the page never
168
+ // works out where a request goes on its own (handoff.js).
169
+ handoffNote: describeHandoff(r.handoff, r.attempt, r.chainStopped),
170
+ ...(r.action === "write" && r.grant !== r.target ? { fileNote: fileOnlyNote(r.role, lastAgentOf(all, r.role)) } : {}),
171
+ })),
156
172
  since,
157
173
  // The file itself, not a regeneration of it. The page can render a policy
158
174
  // from its own model, and that model has no comments — so showing it under
@@ -165,6 +181,9 @@ export function state(configPath, { since = null, snap = snapshot(configPath) }
165
181
  // so a policy edited in between is not overwritten by a stale decision.
166
182
  base: hashOf(text),
167
183
  allowedDomains: cfg.allowedDomains,
184
+ read: cfg.read,
185
+ isolate: cfg.isolate,
186
+ warnings: inspect(cfg).warnings,
168
187
  roles,
169
188
  causes: derived.causes,
170
189
  walls: derived.walls,
@@ -208,7 +227,7 @@ function settingsOf(configPath, role) {
208
227
  * an exception for loopback. That is what lets the console hold the half the
209
228
  * MCP server deliberately does not.
210
229
  */
211
- function decide(configPath, { key, decision, reason }) {
230
+ function decide(configPath, { key, decision, reason, scope = "folder" }) {
212
231
  /**
213
232
  * By the request's id, never by its place in the queue.
214
233
  *
@@ -220,13 +239,8 @@ function decide(configPath, { key, decision, reason }) {
220
239
  * The CLI stopped doing this long ago; the console had not.
221
240
  */
222
241
  if (typeof key !== "string" || !key) throw bad("key is required — the id of the request on screen");
223
- /**
224
- * A person declines; the boundary denies. The console sends "declined".
225
- * "denied" is still accepted from anything that scripted against the old
226
- * value, and is what the queue stores either way — renaming a stored value
227
- * is a breaking change, listed in docs/glossary.md, not made here.
228
- */
229
- if (decision === "denied") decision = "declined";
242
+ // A person declines; the boundary denies (docs/glossary.md). One word each,
243
+ // here and in the queue.
230
244
  if (decision !== "granted" && decision !== "declined")
231
245
  throw bad(`decision must be granted or declined`);
232
246
  /**
@@ -238,9 +252,11 @@ function decide(configPath, { key, decision, reason }) {
238
252
  if (typeof reason !== "string" || !cleanReason(reason))
239
253
  throw bad("a reason is required — it is written next to the decision");
240
254
  // It grants the folder the request is keyed by (docs/api.md asked, docs/**
241
- // granted), and the page says so before the click. Granting the one file
242
- // instead would need the queue to key by file: a later ask for a sibling
243
- // folds into this settled request and would never reach the queue.
255
+ // granted), and the page says so before the click — or, with scope "file",
256
+ // the one file that was asked for, as a literal path. The queue stays keyed
257
+ // by folder; the settle line records the file, so a later ask for a sibling
258
+ // reopens the request instead of folding into it (requests.js, reduce).
259
+ if (scope !== "folder" && scope !== "file") throw bad(`scope must be folder or file`);
244
260
  const cfg = loadConfig(configPath);
245
261
  const file = requestsPath(cfg.root);
246
262
 
@@ -259,12 +275,14 @@ function decide(configPath, { key, decision, reason }) {
259
275
  let result = null;
260
276
  try {
261
277
  result = editPolicy(cfg, (before) => {
262
- req = pending(file, { keyDirs: cfg.keyDirs }).find((r) => r.key === key);
278
+ const current = loadConfig(configPath, before);
279
+ req = pending(file, { keyDirs: current.keyDirs }).find((r) => r.key === key);
263
280
  if (!req) throw bad(`request ${key} is no longer pending — reload to see the queue as it is now`);
264
281
  if (decision !== "granted") return { toml: before, changed: false };
265
- refuseIfBarred(cfg, req);
282
+ if (scope === "file") req = asFileGrant(req);
283
+ refuseIfBarred(current, req);
266
284
  return applyGrant(before, req, reason);
267
- }, { after: () => settle(file, req.key, decision === "granted" ? "granted" : "denied", reason) });
285
+ }, { after: () => settle(file, req.key, decision, reason, { only: req.only }) });
268
286
  } catch (e) {
269
287
  if (e.code === "ELOCKED") throw Object.assign(new Error("another grant is in progress — try again in a moment"), { status: 503 });
270
288
  throw e;
@@ -428,7 +446,7 @@ function declineAll(configPath, { keys, reason }) {
428
446
  after: () => {
429
447
  for (const req of pending(file)) {
430
448
  if (!wanted.has(req.key)) continue;
431
- settle(file, req.key, "denied", reason);
449
+ settle(file, req.key, "declined", reason);
432
450
  declined++;
433
451
  }
434
452
  },
package/src/spool.js CHANGED
@@ -29,6 +29,7 @@
29
29
  import { createServer, createConnection, Socket } from "node:net";
30
30
  import { unlinkSync, existsSync, openSync, writeSync, closeSync, constants } from "node:fs";
31
31
  import { execFileSync } from "node:child_process";
32
+ import { lineReader as readLines } from "./lines.js";
32
33
 
33
34
  /** The env var a confined hook looks for. Absent means "write the file". */
34
35
  export const SOCK_ENV = "SEISIN_SPOOL";
@@ -62,24 +63,14 @@ const PIPE_BUF = 4096;
62
63
 
63
64
  /** Parses newline-separated JSON messages and hands each to `sink`. */
64
65
  function lineReader(sink, onTooLong) {
65
- let buffer = "";
66
- return (chunk) => {
67
- buffer += chunk;
68
- // A 1 MB line is not a log entry, it is something trying to fill a disk.
69
- if (buffer.length > 1_000_000) { buffer = ""; return onTooLong?.(); }
70
- let nl;
71
- while ((nl = buffer.indexOf("\n")) !== -1) {
72
- const line = buffer.slice(0, nl);
73
- buffer = buffer.slice(nl + 1);
74
- if (!line.trim()) continue;
75
- try {
76
- const msg = JSON.parse(line);
77
- if (msg && (msg.to === "log" || msg.to === "requests")) sink(msg.to, msg.entry);
78
- } catch {
79
- // Garbage from inside the sandbox is not the parent's emergency.
80
- }
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.
81
72
  }
82
- };
73
+ }, { onTooLong });
83
74
  }
84
75
 
85
76
  /**
package/src/srt.js CHANGED
@@ -14,24 +14,30 @@
14
14
  * error, which is most of what this file is for.
15
15
  */
16
16
  import { unknownRole } from "./suggest.js";
17
+ import { privateDir } from "./config.js";
17
18
  import { existsSync, mkdirSync } from "node:fs";
18
19
  import { join, dirname } from "node:path";
20
+ import { homedir } from "node:os";
19
21
  import { STATE_DIR, CONFIG_NAME } from "./layout.js";
20
- import { entriesOf } from "./keys.js";
22
+ import { entriesOf, injectKeysOf } from "./keys.js";
21
23
  import { enforcedNeverWrites } from "./owners.js";
22
24
  import { realAncestor } from "./paths.js";
23
25
  import { denyFor } from "./surface.js";
24
- import { runsRootOf } from "./rundir.js";
26
+ import { runsRootOf, SETTINGS_NAME, TMP_NAME } from "./rundir.js";
25
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";
26
31
  import {
27
32
  SOCKET_MAX, isLink, realOrSelf, expand, homeFits, roleHome, roleHomeRoot,
28
- CREDENTIAL_HOMES, toWritePath, writePathsOf,
33
+ CREDENTIAL_HOMES, toWritePath, writePathsOf, otherAgentsCredentials,
29
34
  } from "./grants.js";
30
35
 
31
36
  // Re-exported: these were srt.js's exports before grants.js existed, and
32
37
  // callers (the CLI, the tests, anyone embedding seisin) import them from here.
33
38
  export {
34
39
  expand, homeFits, roleHome, roleHomeRoot, CREDENTIAL_HOMES, RUNTIME_WRITES,
40
+ AGENTS, AGENT_HOMES, AGENT_CREDENTIALS, agentOf,
35
41
  } from "./grants.js";
36
42
 
37
43
 
@@ -77,7 +83,7 @@ export const NO_PROXY_WITHOUT_LOOPBACK = "169.254.0.0/16,10.0.0.0/8,172.16.0.0/1
77
83
 
78
84
  /**
79
85
  * The command a role runs, with loopback routed through the proxy when it
80
- * has `local_ports`.
86
+ * has `local_ports` and the sandbox is sandbox-runtime.
81
87
  *
82
88
  * An `env` in front and not a variable in the environment seisin passes: the
83
89
  * runtime sets NO_PROXY itself, after seisin's environment, so the only place
@@ -86,14 +92,35 @@ export const NO_PROXY_WITHOUT_LOOPBACK = "169.254.0.0/16,10.0.0.0/8,172.16.0.0/1
86
92
  * to be pointed at the proxy, or the port needs `local_binding`, which opens
87
93
  * every port.
88
94
  */
89
- export function loopbackVia(role, cmd) {
90
- 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;
91
102
  return ["env", `NO_PROXY=${NO_PROXY_WITHOUT_LOOPBACK}`, `no_proxy=${NO_PROXY_WITHOUT_LOOPBACK}`, ...cmd];
92
103
  }
93
104
 
94
- 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 } = {}) {
95
115
  const role = config.roles[roleName];
96
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
+ );
97
124
 
98
125
  const abs = (p) => (p.startsWith("/") ? p : join(config.root, p));
99
126
  // "credentials" closes the places credentials live; "home" does that and
@@ -126,10 +153,25 @@ export function settingsFor(config, roleName, spool = null, observe = false) {
126
153
  * any directory that exists. It is the directory Claude Code itself logs
127
154
  * to; making it empty changes nothing for anyone.
128
155
  */
129
- const scratch = writePathsOf(config, config.roles[roleName] ?? { name: roleName, writes: [] });
156
+ const scratch = writePathsOf(config, config.roles[roleName] ?? { name: roleName, writes: [] }, { agent });
130
157
  const runtimeConvenience = ["~/.npm/_logs", "~/.claude/debug"]
131
158
  .map(expand)
132
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));
133
175
 
134
176
  /**
135
177
  * Refuse before the runtime does, because the runtime's refusal says nothing.
@@ -195,9 +237,29 @@ export function settingsFor(config, roleName, spool = null, observe = false) {
195
237
  * touches, gets one wrong, and fails as an unexplainable crash inside the
196
238
  * agent. Naming the places credentials actually live is narrower than the
197
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.
198
245
  */
199
246
  if (shielded) denyRead.push(...CREDENTIAL_HOMES.map(expand));
200
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
+
201
263
  /**
202
264
  * `~/.claude` and `~/.codex` are closed only at the `home` level, and the
203
265
  * difference is the whole reason the two levels exist.
@@ -226,7 +288,7 @@ export function settingsFor(config, roleName, spool = null, observe = false) {
226
288
  *
227
289
  * A reference has no path, so there is nothing here to allow: it is resolved
228
290
  * by the parent before the sandbox starts and handed over as an environment
229
- * 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
230
292
  * upstream keeps the two kinds from having to know about each other — the
231
293
  * settings this function emits are still, entirely, about the filesystem.
232
294
  */
@@ -262,9 +324,9 @@ export function settingsFor(config, roleName, spool = null, observe = false) {
262
324
  /**
263
325
  * Other runs' directories are unreadable; this run's is readable.
264
326
  *
265
- * 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),
266
328
  * and every role runs as the same user, so the directory mode protects
267
- * 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
268
330
  * both ran. Same shape as the key directories: deny the parent, allow your own.
269
331
  */
270
332
  const runs = runsRootOf(spool);
@@ -272,7 +334,101 @@ export function settingsFor(config, roleName, spool = null, observe = false) {
272
334
  if (runs) {
273
335
  denyRead.push(runs);
274
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));
357
+ }
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);
275
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);
276
432
 
277
433
  return {
278
434
  network: {
@@ -280,38 +436,35 @@ export function settingsFor(config, roleName, spool = null, observe = false) {
280
436
  // proxy is the one place that sees the destination PORT. See loopbackVia.
281
437
  // Without ports the value is passed through untouched, `undefined` included: that
282
438
  // is how a config with no network list has always read, and a spread would throw.
283
- allowedDomains: role.localPorts?.length
284
- ? [...(role.network ?? config.allowedDomains ?? []), ...localPortDomains(role.localPorts)]
285
- : role.network ?? config.allowedDomains,
439
+ allowedDomains,
286
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 } : {}),
287
453
  // Exactly one socket: the parent's audit spool, when there is a parent.
288
454
  // Granted by path, not by turning unix sockets on.
289
455
  allowUnixSockets: spool && !isFifo ? [spool] : [],
290
456
  // Per role, `local_binding = true`. Off by default: listening is a
291
457
  // capability, and most roles never need it.
292
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}*`) } : {}),
293
463
  },
294
464
  filesystem: {
295
- denyRead,
465
+ denyRead: spelt,
296
466
  allowRead,
297
- allowWrite: [
298
- // Observing means the kernel stops refusing, because otherwise there is
299
- // nothing to observe. `--observe` used to relax only the hook, so the
300
- // sandbox denied the write anyway and the banner said "nothing denied"
301
- // over a transcript of denials — and `init --from-observations` builds
302
- // a policy out of that transcript. It would read "the agent needs
303
- // nothing outside its territory", which is the opposite of the truth.
304
- //
305
- // The repo, and only the repo. Everything in denyWrite below still
306
- // holds: the config, the state directory and the key directories stay
307
- // shut even here, because observing is not a reason to hand over the
308
- // paperwork of the confinement.
309
- // One function answers "what may this role write" for the settings and
310
- // for surface.js, which needs the union over every role. Two copies of
311
- // that arithmetic would be two answers the day one of them changes.
312
- ...writePathsOf(config, role, { observe }),
313
- ...(isFifo ? [spool] : []),
314
- ],
467
+ allowWrite,
315
468
  /**
316
469
  * The confinement's own paperwork, never writable — not even by a role
317
470
  * that was granted the whole repo.
@@ -329,6 +482,8 @@ export function settingsFor(config, roleName, spool = null, observe = false) {
329
482
  */
330
483
  denyWrite: [
331
484
  ...runtimeConvenience,
485
+ ...sharedTmp,
486
+ ...notMine,
332
487
  /**
333
488
  * The role's own subtractions, `never_writes`.
334
489
  *
@@ -353,6 +508,7 @@ export function settingsFor(config, roleName, spool = null, observe = false) {
353
508
  * each member of the family is on it. `seisin check` prints it.
354
509
  */
355
510
  ...denyFor(config, role, { observe }).map((e) => e.path),
511
+ ...shut,
356
512
  // The audit socket, and the directory holding it. Denying only the
357
513
  // socket left `mv /tmp/seisin-xxxx /tmp/gone` as a way to take the
358
514
  // channel out without ever touching the file that was protected.
@@ -375,7 +531,81 @@ export function settingsFor(config, roleName, spool = null, observe = false) {
375
531
  * and off by default, so the answer is written next to the role that needs it.
376
532
  */
377
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 } : {}),
542
+ };
543
+ }
544
+
545
+ /**
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 })),
378
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).
594
+ */
595
+ export function othersPrivate(config, roleName, abs = (p) => (p.startsWith("/") ? p : join(config.root, p)), { make = false } = {}) {
596
+ const 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;
379
609
  }
380
610
 
381
611
  /** Creates `p` when its parent exists and a role may write there; says whether it now exists. */