seisin 0.4.2 → 0.6.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (82) hide show
  1. package/README.md +506 -162
  2. package/package.json +14 -6
  3. package/src/browsers.js +52 -0
  4. package/src/canary.js +149 -0
  5. package/src/cli.js +175 -52
  6. package/src/commands/check.js +157 -5
  7. package/src/commands/explain.js +31 -13
  8. package/src/commands/hook.js +20 -6
  9. package/src/commands/init.js +183 -88
  10. package/src/commands/log.js +48 -9
  11. package/src/commands/requests.js +122 -70
  12. package/src/commands/review.js +73 -21
  13. package/src/commands/run.js +422 -78
  14. package/src/commands/scan.js +18 -3
  15. package/src/commands/ui.js +18 -5
  16. package/src/commands/walls.js +62 -11
  17. package/src/commands/whose.js +9 -7
  18. package/src/commands/wire.js +156 -17
  19. package/src/config.js +505 -37
  20. package/src/controls.js +244 -0
  21. package/src/diagnose.js +102 -49
  22. package/src/env.js +14 -8
  23. package/src/grants.js +122 -9
  24. package/src/handoff.js +285 -10
  25. package/src/help.js +179 -0
  26. package/src/hook.js +101 -42
  27. package/src/index.d.ts +217 -0
  28. package/src/index.js +24 -16
  29. package/src/inspect/codex.js +64 -0
  30. package/src/inspect/credentials.js +48 -0
  31. package/src/inspect/enforce.js +24 -0
  32. package/src/inspect/files.js +71 -0
  33. package/src/inspect/home.js +43 -0
  34. package/src/inspect/inject.js +68 -0
  35. package/src/inspect/keys.js +74 -0
  36. package/src/inspect/layout.js +258 -0
  37. package/src/inspect/limits.js +98 -0
  38. package/src/inspect/machine.js +107 -0
  39. package/src/inspect/network.js +37 -0
  40. package/src/inspect/providers.js +71 -0
  41. package/src/inspect/read-territory.js +105 -0
  42. package/src/inspect/role-keys.js +136 -0
  43. package/src/inspect/shared.js +37 -0
  44. package/src/inspect/warnings.js +58 -0
  45. package/src/inspect.js +20 -756
  46. package/src/intake.js +150 -35
  47. package/src/keys.js +231 -73
  48. package/src/kinds.js +249 -0
  49. package/src/lines.js +26 -0
  50. package/src/log.js +242 -52
  51. package/src/mcp.js +57 -58
  52. package/src/nested.js +12 -3
  53. package/src/notify.js +66 -20
  54. package/src/owners.js +358 -39
  55. package/src/paths.js +54 -2
  56. package/src/provider-run.js +81 -0
  57. package/src/redact.js +46 -13
  58. package/src/render.js +101 -17
  59. package/src/requests.js +506 -87
  60. package/src/review.js +50 -25
  61. package/src/rundir.js +33 -14
  62. package/src/sandboxes/command.js +93 -0
  63. package/src/sandboxes/index.js +113 -0
  64. package/src/sandboxes/mach-profile.js +31 -0
  65. package/src/sandboxes/srt-mach.mjs +43 -0
  66. package/src/sandboxes/srt.js +163 -0
  67. package/src/sandboxes/tartaros.js +80 -0
  68. package/src/scan.js +181 -19
  69. package/src/serve.js +396 -230
  70. package/src/spool.js +16 -45
  71. package/src/srt.js +296 -49
  72. package/src/suggest.js +58 -0
  73. package/src/surface.js +314 -54
  74. package/src/territory.js +261 -0
  75. package/src/trace.js +33 -0
  76. package/src/uilink.js +24 -9
  77. package/src/views.js +315 -0
  78. package/src/violations.js +247 -23
  79. package/src/walls.js +76 -46
  80. package/src/worktree.js +3 -11
  81. package/ui/index.html +1429 -538
  82. package/ui/favicon.svg +0 -1
package/src/config.js CHANGED
@@ -8,11 +8,14 @@
8
8
  *
9
9
  * If your config outgrows this subset, the config is doing too much.
10
10
  */
11
+ import { createHash } from "node:crypto";
11
12
  import { readFileSync, existsSync } from "node:fs";
12
13
  import { resolve, dirname, join } from "node:path";
14
+ import { isIP } from "node:net";
13
15
  import { CONFIG_NAME } from "./layout.js";
14
- import { parseKey, MODES, checkNames, BUILTIN } from "./keys.js";
16
+ import { parseKey, MODES, checkNames, BUILTIN, unknownMode } from "./keys.js";
15
17
  import { readNotify } from "./notify.js";
18
+ import { BROWSERS } from "./browsers.js";
16
19
 
17
20
  export { CONFIG_NAME } from "./layout.js";
18
21
 
@@ -31,7 +34,6 @@ export function findConfig(from = process.cwd()) {
31
34
  const RE_TABLE = /^\[([A-Za-z0-9_.\-]+)\]$/;
32
35
  const RE_PAIR = /^([A-Za-z0-9_\-]+)\s*=\s*(.+)$/;
33
36
 
34
- /** Minimal TOML subset -> plain object. Throws with a line number on bad input. */
35
37
  /**
36
38
  * Names a table or key may not have.
37
39
  *
@@ -59,8 +61,20 @@ export function own(obj, key) {
59
61
  return obj && Object.prototype.hasOwnProperty.call(obj, key) ? obj[key] : undefined;
60
62
  }
61
63
 
64
+ /**
65
+ * Where each table and key of a parsed file was written, by dotted name.
66
+ *
67
+ * Kept beside the result rather than in it, so the object stays exactly the
68
+ * data that was written; loadConfig reads it to put a line number on a name it
69
+ * does not know.
70
+ */
71
+ const LINES = new WeakMap();
72
+
73
+ /** Minimal TOML subset -> plain object. Throws with a line number on bad input. */
62
74
  export function parseToml(text) {
63
75
  const out = dict();
76
+ const where = new Map();
77
+ LINES.set(out, where);
64
78
  let table = out;
65
79
  let tableName = "";
66
80
  // "Last one wins" is TOML-ish and wrong for a permission file. A policy can
@@ -87,9 +101,13 @@ export function parseToml(text) {
87
101
  if (seenTables.has(tableName))
88
102
  throw new Error(
89
103
  `${CONFIG_NAME}:${i + 1}: [${tableName}] appears twice. ` +
90
- `A later block would silently override the earlier one — put every setting for ` +
104
+ `A later table would silently override the earlier one — put every setting for ` +
91
105
  `${tableName} in one place.`);
92
106
  seenTables.add(tableName);
107
+ tableName.split(".").forEach((_, k, parts) => {
108
+ const name = parts.slice(0, k + 1).join(".");
109
+ if (!where.has(name)) where.set(name, i + 1);
110
+ });
93
111
  table = tableName.split(".").reduce((node, key, k, parts) => {
94
112
  if (!Object.prototype.hasOwnProperty.call(node, key)) node[key] = dict();
95
113
  else if (!isTable(node[key]))
@@ -105,16 +123,18 @@ export function parseToml(text) {
105
123
  if (!pair) throw new Error(`${CONFIG_NAME}:${i + 1}: cannot read "${raw.trim()}"`);
106
124
 
107
125
  let [, key, value] = pair;
126
+ const at = i + 1;
108
127
  // An array may span lines; keep pulling until the brackets balance. Only
109
128
  // the new line is searched: searching the whole growing value made an
110
- // unclosed array of 100k lines take 20 s to be refused.
111
- if (value.startsWith("[") && !value.includes("]")) {
129
+ // unclosed array of 100k lines take 20 s to be refused. A `]` inside a
130
+ // quoted item does not close it — `["a]b",` used to end the array there.
131
+ if (value.startsWith("[") && !closesArray(value)) {
112
132
  const parts = [value];
113
133
  let closed = false;
114
134
  while (!closed && i + 1 < lines.length) {
115
135
  const next = stripComment(lines[++i]).trim();
116
136
  parts.push(next);
117
- closed = next.includes("]");
137
+ closed = closesArray(next);
118
138
  }
119
139
  value = parts.join(" ");
120
140
  }
@@ -126,6 +146,7 @@ export function parseToml(text) {
126
146
  `${CONFIG_NAME}:${i + 1}: "${key}" is set twice in [${tableName || "the root"}]. ` +
127
147
  `The second would win, which is not a thing a permission file should do quietly.`);
128
148
  seenKeys.add(seen);
149
+ where.set(tableName ? `${tableName}.${key}` : key, at);
129
150
  /**
130
151
  * A key may not be a table that is declared elsewhere, in either order.
131
152
  *
@@ -145,8 +166,19 @@ export function parseToml(text) {
145
166
  return out;
146
167
  }
147
168
 
169
+ /** Is there a `]` on this line outside a quoted string? Strings do not span lines. */
170
+ export function closesArray(line) {
171
+ let quoted = false;
172
+ for (let i = 0; i < line.length; i++) {
173
+ const c = line[i];
174
+ if (c === '"') quoted = !quoted;
175
+ else if (c === "]" && !quoted) return true;
176
+ }
177
+ return false;
178
+ }
179
+
148
180
  /** Strips a trailing `#` comment, but not one inside a quoted string. */
149
- function stripComment(line) {
181
+ export function stripComment(line) {
150
182
  let quoted = false;
151
183
  for (let i = 0; i < line.length; i++) {
152
184
  const c = line[i];
@@ -241,14 +273,6 @@ function readValue(value, lineNo) {
241
273
  bad("value must be a string, an array of strings, a whole number, or a boolean");
242
274
  }
243
275
 
244
- /**
245
- * Turns the parsed file into the shape the rest of the program uses, and
246
- * rejects anything ambiguous.
247
- *
248
- * Rejecting is the point. The sandbox runtime refuses to start on an invalid
249
- * settings file rather than falling back to a permissive default, and seisin
250
- * matches that: a permission tool that guesses is worse than no permission tool.
251
- */
252
276
  /**
253
277
  * `[runtime] isolate` — one setting, two levels, because they were one and
254
278
  * should not have been.
@@ -281,6 +305,36 @@ function readValue(value, lineNo) {
281
305
  * application and `security` is the application. That trades one hole for a
282
306
  * larger one: the login keychain is every password you have.
283
307
  */
308
+ /** `[runtime] read`: "all" (default) or "territory". An unknown value is refused, never read as "all". */
309
+ export function readReadMode(v, path = "seisin.toml") {
310
+ if (v === undefined || v === "all") return "all";
311
+ if (v === "territory") return "territory";
312
+ throw new Error(
313
+ `${path}: runtime.read must be "all" or "territory", not ${JSON.stringify(v)}. ` +
314
+ `An unknown value cannot silently leave every file readable.`);
315
+ }
316
+
317
+ /**
318
+ * `reads` and `toolchain`: paths, absolute, `~/…`, or relative to the repo.
319
+ *
320
+ * Same rule as `writes`, for the same reason: the sandbox opens a path and
321
+ * everything under it, so a literal path or a `dir/**` subtree is all that can
322
+ * be said. `docs/*.md` would have to be widened to `docs` or frozen to today's
323
+ * files; either way the policy would say something the kernel does not.
324
+ */
325
+ function readReadList(v, at) {
326
+ const list = v === undefined ? [] : asArray(v, at);
327
+ for (const p of list) {
328
+ const above = p.endsWith("/**") ? p.slice(0, -3) : p;
329
+ if (/[*?[\]{}]/.test(above) || p === "**")
330
+ throw new Error(
331
+ `${at} = "${p}" cannot be enforced as written.\n` +
332
+ ` A read grant is a path and everything under it. Write "${above.replace(/\/?[^/]*[*?[\]{}].*$/, "") || "."}/**" ` +
333
+ `for the subtree, or name the paths.`);
334
+ }
335
+ return list;
336
+ }
337
+
284
338
  export function readIsolate(v) {
285
339
  if (v === true || v === "home") return "home";
286
340
  if (v === "credentials") return "credentials";
@@ -290,8 +344,32 @@ export function readIsolate(v) {
290
344
  `An unknown value cannot silently turn credential isolation off.`);
291
345
  }
292
346
 
293
- export function loadConfig(path) {
294
- const parsed = parseToml(readFileSync(path, "utf8"));
347
+ /**
348
+ * A hash of the policy file as it is now, short: it names a version, it proves
349
+ * nothing. Every log line carries the one its run started with (intake.js), and
350
+ * every handoff decision the one it was made against (requests.js, queueOf).
351
+ */
352
+ export function policyId(config) {
353
+ try {
354
+ return createHash("sha256").update(readFileSync(config.path)).digest("hex").slice(0, 12);
355
+ } catch {
356
+ return null;
357
+ }
358
+ }
359
+
360
+ /**
361
+ * Turns the parsed file into the shape the rest of the program uses, and
362
+ * rejects anything ambiguous.
363
+ *
364
+ * Rejecting is the point. The sandbox runtime refuses to start on an invalid
365
+ * settings file rather than falling back to a permissive default, and seisin
366
+ * matches that: a permission tool that guesses is worse than no permission tool.
367
+ */
368
+ export function loadConfig(path, text = readFileSync(path, "utf8")) {
369
+ // `text` lets a caller validate an edit before writing it — the console
370
+ // previews a policy that does not exist on disk yet, against the real root.
371
+ const parsed = parseToml(text);
372
+ checkShape(parsed, SHAPE, "", path, LINES.get(parsed));
295
373
  // Read only what the file actually declared. The parser refuses the names
296
374
  // that make inheritance possible, and this is the second half of the same
297
375
  // rule: if one of them ever gets through, a role still cannot pick up a
@@ -320,13 +398,14 @@ export function loadConfig(path) {
320
398
  * bench died on it, and the surviving three were the model names with no dots
321
399
  * in them.
322
400
  *
323
- * There is no ambiguity to preserve here — a nested table under [roles] has
324
- * no meaning in this tool — so it is an error, and it names both what was
325
- * written and what it became.
401
+ * There is almost no ambiguity to preserve here — a nested table under
402
+ * [roles] has one meaning in this tool, `inject_hosts`, and any other is an
403
+ * error that names both what was written and what it became.
326
404
  */
327
405
  for (const name of Object.keys(roles)) {
328
406
  const r = own(roles, name);
329
- const nested = Object.keys(r ?? {}).filter((k) => r[k] && typeof r[k] === "object" && !Array.isArray(r[k]));
407
+ const nested = Object.keys(r ?? {})
408
+ .filter((k) => k !== "inject_hosts" && r[k] && typeof r[k] === "object" && !Array.isArray(r[k]));
330
409
  if (nested.length)
331
410
  throw new Error(
332
411
  `${path}: [roles.${name}.${nested[0]}] — a dot separates keys in TOML, so this ` +
@@ -353,7 +432,11 @@ export function loadConfig(path) {
353
432
  // than one kind of secret, and making people flatten them to satisfy the tool
354
433
  // is how a tool gets kept out.
355
434
  const keyDir = own(own(parsed, "keys"), "dir");
356
- const keyDirs = keyDir === undefined ? [] : asArray(keyDir, "keys.dir");
435
+ // `.secrets/` and `.secrets` are one directory, and every consumer compares
436
+ // them as prefixes: with the slash kept, `scan` walked into the protected
437
+ // directory and a bare key resolved to `.secrets//x`, which matched nothing.
438
+ const keyDirs = (keyDir === undefined ? [] : asArray(keyDir, "keys.dir"))
439
+ .map((d) => d.replace(/\/+$/, "") || d);
357
440
  const keyProviders = readProviders(own(own(parsed, "keys"), "providers"), path);
358
441
  // `undefined` means "use the defaults"; an explicit empty array means "none".
359
442
  // The difference matters: one is a user who has not thought about it, the
@@ -363,14 +446,28 @@ export function loadConfig(path) {
363
446
 
364
447
  const out = {
365
448
  root: dirname(path), path, keyDirs, keyProviders,
366
- allowedDomains: own(own(parsed, "network"), "allow") ?? [],
449
+ // Through asArray like every other list: `allow = "github.com"` was kept as
450
+ // a string, and a string iterated is its characters.
451
+ allowedDomains: asArray(own(own(parsed, "network"), "allow"), "network.allow"),
452
+ // `[network] deny_addresses` — where an allowed name may not lead. An allowed
453
+ // domain whose DNS somebody else controls can be pointed at a host on your
454
+ // LAN; the runtime denies loopback, link-local and metadata addresses by
455
+ // itself and leaves private ranges to the policy, because an intranet name
456
+ // is a legitimate thing to allow. Checked here, with the line, rather than
457
+ // by the sandbox at start.
458
+ deniedAddresses: readAddresses(own(own(parsed, "network"), "deny_addresses"), `${path}: network.deny_addresses`),
367
459
  runtimeWrites: runtimeWrites === undefined ? undefined : asArray(runtimeWrites, "runtime.writes"),
368
460
  // `[runtime] isolate` — how much of your home a role stops reaching. See
369
461
  // srt.js for what each level emits, and readIsolate() below for why there
370
462
  // are two of them.
371
463
  isolate: readIsolate(own(runtime, "isolate")),
372
- redact: own(runtime, "redact"),
464
+ // `[runtime] read` — what a role may read. "all" is every file but the ones
465
+ // denied by name; "territory" denies the places data lives and re-opens
466
+ // only what the role needs. See territory.js.
467
+ read: readReadMode(own(runtime, "read"), path),
468
+ redact: readBoolean(own(runtime, "redact"), `${path}: runtime.redact`),
373
469
  scanIgnore: asArray(own(own(parsed, "scan"), "ignore"), "scan.ignore"),
470
+ protect: readProtect(own(parsed, "protect"), path),
374
471
  roles: {},
375
472
  };
376
473
 
@@ -420,9 +517,8 @@ export function loadConfig(path) {
420
517
  checkNames({ name, keyEntries });
421
518
  const keyMode = own(r, "key_mode");
422
519
  if (keyMode !== undefined && !MODES.includes(keyMode))
423
- throw new Error(
424
- `${path}: roles.${name}: key_mode = "${keyMode}" is not a delivery mode. ` +
425
- `Known: ${MODES.join(", ")}.`);
520
+ throw unknownMode(`${path}: roles.${name}:`, "key_mode", keyMode);
521
+ const injectHosts = readInjectHosts(own(r, "inject_hosts"), `${path}: roles.${name}.inject_hosts`);
426
522
  const neverWrites = readNeverWrites(own(r, "never_writes"), `${path}: roles.${name}.never_writes`);
427
523
  const localBinding = own(r, "local_binding");
428
524
  const trustd = own(r, "trustd");
@@ -430,8 +526,20 @@ export function loadConfig(path) {
430
526
  throw new Error(`${path}: roles.${name}.trustd must be true or false, not ${JSON.stringify(trustd)}`);
431
527
  if (localBinding !== undefined && typeof localBinding !== "boolean")
432
528
  throw new Error(`${path}: roles.${name}.local_binding must be true or false, not ${JSON.stringify(localBinding)}`);
529
+ const handoff = own(r, "handoff");
530
+ if (handoff !== undefined && typeof handoff !== "boolean")
531
+ throw new Error(`${path}: roles.${name}.handoff must be true or false, not ${JSON.stringify(handoff)}`);
433
532
  const localPorts = readLocalPorts(own(r, "local_ports"), `${path}: roles.${name}.local_ports`);
533
+ const browsers = readBrowsers(own(r, "browsers"), `${path}: roles.${name}.browsers`);
434
534
  const mcp = readMcp(own(r, "mcp"), `${path}: roles.${name}.mcp`);
535
+ const controlFiles = readControlFiles(own(r, "control_files"), `${path}: roles.${name}.control_files`);
536
+ const reads = readReadList(own(r, "reads"), `${path}: roles.${name}.reads`);
537
+ const toolchain = readReadList(own(r, "toolchain"), `${path}: roles.${name}.toolchain`);
538
+ const priv = readPrivate(own(r, "private"), `${path}: roles.${name}.private`);
539
+ const verify = own(r, "verify") === undefined ? null : asArray(own(r, "verify"), `roles.${name}.verify`);
540
+ if (verify && verify.length === 0)
541
+ throw new Error(`${path}: roles.${name}.verify is empty. It is the command that proves the role can check ` +
542
+ `its own work — ["python", "-m", "pytest", "--version"] — or leave it out.`);
435
543
  out.roles[name] = {
436
544
  name,
437
545
  /**
@@ -445,11 +553,21 @@ export function loadConfig(path) {
445
553
  * kept so that regenerating a config writes back what a person typed
446
554
  * rather than the expansion.
447
555
  */
448
- writes: withSidecars(writes),
556
+ writes: withSidecars([...new Set([...writes, ...priv])]),
449
557
  writesDeclared: writes,
558
+ /**
559
+ * Folders only this role reads and writes. Each is also part of its
560
+ * territory (added to `writes` above, so `explain`, `whose` and the
561
+ * settings agree it is this role's), and every OTHER role is given a
562
+ * denyRead and a denyWrite on it — in either read mode, isolated or not.
563
+ * Deny-only: an allow nested in a deny breaks on one platform or the
564
+ * other unless it is handled the way territory.js handles its own.
565
+ */
566
+ private: priv,
450
567
  keys,
451
568
  keyEntries,
452
569
  keyMode: keyMode ?? null,
570
+ injectHosts,
453
571
  env: asArray(own(r, "env"), `roles.${name}.env`),
454
572
  network: own(r, "network") === undefined ? null : asArray(own(r, "network"), `roles.${name}.network`),
455
573
  /**
@@ -490,6 +608,12 @@ export function loadConfig(path) {
490
608
  * go through the proxy, which HTTP clients do and a MySQL driver does not.
491
609
  */
492
610
  localPorts,
611
+ /**
612
+ * The headless browsers this role may start (browsers.js): the Mach
613
+ * services each needs, by name, and nothing else. `[]` unless the role
614
+ * says so — every other role keeps the profile as it was.
615
+ */
616
+ browsers,
493
617
  /**
494
618
  * The MCP servers this role may load, by name, or null when the policy
495
619
  * does not say. `[]` is "none". seisin declares this and does not
@@ -505,6 +629,42 @@ export function loadConfig(path) {
505
629
  * the role says so. See srt.js for what it costs.
506
630
  */
507
631
  trustd: trustd === true,
632
+ /**
633
+ * The families of control files this role may edit inside its own
634
+ * territory: "ide" (`.vscode/`, `.cursor/`, `.windsurf/`) and
635
+ * "instructions" (`CLAUDE.md`, `AGENTS.md`…). surface.js says what each
636
+ * is and why only these two can be handed out. Absent is none.
637
+ */
638
+ controlFiles,
639
+ /**
640
+ * Whether this role takes work handed to it (handoff.js). True unless the
641
+ * role says `handoff = false`: a role that is a pipeline phase rather
642
+ * than a worker — it prepares the tree, it is not someone to pass a
643
+ * file to — still owns its territory, and a write denied there goes to
644
+ * another owner or to a person instead of starting it.
645
+ */
646
+ handoff: handoff !== false,
647
+ /**
648
+ * What this role reads beyond its repo, under `read = "territory"`.
649
+ *
650
+ * `reads` is data — a sibling checkout, a shared docs folder. `toolchain`
651
+ * is what runs — a venv, `~/.nvm`, `~/.cargo` — kept apart because `check`
652
+ * treats the two differently: a toolchain that does not exist is the
653
+ * reason a role cannot verify itself, and that is worth a finding. Both
654
+ * are read grants and nothing else; neither is ever writable. Ignored
655
+ * (and said so by `check`) under `read = "all"`, where everything is
656
+ * readable already.
657
+ */
658
+ reads,
659
+ toolchain,
660
+ /**
661
+ * The command that proves this role can check its own work, run by
662
+ * `seisin check --verify` inside the role's sandbox. Measured in
663
+ * an agent orchestrator (2026-10-09): an agent asked to verify with nothing to verify
664
+ * with cost 80% more per task and verified nothing; the same agent with
665
+ * its toolchain ran the suite every time at the original cost.
666
+ */
667
+ verify,
508
668
  // Kept so `check` can name a misspelt key instead of ignoring it. An
509
669
  // unknown key in a role table used to be dropped silently, and for a
510
670
  // subtraction that is failing open: `never_write` would read as a rule
@@ -512,6 +672,8 @@ export function loadConfig(path) {
512
672
  unknownKeys: Object.keys(r).filter((k) => !ROLE_KEYS.includes(k)),
513
673
  };
514
674
  }
675
+ checkPrivateOverlap(out.roles, path);
676
+ out.sandbox = readSandbox(own(parsed, "sandbox"), path);
515
677
  out.notify = readNotify(own(parsed, "notify"), { path, root: out.root, keyDirs, roles: out.roles, own });
516
678
  return out;
517
679
  }
@@ -553,18 +715,85 @@ function readProviders(table, path) {
553
715
  ` Put {ref} where the reference goes: command = [${command.map((c) => `"${c}"`).join(", ")}, "{ref}"]`);
554
716
  const mode = own(p, "mode");
555
717
  if (mode !== undefined && !MODES.includes(mode))
556
- throw new Error(
557
- `${path}: [keys.providers.${name}] mode = "${mode}" is not a delivery mode. ` +
558
- `Known: ${MODES.join(", ")}.`);
718
+ throw unknownMode(`${path}: [keys.providers.${name}]`, "mode", mode);
559
719
  out[name] = { name, command, mode: mode ?? null };
560
720
  }
561
721
  return out;
562
722
  }
563
723
 
724
+ /** The sandboxes seisin ships (sandboxes/index.js). Named here too, so config.js imports no runtime code. */
725
+ const BUILTIN_SANDBOXES = ["srt", "tartaros"];
726
+
727
+ /**
728
+ * `[sandbox]`: which sandbox enforces the roles, and any declared by command.
729
+ *
730
+ * `provider` is one name or a list in order of preference; absent, it is
731
+ * `srt`, as it always was. A name is one seisin ships or one declared under
732
+ * `[sandbox.providers.<name>]` — an unknown one is refused here, never read as
733
+ * the default, because "the config asks for tartaros and the run used srt"
734
+ * without a word is the silent substitution sandboxes/index.js exists to stop.
735
+ */
736
+ function readSandbox(table, path) {
737
+ if (table === undefined) return null;
738
+ const providers = Object.create(null);
739
+ const declared = own(table, "providers");
740
+ for (const name of declared === undefined ? [] : Object.keys(declared)) {
741
+ if (BUILTIN_SANDBOXES.includes(name))
742
+ throw new Error(`${path}: [sandbox.providers.${name}] — "${name}" is built in and cannot be redefined. ` +
743
+ `Give your provider another name.`);
744
+ const command = own(own(declared, name), "command");
745
+ if (!Array.isArray(command) || command.length === 0 || command.some((c) => typeof c !== "string" || c === ""))
746
+ throw new Error(`${path}: [sandbox.providers.${name}] needs a command, as an array of strings:\n` +
747
+ ` command = ["mybox", "run", "--policy", "{policy}", "--"]`);
748
+ if (!command.slice(1).some((c) => c.includes("{policy}")))
749
+ throw new Error(`${path}: [sandbox.providers.${name}] has no {policy} in its command, so the sandbox would never ` +
750
+ `see the policy it is meant to enforce.\n Put {policy} where the settings file goes: ` +
751
+ `command = [${[...command, "{policy}"].map((c) => `"${c}"`).join(", ")}]`);
752
+ // Optional: asks the sandbox, before anything runs, whether it can hold a
753
+ // policy. Exit 0 is yes; anything else is no, and the last line it prints says why.
754
+ // Without it a declared sandbox is only ever found out by the canary, so a
755
+ // list like ["mybox", "srt"] would stop at exit 86 instead of moving on.
756
+ const check = own(own(declared, name), "check");
757
+ if (check !== undefined) {
758
+ if (!Array.isArray(check) || check.length === 0 || check.some((c) => typeof c !== "string" || c === ""))
759
+ throw new Error(`${path}: [sandbox.providers.${name}] check must be a command, as an array of strings:\n` +
760
+ ` check = ["mybox", "check", "--policy", "{policy}"]`);
761
+ if (!check.slice(1).some((c) => c.includes("{policy}")))
762
+ throw new Error(`${path}: [sandbox.providers.${name}] has no {policy} in its check, so it would answer ` +
763
+ `without seeing the policy it is asked about.\n Put {policy} where the settings file goes: ` +
764
+ `check = [${[...check, "{policy}"].map((c) => `"${c}"`).join(", ")}]`);
765
+ }
766
+ // Declared, because it cannot be detected: its Seatbelt denials carry
767
+ // sandbox-runtime's tag, so the kernel's refusals under it can be recorded.
768
+ const tags = own(own(declared, name), "tags_denials");
769
+ if (tags !== undefined && typeof tags !== "boolean")
770
+ throw new Error(`${path}: [sandbox.providers.${name}] tags_denials is true or false, not ${JSON.stringify(tags)}.`);
771
+ providers[name] = { name, command, check: check ?? null, tagsDenials: tags ?? false };
772
+ }
773
+ const raw = own(table, "provider");
774
+ if (raw === undefined)
775
+ return { provider: null, providers };
776
+ const names = typeof raw === "string" ? [raw] : raw;
777
+ if (!Array.isArray(names) || names.length === 0 || names.some((n) => typeof n !== "string"))
778
+ throw new Error(`${path}: sandbox.provider = ${JSON.stringify(raw)} — a name, or a list of names in order of preference: ` +
779
+ `provider = ["tartaros", "srt"]`);
780
+ for (const n of names) {
781
+ if (BUILTIN_SANDBOXES.includes(n) || providers[n]) continue;
782
+ const known = [...BUILTIN_SANDBOXES, ...Object.keys(providers)];
783
+ const near = closest(n, known);
784
+ throw new Error(`${path}: sandbox.provider names "${n}", which is not a sandbox seisin knows` +
785
+ `${near ? ` — did you mean "${near}"?` : "."}\n Built in: ${BUILTIN_SANDBOXES.join(", ")}. ` +
786
+ `Any other is declared with a command: [sandbox.providers.${n}] command = ["…", "{policy}"].`);
787
+ }
788
+ if (new Set(names).size !== names.length)
789
+ throw new Error(`${path}: sandbox.provider lists a sandbox twice (${JSON.stringify(names)}).`);
790
+ return { provider: names, providers };
791
+ }
792
+
564
793
  /**
565
794
  * A SQLite database is four files, and a policy should say it once.
566
795
  *
567
- * `bitacora/x/equipo.sqlite` is not the database — it is one of the files the
796
+ * `data/x/app.sqlite` is not the database — it is one of the files the
568
797
  * database is made of. Writing to it in WAL mode also writes `-wal` and
569
798
  * `-shm`; in rollback mode, `-journal`. So "this role writes this database"
570
799
  * takes four lines, and a policy with eight databases and thirty-one roles
@@ -579,7 +808,7 @@ function readProviders(table, path) {
579
808
  * it, or corrupt it. The sidecar is not a second resource — it is the same one.
580
809
  *
581
810
  * It is also not a glob. The kernel grants a path and everything under it, so
582
- * `equipo.sqlite*` is unenforceable and `seisin check` refuses it; these are
811
+ * `app.sqlite*` is unenforceable and `seisin check` refuses it; these are
583
812
  * emitted as literal paths, exactly as if they had been typed.
584
813
  *
585
814
  * **The suffix list is closed on purpose** and this is the line that keeps the
@@ -603,8 +832,124 @@ export function withSidecars(paths) {
603
832
  return [...new Set(out)];
604
833
  }
605
834
 
835
+ /**
836
+ * Every table and key a policy may have outside `[roles.*]`. `true` is a value,
837
+ * an object is a table, and `*` stands for any name (a provider's).
838
+ *
839
+ * Anything else is refused at load, with its line. The roles table reports an
840
+ * unknown key through `check` because a role's typo is usually one extra
841
+ * grant; here the settings are global, and the one that made this necessary
842
+ * was `isolaet = true` under [runtime] — read as nothing, so isolation stayed
843
+ * off for every role and nothing on screen said so. The same "fail open by
844
+ * typo" that was closed for roles, closed harder because it covers them all.
845
+ */
846
+ const SHAPE = {
847
+ roles: null, // validated where roles are read
848
+ keys: { dir: true, providers: { "*": { command: true, mode: true } } },
849
+ network: { allow: true, deny_addresses: true },
850
+ runtime: { writes: true, isolate: true, redact: true, read: true },
851
+ scan: { ignore: true },
852
+ protect: { instructions: true },
853
+ notify: { url: true, url_file: true, format: true },
854
+ sandbox: { provider: true, providers: { "*": { command: true, check: true, tags_denials: true } } },
855
+ };
856
+
857
+ function checkShape(table, shape, prefix, path, lines) {
858
+ const known = Object.keys(shape).filter((k) => k !== "*");
859
+ for (const key of Object.keys(table)) {
860
+ const name = prefix ? `${prefix}.${key}` : key;
861
+ const at = `${path}:${lines?.get(name) ?? "?"}`;
862
+ const rule = Object.prototype.hasOwnProperty.call(shape, key) ? shape[key] : shape["*"];
863
+ if (rule === undefined) {
864
+ const near = closest(key, known);
865
+ throw new Error(
866
+ `${at}: ${prefix ? `[${prefix}] ` : ""}"${key}" is not a setting` +
867
+ `${prefix ? "" : " or a table"} seisin knows${near ? ` — did you mean "${near}"?` : "."}\n` +
868
+ ` Known${prefix ? ` in [${prefix}]` : " at the top level"}: ${known.join(", ")}. ` +
869
+ `An unknown name is refused rather than ignored: ignored, a misspelt setting ` +
870
+ `reads as a rule and does nothing.`);
871
+ }
872
+ if (rule === null) continue;
873
+ const value = table[key];
874
+ if (rule === true && isTable(value))
875
+ throw new Error(`${at}: [${name}] is a table, but ${name} is a setting — write ${key} = … under [${prefix}].`);
876
+ if (typeof rule === "object" && !isTable(value))
877
+ throw new Error(`${at}: ${name} = ${JSON.stringify(value)} — ${name} is a table, [${name}], with its settings under it.`);
878
+ if (typeof rule === "object") checkShape(value, rule, name, path, lines);
879
+ }
880
+ }
881
+
882
+ /** The candidate within edit distance 3 of `word`, or null. Shared with `check`. */
883
+ export function closest(word, candidates) {
884
+ let best = null, bestD = 4;
885
+ for (const c of candidates) {
886
+ const d = distance(word.toLowerCase(), c);
887
+ if (d < bestD) { best = c; bestD = d; }
888
+ }
889
+ return best;
890
+ }
891
+
892
+ /** Levenshtein distance. */
893
+ function distance(a, b) {
894
+ const row = Array.from({ length: b.length + 1 }, (_, i) => i);
895
+ for (let i = 1; i <= a.length; i++) {
896
+ let prev = row[0]; row[0] = i;
897
+ for (let j = 1; j <= b.length; j++) {
898
+ const cur = row[j];
899
+ row[j] = Math.min(row[j] + 1, row[j - 1] + 1, prev + (a[i - 1] === b[j - 1] ? 0 : 1));
900
+ prev = cur;
901
+ }
902
+ }
903
+ return row[b.length];
904
+ }
905
+
906
+ /** true, false, or absent (undefined). Anything else is a mistake, said as one. */
907
+ function readBoolean(value, where) {
908
+ if (value === undefined || typeof value === "boolean") return value;
909
+ throw new Error(`${where} must be true or false, not ${JSON.stringify(value)}`);
910
+ }
911
+
606
912
  /** The keys a `[roles.<name>]` table can hold. Anything else is reported by `check`. */
607
- export const ROLE_KEYS = ["writes", "keys", "key_mode", "env", "network", "never_writes", "local_binding", "local_ports", "mcp", "trustd"];
913
+ export const ROLE_KEYS = ["writes", "private", "keys", "key_mode", "inject_hosts", "env", "network", "never_writes", "local_binding", "local_ports", "browsers", "mcp", "trustd", "control_files", "reads", "toolchain", "verify", "handoff"];
914
+
915
+ /**
916
+ * `control_files`: which families of control files a role may edit. Refused at
917
+ * load when it names anything else — `.claude`, git hooks and the rest are not
918
+ * on the list on purpose, and a word the reader does not know must not load as
919
+ * a permission it thinks it wrote.
920
+ */
921
+ export const CONTROL_FAMILIES = ["ide", "instructions"];
922
+
923
+ function readControlFiles(value, where) {
924
+ if (value === undefined) return [];
925
+ const out = [];
926
+ for (const f of asArray(value, where)) {
927
+ if (!CONTROL_FAMILIES.includes(f))
928
+ throw new Error(`${where}: ${JSON.stringify(f)} is not a family of control files. ` +
929
+ `Known: ${CONTROL_FAMILIES.map((x) => JSON.stringify(x)).join(", ")}. ` +
930
+ `The others (.claude, git hooks, .mcp.json, .envrc) are never handed to a role.`);
931
+ if (!out.includes(f)) out.push(f);
932
+ }
933
+ return out;
934
+ }
935
+
936
+ /**
937
+ * `[protect]` — what is protected beyond the defaults. One key today:
938
+ * `instructions = true` protects `CLAUDE.md`, `AGENTS.md` and their kin from
939
+ * every role that is not handed them. Anything else in the table is refused.
940
+ */
941
+ function readProtect(value, path) {
942
+ if (value === undefined) return { instructions: false };
943
+ if (!value || typeof value !== "object" || Array.isArray(value))
944
+ throw new Error(`${path}: [protect] must be a table`);
945
+ for (const k of Object.keys(value))
946
+ if (k !== "instructions")
947
+ throw new Error(`${path}: [protect] ${k} is not a setting. Known: instructions.`);
948
+ const v = own(value, "instructions");
949
+ if (v !== undefined && typeof v !== "boolean")
950
+ throw new Error(`${path}: [protect] instructions must be true or false, not ${JSON.stringify(v)}`);
951
+ return { instructions: v === true };
952
+ }
608
953
 
609
954
  /**
610
955
  * `mcp`: names of MCP servers, as the CLI's config calls them. Absent is null,
@@ -624,6 +969,23 @@ function readMcp(value, where) {
624
969
  return out;
625
970
  }
626
971
 
972
+ /**
973
+ * `browsers`, refused rather than guessed: a name seisin has no preset for
974
+ * would read as a grant that was made, and grant nothing.
975
+ */
976
+ function readBrowsers(value, where) {
977
+ if (value === undefined) return [];
978
+ if (!Array.isArray(value) || value.some((v) => typeof v !== "string"))
979
+ throw new Error(`${where} is a list of browser names, for example browsers = ["chromium"].`);
980
+ for (const b of value)
981
+ if (!Object.hasOwn(BROWSERS, b)) {
982
+ const near = closest(b, Object.keys(BROWSERS));
983
+ throw new Error(`${where}: "${b}" is not a browser seisin has a preset for${near ? ` — did you mean "${near}"?` : ""} ` +
984
+ `Known: ${Object.keys(BROWSERS).join(", ")}.`);
985
+ }
986
+ return [...new Set(value)];
987
+ }
988
+
627
989
  /**
628
990
  * `local_ports`, refused rather than guessed when it is not a list of ports.
629
991
  *
@@ -646,6 +1008,45 @@ function readLocalPorts(value, where) {
646
1008
  return out;
647
1009
  }
648
1010
 
1011
+ /**
1012
+ * `inject_hosts`: for `key_mode = "inject"`, the hosts each masked credential
1013
+ * is substituted into, keyed by the delivered variable name.
1014
+ *
1015
+ * [roles.frontend.inject_hosts]
1016
+ * GH_TOKEN = ["api.github.com"]
1017
+ *
1018
+ * One list per credential and not one for the role, because the runtime gates
1019
+ * substitution per credential: credential A's real value reaches only A's
1020
+ * hosts, never B's. A host is an exact name — no wildcard — so `check` can say
1021
+ * precisely which traffic is intercepted; the match against a wider wildcard in
1022
+ * `allowedDomains` is computed later (srt.js), where the terminated set is.
1023
+ */
1024
+ function readInjectHosts(value, where) {
1025
+ if (value === undefined) return null;
1026
+ if (typeof value !== "object" || Array.isArray(value))
1027
+ throw new Error(`${where}: inject_hosts is a table of VAR = ["host", …], ` +
1028
+ `for example [roles.x.inject_hosts] GH_TOKEN = ["api.github.com"].`);
1029
+ const out = {};
1030
+ for (const [name, hosts] of Object.entries(value)) {
1031
+ const list = asArray(hosts, `${where}.${name}`);
1032
+ if (list.length === 0)
1033
+ throw new Error(`${where}.${name}: a masked credential with no host injects nowhere. ` +
1034
+ `List the host its token authenticates to, like ["api.github.com"].`);
1035
+ for (const h of list) {
1036
+ if (typeof h !== "string" || h.trim() === "")
1037
+ throw new Error(`${where}.${name}: every host is a non-empty string`);
1038
+ if (h.includes("*"))
1039
+ throw new Error(`${where}.${name}: "${h}" is a wildcard. An inject host is an exact name, ` +
1040
+ `so check can say which traffic is intercepted. A wildcard in allowedDomains still ` +
1041
+ `covers it; that is reported, not declared here.`);
1042
+ if (/^https?:\/\//.test(h) || h.includes("/"))
1043
+ throw new Error(`${where}.${name}: "${h}" is not a host. Write the bare domain, like "api.github.com".`);
1044
+ }
1045
+ out[name] = [...new Set(list)];
1046
+ }
1047
+ return out;
1048
+ }
1049
+
649
1050
  /**
650
1051
  * `never_writes`, refused rather than guessed when it cannot mean what it says.
651
1052
  *
@@ -667,14 +1068,64 @@ function readNeverWrites(value, where) {
667
1068
  return list;
668
1069
  }
669
1070
 
1071
+ /**
1072
+ * `private`: folders inside the repo, written `dir` or `dir/**`.
1073
+ *
1074
+ * A folder and not a glob, because what is enforced is a deny on one path for
1075
+ * every other role: `notes/*.md` would have to be widened to `notes` or frozen
1076
+ * to today's files. Not absolute and no `..`, because the denies are given
1077
+ * against the repo the policy describes and `explain` answers in it. Not the
1078
+ * repo itself: private to one role, the whole repo is shut to all the others,
1079
+ * which is a policy of one role written in the long way.
1080
+ */
1081
+ function readPrivate(value, where) {
1082
+ const list = value === undefined ? [] : asArray(value, where);
1083
+ for (const p of list) {
1084
+ const dir = p.replace(/\/\*\*$/, "").replace(/\/+$/, "").replace(/^\.\//, "");
1085
+ if (p.startsWith("/"))
1086
+ throw new Error(`${where}: "${p}" is absolute. A private folder is relative to the repo root, like writes.`);
1087
+ if (dir.split("/").includes(".."))
1088
+ throw new Error(`${where}: "${p}" contains "..". A private folder is inside the repo.`);
1089
+ if (dir === "" || dir === "." || dir === "**")
1090
+ throw new Error(`${where}: "${p}" is the whole repo. A private folder is a folder inside it — ` +
1091
+ `"notes/**" — and every other role is denied it.`);
1092
+ if (/[*?[\]{}]/.test(dir))
1093
+ throw new Error(`${where}: "${p}" is a pattern. A private folder is one path and everything under it: ` +
1094
+ `write "${dir.replace(/\/?[^/]*[*?[\]{}].*$/, "") || "dir"}/**".`);
1095
+ }
1096
+ return list;
1097
+ }
1098
+
1099
+ /** `private` with its `/**` and `./` taken off: the folder itself. */
1100
+ export const privateDir = (p) => p.replace(/\/\*\*$/, "").replace(/\/+$/, "").replace(/^\.\//, "");
1101
+
1102
+ /**
1103
+ * One owner per private folder. Two roles naming the same folder, or one
1104
+ * inside the other, would each be denied the other's — so neither could read
1105
+ * its own. Refused at load, with both names.
1106
+ */
1107
+ function checkPrivateOverlap(roles, path) {
1108
+ const all = Object.values(roles).flatMap((r) => (r.private ?? []).map((p) => ({ role: r.name, p, dir: privateDir(p) })));
1109
+ const inside = (a, b) => a === b || a.startsWith(b + "/");
1110
+ for (let i = 0; i < all.length; i++)
1111
+ for (let j = i + 1; j < all.length; j++) {
1112
+ const a = all[i], b = all[j];
1113
+ if (a.role === b.role || !(inside(a.dir, b.dir) || inside(b.dir, a.dir))) continue;
1114
+ throw new Error(
1115
+ `${path}: roles.${a.role}.private "${a.p}" and roles.${b.role}.private "${b.p}" ` +
1116
+ `${a.dir === b.dir ? "are the same folder" : "overlap"}. A private folder has one owner: every ` +
1117
+ `other role is denied it, so each of these two would be denied its own. Give it to one of them.`);
1118
+ }
1119
+ }
1120
+
670
1121
  /**
671
1122
  * A territory is relative to the repo root. An absolute one would ask the kernel
672
1123
  * for a path on the machine and make `explain` (which reads absolute as outside
673
1124
  * the repo) disagree with what the profile grants — see the CODEOWNERS `/apps/`
674
1125
  * case. `..` is allowed on purpose: a role legitimately writes a sibling
675
- * directory (`../bitacora/...`), and it is resolved relative to the root, so a
676
- * territory that climbs above it grants what it resolves to, by the operator's
677
- * choice.
1126
+ * directory (`../shared/...`), and it is resolved relative to the root, so a
1127
+ * territory that climbs above it grants what it resolves to, by the choice of
1128
+ * whoever wrote the policy.
678
1129
  */
679
1130
  function assertNotAbsolute(list, where) {
680
1131
  for (const g of list) {
@@ -686,6 +1137,23 @@ function assertNotAbsolute(list, where) {
686
1137
  return list;
687
1138
  }
688
1139
 
1140
+ /**
1141
+ * An IP address or a CIDR range: `10.0.0.0/8`, `192.168.1.10`, `fc00::/7`
1142
+ * (IPv6 without brackets). Anything else is refused with what was written.
1143
+ */
1144
+ function readAddresses(value, where) {
1145
+ const list = asArray(value, where);
1146
+ for (const v of list) {
1147
+ const [addr, prefix, extra] = v.split("/");
1148
+ const family = isIP(addr);
1149
+ const max = family === 6 ? 128 : 32;
1150
+ const okPrefix = prefix === undefined || (/^\d+$/.test(prefix) && Number(prefix) <= max);
1151
+ if (!family || !okPrefix || extra !== undefined)
1152
+ throw new Error(`${where}: "${v}" is not an IP address or a CIDR range (e.g. "10.0.0.0/8", "fc00::/7")`);
1153
+ }
1154
+ return list;
1155
+ }
1156
+
689
1157
  function asArray(value, where) {
690
1158
  if (value === undefined) return [];
691
1159
  if (typeof value === "string") return [value];