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/config.js CHANGED
@@ -11,6 +11,8 @@
11
11
  import { readFileSync, existsSync } from "node:fs";
12
12
  import { resolve, dirname, join } from "node:path";
13
13
  import { CONFIG_NAME } from "./layout.js";
14
+ import { parseKey, MODES, checkNames, BUILTIN } from "./keys.js";
15
+ import { readNotify } from "./notify.js";
14
16
 
15
17
  export { CONFIG_NAME } from "./layout.js";
16
18
 
@@ -170,10 +172,34 @@ function readValue(value, lineNo) {
170
172
  expectItem = true; i++; continue;
171
173
  }
172
174
  if (!expectItem) bad(`missing comma before ${JSON.stringify(body.slice(i, i + 12))}`);
175
+ // Whole numbers, for `local_ports`. Digits only: no sign, no decimal, no
176
+ // underscore — a port is none of those, and every other key still
177
+ // refuses a number through asArray.
178
+ const digits = /^\d+/.exec(body.slice(i));
179
+ if (digits) {
180
+ const after = body[i + digits[0].length];
181
+ if (after !== undefined && after !== "," && after !== " " && after !== "\t")
182
+ bad(`${JSON.stringify(body.slice(i, i + 12))} is not a whole number`);
183
+ items.push(Number(digits[0]));
184
+ i += digits[0].length;
185
+ expectItem = false;
186
+ continue;
187
+ }
173
188
  if (c !== '"') bad(`array items must be double-quoted, found ${JSON.stringify(body.slice(i, i + 12))}`);
174
189
 
175
190
  const end = body.indexOf('"', i + 1);
176
191
  if (end === -1) bad("unterminated string inside the array");
192
+ // A backslash before the closing quote means somebody wrote `\"` and
193
+ // expected an escape. There are none — the quote just ended the string,
194
+ // and the error they get otherwise is about a missing comma somewhere in
195
+ // the middle of a shell pipeline, which names the symptom and hides the
196
+ // cause. Measured while writing the docs: every attempt at a one-line
197
+ // provider command landed here.
198
+ if (body[end - 1] === "\\")
199
+ bad(`there are no escapes in this format, so the \\" in ` +
200
+ `${JSON.stringify(body.slice(i, Math.min(end + 2, i + 40)))} ended the string early.\n` +
201
+ ` A value cannot contain a double quote. If this is a shell command, put it in a ` +
202
+ `script and name the script instead — which is also how it stops being unreadable.`);
177
203
  items.push(body.slice(i + 1, end));
178
204
  i = end + 1;
179
205
  expectItem = false; // a trailing comma is fine; a trailing item is not
@@ -181,7 +207,9 @@ function readValue(value, lineNo) {
181
207
  return items;
182
208
  }
183
209
 
184
- bad("value must be a string, an array of strings, or a boolean");
210
+ if (/^\d+$/.test(v)) return Number(v);
211
+
212
+ bad("value must be a string, an array of strings, a whole number, or a boolean");
185
213
  }
186
214
 
187
215
  /**
@@ -273,6 +301,7 @@ export function loadConfig(path) {
273
301
  // is how a tool gets kept out.
274
302
  const keyDir = own(own(parsed, "keys"), "dir");
275
303
  const keyDirs = keyDir === undefined ? [] : asArray(keyDir, "keys.dir");
304
+ const keyProviders = readProviders(own(own(parsed, "keys"), "providers"), path);
276
305
  // `undefined` means "use the defaults"; an explicit empty array means "none".
277
306
  // The difference matters: one is a user who has not thought about it, the
278
307
  // other is a user who has.
@@ -280,7 +309,7 @@ export function loadConfig(path) {
280
309
  const runtimeWrites = own(runtime, "writes");
281
310
 
282
311
  const out = {
283
- root: dirname(path), path, keyDirs,
312
+ root: dirname(path), path, keyDirs, keyProviders,
284
313
  allowedDomains: own(own(parsed, "network"), "allow") ?? [],
285
314
  runtimeWrites: runtimeWrites === undefined ? undefined : asArray(runtimeWrites, "runtime.writes"),
286
315
  // `[runtime] isolate` — how much of your home a role stops reaching. See
@@ -296,22 +325,298 @@ export function loadConfig(path) {
296
325
  const r = own(roles, name);
297
326
  const writes = asArray(own(r, "writes"), `roles.${name}.writes`);
298
327
  const keys = asArray(own(r, "keys"), `roles.${name}.keys`);
299
- if (keys.length && keyDirs.length === 0)
328
+ // Read once, here, so every consumer sees the same answer about what an
329
+ // entry is. `keys` itself stays the array of strings that was written: it
330
+ // is what `render`, `serve` and `init` print, and the whole point of a
331
+ // reference is that the written form is the reviewable one.
332
+ const keyEntries = keys.map((k) => {
333
+ try {
334
+ return parseKey(k);
335
+ } catch (e) {
336
+ throw new Error(`${path}: roles.${name}: ${e.message}`);
337
+ }
338
+ });
339
+ // Only a path needs somewhere to live. A reference does not, and demanding
340
+ // `[keys] dir` from a config whose secrets are all in a vault would be the
341
+ // tool insisting on the arrangement it was built for.
342
+ if (keyEntries.some((k) => k.kind === "file") && keyDirs.length === 0)
300
343
  throw new Error(`${path}: roles.${name} lists keys, but [keys] dir is not set`);
344
+ /**
345
+ * An unknown scheme is refused, not ignored.
346
+ *
347
+ * Same rule as everywhere else here: a `keys = ["vault://x"]` with no
348
+ * `[keys.providers.vault]` is a configuration error, and the alternative —
349
+ * treating it as a filename, or as a key that quietly never arrives — is a
350
+ * policy that reads as protecting something it is not.
351
+ */
352
+ for (const k of keyEntries) {
353
+ // `file://` ships with seisin, so it needs no declaration. It is the one
354
+ // builtin, and the reason is in keys.js: a secret in a plain file is the
355
+ // case this exists for, and making that case declare a provider that
356
+ // runs `cat` is a papercut on the only path most people take.
357
+ if (k.kind !== "ref" || BUILTIN.has(k.scheme) || own(keyProviders, k.scheme)) continue;
358
+ const known = Object.keys(keyProviders);
359
+ throw new Error(
360
+ `${path}: roles.${name}: key "${k.raw}" uses the "${k.scheme}" scheme, and no ` +
361
+ `[keys.providers.${k.scheme}] is declared.\n` +
362
+ ` ${known.length ? `Declared providers: ${known.join(", ")}.` : "No providers are declared."}\n` +
363
+ ` A provider is a command with a placeholder:\n` +
364
+ ` [keys.providers.${k.scheme}]\n` +
365
+ ` command = ["your-cli", "read", "{ref}"]`);
366
+ }
367
+ checkNames({ name, keyEntries });
368
+ const keyMode = own(r, "key_mode");
369
+ if (keyMode !== undefined && !MODES.includes(keyMode))
370
+ throw new Error(
371
+ `${path}: roles.${name}: key_mode = "${keyMode}" is not a delivery mode. ` +
372
+ `Known: ${MODES.join(", ")}.`);
373
+ const neverWrites = readNeverWrites(own(r, "never_writes"), `${path}: roles.${name}.never_writes`);
374
+ const localBinding = own(r, "local_binding");
375
+ const trustd = own(r, "trustd");
376
+ if (trustd !== undefined && typeof trustd !== "boolean")
377
+ throw new Error(`${path}: roles.${name}.trustd must be true or false, not ${JSON.stringify(trustd)}`);
378
+ if (localBinding !== undefined && typeof localBinding !== "boolean")
379
+ throw new Error(`${path}: roles.${name}.local_binding must be true or false, not ${JSON.stringify(localBinding)}`);
380
+ const localPorts = readLocalPorts(own(r, "local_ports"), `${path}: roles.${name}.local_ports`);
381
+ const mcp = readMcp(own(r, "mcp"), `${path}: roles.${name}.mcp`);
301
382
  out.roles[name] = {
302
383
  name,
303
- writes,
384
+ /**
385
+ * `writes` is what is GRANTED; `writesDeclared` is what was written.
386
+ *
387
+ * Expanded here and not at emit time, because two answers is how the
388
+ * document ends up looser or tighter than the boundary. Everything
389
+ * downstream — `ownersOf`, `explain`, `whose`, the settings, the console
390
+ * — reads `writes`, so the owner of a database is the owner of its `-wal`
391
+ * everywhere, and the kernel and the sentence agree. The declared form is
392
+ * kept so that regenerating a config writes back what a person typed
393
+ * rather than the expansion.
394
+ */
395
+ writes: withSidecars(writes),
396
+ writesDeclared: writes,
304
397
  keys,
398
+ keyEntries,
399
+ keyMode: keyMode ?? null,
305
400
  env: asArray(own(r, "env"), `roles.${name}.env`),
306
401
  network: own(r, "network") === undefined ? null : asArray(own(r, "network"), `roles.${name}.network`),
402
+ /**
403
+ * What this role may never write, even inside its own `writes`.
404
+ *
405
+ * A subtraction, and the only one the format has. It exists for the case
406
+ * a territory glob cannot say: `repo/**` minus `repo/.git/index.lock`, for
407
+ * a role that works in a worktree and has its own index. Without it the
408
+ * alternative is enumerating every top-level entry of the repo but `.git`
409
+ * — a list that goes stale the day somebody adds a directory.
410
+ *
411
+ * Per role and never global: a global deny on `.git/index.lock` breaks
412
+ * every role that legitimately commits in the shared checkout. Absent
413
+ * means exactly what it meant before the key existed.
414
+ */
415
+ // Sidecars too: `data/app.sqlite` minus its `-wal` is not a subtraction.
416
+ // A fabricated -wal is applied to the database the next time it opens.
417
+ neverWrites: withSidecars(neverWrites),
418
+ neverWritesDeclared: neverWrites,
419
+ /**
420
+ * Whether this role may listen on a local port — a dev server, the
421
+ * backend an end-to-end test drives, a test database.
422
+ *
423
+ * Off unless the role says so, and per role because most roles never
424
+ * start a server: an agent that edits docs has no business accepting
425
+ * connections. Wider than the name: the runtime also lets the role listen
426
+ * on every interface and connect to every localhost port. See `check`.
427
+ */
428
+ localBinding: localBinding === true,
429
+ /**
430
+ * The ports on this machine the role may connect to, and no others.
431
+ *
432
+ * The narrow form of reaching a local service. `local_binding` opens
433
+ * every port on localhost (macOS), which includes whatever control API
434
+ * is listening there without a password. This names the ports instead,
435
+ * and the runtime's proxy enforces the list — the same allowlist the
436
+ * domains go through. See srt.js for what that costs: the client has to
437
+ * go through the proxy, which HTTP clients do and a MySQL driver does not.
438
+ */
439
+ localPorts,
440
+ /**
441
+ * The MCP servers this role may load, by name, or null when the policy
442
+ * does not say. `[]` is "none". seisin declares this and does not
443
+ * enforce it: MCP servers are started by the agent's CLI, so the launcher
444
+ * builds the CLI's MCP config from this list (Claude Code:
445
+ * `--mcp-config` with only these + `--strict-mcp-config`).
446
+ */
447
+ mcp,
448
+ /**
449
+ * Whether this role may reach com.apple.trustd.agent (macOS). Needed by
450
+ * Dart/Flutter and by Go built before 1.27 to verify TLS at all; Go 1.27+
451
+ * does without it through the SSL_CERT_FILE that env.js sets. Off unless
452
+ * the role says so. See srt.js for what it costs.
453
+ */
454
+ trustd: trustd === true,
455
+ // Kept so `check` can name a misspelt key instead of ignoring it. An
456
+ // unknown key in a role table used to be dropped silently, and for a
457
+ // subtraction that is failing open: `never_write` would read as a rule
458
+ // and subtract nothing.
459
+ unknownKeys: Object.keys(r).filter((k) => !ROLE_KEYS.includes(k)),
307
460
  };
308
461
  }
462
+ out.notify = readNotify(own(parsed, "notify"), { path, root: out.root, keyDirs, roles: out.roles, own });
309
463
  return out;
310
464
  }
311
465
 
466
+ /**
467
+ * `[keys.providers.<name>]` — the commands that turn a reference into a value.
468
+ *
469
+ * Validated here and not at resolution time, because the whole argument for
470
+ * references is that a broken policy should be visible in `seisin check`,
471
+ * without asking anybody's keychain for a password to find out.
472
+ *
473
+ * `{ref}` is required. A command without it resolves every reference to the
474
+ * same thing, which is the kind of mistake that looks like it works: the first
475
+ * key comes back, so does the second, and they are both the first one.
476
+ */
477
+ function readProviders(table, path) {
478
+ const out = Object.create(null);
479
+ if (table === undefined) return out;
480
+ for (const name of Object.keys(table)) {
481
+ const p = own(table, name);
482
+ // Redefining `file://` is refused rather than honoured. Two configs where
483
+ // the same scheme means different things is the failure this whole feature
484
+ // is supposed to remove — and it would be invisible, because the policy
485
+ // reads identically in both.
486
+ if (BUILTIN.has(name))
487
+ throw new Error(
488
+ `${path}: [keys.providers.${name}] — "${name}://" is built in and cannot be redefined.\n` +
489
+ ` It reads a file, optionally one KEY out of it: "file://.secrets/all.env#TOKEN".\n` +
490
+ ` For different behaviour, give your provider another name.`);
491
+ const command = own(p, "command");
492
+ if (!Array.isArray(command) || command.length === 0 || command.some((c) => typeof c !== "string"))
493
+ throw new Error(
494
+ `${path}: [keys.providers.${name}] needs a command, as an array of strings.\n` +
495
+ ` e.g. command = ["security", "find-generic-password", "-w", "-s", "{ref}"]`);
496
+ if (!command.some((c) => c.includes("{ref}")))
497
+ throw new Error(
498
+ `${path}: [keys.providers.${name}] has no {ref} in its command, so every key of this ` +
499
+ `provider would resolve to the same value.\n` +
500
+ ` Put {ref} where the reference goes: command = [${command.map((c) => `"${c}"`).join(", ")}, "{ref}"]`);
501
+ const mode = own(p, "mode");
502
+ if (mode !== undefined && !MODES.includes(mode))
503
+ throw new Error(
504
+ `${path}: [keys.providers.${name}] mode = "${mode}" is not a delivery mode. ` +
505
+ `Known: ${MODES.join(", ")}.`);
506
+ out[name] = { name, command, mode: mode ?? null };
507
+ }
508
+ return out;
509
+ }
510
+
511
+ /**
512
+ * A SQLite database is four files, and a policy should say it once.
513
+ *
514
+ * `bitacora/x/equipo.sqlite` is not the database — it is one of the files the
515
+ * database is made of. Writing to it in WAL mode also writes `-wal` and
516
+ * `-shm`; in rollback mode, `-journal`. So "this role writes this database"
517
+ * takes four lines, and a policy with eight databases and thirty-one roles
518
+ * takes nine hundred and ninety-two of them. Measured on a real one: 744 of
519
+ * 1,248 write lines — 60% — were sidecars, and every single one had its
520
+ * `.sqlite` declared beside it. Not one case wanted the `-wal` without the
521
+ * database.
522
+ *
523
+ * **This grants no authority that was not already there**, which is the whole
524
+ * reason it is safe to do implicitly. The `-wal` holds that database's pending
525
+ * transactions: whoever can write the database can already empty it, replace
526
+ * it, or corrupt it. The sidecar is not a second resource — it is the same one.
527
+ *
528
+ * It is also not a glob. The kernel grants a path and everything under it, so
529
+ * `equipo.sqlite*` is unenforceable and `seisin check` refuses it; these are
530
+ * emitted as literal paths, exactly as if they had been typed.
531
+ *
532
+ * **The suffix list is closed on purpose** and this is the line that keeps the
533
+ * idea from becoming the exception list it could be: three suffixes, and only
534
+ * after a name that says SQLite. `.db` is deliberately not in it — plenty of
535
+ * things are called `.db` and a grant to create `whatever.db-wal` inside a
536
+ * directory somebody else owns would be new authority, arriving quietly. A
537
+ * database with another name declares its sidecars by hand, as before.
538
+ */
539
+ const SIDECARS = ["-wal", "-shm", "-journal"];
540
+ const IS_SQLITE = /\.sqlite3?$/;
541
+
542
+ export function withSidecars(paths) {
543
+ const out = [];
544
+ for (const p of paths) {
545
+ out.push(p);
546
+ if (IS_SQLITE.test(p)) for (const s of SIDECARS) out.push(p + s);
547
+ }
548
+ // A policy written before this existed already lists them. Keeping the first
549
+ // occurrence keeps the order somebody chose.
550
+ return [...new Set(out)];
551
+ }
552
+
553
+ /** The keys a `[roles.<name>]` table can hold. Anything else is reported by `check`. */
554
+ export const ROLE_KEYS = ["writes", "keys", "key_mode", "env", "network", "never_writes", "local_binding", "local_ports", "mcp", "trustd"];
555
+
556
+ /**
557
+ * `mcp`: names of MCP servers, as the CLI's config calls them. Absent is null,
558
+ * not [], so a policy that never mentions MCP changes nothing for a launcher
559
+ * that reads it; `mcp = []` is the explicit "none".
560
+ */
561
+ function readMcp(value, where) {
562
+ if (value === undefined) return null;
563
+ const list = asArray(value, where);
564
+ const out = [];
565
+ for (const n of list) {
566
+ if (!/^[A-Za-z0-9][A-Za-z0-9._-]*$/.test(n))
567
+ throw new Error(`${where}: ${JSON.stringify(n)} is not an MCP server name. ` +
568
+ `Use the name from the CLI's config, for example mcp = ["playwright"].`);
569
+ if (!out.includes(n)) out.push(n);
570
+ }
571
+ return out;
572
+ }
573
+
574
+ /**
575
+ * `local_ports`, refused rather than guessed when it is not a list of ports.
576
+ *
577
+ * Refused at load for the same reason as `never_writes`: a port list that loads
578
+ * with a typo in it opens a port nobody meant, or silently fails to open the
579
+ * one they did. A string is accepted when it is only digits, because TOML
580
+ * written by hand says `"8001"` as often as `8001`.
581
+ */
582
+ function readLocalPorts(value, where) {
583
+ if (value === undefined) return [];
584
+ const list = Array.isArray(value) ? value : [value];
585
+ const out = [];
586
+ for (const v of list) {
587
+ const n = typeof v === "string" && /^\d+$/.test(v) ? Number(v) : v;
588
+ if (!Number.isInteger(n) || n < 1 || n > 65535)
589
+ throw new Error(`${where}: ${JSON.stringify(v)} is not a port. Write numbers from 1 to 65535, ` +
590
+ `for example local_ports = [8001, 8081].`);
591
+ if (!out.includes(n)) out.push(n);
592
+ }
593
+ return out;
594
+ }
595
+
596
+ /**
597
+ * `never_writes`, refused rather than guessed when it cannot mean what it says.
598
+ *
599
+ * Refused at load, not warned about: this is a list of things a role must not
600
+ * do, and a malformed entry that loads anyway fails open. An absolute path or a
601
+ * `..` would point outside the repo the rest of the policy is written against,
602
+ * so it cannot be the subtraction the person meant.
603
+ */
604
+ function readNeverWrites(value, where) {
605
+ const list = asArray(value, where);
606
+ for (const g of list) {
607
+ if (typeof g !== "string" || g.trim() === "")
608
+ throw new Error(`${where}: every entry must be a non-empty string`);
609
+ if (g.startsWith("/"))
610
+ throw new Error(`${where}: "${g}" is absolute. never_writes is relative to the repo root, like writes.`);
611
+ if (g.split("/").includes(".."))
612
+ throw new Error(`${where}: "${g}" contains "..". never_writes names paths inside the repo.`);
613
+ }
614
+ return list;
615
+ }
616
+
312
617
  function asArray(value, where) {
313
618
  if (value === undefined) return [];
314
619
  if (typeof value === "string") return [value];
315
- if (Array.isArray(value)) return value;
620
+ if (Array.isArray(value) && value.every((v) => typeof v === "string")) return value;
316
621
  throw new Error(`${where}: expected a string or an array of strings`);
317
622
  }
@@ -0,0 +1,172 @@
1
+ /**
2
+ * The refusal the agent never heard, said after the fact.
3
+ *
4
+ * PreToolUse sees the path a tool call names, and that is all it sees. What the
5
+ * kernel refuses on its own — a child process, `/bin/rm` inside a script, a
6
+ * path the regex did not catch — reaches the agent as `Operation not permitted`
7
+ * with no path and no reason, and it retries. Measured on a real portfolio from
8
+ * 2026-09-14 to 2026-09-22: 5,617 refusals, all of them from the kernel, 72%
9
+ * the same role hitting the same path again.
10
+ *
11
+ * Two hooks close that, the shape nono uses for its sandbox diagnostics:
12
+ *
13
+ * - after a tool call fails (PostToolUseFailure, and PostToolUse for Bash,
14
+ * which reports a failed command as a result): read this role's kernel
15
+ * refusals from the last moments and hand the agent the same sentence the
16
+ * PreToolUse hook would have — whose it is, how many times, and that it
17
+ * should hand it over rather than retry. The kernel's line reaches the log
18
+ * about 30 ms after the refusal (measured inside the box), so this reads it.
19
+ * - when a session starts, resumes or is compacted (SessionStart): the role's
20
+ * territory and what it keeps being refused, before the first refusal —
21
+ * and right after compaction, which is when an agent forgets what it was
22
+ * already told.
23
+ *
24
+ * What it does not do, on purpose: suggest widening. nono's message offers
25
+ * `--allow /path`; that assumes the profile was too narrow. seisin assumes the
26
+ * file is somebody else's, and the way out is handing it to them.
27
+ *
28
+ * On Linux the runtime's refusals cannot be read from outside (violations.js),
29
+ * so the after-the-fact half finds nothing there and stays quiet.
30
+ */
31
+ import { closeSync, existsSync, openSync, readSync, statSync } from "node:fs";
32
+ import { explain } from "./owners.js";
33
+ import { timesHit, walls } from "./walls.js";
34
+ import { collapseSidecars } from "./render.js";
35
+
36
+ /** What a refused write or read looks like in a tool's output. A trigger to look, not a verdict. */
37
+ export const DENIAL_SIGNS = /Operation not permitted|EPERM|EACCES|Permission denied|Read-only file system/i;
38
+
39
+ /**
40
+ * Refusals the runtime's profile makes that never reach the log, recognised by
41
+ * what the failing program printed.
42
+ *
43
+ * The log holds what the kernel refused on a path or a port. A Mach service
44
+ * registration is neither: the profile refuses it, the program dies with its
45
+ * own words, and nothing in `.seisin/` says why. Each entry here is one of those
46
+ * signatures, measured, with the sentence that sends the agent to the fix
47
+ * instead of to a retry. Kept to exact signatures — a guess here would be an
48
+ * explanation for a failure seisin did not cause.
49
+ */
50
+ export const KNOWN_REFUSALS = [
51
+ {
52
+ // Measured 2026-09-23 with Playwright's Chromium under seisin on macOS. It
53
+ // cost a team an afternoon: the error names a Mach port, not a file.
54
+ sign: /bootstrap_check_in\s+org\.chromium\.Chromium\.MachPortRendezvousServer\S*:\s*Permission denied \(1100\)/,
55
+ say: "Chromium could not register its Mach port: the sandbox does not allow it, and no grant " +
56
+ "changes that. Launch it with --single-process (Playwright: launchOptions.args) and one worker.",
57
+ },
58
+ ];
59
+
60
+ /** The known refusals whose signature is in this output. */
61
+ export function knownRefusals(evidence) {
62
+ return KNOWN_REFUSALS.filter((k) => k.sign.test(evidence)).map((k) => k.say);
63
+ }
64
+
65
+ /** How far back a refusal still belongs to the call that just failed. */
66
+ const WINDOW_MS = 120_000;
67
+ /** Read only the end of the log: this runs on every failed command. */
68
+ const TAIL_BYTES = 512 * 1024;
69
+ /** How many paths to name. More than this is a wall of text nobody acts on. */
70
+ const MAX_NAMED = 3;
71
+
72
+ function tail(file) {
73
+ if (!existsSync(file)) return [];
74
+ const { size } = statSync(file);
75
+ const from = Math.max(0, size - TAIL_BYTES);
76
+ const fd = openSync(file, "r");
77
+ try {
78
+ const buf = Buffer.alloc(size - from);
79
+ readSync(fd, buf, 0, buf.length, from);
80
+ const lines = buf.toString("utf8").split("\n");
81
+ if (from > 0) lines.shift(); // the first line is cut in half
82
+ const out = [];
83
+ for (const l of lines) {
84
+ if (!l.trim()) continue;
85
+ try { out.push(JSON.parse(l)); } catch { /* a line being written */ }
86
+ }
87
+ return out;
88
+ } finally {
89
+ closeSync(fd);
90
+ }
91
+ }
92
+
93
+ /** This role's refusals by the kernel since `sinceMs`, one per path, newest first. */
94
+ export function recentKernelDenials(file, role, sinceMs) {
95
+ const since = new Date(sinceMs).toISOString();
96
+ const seen = new Map();
97
+ for (const e of tail(file).reverse()) {
98
+ if (e.role !== role || e.verdict !== "denied" || e.source !== "kernel") continue;
99
+ if (!e.at || e.at < since || !e.target) continue;
100
+ const k = `${e.action}\u0000${e.target}`;
101
+ if (!seen.has(k)) seen.set(k, e);
102
+ }
103
+ return [...seen.values()];
104
+ }
105
+
106
+ /** The sentence for one refusal, in the words the PreToolUse hook uses. */
107
+ function sentence(config, role, file, e) {
108
+ const action = e.kind === "key" ? "read" : e.action === "read" || e.action === "connect" ? e.action : "write";
109
+ const v = explain(config, role, action, e.target);
110
+ if (v.allowed) return null; // the policy moved since: nothing to say
111
+ const times = timesHit(file, role, action, e.target);
112
+ const again = times > 1 ? ` Refused ${times} times now; the next try ends the same way.` : "";
113
+ if (v.neverWrites) return `${v.reason}. Do not ask for it.${again}`;
114
+ if (v.owners?.length)
115
+ return `${e.target} belongs to ${v.owners.join(", ")}. It is not ${role}'s to change — ` +
116
+ `hand it over rather than working around it.${again}`;
117
+ return `${e.target}: ${v.reason}.${again}`;
118
+ }
119
+
120
+ const PREFACE =
121
+ "seisin — the sandbox this agent runs in — refused part of that. This is not a Unix permission " +
122
+ "or a macOS privacy prompt: chmod, sudo or a different path to the same file will not change it.";
123
+
124
+ /**
125
+ * After a tool call failed. Returns the hook output, or null to say nothing.
126
+ *
127
+ * `wait` retries the read a few times: the kernel's line lands ~30 ms after the
128
+ * refusal, and a loaded machine can be slower than the hook.
129
+ */
130
+ export async function afterTool(config, role, event, { file, now = Date.now, wait = [0, 100, 300] } = {}) {
131
+ const evidence = JSON.stringify({ r: event.tool_response, e: event.error, o: event.tool_output });
132
+ if (!DENIAL_SIGNS.test(evidence)) return null;
133
+ // JSON escapes what the program printed; the signatures are written against the text.
134
+ const known = knownRefusals(evidence.replace(/\\n/g, "\n").replace(/\\"/g, '"'));
135
+ let found = [];
136
+ for (const ms of wait) {
137
+ if (ms) await new Promise((ok) => setTimeout(ok, ms));
138
+ found = recentKernelDenials(file, role, now() - WINDOW_MS);
139
+ if (found.length) break;
140
+ }
141
+ const lines = [...known, ...found.map((e) => sentence(config, role, file, e)).filter(Boolean)].slice(0, MAX_NAMED);
142
+ if (!lines.length) return null;
143
+ const total = known.length + found.length;
144
+ const more = total > MAX_NAMED ? ` (and ${total - MAX_NAMED} more — \`seisin walls ${role}\`)` : "";
145
+ return {
146
+ hookSpecificOutput: {
147
+ hookEventName: event.hook_event_name,
148
+ additionalContext: [PREFACE, ...lines.map((l) => `- ${l}`)].join("\n") + more,
149
+ },
150
+ };
151
+ }
152
+
153
+ /** When a session starts, resumes or is compacted: the map, before the first wall. */
154
+ export function atSessionStart(config, role, event, { file } = {}) {
155
+ const r = config.roles[role];
156
+ const writes = collapseSidecars(r.writesDeclared ?? r.writes);
157
+ const lines = [
158
+ `seisin: this session runs as the role "${role}". The kernel enforces it; nothing inside can widen it.`,
159
+ `You may write: ${writes.length ? writes.join(", ") : "nothing"}. Reading the rest of the repo is fine.`,
160
+ ];
161
+ if ((r.neverWritesDeclared ?? r.neverWrites ?? []).length)
162
+ lines.push(`Never, even inside that: ${(r.neverWritesDeclared ?? r.neverWrites).join(", ")}.`);
163
+ if (r.keys.length) lines.push(`Keys you hold: ${r.keys.join(", ")}.`);
164
+ const w = file ? walls(config, role, { file, limit: 5, fresh: true }) : [];
165
+ if (w.length) {
166
+ lines.push("Already refused more than once, and still refused — do not try again, hand it over:");
167
+ for (const x of w)
168
+ lines.push(`- ${x.action} ${x.target} (${x.times}×) — ${x.owners?.length ? `belongs to ${x.owners.join(", ")}` : x.reason}`);
169
+ }
170
+ lines.push("A file outside that is somebody else's: say whose it is and hand it over, rather than working around it.");
171
+ return { hookSpecificOutput: { hookEventName: "SessionStart", additionalContext: lines.join("\n") } };
172
+ }
package/src/env.js CHANGED
@@ -10,6 +10,9 @@
10
10
  * unless it is named. The base list below is what a command needs to be a
11
11
  * command at all, and it deliberately contains no credential-shaped names.
12
12
  */
13
+ import { existsSync } from "node:fs";
14
+
15
+ const SYSTEM_BUNDLE = "/etc/ssl/cert.pem";
13
16
 
14
17
  /**
15
18
  * What survives with no policy at all.
@@ -57,7 +60,34 @@ export const BASE = [
57
60
  * A role that wants the old behaviour names `GIT_OPTIONAL_LOCKS` in its `env`
58
61
  * and sets it in the parent — the loop below then overwrites this.
59
62
  */
60
- export const DEFAULTS = { GIT_OPTIONAL_LOCKS: "0" };
63
+ export const DEFAULTS = {
64
+ GIT_OPTIONAL_LOCKS: "0",
65
+ /**
66
+ * Node's built-in `fetch()` ignores HTTP_PROXY/HTTPS_PROXY unless told
67
+ * otherwise, so inside the box it tries DNS directly and dies with ENOTFOUND
68
+ * while `npm`, which reads the proxy itself, works. Measured on Node 24.2:
69
+ * ENOTFOUND without it, 200 with it. It only makes Node use the proxy the
70
+ * sandbox already routes everything through — nothing is widened. Older
71
+ * Nodes ignore the variable.
72
+ */
73
+ NODE_USE_ENV_PROXY: "1",
74
+ /**
75
+ * The system's CA bundle, when there is one at this path (macOS ships it).
76
+ *
77
+ * Go 1.27+ verifies against SSL_CERT_FILE instead of asking the system when
78
+ * the variable is set ("the native Go verifier is used", go1.27 notes), and
79
+ * asking the system means com.apple.trustd.agent, which the sandbox closes.
80
+ * Measured: `go get` fails with `x509: OSStatus -26276` without it and works
81
+ * with it, with trustd still shut. Binaries built with Go before 1.27 (gh
82
+ * 2.89 is 1.26) ignore it; for those, and for Dart, see `trustd = true`.
83
+ *
84
+ * Only if the file exists: on a system without it, pointing every TLS client
85
+ * at a missing file would break the ones that work today. And a parent that
86
+ * sets its own SSL_CERT_FILE or SSL_CERT_DIR — a corporate CA — keeps it;
87
+ * see buildEnv.
88
+ */
89
+ ...(existsSync(SYSTEM_BUNDLE) ? { SSL_CERT_FILE: SYSTEM_BUNDLE } : {}),
90
+ };
61
91
 
62
92
  /**
63
93
  * Names that must never ride along, even if a role asks for them by pattern.
@@ -87,8 +117,31 @@ export function buildEnv(parent, role, extra = []) {
87
117
  for (const [name, value] of Object.entries(parent)) {
88
118
  if (!wanted.has(name)) { dropped.push(name); continue; }
89
119
  if (NEVER.test(name) && !(role.env ?? []).includes(name)) { dropped.push(name); continue; }
120
+ // Not even when named: whoever can read the notify URL can send the person
121
+ // a fake "approve #3". Only the parent uses it (notify.js).
122
+ if (name === "SEISIN_NOTIFY_URL") { dropped.push(name); continue; }
90
123
  env[name] = value;
91
124
  }
125
+ // A trust store the parent chose is a decision about who to trust, not an
126
+ // incidental variable: it crosses whether or not the role named it, and it
127
+ // replaces the default rather than sitting next to it. They are paths, never
128
+ // secrets. A role that names SSL_CERT_FILE while the parent has none opts out
129
+ // of the default.
130
+ // A parent that already says whether Node should use the proxy is answered
131
+ // as it asked — `=0` included — rather than overwritten by the default.
132
+ if (parent.NODE_USE_ENV_PROXY !== undefined) env.NODE_USE_ENV_PROXY = parent.NODE_USE_ENV_PROXY;
133
+ // A role with `trustd = true` can ask the system verifier, which sees the
134
+ // Keychain — corporate CAs included — and /etc/ssl/cert.pem does not. Setting
135
+ // the bundle there would make Go 1.27+ skip the verifier the role was given
136
+ // trustd for, and fail behind a corporate proxy (review of 2026-09-22, #7).
137
+ if (role.trustd === true) delete env.SSL_CERT_FILE;
138
+ if (parent.SSL_CERT_FILE || parent.SSL_CERT_DIR) {
139
+ delete env.SSL_CERT_FILE;
140
+ for (const k of ["SSL_CERT_FILE", "SSL_CERT_DIR"])
141
+ if (parent[k]) { env[k] = parent[k]; const i = dropped.indexOf(k); if (i !== -1) dropped.splice(i, 1); }
142
+ } else if ((role.env ?? []).includes("SSL_CERT_FILE")) {
143
+ delete env.SSL_CERT_FILE;
144
+ }
92
145
  // A role may name a variable the parent does not have. That is not an error —
93
146
  // it is a policy written ahead of the environment, which is the right order.
94
147
  return { env, dropped: dropped.sort() };