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
package/src/surface.js CHANGED
@@ -300,7 +300,8 @@ function computeParentInputs(config, env) {
300
300
  * Git runs `.git/hooks/*` and whatever `.git/config` names (core.hooksPath,
301
301
  * core.fsmonitor). Claude Code runs the hooks, plugins and skills its settings
302
302
  * declare, and the MCP servers in `.mcp.json`. direnv runs `.envrc` on `cd`.
303
- * Codex starts what `config.toml` lists. A role that writes one of these does
303
+ * Codex starts what its `config.toml` and `hooks.json` list and lets its `rules`
304
+ * run without asking. A role that writes one of these does
304
305
  * not escape the sandbox; it leaves a command for you to run the next time you
305
306
  * open that project — measured on 2026-09-23: every role could open
306
307
  * `~/.claude/settings.json` for writing through the shared runtime scratch.
@@ -315,11 +316,22 @@ export const CONTROL_FILES = [
315
316
  ".git/hooks", ".git/config",
316
317
  ".claude/settings.json", ".claude/settings.local.json",
317
318
  ".claude/hooks", ".claude/plugins", ".claude/skills", ".claude/commands", ".claude/agents",
318
- ".mcp.json", ".envrc", ".codex/config.toml",
319
+ ".mcp.json", ".envrc", ".codex",
319
320
  ];
320
321
 
321
322
  const RUNNER = (f) =>
322
- f.startsWith(".git/") ? "git" : f === ".envrc" ? "direnv" : f.startsWith(".codex/") ? "Codex" : "Claude Code";
323
+ f.startsWith(".git/") ? "git" : f === ".envrc" ? "direnv" : f.startsWith(".codex") ? "Codex" : "Claude Code";
324
+
325
+ /**
326
+ * A project's `.codex/` is protected whole, as `.claude/` is, and for the same
327
+ * reason plus one: it holds `config.toml` (MCP servers, profiles, and the trust
328
+ * a hook needs before Codex runs it), `hooks.json` and `rules/`, which Codex
329
+ * reads from any project the user has trusted. Codex itself keeps `.codex`
330
+ * read-only inside the directories its own sandbox lets it write, so a role
331
+ * loses nothing there that Codex would have given it.
332
+ */
333
+ const CODEX_WHY = ".codex, whose config, hooks and rules Codex runs outside the sandbox";
334
+ const controlWhy = (f) => f === ".codex" ? CODEX_WHY : `${f}, run by ${RUNNER(f)} outside the sandbox`;
323
335
 
324
336
  /** `s` as a literal inside a RegExp — every metacharacter, not only `.`. */
325
337
  const literal = (s) => s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
@@ -462,9 +474,9 @@ function controlsOf(dir, platform, families = []) {
462
474
  out.push({ path: join(dir, ".claude"), why: ".claude, whose settings, hooks and skills Claude Code runs outside the sandbox" });
463
475
  // The single files only where they exist: where they do not, the patterns
464
476
  // refuse creating them, and a literal each would be start-up for nothing.
465
- for (const f of [".mcp.json", ".envrc", ".codex/config.toml"]) {
477
+ for (const f of [".mcp.json", ".envrc", ".codex"]) {
466
478
  const p = join(dir, f);
467
- if (existsSync(p)) out.push({ path: p, why: `${f}, run by ${RUNNER(f)} outside the sandbox`, ifPresent: true });
479
+ if (existsSync(p)) out.push({ path: p, why: controlWhy(f), ifPresent: true });
468
480
  }
469
481
  // The families literally only where they exist, on every platform. Where
470
482
  // they do not, macOS gets a pattern for creating them (computeDenies) and
@@ -486,16 +498,75 @@ function controlsOf(dir, platform, families = []) {
486
498
  * with `claude -p` under the new profile: it answered, and what it was refused
487
499
  * were lock and cache files under plugins/, which it tolerates).
488
500
  */
489
- function homeControls(platform) {
490
- const home = homedir();
501
+ function homeControls(platform, home = homedir()) {
491
502
  return [
492
503
  ".claude/settings.json", ".claude/settings.local.json", ".claude/hooks", ".claude/plugins",
493
- ".claude/skills", ".claude/commands", ".claude/agents", ".codex/config.toml",
504
+ ".claude/skills", ".claude/commands", ".claude/agents",
505
+ ...CODEX_HOME_CONTROLS, ...codexProfiles(home),
494
506
  ]
495
507
  .map((f) => ({ path: join(home, f), why: `${f}, run by ${RUNNER(f)} outside the sandbox`, ifPresent: true }))
496
508
  .filter((e) => platform !== "linux" || existsSync(e.path));
497
509
  }
498
510
 
511
+ /**
512
+ * What Codex runs or obeys from its home, file by file — never `~/.codex`
513
+ * whole, because Codex writes its sessions, logs and state databases there on
514
+ * every turn (measured 2026-09-30 with codex 0.150.1 under seisin: sessions/,
515
+ * logs_2.sqlite, state_5.sqlite, models_cache.json, cache/, plugins/cache/).
516
+ *
517
+ * - `config.toml`: MCP servers, profiles, and `hooks.state.*.trusted_hash`, the
518
+ * trust Codex asks before running a hook. Codex tries to rewrite it at every
519
+ * start and carries on when refused.
520
+ * - `hooks.json`: commands run on every tool call, outside any sandbox.
521
+ * - `rules/`: the commands Codex runs without asking.
522
+ * - `skills/`, `plugins/`: what the next session loads. Codex installs OpenAI's
523
+ * curated plugins into `plugins/cache/` by itself and carries on when refused.
524
+ * - `managed_config.toml`: a layer that overrides `config.toml`.
525
+ * - `.tmp/plugins`: Codex's copy of OpenAI's curated plugin marketplace, a git
526
+ * checkout it syncs at start. `codex plugin add` installs from it as it is on
527
+ * disk — measured on 2026-09-30 with codex 0.150.1 in a scratch CODEX_HOME: a
528
+ * file and an `.mcp.json` planted in the copy landed in `plugins/cache/` and
529
+ * the plugin was enabled. Codex re-fetches the copy only when the upstream
530
+ * commit differs from `.tmp/plugins.sha`, so an edit made while they match
531
+ * stays until upstream moves. The `.sha` file is left writable: changing it
532
+ * makes Codex fetch a clean copy, which repairs rather than plants.
533
+ */
534
+ export const CODEX_HOME_CONTROLS = [
535
+ ".codex/config.toml", ".codex/hooks.json", ".codex/rules", ".codex/skills", ".codex/plugins",
536
+ ".codex/managed_config.toml", ".codex/.tmp/plugins",
537
+ ];
538
+
539
+ /**
540
+ * `CODEX_HOME`, when it names somewhere other than `~/.codex`, absolute and
541
+ * resolved — or null. Codex takes it from the environment of whoever runs it,
542
+ * so the parent's environment is the one that says where your Codex looks.
543
+ */
544
+ export function codexHomeOf(env = process.env) {
545
+ const v = env?.CODEX_HOME;
546
+ if (!v || !isAbsolute(v)) return null;
547
+ const real = realOrSelf(v);
548
+ return real === realOrSelf(join(homedir(), ".codex")) ? null : real;
549
+ }
550
+
551
+ /** The control entries of a Codex home that is not `~/.codex`, by the same list. */
552
+ function codexControlsAt(dir, platform) {
553
+ let profiles = [];
554
+ try { profiles = readdirSync(dir).filter((f) => f.endsWith(".config.toml") && f !== "config.toml"); } catch {}
555
+ return [...CODEX_HOME_CONTROLS.map((c) => c.replace(/^\.codex\//, "")), ...profiles]
556
+ .map((f) => ({ path: join(dir, f), why: `CODEX_HOME/${f}, run by Codex outside the sandbox`, ifPresent: true }))
557
+ .filter((e) => platform !== "linux" || existsSync(e.path));
558
+ }
559
+
560
+ /** `codex -p <name>` layers `~/.codex/<name>.config.toml`: the ones there now. */
561
+ function codexProfiles(home) {
562
+ try {
563
+ return readdirSync(join(home, ".codex")).filter((f) => f.endsWith(".config.toml") && f !== "config.toml")
564
+ .map((f) => `.codex/${f}`);
565
+ } catch {
566
+ return [];
567
+ }
568
+ }
569
+
499
570
  /**
500
571
  * Everything this role's profile must deny beyond its own `never_writes`.
501
572
  *
@@ -512,7 +583,7 @@ export function denyFor(config, role, { env = process.env, platform = process.pl
512
583
  // Memoised per config: `check` builds every role's settings and then asks
513
584
  // for the same list again to show it; on a multi-repo workspace that was 2.6 s twice.
514
585
  const memo = DENIES.get(config) ?? DENIES.set(config, new Map()).get(config);
515
- const key = `${role.name}\0${observe}\0${platform}\0${env.PATH ?? ""}`;
586
+ const key = `${role.name}\0${observe}\0${platform}\0${env.PATH ?? ""}\0${env.CODEX_HOME ?? ""}`;
516
587
  if (memo.has(key)) return memo.get(key);
517
588
  const result = computeDenies(config, role, { env, platform, observe });
518
589
  memo.set(key, result);
@@ -536,10 +607,17 @@ function computeDenies(config, role, { env, platform, observe }) {
536
607
  const dirs = mine.filter((p) => { try { return statSync(p).isDirectory(); } catch { return false; } });
537
608
  const repoDirs = dirs.filter((p) => under(p, config.root));
538
609
  const families = familiesFor(config, role);
539
- const roots = [...new Set(repoDirs.flatMap((d) => rootsUnder(config, d)))];
540
- const controls = roots.flatMap((d) => controlsOf(d, platform, families));
610
+ // Discover projects from the policy root, not from the territory. A territory
611
+ // may itself be .claude; starting the walk there called <repo>/.claude/.claude
612
+ // protected and left the real settings and hooks writable. A project's
613
+ // control files win over every grant that covers or sits inside the project.
614
+ const roots = [...new Set(rootsUnder(config, config.root)
615
+ .filter((d) => minePaths.some((p) => under(p, d) || under(d, p))))];
616
+ const controls = roots.flatMap((d) => controlsOf(d, platform, families))
617
+ .filter((e) => inMine(e.path) || minePaths.some((p) => under(p, e.path)));
541
618
  // The home is a project too, for Claude Code and Codex: ~/.claude/settings.json
542
619
  // is read by every session. Not walked — only its own control files.
620
+ const codexHome = join(homedir(), ".codex");
543
621
  if (["~/.claude", "~/.codex"].some((h) => inMine(join(homedir(), h.slice(2)))))
544
622
  controls.push(...homeControls(platform));
545
623
 
@@ -554,7 +632,7 @@ function computeDenies(config, role, { env, platform, observe }) {
554
632
  * files stay literal (homeControls).
555
633
  */
556
634
  const globs = platform === "darwin"
557
- ? repoDirs.flatMap((d) => [
635
+ ? roots.flatMap((d) => [
558
636
  `${d}/**/.claude`, `${d}/**/.claude/**`,
559
637
  `${d}/**/.git/hooks/**`, `${d}/**/.git/config`,
560
638
  // What git runs that lives beside, not in, `.git/config`: a submodule's
@@ -566,16 +644,28 @@ function computeDenies(config, role, { env, platform, observe }) {
566
644
  `${d}/**/.git/config.worktree`,
567
645
  `${d}/**/.git/modules/**/config`, `${d}/**/.git/modules/**/hooks/**`,
568
646
  `${d}/**/.git/worktrees/**/config.worktree`, `${d}/**/.git/worktrees/**/commondir`,
569
- `${d}/**/.mcp.json`, `${d}/**/.envrc`, `${d}/**/.codex/config.toml`,
647
+ `${d}/**/.mcp.json`, `${d}/**/.envrc`, `${d}/**/.codex`, `${d}/**/.codex/**`,
570
648
  ]).map((path) => ({ path, why: "any project's control files in this territory, by pattern" }))
571
649
  : [];
650
+ // A profile created after the walk: `codex -p x` reads `~/.codex/x.config.toml`.
651
+ if (platform === "darwin" && inMine(codexHome))
652
+ globs.push({ path: `${codexHome}/*.config.toml`, why: "a Codex profile, if it is created" });
653
+ // `CODEX_HOME` moves all of it. Codex run with it set — by you, outside the
654
+ // sandbox — reads its config, hooks, rules and plugins from there, so when a
655
+ // role can write that directory the same entries are taken back there.
656
+ const elsewhere = codexHomeOf(env);
657
+ if (elsewhere && inMine(elsewhere)) {
658
+ controls.push(...codexControlsAt(elsewhere, platform));
659
+ if (platform === "darwin")
660
+ globs.push({ path: `${elsewhere}/*.config.toml`, why: "a Codex profile under CODEX_HOME, if it is created" });
661
+ }
572
662
  // Creating one of the families where it is not there yet: one exact pattern
573
663
  // per project root, never `**` (see `exactly`).
574
664
  if (platform === "darwin")
575
665
  for (const root of roots)
576
666
  for (const family of families)
577
667
  for (const f of FAMILIES[family])
578
- if (!existsSync(join(root, f)))
668
+ if (inMine(join(root, f)) && !existsSync(join(root, f)))
579
669
  globs.push({ path: exactly(root, f), why: `${familyWhy(family, f)}, if it is created`, family });
580
670
 
581
671
  // The policy, `.seisin/` and the key directories are denied whatever the
@@ -594,13 +684,36 @@ function computeDenies(config, role, { env, platform, observe }) {
594
684
  * to be filed as "belongs to dev" for a role with `writes = ["**"]`, and
595
685
  * granting it would have granted nothing.
596
686
  */
597
- export function protectedBy(config, target, { platform = process.platform, role = null } = {}) {
687
+ export function protectedBy(config, target, { platform = process.platform, role = null, env = process.env } = {}) {
598
688
  /**
599
689
  * A project's `.claude/`, any file in it, and the other control files — the
600
690
  * same rule the kernel is given. On Linux the kernel only holds the ones
601
691
  * that exist, so only those are called protected there: a sentence stricter
602
692
  * than the boundary is the direction this project refuses.
603
693
  */
694
+ /**
695
+ * Codex's home is not a project's `.codex`: Codex writes its sessions there
696
+ * on every turn and the kernel lets it. Only the entries it runs or obeys are
697
+ * protected, so only those are called protected.
698
+ */
699
+ const codexHome = join(homedir(), ".codex");
700
+ const elsewhere = codexHomeOf(env);
701
+ if (elsewhere && isAbsolute(target) && under(target, elsewhere)) {
702
+ const rel = target.slice(elsewhere.length + 1);
703
+ const hit = CODEX_HOME_CONTROLS.map((c) => c.replace(/^\.codex\//, "")).find((c) => under(rel, c)) ??
704
+ (dirname(target) === elsewhere && target.endsWith(".config.toml") ? rel : null);
705
+ return hit && (platform !== "linux" || existsSync(join(elsewhere, hit)))
706
+ ? { path: target, why: `CODEX_HOME/${hit}, run by Codex outside the sandbox` }
707
+ : null;
708
+ }
709
+ if (isAbsolute(target) && under(target, codexHome)) {
710
+ const rel = target.slice(homedir().length + 1);
711
+ const hit = [...CODEX_HOME_CONTROLS].find((c) => under(rel, c)) ??
712
+ (dirname(target) === codexHome && target.endsWith(".config.toml") ? rel : null);
713
+ return hit && (platform !== "linux" || existsSync(join(homedir(), hit)))
714
+ ? { path: target, why: `${hit}, run by Codex outside the sandbox` }
715
+ : null;
716
+ }
604
717
  const f = CLAUDE_DIR.test(target)
605
718
  ? ".claude"
606
719
  : CONTROL_RES.find(([, re]) => re.test(target))?.[0];
@@ -608,7 +721,7 @@ export function protectedBy(config, target, { platform = process.platform, role
608
721
  if (f && (platform !== "linux" || heldOnLinux(config, target, re))) {
609
722
  const why = f === ".claude"
610
723
  ? ".claude, whose settings, hooks and skills Claude Code runs outside the sandbox"
611
- : `${f}, run by ${RUNNER(f)} outside the sandbox`;
724
+ : controlWhy(f);
612
725
  return { path: target, why };
613
726
  }
614
727
  /**
@@ -0,0 +1,261 @@
1
+ /**
2
+ * `[runtime] read = "territory"`: reading denied where people keep their data,
3
+ * re-opened only along the paths a role needs.
4
+ *
5
+ * The default read model is a list of what not to read — key directories, the
6
+ * places credentials live, other agents' sign-in. It holds for agents you run
7
+ * yourself. Against one you would not trust it loses by one every time a new
8
+ * product leaves a folder of its own: measured on 2026-10-06 by an orchestrator that evaluates agents,
9
+ * a role confined that way could still list `~/.claude`, list the whole
10
+ * project tree of the person running it, and `cat` an evaluation check kept
11
+ * outside its worktree. None of the three was a credential, so none was on
12
+ * the list.
13
+ *
14
+ * So this mode turns the list around, but not all the way. Denying `/` and
15
+ * allowing back is the read allowlist decisions.md rejected, for a reason that
16
+ * still holds: every interpreter, library and cache a toolchain touches would
17
+ * have to be named. The data roots are what gets denied — homes, temp
18
+ * directories, mounted volumes — and the system stays readable, because there
19
+ * is nobody's data in `/usr`. What a role needs from inside a data root is
20
+ * finite and known before it starts: its repo, its territory, its run, its
21
+ * toolchain. That list is the short one, and it is the one written.
22
+ *
23
+ * **Denied whole, allowed back.** Each data root is one deny, and what the
24
+ * role keeps is an `allowRead` inside it. A deny covers what is created after
25
+ * it, so another run's directory made beside the repo an hour into this one
26
+ * is shut the moment it exists. That is the point of it.
27
+ *
28
+ * It was carved before — every sibling on the way to a kept path denied by
29
+ * name, which is a photograph: an entry created during the run beside a kept
30
+ * path was not covered, and read (measured, 2026-10-12). Two measured runtime
31
+ * behaviours kept the allow out then, and each is now handled where it bites:
32
+ *
33
+ * - macOS: an allow is emitted after the denies, and a deny that sits inside
34
+ * an allow is re-emitted after it whole (last match wins). For the key
35
+ * directory inside the repo that re-emitted deny also shuts the key files
36
+ * `[keys]` re-allows inside it. A deny spelt as a pattern is re-emitted
37
+ * MINUS the allows inside it, so those denies are spelt as one
38
+ * (outlastAllows, below).
39
+ * - Linux: the runtime restores the write mounts a deny wiped and then the
40
+ * read allows, read-only, on top of them — so a repo allowed back covers
41
+ * the territory inside it and every write fails with "Read-only file
42
+ * system" (srt 0.0.75/0.0.78). A kept path that holds a write path is
43
+ * allowed back piece by piece around it (territoryReads, below).
44
+ *
45
+ * **What that costs, said where it is decided.** On Linux, inside a kept
46
+ * directory that is on the way to a write path, an entry created during the
47
+ * run is not there for the role until the next run: it fails closed, it does
48
+ * not leak. On both, the names of what the role keeps are visible where the
49
+ * runtime has to show them (a Linux `ls ~` lists the mount points), and
50
+ * nothing else in a data root is.
51
+ */
52
+ import { readdirSync, lstatSync } from "node:fs";
53
+ import { homedir, tmpdir } from "node:os";
54
+ import { realOrSelf, writePathsOf, expand } from "./grants.js";
55
+ import { realAncestor } from "./paths.js";
56
+
57
+ export const READ_MODES = ["all", "territory"];
58
+
59
+ /** Files an agent reads at start that live outside its home directory. */
60
+ export const AGENT_FILES = { claude: ["~/.claude.json"], codex: [] };
61
+
62
+ /**
63
+ * Where data lives, per platform. Whole roots: what is under them belongs to
64
+ * somebody. `/opt/homebrew/var` and `/usr/local/var` hold the databases a
65
+ * package manager runs (Postgres, Redis); the rest of `/opt/homebrew` is the
66
+ * toolchain and stays readable.
67
+ */
68
+ export const DATA_ROOTS = {
69
+ darwin: [
70
+ "/Users", "/private/tmp", "/private/var/folders", "/private/var/root", "/Volumes",
71
+ "/opt/homebrew/var", "/usr/local/var", "/Library/Keychains",
72
+ ],
73
+ linux: [
74
+ "/home", "/root", "/tmp", "/var/tmp", "/mnt", "/media", "/srv", "/run/user",
75
+ "/run/secrets", "/var/lib/docker",
76
+ ],
77
+ };
78
+
79
+ /** The data roots of this machine: the platform's, plus HOME and TMPDIR wherever they are. */
80
+ export function dataRoots(platform = process.platform, home = homedir(), tmp = tmpdir(), real = realOrSelf) {
81
+ const base = DATA_ROOTS[platform] ?? DATA_ROOTS.linux;
82
+ return [...new Set([...base, home, tmp].map(real))];
83
+ }
84
+
85
+ /**
86
+ * The most entries one directory may have where a kept path is allowed back
87
+ * piece by piece (Linux, around a write path).
88
+ *
89
+ * Every piece is one allow, and one mount. The carving this replaced hit the
90
+ * same wall from the other side: a repo kept inside a Mac's TMPDIR, 9,105
91
+ * entries, made 9,163 denies, 815 KB of profile, and `spawn E2BIG` — no
92
+ * agent, and no reason anyone could read. Past this the answer is a refusal
93
+ * that names the directory, not a sandbox that cannot start.
94
+ */
95
+ export const MAX_CARVE = 1000;
96
+
97
+ /** What the runtime treats as glob syntax in a path (sandbox-utils containsGlobChars). */
98
+ const GLOB = /[[\]*?]/;
99
+
100
+ const under = (p, dir) => p === dir || p.startsWith(dir === "/" ? "/" : dir + "/");
101
+ const inside = (p, dir) => p !== dir && under(p, dir);
102
+
103
+ /**
104
+ * The read rules that leave exactly `keep` readable inside `roots`:
105
+ * `{ deny, allow }`, for denyRead and allowRead.
106
+ *
107
+ * Each root is denied whole, unless something kept covers it. Each kept path
108
+ * under a root is allowed back, in both spellings. On Linux a kept path that
109
+ * holds one of `writes` is not allowed back itself — its read mount would
110
+ * land on the write mount inside it — but walked toward the writes, and every
111
+ * child not on the way is allowed instead; the writes come back writable
112
+ * through the runtime's own restore. Nothing is denied by name, so nothing
113
+ * created later escapes the deny.
114
+ *
115
+ * Nothing at or under `denied` — the key directories, other roles' private
116
+ * folders, the credential homes — is allowed back: an allow there would win
117
+ * over the deny on macOS, and on Linux the deny's own mount restores it.
118
+ *
119
+ * `fs` is injectable for tests; it needs `readdir(dir) -> names`,
120
+ * `kind(path) -> "dir" | "link" | "other" | null` and `real(path) -> path`.
121
+ */
122
+ export function territoryReads(roots, keep, { writes = [], denied = [], platform = process.platform, fs = realFs } = {}) {
123
+ // realAncestor, not realOrSelf: a territory not created yet still has to
124
+ // be spelt the way the kernel will meet it (`/tmp/x` is `/private/tmp/x`).
125
+ // Both spellings of each: where it resolves, and where its NAME lives (the
126
+ // directories above resolved, the last component as written). A kept
127
+ // symlink — `~/.local/bin/claude` — has to be reachable by name for a PATH
128
+ // lookup to find it, and by target for the read to land.
129
+ const nameOf = (k) => { const d = k.slice(0, k.lastIndexOf("/")) || "/"; const r = fs.real(d); return `${r === "/" ? "" : r}/${k.slice(k.lastIndexOf("/") + 1)}`; };
130
+ const no = [...new Set(denied.map(fs.real))];
131
+ const open = (p) => !no.some((d) => under(p, d));
132
+ const kept = [...new Set(keep.flatMap((k) => [fs.real(k), nameOf(k)]))].filter(open);
133
+ // Nested roots (`/private/var/folders/…/T` inside `/private/var/folders`)
134
+ // are covered by the outer deny; one kept whole needs no deny at all.
135
+ const real = [...new Set(roots.map(fs.real))].filter((r) => fs.kind(r) === "dir");
136
+ const deny = real.filter((r) => !real.some((o) => inside(r, o)) && !kept.some((k) => under(r, k)));
137
+ const shut = kept.filter((k) => deny.some((r) => inside(k, r)));
138
+ if (platform !== "linux") return { deny, allow: shut };
139
+
140
+ const w = [...new Set(writes.map(fs.real))];
141
+ const allow = [];
142
+ const walk = (dir) => {
143
+ let names;
144
+ try { names = fs.readdir(dir); } catch { return; } // unreadable: nothing comes back
145
+ if (names.length > MAX_CARVE)
146
+ throw new Error(
147
+ `read = "territory" cannot keep ${dir} readable around what the role writes inside it: it holds ` +
148
+ `${names.length} entries, and on Linux each is one mount (the most it takes is ${MAX_CARVE} per directory).\n` +
149
+ ` Written inside it: ${w.filter((x) => inside(x, dir)).join(", ")}.\n` +
150
+ ` Move the repo to a directory with fewer neighbours, or narrow the reads entry that keeps this path.`);
151
+ for (const name of names.sort()) {
152
+ const child = dir === "/" ? `/${name}` : `${dir}/${name}`;
153
+ if (w.some((x) => under(child, x)) || !open(child)) continue; // a write, or denied
154
+ if (fs.kind(child) === "dir" && w.some((x) => inside(x, child))) walk(child);
155
+ else allow.push(child);
156
+ }
157
+ };
158
+ for (const k of shut) {
159
+ if (w.some((x) => under(k, x))) continue; // comes back as a write
160
+ if (w.some((x) => inside(x, k)) && fs.kind(k) === "dir") walk(k);
161
+ else allow.push(k);
162
+ }
163
+ return { deny, allow };
164
+ }
165
+
166
+ /**
167
+ * The denies, respelt so that each keeps its own allows open on macOS.
168
+ *
169
+ * A deny strictly inside an allow is re-emitted after the allows, because the
170
+ * last rule that matches wins and the allow would otherwise re-open it. A
171
+ * deny written as a path comes back whole, and that shuts every allow inside
172
+ * IT — the key files inside the key directory inside the repo. A deny written
173
+ * as a pattern comes back minus each allow its pattern covers (srt 0.0.78,
174
+ * macos-sandbox-utils lateReadDenyFilters). So those denies, and only those,
175
+ * are spelt as a pattern that matches exactly their own path: one character
176
+ * of the last name in brackets, `.secrets` as `.secret[s]`.
177
+ *
178
+ * A path that already holds glob syntax cannot be spelt that way without
179
+ * matching something else, and is refused.
180
+ */
181
+ export function outlastAllows(denies, allows, real = (p) => p) {
182
+ const open = allows.map(real);
183
+ return denies.map((written) => {
184
+ const d = real(written);
185
+ if (!open.some((a) => inside(a, d)) || !open.some((a) => inside(d, a))) return written;
186
+ const slash = d.lastIndexOf("/");
187
+ let at = d.length - 1;
188
+ while (at > slash && !/[A-Za-z0-9_-]/.test(d[at])) at--;
189
+ if (GLOB.test(d) || at <= slash)
190
+ throw new Error(
191
+ `read = "territory" cannot keep ${d} shut around what is allowed inside it: its name holds ` +
192
+ `characters the sandbox reads as a pattern, or none it can bracket.\n Rename it.`);
193
+ return `${d.slice(0, at)}[${d[at]}]${d.slice(at + 1)}`;
194
+ });
195
+ }
196
+
197
+ const realFs = {
198
+ real: realAncestor,
199
+ readdir: (d) => readdirSync(d),
200
+ kind: (p) => {
201
+ try {
202
+ const s = lstatSync(p);
203
+ return s.isSymbolicLink() ? "link" : s.isDirectory() ? "dir" : "other";
204
+ } catch { return null; }
205
+ },
206
+ };
207
+
208
+ /**
209
+ * What a role keeps readable: its repo, what it writes, its `reads` and its
210
+ * `toolchain`. One answer for the settings and for `check`, so the warning
211
+ * about an unreadable PATH entry and the kernel agree.
212
+ */
213
+ export function keptFor(config, role, { observe = false, agent, program } = {}) {
214
+ return [
215
+ config.root,
216
+ // The program the run starts, and where its link points: without it there
217
+ // is no run. Measured: `claude` installs as ~/.local/bin/claude ->
218
+ // ~/.local/share/claude/versions/<v>, and the run died on `env: claude: No
219
+ // such file or directory`. The file, not its directory — the rest of
220
+ // ~/.local/bin stays shut.
221
+ ...(program ? [program, realOrSelf(program)] : []),
222
+ // The agent's own files that are not under its home directory. Its home
223
+ // (~/.claude) is scratch and kept with the writes; ~/.claude.json, its
224
+ // settings and session index, sits beside it, and without it Claude Code
225
+ // starts and says "Not logged in" (measured, 2026-10-09). A run of no
226
+ // agent, or of another, does not get it.
227
+ ...(agent ? (AGENT_FILES[agent] ?? []).map(expand) : []),
228
+ ...writePathsOf(config, role, { observe, agent }),
229
+ ...(role.reads ?? []).map((p) => readPath(config.root, p)),
230
+ ...(role.toolchain ?? []).map((p) => readPath(config.root, p)),
231
+ ];
232
+ }
233
+
234
+ /** Whether `p` is shut by the mode: under a data root, and under nothing kept. */
235
+ export function shutByTerritory(p, keep, roots = dataRoots(), real = realAncestor) {
236
+ const r = real(p);
237
+ const kept = keep.map(real);
238
+ if (!roots.some((root) => under(r, root))) return false;
239
+ return !kept.some((k) => under(r, k));
240
+ }
241
+
242
+ /**
243
+ * The `bin` directory of each toolchain entry that has one, for the front of
244
+ * PATH. A venv, `~/.cargo`, a node install all keep their programs there;
245
+ * declaring the toolchain and then finding `python` resolves to another one
246
+ * would be a second way of not having it.
247
+ */
248
+ export function toolchainBins(config, role, isDir) {
249
+ return (role.toolchain ?? []).map((p) => readPath(config.root, p))
250
+ .flatMap((p) => [p.endsWith("/bin") ? p : `${p}/bin`].filter(isDir));
251
+ }
252
+
253
+ /** `~` and relative spellings of a read entry, absolute. Missing paths are kept as written. */
254
+ export function readPath(root, p) {
255
+ const trimmed = p.replace(/\/\*\*$/, "") || ".";
256
+ if (trimmed === "~") return homedir();
257
+ if (trimmed.startsWith("~/")) return `${homedir()}/${trimmed.slice(2)}`;
258
+ if (trimmed.startsWith("/")) return trimmed;
259
+ return `${root}/${trimmed}`.replace(/\/\.$/, "");
260
+ }
261
+
package/src/trace.js ADDED
@@ -0,0 +1,33 @@
1
+ /**
2
+ * The launcher's trace, carried into the log.
3
+ *
4
+ * A launcher that runs many agents (a team, a CI job) gives each run a W3C
5
+ * `TRACEPARENT`. Without it in the log, nothing says which of these lines belong
6
+ * to which of the launcher's runs, and the log cannot be joined with anything
7
+ * else that observed the same run (a tracer, the launcher's own spans). With it,
8
+ * any OpenTelemetry tool joins them by trace id; seisin does not need to know
9
+ * who else is looking.
10
+ *
11
+ * Only a well-formed value is used (W3C Trace Context, version 00): a malformed
12
+ * one is ignored rather than copied into a log that other tools will trust.
13
+ */
14
+
15
+ const PART = { version: /^[0-9a-f]{2}$/, trace: /^[0-9a-f]{32}$/, span: /^[0-9a-f]{16}$/, flags: /^[0-9a-f]{2}$/ };
16
+
17
+ /** `{ trace_id, span_id }` from a `TRACEPARENT` value, or null. */
18
+ export function parseTraceparent(value) {
19
+ if (typeof value !== "string") return null;
20
+ const parts = value.trim().split("-");
21
+ if (parts.length < 4) return null;
22
+ const [version, trace, span, flags] = parts;
23
+ if (!PART.version.test(version) || version === "ff") return null;
24
+ if (version === "00" && parts.length !== 4) return null;
25
+ if (!PART.trace.test(trace) || !PART.span.test(span) || !PART.flags.test(flags)) return null;
26
+ if (/^0+$/.test(trace) || /^0+$/.test(span)) return null;
27
+ return { trace_id: trace, span_id: span };
28
+ }
29
+
30
+ /** The fields a log entry carries for the launcher's trace: `{}` when there is none. */
31
+ export function traceFields(env = process.env) {
32
+ return parseTraceparent(env.TRACEPARENT) ?? {};
33
+ }
package/src/uilink.js CHANGED
@@ -18,9 +18,11 @@
18
18
  * The pid is stored so a file left by a crashed run is recognisable: a link
19
19
  * whose writer is gone is stale, not a running console.
20
20
  */
21
- import { writeFileSync, readFileSync, unlinkSync } from "node:fs";
21
+ import { chmodSync, renameSync, writeFileSync, readFileSync, unlinkSync } from "node:fs";
22
+ import { randomBytes } from "node:crypto";
22
23
  import { join } from "node:path";
23
24
  import { runsRoot } from "./rundir.js";
25
+ import { alive as processAlive } from "./log.js";
24
26
 
25
27
  function linkFile(port, base) {
26
28
  return join(runsRoot(base), `ui-${port}.url`);
@@ -34,7 +36,17 @@ function linkFile(port, base) {
34
36
  * with nothing on it to say so. The policy's path travels with the link now.
35
37
  */
36
38
  export function writeUiLink(port, url, base, policy = null) {
37
- writeFileSync(linkFile(port, base), JSON.stringify({ url, pid: process.pid, policy }) + "\n", { mode: 0o600 });
39
+ const file = linkFile(port, base);
40
+ const temp = `${file}.tmp-${randomBytes(12).toString("hex")}`;
41
+ writeFileSync(temp, JSON.stringify({ url, pid: process.pid, policy }) + "\n", { flag: "wx", mode: 0o600 });
42
+ try {
43
+ // Replacing an existing record must neither inherit its mode nor follow
44
+ // its symlink. Readers see one complete record, including after a crash.
45
+ chmodSync(temp, 0o600);
46
+ renameSync(temp, file);
47
+ } finally {
48
+ try { unlinkSync(temp); } catch {}
49
+ }
38
50
  }
39
51
 
40
52
  /**
@@ -48,10 +60,7 @@ export function readUiLink(port, base) {
48
60
  try { e = JSON.parse(raw); } catch { return null; }
49
61
  if (!e || typeof e.url !== "string") return null;
50
62
  let alive = null;
51
- if (typeof e.pid === "number") {
52
- // Signal 0 tests for the process without touching it: it throws if gone.
53
- try { process.kill(e.pid, 0); alive = true; } catch { alive = false; }
54
- }
63
+ if (Number.isInteger(e.pid) && e.pid > 0) alive = processAlive(e.pid);
55
64
  return { url: e.url, pid: e.pid ?? null, alive, policy: typeof e.policy === "string" ? e.policy : null };
56
65
  }
57
66