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.
- package/README.md +385 -129
- package/package.json +13 -5
- package/src/browsers.js +52 -0
- package/src/canary.js +149 -0
- package/src/cli.js +47 -23
- package/src/commands/check.js +154 -3
- package/src/commands/explain.js +2 -5
- package/src/commands/hook.js +7 -3
- package/src/commands/init.js +57 -50
- package/src/commands/requests.js +31 -56
- package/src/commands/review.js +33 -6
- package/src/commands/run.js +332 -110
- package/src/commands/walls.js +39 -7
- package/src/commands/whose.js +2 -1
- package/src/config.js +321 -15
- package/src/controls.js +5 -5
- package/src/diagnose.js +67 -16
- package/src/env.js +3 -0
- package/src/grants.js +107 -2
- package/src/handoff.js +285 -10
- package/src/help.js +39 -19
- package/src/hook.js +59 -30
- package/src/index.d.ts +217 -0
- package/src/index.js +20 -12
- package/src/inspect/codex.js +64 -0
- package/src/inspect/credentials.js +48 -0
- package/src/inspect/enforce.js +24 -0
- package/src/inspect/files.js +71 -0
- package/src/inspect/home.js +43 -0
- package/src/inspect/inject.js +68 -0
- package/src/inspect/keys.js +74 -0
- package/src/inspect/layout.js +258 -0
- package/src/inspect/limits.js +98 -0
- package/src/inspect/machine.js +107 -0
- package/src/inspect/network.js +37 -0
- package/src/inspect/providers.js +71 -0
- package/src/inspect/read-territory.js +105 -0
- package/src/inspect/role-keys.js +136 -0
- package/src/inspect/shared.js +37 -0
- package/src/inspect/warnings.js +58 -0
- package/src/inspect.js +13 -815
- package/src/intake.js +131 -21
- package/src/keys.js +223 -67
- package/src/kinds.js +42 -0
- package/src/lines.js +26 -0
- package/src/log.js +7 -2
- package/src/mcp.js +28 -43
- package/src/nested.js +12 -3
- package/src/notify.js +53 -12
- package/src/owners.js +165 -3
- package/src/paths.js +17 -1
- package/src/provider-run.js +81 -0
- package/src/redact.js +34 -15
- package/src/render.js +35 -9
- package/src/requests.js +363 -45
- package/src/review.js +21 -9
- package/src/rundir.js +29 -8
- package/src/sandboxes/command.js +93 -0
- package/src/sandboxes/index.js +113 -0
- package/src/sandboxes/mach-profile.js +31 -0
- package/src/sandboxes/srt-mach.mjs +43 -0
- package/src/sandboxes/srt.js +163 -0
- package/src/sandboxes/tartaros.js +80 -0
- package/src/scan.js +7 -3
- package/src/serve.js +35 -17
- package/src/spool.js +8 -17
- package/src/srt.js +263 -33
- package/src/surface.js +129 -16
- package/src/territory.js +261 -0
- package/src/trace.js +33 -0
- package/src/uilink.js +15 -6
- package/src/views.js +40 -14
- package/src/violations.js +143 -7
- package/src/walls.js +62 -9
- package/ui/index.html +85 -13
- 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`
|
|
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
|
|
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
|
|
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
|
|
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:
|
|
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",
|
|
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
|
-
|
|
540
|
-
|
|
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
|
-
?
|
|
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
|
|
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
|
-
:
|
|
724
|
+
: controlWhy(f);
|
|
612
725
|
return { path: target, why };
|
|
613
726
|
}
|
|
614
727
|
/**
|
package/src/territory.js
ADDED
|
@@ -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
|
-
|
|
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 (
|
|
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
|
|