seisin 0.2.0 → 0.3.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.
package/src/srt.js CHANGED
@@ -15,170 +15,80 @@
15
15
  */
16
16
  import { join, dirname } from "node:path";
17
17
  import { STATE_DIR, CONFIG_NAME } from "./layout.js";
18
- import { homedir, tmpdir } from "node:os";
19
18
  import { entriesOf } from "./keys.js";
20
- import { realpathSync, lstatSync } from "node:fs";
21
- import { createHash } from "node:crypto";
19
+ import { enforcedNeverWrites } from "./owners.js";
20
+ import { realAncestor } from "./paths.js";
21
+ import { denyFor } from "./surface.js";
22
+ import { runsRootOf } from "./rundir.js";
23
+ import { FIFO_SUFFIX } from "./spool.js";
24
+ import {
25
+ SOCKET_MAX, isLink, realOrSelf, expand, homeFits, roleHome, roleHomeRoot,
26
+ CREDENTIAL_HOMES, toWritePath, writePathsOf,
27
+ } from "./grants.js";
22
28
 
29
+ // Re-exported: these were srt.js's exports before grants.js existed, and
30
+ // callers (the CLI, the tests, anyone embedding seisin) import them from here.
31
+ export {
32
+ expand, homeFits, roleHome, roleHomeRoot, CREDENTIAL_HOMES, RUNTIME_WRITES,
33
+ } from "./grants.js";
34
+
35
+
36
+ /** Reads are denied wholesale under the key directory, then re-allowed one file at a time. */
23
37
  /**
24
- * What every agent needs to write no matter which role it is.
25
- *
26
- * Found by running a real cell config through the sandbox: territory alone
27
- * looks correct and is unusable. An agent writes its session state under its
28
- * own config directory and its tools write scratch files to the temp dir, so a
29
- * policy of "your folders and nothing else" stops the agent before it starts.
30
- *
31
- * The list is not Claude-shaped by accident and it was Claude-shaped by
32
- * mistake: the first version named `~/.claude` and `~/.codex` and stopped
33
- * there, so the first agent that was neither — opencode, which logs to
34
- * `~/.local/share/opencode` — died on startup with `FileSystem.open`. Hence the
35
- * XDG directories, which is where a CLI that follows convention puts its state.
36
- *
37
- * `~/.config` is deliberately NOT here. That is where credentials live —
38
- * `~/.config/gh/hosts.yml` holds a GitHub token — and while reads outside the
39
- * declared key directories are open anyway, letting an agent WRITE there is a
40
- * different thing. A CLI that needs it can be granted it by name.
41
- *
42
- * These are grants, so they are listed rather than assumed: `seisin check`
43
- * prints them, and `[runtime] writes = []` turns them off for anyone who wants
44
- * to find out the hard way. Note what is NOT here — the home directory, the
45
- * shell profile, anything under the repo. Scratch space is not a back door.
46
- */
47
- /**
48
- * Where a role's home and scratch live when `[runtime] isolate = true`.
38
+ * The settings one role gets.
49
39
  *
50
- * Under the state directory but outside the repo tree the roles write, so one
51
- * role cannot reach another's. Kept out of `.seisin/` inside the repo because
52
- * that whole directory is denyWrite now.
40
+ * `spool` is the path of the parent's audit socket, when a parent is holding
41
+ * one. It is granted by path so the hook can report what it decided without
42
+ * the repo's state directory being writable from inside — see spool.js. With
43
+ * no parent there is nothing to grant and nothing to reach.
53
44
  */
54
45
  /**
55
- * Short on purpose, and the reason is the same one spool.js already names: a
56
- * unix socket path is capped near 104 bytes on macOS and the failure when it is
57
- * too long is an unhelpful EINVAL.
46
+ * `local_ports`, as the allowlist entries that let the proxy dial them.
58
47
  *
59
- * This file forgot that one layer down. The runtime creates its multiplexing
60
- * socket INSIDE the role's home — `<home>/tmp/srt-mux-<pid>-0.sock`, 25 bytes —
61
- * and the name here used to be `seisin-home-` plus 16 characters. On macOS
62
- * `tmpdir()` is 48 bytes on its own, so the total cleared 104 for **every role
63
- * name**, `qa` included: `isolate = true` did not misbehave, it failed to start.
64
- * Measured, and it is why `homeFits` below exists rather than a comment.
48
+ * Why the proxy and not the kernel. The kernel profile has exactly two answers
49
+ * for loopback: nothing, or `localhost:*` (`allowLocalBinding`), and the runtime
50
+ * exposes no third. The proxy is the one component that sees a destination
51
+ * port, and it already takes `host:port` entries. It refuses loopback unless
52
+ * the literal and port are listed, which is the property this relies on: 9000
53
+ * stays refused when 8001 is open. Measured, 2026-09-23: curl, Node's fetch
54
+ * and Python's urllib reach a listed port and get "Connection blocked by
55
+ * network allowlist" on an unlisted one.
65
56
  *
66
- * Eleven bytes instead of twenty-eight leaves room for a role name up to
67
- * {@link MAX_ROLE_FOR_HOME} characters.
57
+ * All three spellings, because a client says whichever it says.
68
58
  */
69
- export function roleHomeRoot(config) {
70
- /**
71
- * A hash of the whole path, not a slice of it.
72
- *
73
- * This was `base64url(root).slice(-16)` — the TAIL of the encoded path, which
74
- * is the tail of the path itself. Two checkouts that end the same way get the
75
- * same id: `/Users/ana/dev/proyecto` and `/Users/bob/dev/proyecto` collide, and
76
- * so do `/home/a/work/api` and `/home/b/work/api`. A collision here is not a
77
- * cosmetic clash — both repos' role `dev` would share one HOME, which is the
78
- * session token of one handed to the other. Measured on three of four ordinary
79
- * pairs; shortening the slice to fit the socket limit would have made it more
80
- * likely, not less.
81
- */
82
- const id = createHash("sha256").update(config.root).digest("base64url").slice(0, 8);
83
- return join(tmpdir(), `sn-${id}`);
59
+ export function localPortDomains(ports = []) {
60
+ return (ports ?? []).flatMap((p) => [`localhost:${p}`, `127.0.0.1:${p}`, `[::1]:${p}`]);
84
61
  }
85
62
 
86
- /** What the runtime appends inside the role's home, at its longest. */
87
- const SRT_SOCKET_TAIL = "/tmp/srt-mux-999999-0.sock".length;
88
-
89
- /** The platform's cap on a unix socket path. macOS is the tight one. */
90
- const SOCKET_MAX = 104;
91
-
92
63
  /**
93
- * Whether a role's isolated home leaves room for the runtime's socket.
64
+ * What the runtime puts in NO_PROXY, minus loopback.
94
65
  *
95
- * Returns the room left over, negative when it does not fit. Callers refuse
96
- * rather than let the runtime fail with EINVAL and no explanation — a boundary
97
- * that cannot start should say so in its own words.
98
- */
99
- export function homeFits(config, role) {
100
- return SOCKET_MAX - (roleHome(config, role).length + SRT_SOCKET_TAIL);
101
- }
102
-
103
- export function roleHome(config, role) {
104
- return join(roleHomeRoot(config), role);
105
- }
106
-
107
- /**
108
- * Where credentials live in a home directory, denied at both isolate levels.
66
+ * The runtime always exempts localhost from the proxy, so a client talks to
67
+ * 127.0.0.1:8001 directly — and the kernel, which has no port list, refuses
68
+ * it. For a role with `local_ports` the exemption is taken back, so loopback
69
+ * goes through the proxy, where the port list is. Only for those roles: for
70
+ * every other role loopback is refused either way and nothing changes.
109
71
  *
110
- * Denies rather than a read allowlist, and the reasoning is above: a
111
- * default-deny read set has to enumerate every library, interpreter and cache a
112
- * toolchain touches, gets one wrong, and fails as an unexplainable crash inside
113
- * the agent. Naming the places credentials actually live is narrower than the
114
- * ideal and holds up.
115
- *
116
- * `~/.config` is here whole rather than `~/.config/gh`: it is where a growing
117
- * number of CLIs keep their tokens, and listing them one by one is the
118
- * enumeration this list exists to avoid. A tool that needs a directory under it
119
- * can be granted it by name.
72
+ * Kept in step with sandbox-utils.js of the pinned runtime by a test.
120
73
  */
121
- export const CREDENTIAL_HOMES = [
122
- // The order is not arbitrary: `check` names the first few, so the ones a
123
- // reader recognises instantly go first. Everything in the list is denied
124
- // either way.
125
- "~/.ssh", "~/.aws", "~/.npmrc", "~/.config",
126
- "~/.gnupg", "~/.kube", "~/.docker", "~/.netrc", "~/.git-credentials",
127
- ];
128
-
129
- export const RUNTIME_WRITES = [
130
- "~/.claude", "~/.codex", // the CLIs that keep state under their own name
131
- "~/.local/share", "~/.local/state", // XDG data and state: where most others log
132
- "~/.cache", "$TMPDIR", "/tmp",
133
- ];
74
+ export const NO_PROXY_WITHOUT_LOOPBACK = "169.254.0.0/16,10.0.0.0/8,172.16.0.0/12,192.168.0.0/16";
134
75
 
135
76
  /**
136
- * `~` and `$TMPDIR` are the only expansions; everything else is a literal path.
77
+ * The command a role runs, with loopback routed through the proxy when it
78
+ * has `local_ports`.
137
79
  *
138
- * Symlinks are then resolved, and that is not a nicety. On macOS `/tmp` is a
139
- * link to `/private/tmp`, and the sandbox enforces on the destination — so a
140
- * grant written as `/tmp` grants exactly nothing, silently. Measured: with the
141
- * literal path, writing to /tmp inside the sandbox failed while the policy
142
- * looked correct on screen.
80
+ * An `env` in front and not a variable in the environment seisin passes: the
81
+ * 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,
83
+ * Chromium, which exempts loopback on its own — still get refused. They need
84
+ * to be pointed at the proxy, or the port needs `local_binding`, which opens
85
+ * every port.
143
86
  */
144
- /** Is this exact path a symlink? False if it is not on disk. */
145
- function isLink(p) {
146
- try {
147
- return lstatSync(p).isSymbolicLink();
148
- } catch {
149
- return false;
150
- }
151
- }
152
-
153
- /** The path with symlinks followed, or the path itself if it is not on disk. */
154
- function realOrSelf(p) {
155
- try {
156
- return realpathSync(p);
157
- } catch {
158
- return p;
159
- }
87
+ export function loopbackVia(role, cmd) {
88
+ if (!role?.localPorts?.length) return cmd;
89
+ return ["env", `NO_PROXY=${NO_PROXY_WITHOUT_LOOPBACK}`, `no_proxy=${NO_PROXY_WITHOUT_LOOPBACK}`, ...cmd];
160
90
  }
161
91
 
162
- export function expand(p) {
163
- let out = p;
164
- if (p === "$TMPDIR") out = tmpdir();
165
- else if (p === "~" || p.startsWith("~/")) out = join(homedir(), p.slice(2));
166
- try {
167
- return realpathSync(out);
168
- } catch {
169
- return out; // not on disk yet; hand the literal through rather than drop it
170
- }
171
- }
172
-
173
- /** Reads are denied wholesale under the key directory, then re-allowed one file at a time. */
174
- /**
175
- * The settings one role gets.
176
- *
177
- * `spool` is the path of the parent's audit socket, when a parent is holding
178
- * one. It is granted by path so the hook can report what it decided without
179
- * the repo's state directory being writable from inside — see spool.js. With
180
- * no parent there is nothing to grant and nothing to reach.
181
- */
182
92
  export function settingsFor(config, roleName, spool = null, observe = false) {
183
93
  const role = config.roles[roleName];
184
94
  if (!role) throw new Error(`unknown role "${roleName}". Known: ${Object.keys(config.roles).join(", ")}`);
@@ -322,14 +232,37 @@ export function settingsFor(config, roleName, spool = null, observe = false) {
322
232
  allowRead.push(path);
323
233
  }
324
234
 
235
+ /**
236
+ * Other runs' directories are unreadable; this run's is readable.
237
+ *
238
+ * Every run keeps its socket and `scratch` keys under one root (rundir.js),
239
+ * 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
241
+ * both ran. Same shape as the key directories: deny the parent, allow your own.
242
+ */
243
+ const runs = runsRootOf(spool);
244
+ const isFifo = spool?.endsWith(FIFO_SUFFIX) === true;
245
+ if (runs) {
246
+ denyRead.push(runs);
247
+ allowRead.push(dirname(spool));
248
+ }
249
+
325
250
  return {
326
251
  network: {
327
- allowedDomains: role.network ?? config.allowedDomains,
252
+ // `local_ports` rides the same allowlist as the domains: the runtime's
253
+ // proxy is the one place that sees the destination PORT. See loopbackVia.
254
+ // Without ports the value is passed through untouched, `undefined` included: that
255
+ // 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,
328
259
  deniedDomains: [],
329
260
  // Exactly one socket: the parent's audit spool, when there is a parent.
330
261
  // Granted by path, not by turning unix sockets on.
331
- allowUnixSockets: spool ? [spool] : [],
332
- allowLocalBinding: false,
262
+ allowUnixSockets: spool && !isFifo ? [spool] : [],
263
+ // Per role, `local_binding = true`. Off by default: listening is a
264
+ // capability, and most roles never need it.
265
+ allowLocalBinding: role.localBinding === true,
333
266
  },
334
267
  filesystem: {
335
268
  denyRead,
@@ -346,18 +279,11 @@ export function settingsFor(config, roleName, spool = null, observe = false) {
346
279
  // holds: the config, the state directory and the key directories stay
347
280
  // shut even here, because observing is not a reason to hand over the
348
281
  // paperwork of the confinement.
349
- ...(observe ? [abs(".")] : role.writes.map(toWritePath).map(abs)),
350
- // `.seisin/` is deliberately NOT here. It used to be, because the hook
351
- // runs inside the box and has to record what it decided — which made
352
- // the log and the queue writable by the process they are a record of.
353
- // The hook now sends its lines to the parent over a socket and the
354
- // parent holds the file. See spool.js.
355
- // Isolated: this role's own home and scratch, and nothing shared.
356
- // Otherwise: the real ~/.claude, ~/.cache and /tmp, which every role
357
- // shares — convenient, and the reason `isolate` exists.
358
- ...(isolated
359
- ? [home, join(home, ".config"), join(home, ".local"), join(home, ".cache"), join(home, "tmp")]
360
- : (config.runtimeWrites ?? RUNTIME_WRITES).map(expand)),
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] : []),
361
287
  ],
362
288
  /**
363
289
  * The confinement's own paperwork, never writable — not even by a role
@@ -375,36 +301,52 @@ export function settingsFor(config, roleName, spool = null, observe = false) {
375
301
  * relying on someone else's implementation detail staying put.
376
302
  */
377
303
  denyWrite: [
378
- abs(config.path ?? CONFIG_NAME),
379
- abs(STATE_DIR),
380
- ...dirs.map(abs),
304
+ /**
305
+ * The role's own subtractions, `never_writes`.
306
+ *
307
+ * Here and not as a hole cut out of allowWrite, because the profile
308
+ * cannot cut holes: it grants prefixes and denies prefixes, and a deny
309
+ * wins over any grant however wide. That is the property the key is
310
+ * built on, and the same one the entries below rely on.
311
+ */
312
+ ...enforcedNeverWrites(config, role)
313
+ .map((g) => abs(toWritePath(g, "never_writes")))
314
+ // Both spellings: the one written and the one the kernel will meet.
315
+ // With a symlink on the way (`app/data -> store`) the write lands on
316
+ // `store/…`, and a deny on the written path alone let it through —
317
+ // measured by the review of 2026-09-22 (rc=0, file created).
318
+ .flatMap((p) => { const r = realAncestor(p); return r === p ? [p] : [p, r]; }),
319
+ /**
320
+ * 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
322
+ * directories, the key providers, `file://` targets, programs on PATH
323
+ * installed where a role writes, and each project's hooks and settings.
324
+ * One list with a reason per entry, built in surface.js, which says why
325
+ * each member of the family is on it. `seisin check` prints it.
326
+ */
327
+ ...denyFor(config, role, { observe }).map((e) => e.path),
381
328
  // The audit socket, and the directory holding it. Denying only the
382
329
  // socket left `mv /tmp/seisin-xxxx /tmp/gone` as a way to take the
383
330
  // channel out without ever touching the file that was protected.
384
331
  // Connecting is `network-outbound` and both of those are
385
332
  // `file-write-unlink`, so denying them leaves the first working.
386
- ...(spool ? [spool, dirname(spool)] : []),
387
- /**
388
- * Whatever the key providers execute.
389
- *
390
- * `seisin.toml` is denied because a role that can rewrite the policy
391
- * has no policy. A provider command is the same thing one level out:
392
- * the parent runs it, unsandboxed, as you — so a role that can rewrite
393
- * `bin/open-vault.sh` decides what runs outside the box. That is not a
394
- * wider boundary, it is no boundary, and it arrives disguised as an
395
- * ordinary file in somebody's territory.
396
- *
397
- * Found by writing the documentation: every worked example ended up
398
- * using a script, because the config language has no escapes and a
399
- * one-line shell pipeline cannot be spelled. So the shape this protects
400
- * is not a corner case — it is the shape the tool pushes you into.
401
- *
402
- * Only a command that resolves to a path. A bare name like `security`
403
- * or `op` is found on PATH, which is not this policy's to reason about.
404
- */
405
- ...providerPaths(config),
333
+ // And the root all runs live under, so no role can put a symlink where
334
+ // the next run will write its settings and keys.
335
+ // A FIFO channel (Linux) is written, so it is granted below and only
336
+ // its directory is denied here: the role writes lines into it and can
337
+ // neither remove it nor put anything beside it (measured in Docker).
338
+ ...(spool ? [...(isFifo ? [] : [spool]), dirname(spool), ...(runs ? [runs] : [])] : []),
406
339
  ],
407
340
  },
341
+ /**
342
+ * `trustd = true`: the one Mach service TLS needs on macOS for tools that
343
+ * verify through the Security framework — Go before 1.27 (gh, kubectl,
344
+ * terraform) and every Dart/Flutter. The runtime ships it as
345
+ * `enableWeakerNetworkIsolation` and warns it is an exfiltration path:
346
+ * trustd fetches, outside the box, the URLs a certificate carries. Per role
347
+ * and off by default, so the answer is written next to the role that needs it.
348
+ */
349
+ ...(role.trustd === true ? { enableWeakerNetworkIsolation: true } : {}),
408
350
  };
409
351
  }
410
352
 
@@ -425,47 +367,3 @@ export function providerPaths(config) {
425
367
  return [...new Set(out)];
426
368
  }
427
369
 
428
- /**
429
- * `allowWrite` takes directories, not globs: the OS grants a subtree, it does
430
- * not pattern-match. `src/api/**` and `src/api/` mean the same thing to the
431
- * kernel, so the trailing glob is trimmed rather than passed through, where it
432
- * would be read as a literal directory named `**` and silently grant nothing.
433
- */
434
- function toWritePath(glob) {
435
- if (!EXPRESSIBLE(glob)) throw new Error(unexpressible(glob));
436
- if (glob === "**") return ".";
437
- return glob.replace(/\/\*\*$/, "") || ".";
438
- }
439
-
440
- /**
441
- * A write pattern the kernel can be given without changing its meaning.
442
- *
443
- * Exactly two shapes qualify: a literal path, and a subtree ending in `/**`.
444
- * Both map onto what allowWrite actually is — a prefix — so the policy, the
445
- * explanation and the enforcement stay the same sentence.
446
- *
447
- * Everything else is refused, and the one that made this necessary is `src/*`.
448
- * ownersOf() reads it as one level, because `*` compiles to `[^/]*`. The old
449
- * translation trimmed the `/*` and handed the kernel `src`, which is the whole
450
- * subtree. So `seisin explain` said denied and the write landed — measured, not
451
- * theorised. The document was tighter than the boundary, which is the one
452
- * direction a permission tool must never fail in.
453
- *
454
- * There is no exact translation to find later: Seatbelt and bubblewrap grant
455
- * prefixes, and "one level down" is not a prefix. Refusing is not a placeholder
456
- * here, it is the answer.
457
- */
458
- const WILD = /[*?[\]]/; // owners.js treats all four as wildcards
459
- const EXPRESSIBLE = (g) =>
460
- g === "**" || // the whole repo
461
- !WILD.test(g) || // a literal path
462
- (g.endsWith("/**") && !WILD.test(g.slice(0, -3))); // a subtree, wildcard-free above it
463
-
464
- function unexpressible(glob) {
465
- return `writes = "${glob}" cannot be enforced as written.\n` +
466
- ` The sandbox grants a path and everything under it — there is no way to say ` +
467
- `"one level deep".\n` +
468
- ` Use "${glob.replace(/\/\*$/, "")}/**" for the whole subtree, or name the files.\n` +
469
- ` Refusing rather than widening: the old behaviour granted the subtree while ` +
470
- `seisin reported the narrow pattern.`;
471
- }