seisin 0.5.0 → 0.6.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (76) hide show
  1. package/README.md +385 -129
  2. package/package.json +13 -5
  3. package/src/browsers.js +52 -0
  4. package/src/canary.js +149 -0
  5. package/src/cli.js +47 -23
  6. package/src/commands/check.js +154 -3
  7. package/src/commands/explain.js +2 -5
  8. package/src/commands/hook.js +7 -3
  9. package/src/commands/init.js +57 -50
  10. package/src/commands/requests.js +31 -56
  11. package/src/commands/review.js +33 -6
  12. package/src/commands/run.js +332 -110
  13. package/src/commands/walls.js +39 -7
  14. package/src/commands/whose.js +2 -1
  15. package/src/config.js +321 -15
  16. package/src/controls.js +5 -5
  17. package/src/diagnose.js +67 -16
  18. package/src/env.js +3 -0
  19. package/src/grants.js +107 -2
  20. package/src/handoff.js +285 -10
  21. package/src/help.js +39 -19
  22. package/src/hook.js +59 -30
  23. package/src/index.d.ts +217 -0
  24. package/src/index.js +20 -12
  25. package/src/inspect/codex.js +64 -0
  26. package/src/inspect/credentials.js +48 -0
  27. package/src/inspect/enforce.js +24 -0
  28. package/src/inspect/files.js +71 -0
  29. package/src/inspect/home.js +43 -0
  30. package/src/inspect/inject.js +68 -0
  31. package/src/inspect/keys.js +74 -0
  32. package/src/inspect/layout.js +258 -0
  33. package/src/inspect/limits.js +98 -0
  34. package/src/inspect/machine.js +107 -0
  35. package/src/inspect/network.js +37 -0
  36. package/src/inspect/providers.js +71 -0
  37. package/src/inspect/read-territory.js +105 -0
  38. package/src/inspect/role-keys.js +136 -0
  39. package/src/inspect/shared.js +37 -0
  40. package/src/inspect/warnings.js +58 -0
  41. package/src/inspect.js +13 -815
  42. package/src/intake.js +131 -21
  43. package/src/keys.js +223 -67
  44. package/src/kinds.js +42 -0
  45. package/src/lines.js +26 -0
  46. package/src/log.js +7 -2
  47. package/src/mcp.js +28 -43
  48. package/src/nested.js +12 -3
  49. package/src/notify.js +53 -12
  50. package/src/owners.js +165 -3
  51. package/src/paths.js +17 -1
  52. package/src/provider-run.js +81 -0
  53. package/src/redact.js +34 -15
  54. package/src/render.js +35 -9
  55. package/src/requests.js +363 -45
  56. package/src/review.js +21 -9
  57. package/src/rundir.js +29 -8
  58. package/src/sandboxes/command.js +93 -0
  59. package/src/sandboxes/index.js +113 -0
  60. package/src/sandboxes/mach-profile.js +31 -0
  61. package/src/sandboxes/srt-mach.mjs +43 -0
  62. package/src/sandboxes/srt.js +163 -0
  63. package/src/sandboxes/tartaros.js +80 -0
  64. package/src/scan.js +7 -3
  65. package/src/serve.js +35 -17
  66. package/src/spool.js +8 -17
  67. package/src/srt.js +263 -33
  68. package/src/surface.js +129 -16
  69. package/src/territory.js +261 -0
  70. package/src/trace.js +33 -0
  71. package/src/uilink.js +15 -6
  72. package/src/views.js +40 -14
  73. package/src/violations.js +143 -7
  74. package/src/walls.js +62 -9
  75. package/ui/index.html +85 -13
  76. package/ui/favicon.svg +0 -1
@@ -3,30 +3,56 @@ import { unknownRole } from "../suggest.js";
3
3
  import { walls, wasted } from "../walls.js";
4
4
  import { STALE_RUNS } from "../requests.js";
5
5
  import { logPath } from "../log.js";
6
+ import { standingOf } from "../owners.js";
7
+ import { kindsOf } from "../kinds.js";
8
+ import { parseSince } from "../views.js";
6
9
  import { C, out, safe } from "../render.js";
7
10
 
8
11
  export function wallsCommand(config, argv = []) {
9
12
  const role = argv[0];
10
- if (!role) throw new Error("usage: seisin walls <role> [--since <iso>] [--min <n>] [--all]");
13
+ if (!role) throw new Error("usage: seisin walls <role> [--since <date|duration>] [--min <n>] [--all]");
11
14
  if (!config.roles[role]) throw unknownRole(config, role);
12
15
  const at = (flag, fallback) => {
13
16
  const i = argv.indexOf(flag);
14
17
  return i === -1 ? fallback : argv[i + 1];
15
18
  };
19
+ // The same reading of `--since` as review and the MCP: a date or `7d`.
20
+ const since = argv.includes("--since") ? parseSince(at("--since", null)) : null;
21
+ if (argv.includes("--since") && !since)
22
+ throw new Error(`--since takes a date (2026-10-01) or a duration back from now (7d, 12h, 2w), not "${safe(at("--since", "") ?? "")}"`);
16
23
  const every = walls(config, role, {
17
24
  file: logPath(config.root),
18
25
  min: Number(at("--min", 2)),
19
- since: at("--since", null),
26
+ since,
20
27
  limit: 0,
21
28
  });
22
29
  const all = argv.includes("--all");
23
30
  const list = (all ? every : every.filter((w) => !w.stale)).slice(0, 6);
24
31
  const old = every.filter((w) => w.stale).length;
25
- // Nothing to say prints nothing, the way `scan` does when a repo is clean.
26
- // A command that always speaks is one whose output stops being read.
27
- if (list.length) out(renderForPerson(role, list));
32
+ const shown = withKinds(config, list);
33
+ if (shown.length) out(renderForPerson(role, shown));
34
+ else if (!every.length)
35
+ out(`\n ${C.dim}${safe(role)} has no repeated denials in the recorded runs. A path becomes a wall after ${at("--min", 2)} denials.${C.off}\n\n`);
36
+ else if (!all)
37
+ out(`\n ${C.dim}${safe(role)} has no recent walls; older ones are hidden. Use seisin walls ${safe(role)} --all.${C.off}\n\n`);
28
38
  if (old && !all) out(` (${old} older wall(s) not hit in the last ${STALE_RUNS}+ runs: seisin walls ${role} --all)\n`);
29
- return list;
39
+ return shown;
40
+ }
41
+
42
+ /**
43
+ * An unowned path gets the kind and hint the console gives it (kinds.js).
44
+ *
45
+ * The bare reason said "has no owner — no role can write it until one claims
46
+ * it" about `dist/` and `.env` alike: an invitation to give a build folder or a
47
+ * credential an owner, while the console said what kind of thing each was and
48
+ * what to do instead. Same classifier, same sentence, on both screens.
49
+ */
50
+ function withKinds(config, list) {
51
+ const stand = standingOf(config);
52
+ const unowned = list.filter((w) => w.action === "write" && !w.owners?.length &&
53
+ stand({ kind: "file", target: w.target }).kind === "unowned");
54
+ const natures = kindsOf(unowned.map((w) => w.target), { keyDirs: config.keyDirs ?? [] });
55
+ return list.map((w) => (unowned.includes(w) ? { ...w, ...natures.get(w.target) } : w));
30
56
  }
31
57
 
32
58
  /**
@@ -39,7 +65,13 @@ export function wallsCommand(config, argv = []) {
39
65
  export function renderForPerson(role, list) {
40
66
  const lines = [`\n ${C.b}${safe(role)}${C.off} keeps hitting:\n\n`];
41
67
  for (const w of list) {
42
- const why = w.owners?.length ? `belongs to ${w.owners.map(safe).join(", ")}` : safe(w.reason);
68
+ if (w.expected) {
69
+ lines.push(` ${C.dim}${String(w.times).padStart(3)}× ${w.action} ${safe(w.target)} expected · ${safe(w.expected.hint)}${C.off}\n`);
70
+ continue;
71
+ }
72
+ const why = w.owners?.length ? `belongs to ${w.owners.map(safe).join(", ")}`
73
+ : w.kind ? `unowned · ${w.kind === "territory" ? "ownable" : w.kind}: ${safe(w.hint)}`
74
+ : safe(w.reason);
43
75
  lines.push(` ${C.yellow}${String(w.times).padStart(3)}×${C.off} ${w.action} ${safe(w.target)} ${C.dim}${why}${C.off}\n`);
44
76
  }
45
77
  const n = wasted(list);
@@ -18,6 +18,7 @@
18
18
  */
19
19
  import { toRepoRelative, fromCwd } from "../paths.js";
20
20
  import { ownersOf, keyHolders } from "../owners.js";
21
+ import { decideHandoff, nextStep, chainFromEnv } from "../handoff.js";
21
22
  import { twinsOf, whereIs } from "../worktree.js";
22
23
  import { C, out } from "../render.js";
23
24
 
@@ -54,7 +55,7 @@ export function whose(config, argv = []) {
54
55
  (mine
55
56
  ? ` ${C.green}that is you${C.off}${C.dim} — if it was denied, the reason is not ownership.${C.off}\n\n`
56
57
  : asking
57
- ? ` ${C.dim}you are ${asking}. Hand it over rather than working around it.${C.off}\n\n`
58
+ ? ` ${C.dim}you are ${asking}. ${nextStep(decideHandoff({ request: { role: asking, action: isKey ? "read" : "write", target: rel }, config, chain: chainFromEnv() }), asking)}${C.off}\n\n`
58
59
  : "\n")
59
60
  );
60
61
  for (const w of worktree) out(renderElsewhere(w, asking));
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
 
@@ -302,6 +305,36 @@ function readValue(value, lineNo) {
302
305
  * application and `security` is the application. That trades one hole for a
303
306
  * larger one: the login keychain is every password you have.
304
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
+
305
338
  export function readIsolate(v) {
306
339
  if (v === true || v === "home") return "home";
307
340
  if (v === "credentials") return "credentials";
@@ -311,6 +344,19 @@ export function readIsolate(v) {
311
344
  `An unknown value cannot silently turn credential isolation off.`);
312
345
  }
313
346
 
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
+
314
360
  /**
315
361
  * Turns the parsed file into the shape the rest of the program uses, and
316
362
  * rejects anything ambiguous.
@@ -352,13 +398,14 @@ export function loadConfig(path, text = readFileSync(path, "utf8")) {
352
398
  * bench died on it, and the surviving three were the model names with no dots
353
399
  * in them.
354
400
  *
355
- * There is no ambiguity to preserve here — a nested table under [roles] has
356
- * no meaning in this tool — so it is an error, and it names both what was
357
- * 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.
358
404
  */
359
405
  for (const name of Object.keys(roles)) {
360
406
  const r = own(roles, name);
361
- 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]));
362
409
  if (nested.length)
363
410
  throw new Error(
364
411
  `${path}: [roles.${name}.${nested[0]}] — a dot separates keys in TOML, so this ` +
@@ -402,11 +449,22 @@ export function loadConfig(path, text = readFileSync(path, "utf8")) {
402
449
  // Through asArray like every other list: `allow = "github.com"` was kept as
403
450
  // a string, and a string iterated is its characters.
404
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`),
405
459
  runtimeWrites: runtimeWrites === undefined ? undefined : asArray(runtimeWrites, "runtime.writes"),
406
460
  // `[runtime] isolate` — how much of your home a role stops reaching. See
407
461
  // srt.js for what each level emits, and readIsolate() below for why there
408
462
  // are two of them.
409
463
  isolate: readIsolate(own(runtime, "isolate")),
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),
410
468
  redact: readBoolean(own(runtime, "redact"), `${path}: runtime.redact`),
411
469
  scanIgnore: asArray(own(own(parsed, "scan"), "ignore"), "scan.ignore"),
412
470
  protect: readProtect(own(parsed, "protect"), path),
@@ -459,9 +517,8 @@ export function loadConfig(path, text = readFileSync(path, "utf8")) {
459
517
  checkNames({ name, keyEntries });
460
518
  const keyMode = own(r, "key_mode");
461
519
  if (keyMode !== undefined && !MODES.includes(keyMode))
462
- throw new Error(
463
- `${path}: roles.${name}: key_mode = "${keyMode}" is not a delivery mode. ` +
464
- `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`);
465
522
  const neverWrites = readNeverWrites(own(r, "never_writes"), `${path}: roles.${name}.never_writes`);
466
523
  const localBinding = own(r, "local_binding");
467
524
  const trustd = own(r, "trustd");
@@ -469,9 +526,20 @@ export function loadConfig(path, text = readFileSync(path, "utf8")) {
469
526
  throw new Error(`${path}: roles.${name}.trustd must be true or false, not ${JSON.stringify(trustd)}`);
470
527
  if (localBinding !== undefined && typeof localBinding !== "boolean")
471
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)}`);
472
532
  const localPorts = readLocalPorts(own(r, "local_ports"), `${path}: roles.${name}.local_ports`);
533
+ const browsers = readBrowsers(own(r, "browsers"), `${path}: roles.${name}.browsers`);
473
534
  const mcp = readMcp(own(r, "mcp"), `${path}: roles.${name}.mcp`);
474
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.`);
475
543
  out.roles[name] = {
476
544
  name,
477
545
  /**
@@ -485,11 +553,21 @@ export function loadConfig(path, text = readFileSync(path, "utf8")) {
485
553
  * kept so that regenerating a config writes back what a person typed
486
554
  * rather than the expansion.
487
555
  */
488
- writes: withSidecars(writes),
556
+ writes: withSidecars([...new Set([...writes, ...priv])]),
489
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,
490
567
  keys,
491
568
  keyEntries,
492
569
  keyMode: keyMode ?? null,
570
+ injectHosts,
493
571
  env: asArray(own(r, "env"), `roles.${name}.env`),
494
572
  network: own(r, "network") === undefined ? null : asArray(own(r, "network"), `roles.${name}.network`),
495
573
  /**
@@ -530,6 +608,12 @@ export function loadConfig(path, text = readFileSync(path, "utf8")) {
530
608
  * go through the proxy, which HTTP clients do and a MySQL driver does not.
531
609
  */
532
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,
533
617
  /**
534
618
  * The MCP servers this role may load, by name, or null when the policy
535
619
  * does not say. `[]` is "none". seisin declares this and does not
@@ -552,6 +636,35 @@ export function loadConfig(path, text = readFileSync(path, "utf8")) {
552
636
  * is and why only these two can be handed out. Absent is none.
553
637
  */
554
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,
555
668
  // Kept so `check` can name a misspelt key instead of ignoring it. An
556
669
  // unknown key in a role table used to be dropped silently, and for a
557
670
  // subtraction that is failing open: `never_write` would read as a rule
@@ -559,6 +672,8 @@ export function loadConfig(path, text = readFileSync(path, "utf8")) {
559
672
  unknownKeys: Object.keys(r).filter((k) => !ROLE_KEYS.includes(k)),
560
673
  };
561
674
  }
675
+ checkPrivateOverlap(out.roles, path);
676
+ out.sandbox = readSandbox(own(parsed, "sandbox"), path);
562
677
  out.notify = readNotify(own(parsed, "notify"), { path, root: out.root, keyDirs, roles: out.roles, own });
563
678
  return out;
564
679
  }
@@ -600,14 +715,81 @@ function readProviders(table, path) {
600
715
  ` Put {ref} where the reference goes: command = [${command.map((c) => `"${c}"`).join(", ")}, "{ref}"]`);
601
716
  const mode = own(p, "mode");
602
717
  if (mode !== undefined && !MODES.includes(mode))
603
- throw new Error(
604
- `${path}: [keys.providers.${name}] mode = "${mode}" is not a delivery mode. ` +
605
- `Known: ${MODES.join(", ")}.`);
718
+ throw unknownMode(`${path}: [keys.providers.${name}]`, "mode", mode);
606
719
  out[name] = { name, command, mode: mode ?? null };
607
720
  }
608
721
  return out;
609
722
  }
610
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
+
611
793
  /**
612
794
  * A SQLite database is four files, and a policy should say it once.
613
795
  *
@@ -664,11 +846,12 @@ export function withSidecars(paths) {
664
846
  const SHAPE = {
665
847
  roles: null, // validated where roles are read
666
848
  keys: { dir: true, providers: { "*": { command: true, mode: true } } },
667
- network: { allow: true },
668
- runtime: { writes: true, isolate: true, redact: true },
849
+ network: { allow: true, deny_addresses: true },
850
+ runtime: { writes: true, isolate: true, redact: true, read: true },
669
851
  scan: { ignore: true },
670
852
  protect: { instructions: true },
671
853
  notify: { url: true, url_file: true, format: true },
854
+ sandbox: { provider: true, providers: { "*": { command: true, check: true, tags_denials: true } } },
672
855
  };
673
856
 
674
857
  function checkShape(table, shape, prefix, path, lines) {
@@ -727,7 +910,7 @@ function readBoolean(value, where) {
727
910
  }
728
911
 
729
912
  /** The keys a `[roles.<name>]` table can hold. Anything else is reported by `check`. */
730
- export const ROLE_KEYS = ["writes", "keys", "key_mode", "env", "network", "never_writes", "local_binding", "local_ports", "mcp", "trustd", "control_files"];
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"];
731
914
 
732
915
  /**
733
916
  * `control_files`: which families of control files a role may edit. Refused at
@@ -786,6 +969,23 @@ function readMcp(value, where) {
786
969
  return out;
787
970
  }
788
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
+
789
989
  /**
790
990
  * `local_ports`, refused rather than guessed when it is not a list of ports.
791
991
  *
@@ -808,6 +1008,45 @@ function readLocalPorts(value, where) {
808
1008
  return out;
809
1009
  }
810
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
+
811
1050
  /**
812
1051
  * `never_writes`, refused rather than guessed when it cannot mean what it says.
813
1052
  *
@@ -829,6 +1068,56 @@ function readNeverWrites(value, where) {
829
1068
  return list;
830
1069
  }
831
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
+
832
1121
  /**
833
1122
  * A territory is relative to the repo root. An absolute one would ask the kernel
834
1123
  * for a path on the machine and make `explain` (which reads absolute as outside
@@ -848,6 +1137,23 @@ function assertNotAbsolute(list, where) {
848
1137
  return list;
849
1138
  }
850
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
+
851
1157
  function asArray(value, where) {
852
1158
  if (value === undefined) return [];
853
1159
  if (typeof value === "string") return [value];
package/src/controls.js CHANGED
@@ -80,8 +80,8 @@ function tableOf(lines, name) {
80
80
  return { header, end, keys };
81
81
  }
82
82
 
83
- function stamp(note) {
84
- return `# set in the console ${new Date().toISOString().slice(0, 10)}` +
83
+ function stamp(note, source = "in the console") {
84
+ return `# set ${source} ${new Date().toISOString().slice(0, 10)}` +
85
85
  (note ? ` · «${cleanReason(note)}»` : "");
86
86
  }
87
87
 
@@ -92,7 +92,7 @@ function stamp(note) {
92
92
  * value being replaced, and the stamp now describes the new one. Every other
93
93
  * line, comments included, stays byte for byte.
94
94
  */
95
- function setKey(text, table, key, value, note) {
95
+ export function setKey(text, table, key, value, note, source) {
96
96
  const eol = text.includes("\r\n") ? "\r\n" : "\n";
97
97
  const lines = text.split("\n");
98
98
  const cr = eol === "\r\n" ? "\r" : "";
@@ -105,14 +105,14 @@ function setKey(text, table, key, value, note) {
105
105
  const was = lines.slice(at.from, at.to + 1).map((l) => stripComment(l).trim()).join(" ");
106
106
  if (was.slice(was.indexOf("=") + 1).replace(/\s+/g, "").replace(/,\]$/, "]") === value.replace(/\s+/g, "")) return text;
107
107
  const indent = /^\s*/.exec(lines[at.from])[0];
108
- const line = `${indent}${key} = ${value} ${stamp(note)}${cr}`;
108
+ const line = `${indent}${key} = ${value} ${stamp(note, source)}${cr}`;
109
109
  lines.splice(at.from, at.to - at.from + 1, line);
110
110
  } else {
111
111
  // After the last key, not the last line: a comment above the next table
112
112
  // belongs to that table, and a line inserted under it would read as the
113
113
  // next role's.
114
114
  const after = t.keys.length ? t.keys[t.keys.length - 1].to : t.header;
115
- lines.splice(after + 1, 0, `${key} = ${value} ${stamp(note)}${cr}`);
115
+ lines.splice(after + 1, 0, `${key} = ${value} ${stamp(note, source)}${cr}`);
116
116
  }
117
117
  return lines.join("\n");
118
118
  }