seisin 0.1.1 → 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,169 +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
- import { realpathSync, lstatSync } from "node:fs";
20
- import { createHash } from "node:crypto";
18
+ import { entriesOf } from "./keys.js";
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";
21
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. */
22
37
  /**
23
- * What every agent needs to write no matter which role it is.
24
- *
25
- * Found by running a real cell config through the sandbox: territory alone
26
- * looks correct and is unusable. An agent writes its session state under its
27
- * own config directory and its tools write scratch files to the temp dir, so a
28
- * policy of "your folders and nothing else" stops the agent before it starts.
29
- *
30
- * The list is not Claude-shaped by accident and it was Claude-shaped by
31
- * mistake: the first version named `~/.claude` and `~/.codex` and stopped
32
- * there, so the first agent that was neither — opencode, which logs to
33
- * `~/.local/share/opencode` — died on startup with `FileSystem.open`. Hence the
34
- * XDG directories, which is where a CLI that follows convention puts its state.
35
- *
36
- * `~/.config` is deliberately NOT here. That is where credentials live —
37
- * `~/.config/gh/hosts.yml` holds a GitHub token — and while reads outside the
38
- * declared key directories are open anyway, letting an agent WRITE there is a
39
- * different thing. A CLI that needs it can be granted it by name.
40
- *
41
- * These are grants, so they are listed rather than assumed: `seisin check`
42
- * prints them, and `[runtime] writes = []` turns them off for anyone who wants
43
- * to find out the hard way. Note what is NOT here — the home directory, the
44
- * shell profile, anything under the repo. Scratch space is not a back door.
45
- */
46
- /**
47
- * Where a role's home and scratch live when `[runtime] isolate = true`.
38
+ * The settings one role gets.
48
39
  *
49
- * Under the state directory but outside the repo tree the roles write, so one
50
- * role cannot reach another's. Kept out of `.seisin/` inside the repo because
51
- * 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.
52
44
  */
53
45
  /**
54
- * Short on purpose, and the reason is the same one spool.js already names: a
55
- * unix socket path is capped near 104 bytes on macOS and the failure when it is
56
- * too long is an unhelpful EINVAL.
57
- *
58
- * This file forgot that one layer down. The runtime creates its multiplexing
59
- * socket INSIDE the role's home — `<home>/tmp/srt-mux-<pid>-0.sock`, 25 bytes —
60
- * and the name here used to be `seisin-home-` plus 16 characters. On macOS
61
- * `tmpdir()` is 48 bytes on its own, so the total cleared 104 for **every role
62
- * name**, `qa` included: `isolate = true` did not misbehave, it failed to start.
63
- * Measured, and it is why `homeFits` below exists rather than a comment.
46
+ * `local_ports`, as the allowlist entries that let the proxy dial them.
64
47
  *
65
- * Eleven bytes instead of twenty-eight leaves room for a role name up to
66
- * {@link MAX_ROLE_FOR_HOME} characters.
67
- */
68
- export function roleHomeRoot(config) {
69
- /**
70
- * A hash of the whole path, not a slice of it.
71
- *
72
- * This was `base64url(root).slice(-16)` — the TAIL of the encoded path, which
73
- * is the tail of the path itself. Two checkouts that end the same way get the
74
- * same id: `/Users/ana/dev/proyecto` and `/Users/bob/dev/proyecto` collide, and
75
- * so do `/home/a/work/api` and `/home/b/work/api`. A collision here is not a
76
- * cosmetic clash — both repos' role `dev` would share one HOME, which is the
77
- * session token of one handed to the other. Measured on three of four ordinary
78
- * pairs; shortening the slice to fit the socket limit would have made it more
79
- * likely, not less.
80
- */
81
- const id = createHash("sha256").update(config.root).digest("base64url").slice(0, 8);
82
- return join(tmpdir(), `sn-${id}`);
83
- }
84
-
85
- /** What the runtime appends inside the role's home, at its longest. */
86
- const SRT_SOCKET_TAIL = "/tmp/srt-mux-999999-0.sock".length;
87
-
88
- /** The platform's cap on a unix socket path. macOS is the tight one. */
89
- const SOCKET_MAX = 104;
90
-
91
- /**
92
- * Whether a role's isolated home leaves room for the runtime's socket.
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.
93
56
  *
94
- * Returns the room left over, negative when it does not fit. Callers refuse
95
- * rather than let the runtime fail with EINVAL and no explanation — a boundary
96
- * that cannot start should say so in its own words.
57
+ * All three spellings, because a client says whichever it says.
97
58
  */
98
- export function homeFits(config, role) {
99
- return SOCKET_MAX - (roleHome(config, role).length + SRT_SOCKET_TAIL);
100
- }
101
-
102
- export function roleHome(config, role) {
103
- return join(roleHomeRoot(config), role);
59
+ export function localPortDomains(ports = []) {
60
+ return (ports ?? []).flatMap((p) => [`localhost:${p}`, `127.0.0.1:${p}`, `[::1]:${p}`]);
104
61
  }
105
62
 
106
63
  /**
107
- * Where credentials live in a home directory, denied at both isolate levels.
64
+ * What the runtime puts in NO_PROXY, minus loopback.
108
65
  *
109
- * Denies rather than a read allowlist, and the reasoning is above: a
110
- * default-deny read set has to enumerate every library, interpreter and cache a
111
- * toolchain touches, gets one wrong, and fails as an unexplainable crash inside
112
- * the agent. Naming the places credentials actually live is narrower than the
113
- * ideal and holds up.
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.
114
71
  *
115
- * `~/.config` is here whole rather than `~/.config/gh`: it is where a growing
116
- * number of CLIs keep their tokens, and listing them one by one is the
117
- * enumeration this list exists to avoid. A tool that needs a directory under it
118
- * can be granted it by name.
72
+ * Kept in step with sandbox-utils.js of the pinned runtime by a test.
119
73
  */
120
- export const CREDENTIAL_HOMES = [
121
- // The order is not arbitrary: `check` names the first few, so the ones a
122
- // reader recognises instantly go first. Everything in the list is denied
123
- // either way.
124
- "~/.ssh", "~/.aws", "~/.npmrc", "~/.config",
125
- "~/.gnupg", "~/.kube", "~/.docker", "~/.netrc", "~/.git-credentials",
126
- ];
127
-
128
- export const RUNTIME_WRITES = [
129
- "~/.claude", "~/.codex", // the CLIs that keep state under their own name
130
- "~/.local/share", "~/.local/state", // XDG data and state: where most others log
131
- "~/.cache", "$TMPDIR", "/tmp",
132
- ];
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";
133
75
 
134
76
  /**
135
- * `~` 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`.
136
79
  *
137
- * Symlinks are then resolved, and that is not a nicety. On macOS `/tmp` is a
138
- * link to `/private/tmp`, and the sandbox enforces on the destination — so a
139
- * grant written as `/tmp` grants exactly nothing, silently. Measured: with the
140
- * literal path, writing to /tmp inside the sandbox failed while the policy
141
- * 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.
142
86
  */
143
- /** Is this exact path a symlink? False if it is not on disk. */
144
- function isLink(p) {
145
- try {
146
- return lstatSync(p).isSymbolicLink();
147
- } catch {
148
- return false;
149
- }
150
- }
151
-
152
- /** The path with symlinks followed, or the path itself if it is not on disk. */
153
- function realOrSelf(p) {
154
- try {
155
- return realpathSync(p);
156
- } catch {
157
- return p;
158
- }
159
- }
160
-
161
- export function expand(p) {
162
- let out = p;
163
- if (p === "$TMPDIR") out = tmpdir();
164
- else if (p === "~" || p.startsWith("~/")) out = join(homedir(), p.slice(2));
165
- try {
166
- return realpathSync(out);
167
- } catch {
168
- return out; // not on disk yet; hand the literal through rather than drop it
169
- }
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];
170
90
  }
171
91
 
172
- /** Reads are denied wholesale under the key directory, then re-allowed one file at a time. */
173
- /**
174
- * The settings one role gets.
175
- *
176
- * `spool` is the path of the parent's audit socket, when a parent is holding
177
- * one. It is granted by path so the hook can report what it decided without
178
- * the repo's state directory being writable from inside — see spool.js. With
179
- * no parent there is nothing to grant and nothing to reach.
180
- */
181
92
  export function settingsFor(config, roleName, spool = null, observe = false) {
182
93
  const role = config.roles[roleName];
183
94
  if (!role) throw new Error(`unknown role "${roleName}". Known: ${Object.keys(config.roles).join(", ")}`);
@@ -283,7 +194,17 @@ export function settingsFor(config, roleName, spool = null, observe = false) {
283
194
  denyRead.push(realOrSelf(roleHomeRoot(config)));
284
195
  allowRead.push(realOrSelf(home));
285
196
  }
286
- for (const key of role.keys) {
197
+ /**
198
+ * Only a key that IS a file becomes a read grant.
199
+ *
200
+ * A reference has no path, so there is nothing here to allow: it is resolved
201
+ * 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
203
+ * upstream keeps the two kinds from having to know about each other — the
204
+ * settings this function emits are still, entirely, about the filesystem.
205
+ */
206
+ for (const entry of entriesOf(role).filter((e) => e.kind === "file")) {
207
+ const key = entry.raw;
287
208
  const path = key.includes("/") ? abs(key) : abs(join(dirs[0] ?? ".", key));
288
209
  // A key has to live in a declared key directory. Without this check a
289
210
  // slash in the name made the path relative to the repo root instead, so
@@ -311,14 +232,37 @@ export function settingsFor(config, roleName, spool = null, observe = false) {
311
232
  allowRead.push(path);
312
233
  }
313
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
+
314
250
  return {
315
251
  network: {
316
- 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,
317
259
  deniedDomains: [],
318
260
  // Exactly one socket: the parent's audit spool, when there is a parent.
319
261
  // Granted by path, not by turning unix sockets on.
320
- allowUnixSockets: spool ? [spool] : [],
321
- 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,
322
266
  },
323
267
  filesystem: {
324
268
  denyRead,
@@ -335,18 +279,11 @@ export function settingsFor(config, roleName, spool = null, observe = false) {
335
279
  // holds: the config, the state directory and the key directories stay
336
280
  // shut even here, because observing is not a reason to hand over the
337
281
  // paperwork of the confinement.
338
- ...(observe ? [abs(".")] : role.writes.map(toWritePath).map(abs)),
339
- // `.seisin/` is deliberately NOT here. It used to be, because the hook
340
- // runs inside the box and has to record what it decided — which made
341
- // the log and the queue writable by the process they are a record of.
342
- // The hook now sends its lines to the parent over a socket and the
343
- // parent holds the file. See spool.js.
344
- // Isolated: this role's own home and scratch, and nothing shared.
345
- // Otherwise: the real ~/.claude, ~/.cache and /tmp, which every role
346
- // shares — convenient, and the reason `isolate` exists.
347
- ...(isolated
348
- ? [home, join(home, ".config"), join(home, ".local"), join(home, ".cache"), join(home, "tmp")]
349
- : (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] : []),
350
287
  ],
351
288
  /**
352
289
  * The confinement's own paperwork, never writable — not even by a role
@@ -364,61 +301,69 @@ export function settingsFor(config, roleName, spool = null, observe = false) {
364
301
  * relying on someone else's implementation detail staying put.
365
302
  */
366
303
  denyWrite: [
367
- abs(config.path ?? CONFIG_NAME),
368
- abs(STATE_DIR),
369
- ...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),
370
328
  // The audit socket, and the directory holding it. Denying only the
371
329
  // socket left `mv /tmp/seisin-xxxx /tmp/gone` as a way to take the
372
330
  // channel out without ever touching the file that was protected.
373
331
  // Connecting is `network-outbound` and both of those are
374
332
  // `file-write-unlink`, so denying them leaves the first working.
375
- ...(spool ? [spool, dirname(spool)] : []),
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] : [])] : []),
376
339
  ],
377
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 } : {}),
378
350
  };
379
351
  }
380
352
 
381
353
  /**
382
- * `allowWrite` takes directories, not globs: the OS grants a subtree, it does
383
- * not pattern-match. `src/api/**` and `src/api/` mean the same thing to the
384
- * kernel, so the trailing glob is trimmed rather than passed through, where it
385
- * would be read as a literal directory named `**` and silently grant nothing.
386
- */
387
- function toWritePath(glob) {
388
- if (!EXPRESSIBLE(glob)) throw new Error(unexpressible(glob));
389
- if (glob === "**") return ".";
390
- return glob.replace(/\/\*\*$/, "") || ".";
391
- }
392
-
393
- /**
394
- * A write pattern the kernel can be given without changing its meaning.
395
- *
396
- * Exactly two shapes qualify: a literal path, and a subtree ending in `/**`.
397
- * Both map onto what allowWrite actually is — a prefix — so the policy, the
398
- * explanation and the enforcement stay the same sentence.
399
- *
400
- * Everything else is refused, and the one that made this necessary is `src/*`.
401
- * ownersOf() reads it as one level, because `*` compiles to `[^/]*`. The old
402
- * translation trimmed the `/*` and handed the kernel `src`, which is the whole
403
- * subtree. So `seisin explain` said denied and the write landed — measured, not
404
- * theorised. The document was tighter than the boundary, which is the one
405
- * direction a permission tool must never fail in.
354
+ * The provider executables that live at a path, absolute.
406
355
  *
407
- * There is no exact translation to find later: Seatbelt and bubblewrap grant
408
- * prefixes, and "one level down" is not a prefix. Refusing is not a placeholder
409
- * here, it is the answer.
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.
410
359
  */
411
- const WILD = /[*?[\]]/; // owners.js treats all four as wildcards
412
- const EXPRESSIBLE = (g) =>
413
- g === "**" || // the whole repo
414
- !WILD.test(g) || // a literal path
415
- (g.endsWith("/**") && !WILD.test(g.slice(0, -3))); // a subtree, wildcard-free above it
416
-
417
- function unexpressible(glob) {
418
- return `writes = "${glob}" cannot be enforced as written.\n` +
419
- ` The sandbox grants a path and everything under it — there is no way to say ` +
420
- `"one level deep".\n` +
421
- ` Use "${glob.replace(/\/\*$/, "")}/**" for the whole subtree, or name the files.\n` +
422
- ` Refusing rather than widening: the old behaviour granted the subtree while ` +
423
- `seisin reported the narrow pattern.`;
360
+ export function providerPaths(config) {
361
+ 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)];
424
368
  }
369
+