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
@@ -0,0 +1,98 @@
1
+ /**
2
+ * What the tool cannot do at all — as opposed to `warnings`, which are things
3
+ * wrong with *this* config.
4
+ *
5
+ * Kept apart on purpose. A limit printed as a warning reads as something a
6
+ * person could fix, and after the third run it reads as nothing at all.
7
+ *
8
+ * They used to close `check --verbose`, six paragraphs under every repo's
9
+ * findings, identical in each one — so the part of the output about *this*
10
+ * repo was the part nobody reached. `check` now names how many there are and
11
+ * where to read them: `seisin help limits` (help.js prints this list).
12
+ * `inspect()` still returns them, so the MCP hands them out with the map.
13
+ */
14
+ export const LIMITS = [
15
+ ...(process.platform !== "darwin" ? [] : [{
16
+ kind: "kernel-denials-can-drop",
17
+ headline: "a flooded system log loses kernel denials before seisin reads them",
18
+ detail:
19
+ "the kernel's denials reach `seisin run` through `log stream`, and when something floods " +
20
+ "the unified log (thousands of lines a second) the stream drops messages before it filters " +
21
+ "them. The boundary holds; the denial files no request and is not in `seisin log`. A run that " +
22
+ "lost messages says so when it ends and logs `log-dropped`; `seisin check --system-log` " +
23
+ "measures it now and names who writes the most. The system's own copy (`log show`) does not " +
24
+ "help: it lacks the tag that ties a denial to its run.",
25
+ }]),
26
+ ...(process.platform === "darwin" ? [] : [{
27
+ kind: "kernel-denials-unreadable",
28
+ headline: "denials by the kernel are not recorded on this platform",
29
+ detail:
30
+ "on macOS `seisin run` reads them out of the system log, so a denial the hook never " +
31
+ "saw still lands in `seisin log` with its owner and still queues a request. There is no " +
32
+ "equivalent to read here — bubblewrap does not log denials, and the runtime's substitute " +
33
+ "is not reachable from outside it. The boundary holds exactly as well; what you lose is " +
34
+ "seeing it work. Run `seisin wire` so the hook records what it can. The ask is upstream: " +
35
+ "github.com/carlostapiacl/seisin/blob/main/docs/upstream/cli-violations.md",
36
+ }]),
37
+ {
38
+ kind: "keys-only-what-you-declared",
39
+ headline: "key isolation covers the directory you declared, and nothing else",
40
+ detail:
41
+ "a credential sitting in the repo outside [keys] dir is ordinary readable " +
42
+ "content to every role. Run `seisin scan` to find them — check does not, " +
43
+ "because reading the whole tree on every invocation is how a command stops " +
44
+ "being run.",
45
+ },
46
+ {
47
+ kind: "observe-cannot-see-the-network",
48
+ headline: "`--observe` opens the filesystem, never the network",
49
+ detail:
50
+ "so it cannot tell you which domains an agent needs. The runtime does record every " +
51
+ "denial the proxy makes — host, port and reason — but only a caller that embeds the " +
52
+ "library can read them; nothing reaches a caller that runs the binary, which is what " +
53
+ "seisin does. So for an agent whose endpoints are not published, the options are to " +
54
+ "find them another way or drop the network restriction for it. The ask is upstream: " +
55
+ "github.com/carlostapiacl/seisin/blob/main/docs/upstream/cli-violations.md",
56
+ },
57
+ ...(process.platform === "linux" ? [{
58
+ kind: "control-files-linux",
59
+ headline: "a project's hooks and settings are protected only where they already exist",
60
+ detail:
61
+ "bubblewrap denies a path by mounting over it, and for a path that is not there it would " +
62
+ "create it on your disk. So on Linux a role can still create a .git/hooks entry or a " +
63
+ ".claude/settings.json in a project of its territory that had none. On macOS the kernel " +
64
+ "denies creating them too.",
65
+ }] : []),
66
+ {
67
+ kind: "shared-scratch",
68
+ headline: "roles share ~/.cache, ~/.local/share and the temp dir unless isolate = \"home\"",
69
+ detail:
70
+ "programs installed there that are on your PATH are protected, and so are Claude Code's " +
71
+ "settings and plugins. Anything else a program outside the sandbox reads from those places — a " +
72
+ "cache it executes, a tool not on PATH — is writable by every role. isolate = \"home\" " +
73
+ "gives each role its own.",
74
+ },
75
+ {
76
+ kind: "agent-sign-in",
77
+ headline: "~/.claude goes only to runs of claude, ~/.codex only to runs of codex",
78
+ detail:
79
+ "seisin reads the agent off the command after `--`, and a run is denied the other " +
80
+ "agents' sign-in files (~/.codex/auth.json, ~/.claude/.credentials.json). What a run " +
81
+ "of an agent can still read is its own: Codex and every command Codex runs share one " +
82
+ "sandbox, so with a ChatGPT sign-in kept in ~/.codex/auth.json they can read those tokens. " +
83
+ "Codex keeps them in the keychain instead with cli_auth_credentials_store = \"keyring\" " +
84
+ "in its config. A wrapper script is not followed: `seisin run <role> --agent codex -- ./script` " +
85
+ "names it. The protected paths `seisin check --verbose` lists are for every agent at once, " +
86
+ "because a role can be run as any of them.",
87
+ },
88
+ {
89
+ kind: "unlink-uncovered",
90
+ headline: "a role can delete inside its own territory",
91
+ detail:
92
+ // The path that used to be here ships with the repo and not with the npm
93
+ // package, so for most readers it named a file they do not have.
94
+ "writes and deletes are one permission to the kernel, so `rm` inside a " +
95
+ "role's own folders succeeds. Closing it needs denyUnlink in the sandbox " +
96
+ "runtime: github.com/carlostapiacl/seisin#how-it-holds",
97
+ },
98
+ ];
@@ -0,0 +1,107 @@
1
+ import { resolve, delimiter, join } from "node:path";
2
+ import { realOrSelf, runtimeWritesFor, toWritePath } from "../grants.js";
3
+ import { realAncestor } from "../paths.js";
4
+ import { wired, broadlyWired } from "../commands/wire.js";
5
+
6
+ export function relativePathWarnings() {
7
+ const warnings = [];
8
+ // PATH entries that are not absolute resolve against wherever the parent is
9
+ // standing, which is the repo. They are ignored for every lookup seisin makes
10
+ // outside the sandbox; said here because a lookup that skips them can find a
11
+ // different program than your shell does.
12
+ const unanchored = (process.env.PATH ?? "").split(delimiter).filter((d) => !d || !d.startsWith("/"));
13
+ if (unanchored.length)
14
+ warnings.push({
15
+ kind: "relative-path",
16
+ headline: `PATH has ${unanchored.length} relative entr${unanchored.length === 1 ? "y" : "ies"} (${unanchored.map((d) => JSON.stringify(d)).join(", ")})`,
17
+ detail: "They resolve inside the repo, where roles write, so seisin skips them when it looks for a program to run outside the sandbox.",
18
+ });
19
+ return warnings;
20
+ }
21
+
22
+ export function scratchWarnings(config) {
23
+ const warnings = [];
24
+ // Scratch is granted to every role, so a repo sitting inside it is writable
25
+ // by all of them no matter what the territory says. Saying so is the
26
+ // difference between a limitation and a trap — and it is not hypothetical:
27
+ // the test fixture lived in the temp dir and made a boundary test pass for
28
+ // the wrong reason, and a role in a toy repo under /tmp wrote outside its
29
+ // `writes` with nothing on screen to say why.
30
+ // Both sides resolved, or the comparison is a coin flip. `expand()` follows
31
+ // symlinks — it has to, because on macOS /tmp is a link to /private/tmp and a
32
+ // grant written as /tmp grants nothing — so the root has to be followed too.
33
+ // Without this a repo at /tmp/x is never reported as sitting in scratch,
34
+ // which is the one case where the warning matters most.
35
+ //
36
+ // The scratch a role is really given, not the default list: `[runtime]
37
+ // writes` when the policy sets it, and none of it under `isolate = "home"`,
38
+ // where each role gets a home of its own instead (grants.js, writePathsOf).
39
+ const level = config.isolate === true ? "home" : config.isolate;
40
+ const scratch = level === "home" ? [] : runtimeWritesFor(config, undefined);
41
+ const under = (p) => scratch.find((s) => p === s || p.startsWith(s + "/"));
42
+ const fix = (s) => `Move it out of ${s}, or set [runtime] writes to a list that leaves ${s} out ` +
43
+ `(\`[runtime] writes = []\` gives no role any shared scratch).`;
44
+ const root = realOrSelf(resolve(config.root));
45
+ const around = under(root);
46
+ if (around) {
47
+ warnings.push({
48
+ kind: "scratch",
49
+ headline: `every role can write the whole repo: it lives inside ${around}, which every role gets as scratch`,
50
+ detail: "Territory does not hold here — a role's writes is no limit when the directory around the repo " +
51
+ `is granted to all of them. ${fix(around)}`,
52
+ });
53
+ return warnings;
54
+ }
55
+ /**
56
+ * The repo is outside scratch, and part of a territory still is: a `writes`
57
+ * entry that goes through a symlink (`out -> /tmp/out`) or up and out with
58
+ * `..`. The kernel enforces on the real path, so that part of the role's
59
+ * territory is every role's. Said per resolved path, once.
60
+ */
61
+ const seen = new Set();
62
+ for (const r of Object.values(config.roles)) {
63
+ for (const g of r.writes ?? []) {
64
+ let real;
65
+ try { real = realAncestor(join(config.root, toWritePath(g))); } catch { continue; }
66
+ const s = under(real);
67
+ if (!s || seen.has(real)) continue;
68
+ seen.add(real);
69
+ warnings.push({
70
+ kind: "territory-in-scratch",
71
+ headline: `every role can write ${r.name}'s ${g}: it resolves to ${real}, inside ${s}, which every role gets as scratch`,
72
+ detail: `That part of ${r.name}'s territory does not hold — the kernel enforces on the real path. ${fix(s)}`,
73
+ });
74
+ }
75
+ }
76
+ return warnings;
77
+ }
78
+
79
+ export function hookWarnings(config) {
80
+ const warnings = [];
81
+ if (!wired(config.root))
82
+ warnings.push({
83
+ kind: "hook-not-wired",
84
+ headline: "only denials are being recorded: the agent has not been told to run the hook",
85
+ detail:
86
+ process.platform === "darwin"
87
+ ? "the boundary holds either way, and denials are still logged and still queue a " +
88
+ "request — `seisin run` reads them from the kernel. What is missing is every " +
89
+ "action that was allowed, so `review` cannot tell you which grants are unused and " +
90
+ "`init --from-observations` has nothing to build from. Run `seisin wire` once in " +
91
+ "this repo."
92
+ : "the boundary holds either way — but on this platform the kernel's denials are " +
93
+ "not readable either, so log, watch, requests, grant and review all read a record " +
94
+ "that nobody is writing. Run `seisin wire` once in this repo.",
95
+ });
96
+
97
+ else if (broadlyWired(config.root))
98
+ warnings.push({
99
+ kind: "hook-matcher-broad",
100
+ headline: "the hook runs on every tool call: it was wired before 0.5.0 with the matcher \"*\"",
101
+ detail:
102
+ "nothing is lost — every tool the hook reads is still covered — but each Read, Glob " +
103
+ "and TodoWrite starts a process that has nothing to say. Run `seisin wire` again: it " +
104
+ "narrows seisin's own entry in place and leaves everyone else's hooks alone.",
105
+ });
106
+ return warnings;
107
+ }
@@ -0,0 +1,37 @@
1
+ export function modelEndpointWarnings(config, roles) {
2
+ const warnings = [];
3
+ /**
4
+ * A role that can write code and cannot reach any inference API.
5
+ *
6
+ * Reported three separate times, by three different people, with the same
7
+ * shape each time: a list of what agents need, written somewhere that does
8
+ * not know which agent will run. The last instance cost six bench runs that
9
+ * finished in eight seconds with `403 Connection blocked by network
10
+ * allowlist` — which in a results table reads as "this model cannot do the
11
+ * task", not as a policy error.
12
+ *
13
+ * What is deliberately NOT here: a registry of agents and their endpoints.
14
+ * That is the same defect in a new place, and the reporter said so before I
15
+ * could. The list below is only ever consulted to decide whether to print a
16
+ * sentence, so being wrong about it costs a false warning — never a broken
17
+ * run. That asymmetry is the whole reason it is allowed to exist, and the
18
+ * warning says out loud that the list is ours and may not know your agent.
19
+ */
20
+ const INFERENCE = /(anthropic|openai|googleapis|generativelanguage|bedrock|azure|openrouter|mistral|groq|together|deepseek|x\.ai|cohere|ollama|localhost|127\.0\.0\.1)/i;
21
+ for (const r of roles) {
22
+ const domains = r.network ?? config.allowedDomains ?? [];
23
+ if (domains.some((d) => INFERENCE.test(d))) continue;
24
+ warnings.push({
25
+ kind: "no-model-endpoint",
26
+ headline: domains.length
27
+ ? `${r.name} has ${domains.length} allowed domain(s) and none looks like a model API`
28
+ : `${r.name} may reach no network at all`,
29
+ detail:
30
+ "an agent that cannot reach its own model fails at startup with a 403 from the " +
31
+ "egress proxy, which reads as a broken install or a bad model rather than a policy. " +
32
+ "This check knows a handful of providers and will not know yours — it is a question, " +
33
+ "not a verdict.",
34
+ });
35
+ }
36
+ return warnings;
37
+ }
@@ -0,0 +1,71 @@
1
+ import { existsSync } from "node:fs";
2
+ import { entriesOf } from "../keys.js";
3
+ import { resolveExecutable } from "../surface.js";
4
+
5
+ /**
6
+ * Would the parent find this command? The same lookup `run` makes
7
+ * (resolveExecutable), so check does not call "missing" a program that is
8
+ * only in a directory a role writes — that has its own warning, above.
9
+ */
10
+ function onPath(cmd, config) {
11
+ try {
12
+ // A path is resolved against the policy and not looked up, so it is there
13
+ // only if it exists.
14
+ return existsSync(resolveExecutable(cmd, config));
15
+ } catch (e) {
16
+ return /a role can write/.test(e.message);
17
+ }
18
+ }
19
+
20
+ export function providerInTerritoryWarnings(config) {
21
+ const warnings = [];
22
+ // A provider the parent could not find where no role writes. `run` refuses
23
+ // it with the same sentence; here it is found before anybody runs anything.
24
+ for (const [scheme, p] of Object.entries(config.keyProviders ?? {})) {
25
+ const cmd = p.command?.[0];
26
+ if (!cmd || cmd.includes("/")) continue;
27
+ try { resolveExecutable(cmd, config); } catch (e) {
28
+ if (!/a role can write/.test(e.message)) continue;
29
+ warnings.push({
30
+ kind: "provider-in-territory",
31
+ headline: `the ${scheme}:// provider "${cmd}" is only found where a role can write`,
32
+ detail: e.message.split("\n").slice(1).map((l) => l.trim()).join(" "),
33
+ });
34
+ }
35
+ }
36
+ return warnings;
37
+ }
38
+
39
+ export function providerMissingWarnings(config) {
40
+ const warnings = [];
41
+ /**
42
+ * A provider whose command is not on PATH.
43
+ *
44
+ * This is the half of validating references that can be done without
45
+ * resolving one — which is the whole requirement: a broken policy has to be
46
+ * visible in `check`, not on the first run, and certainly not by asking
47
+ * somebody's keychain for a password to find out. The scheme and the
48
+ * provider are checked when the config loads, so by the time we are here
49
+ * they exist; what is left is whether the command does.
50
+ *
51
+ * A warning and not an error: the binary can legitimately be missing on the
52
+ * machine reading the policy and present on the one that runs it, which is
53
+ * every CI checkout.
54
+ */
55
+ const used = new Set();
56
+ for (const r of Object.values(config.roles))
57
+ for (const k of entriesOf(r)) if (k.kind === "ref") used.add(k.scheme);
58
+ for (const scheme of [...used].sort()) {
59
+ const provider = config.keyProviders?.[scheme];
60
+ if (!provider || onPath(provider.command[0], config)) continue;
61
+ warnings.push({
62
+ kind: "provider-missing",
63
+ headline: `the ${scheme} provider runs "${provider.command[0]}", which is not on PATH`,
64
+ detail:
65
+ "every key of that scheme will stop the run rather than resolve. Nothing is " +
66
+ "substituted for a key that did not resolve, so this fails closed — but it fails " +
67
+ "at the start of a run, not here.",
68
+ });
69
+ }
70
+ return warnings;
71
+ }
@@ -0,0 +1,105 @@
1
+ import { delimiter } from "node:path";
2
+ import { homedir } from "node:os";
3
+ import { existsSync } from "node:fs";
4
+ import { keptFor, shutByTerritory, readPath, dataRoots } from "../territory.js";
5
+ import { realAncestor } from "../paths.js";
6
+
7
+ /**
8
+ * What `read = "territory"` takes away from a role that it may need, said
9
+ * before the run rather than discovered inside it.
10
+ *
11
+ * This is the answer to the objection decisions.md wrote against a read
12
+ * allowlist: the toolchain gets one entry wrong and the agent crashes without
13
+ * a reason. The mode leaves the system readable, so interpreters and libraries
14
+ * are not the risk; what breaks is a toolchain kept under the home — a venv,
15
+ * `~/.nvm`, `~/.pyenv` — and that is exactly what these name.
16
+ */
17
+ export function territoryWarnings(config, r) {
18
+ const out = [];
19
+ const declared = [...(r.reads ?? []), ...(r.toolchain ?? [])];
20
+ if (config.read !== "territory") {
21
+ if (declared.length)
22
+ out.push({
23
+ kind: "reads-without-territory",
24
+ headline: `${r.name}: reads / toolchain are set, but [runtime] read is "all" — they change nothing`,
25
+ detail: 'Under read = "all" every file outside the denied ones is readable already. They take effect ' +
26
+ 'with [runtime] read = "territory".',
27
+ });
28
+ return out;
29
+ }
30
+ for (const p of r.toolchain ?? []) {
31
+ const abs = readPath(config.root, p);
32
+ if (!existsSync(abs))
33
+ out.push({
34
+ kind: "toolchain-missing",
35
+ headline: `${r.name}: toolchain ${p} does not exist`,
36
+ detail: `Looked for ${abs}. A role told to verify its work with a toolchain it does not have retries, ` +
37
+ "installs and works around instead — measured at +80% cost per task, and no verification.",
38
+ });
39
+ }
40
+ for (const p of r.reads ?? []) {
41
+ const abs = readPath(config.root, p);
42
+ if (!existsSync(abs))
43
+ out.push({
44
+ kind: "reads-missing",
45
+ headline: `${r.name}: reads ${p} does not exist`,
46
+ detail: `Looked for ${abs}. Remove a stale entry or create the path before relying on it.`,
47
+ });
48
+ }
49
+ // A read grant that covers a whole home or data root turns the mode off for
50
+ // this role without anything saying so: `reads = ["~"]` reads like a
51
+ // detail and is every file the mode exists to shut.
52
+ const roots = dataRoots();
53
+ for (const p of declared) {
54
+ const abs = realAncestor(readPath(config.root, p));
55
+ const covered = roots.filter((root) => abs === "/" || root === abs || root.startsWith(abs + "/"));
56
+ if (covered.length)
57
+ out.push({
58
+ kind: "territory-reopened",
59
+ headline: `${r.name}: ${p} re-opens ${covered[0].replace(homedir(), "~") || "/"} whole — read = "territory" protects nothing there for this role`,
60
+ detail: `${covered.join(", ")} ${covered.length === 1 ? "is a place" : "are places"} the mode denies because people keep their data there. ` +
61
+ "Name the directory the role needs inside it instead.",
62
+ });
63
+ }
64
+ const keep = keptFor(config, r);
65
+ const path = (process.env.PATH ?? "").split(delimiter).filter(Boolean);
66
+ const shut = [...new Set(path.filter((d) => existsSync(d) && shutByTerritory(d, keep)))];
67
+ if (shut.length)
68
+ out.push({
69
+ kind: "path-shut-by-territory",
70
+ headline: `${r.name}: ${shut.length} PATH entr${shut.length === 1 ? "y is" : "ies are"} unreadable under read = "territory"`,
71
+ detail: "A program found in one of these today falls through to another one inside the sandbox, " +
72
+ "or is not found. If the role needs one, name it " +
73
+ `in toolchain: toolchain = ["${shut[0].replace(homedir(), "~").replace(/\/bin$/, "")}"].`,
74
+ // One per line when printed: on a real PATH this was a dozen entries in
75
+ // one sentence, and the one a reader was looking for was in the middle.
76
+ items: shut.map((d) => d.replace(homedir(), "~")),
77
+ });
78
+ /**
79
+ * Claude Code keeps its sign-in in the macOS login keychain, which lives
80
+ * under the home and is shut here. Measured 2026-10-09: the run starts and
81
+ * says "Not logged in"; with ~/Library/Keychains in `reads` it signs in —
82
+ * and the role can then read every item in the keychain (decisions.md,
83
+ * the rejected keychain link), which is not offered. A token handed over as
84
+ * a key keeps the keychain shut.
85
+ */
86
+ const tokenNames = ["CLAUDE_CODE_OAUTH_TOKEN", "ANTHROPIC_API_KEY"];
87
+ const hasToken = (r.keyEntries ?? []).some((k) => tokenNames.includes(k.name)) ||
88
+ (r.env ?? []).some((e) => tokenNames.includes(e));
89
+ if (process.platform === "darwin" && !hasToken)
90
+ out.push({
91
+ kind: "territory-keychain",
92
+ headline: `${r.name}: under read = "territory" Claude Code cannot reach its sign-in in the login keychain`,
93
+ detail: 'It starts and says "Not logged in". Hand it a token as a key instead — ' +
94
+ 'keys = ["CLAUDE_CODE_OAUTH_TOKEN=<provider>://…"] (from `claude setup-token`) or ANTHROPIC_API_KEY. ' +
95
+ "Opening ~/Library/Keychains in reads signs it in and hands the role every password in the keychain.",
96
+ });
97
+ if (!r.verify)
98
+ out.push({
99
+ kind: "no-verify",
100
+ headline: `${r.name}: no verify command — check --verify cannot prove this role can run its own checks`,
101
+ detail: 'Name the command the role checks its work with: verify = ["python", "-m", "pytest", "--version"]. ' +
102
+ "`seisin check --verify` then runs it inside the role's sandbox before any agent does.",
103
+ });
104
+ return out;
105
+ }
@@ -0,0 +1,136 @@
1
+ import { covers, enforcedNeverWrites } from "../owners.js";
2
+ import { ROLE_KEYS, closest } from "../config.js";
3
+ import { territoryWarnings } from "./read-territory.js";
4
+
5
+ /**
6
+ * What is wrong with a role table's keys, as opposed to its paths.
7
+ *
8
+ * Two things, both about `never_writes` and both ways a subtraction can look
9
+ * written and not be:
10
+ *
11
+ * - a setting seisin does not know. It used to be dropped without a word, which
12
+ * for `writes` is a role with less than it thinks, and for a misspelt
13
+ * `never_writes` is a role with MORE than its owner thinks — the one
14
+ * direction a policy must not fail in. Named with the nearest
15
+ * known setting when there is one close enough to be the intended spelling.
16
+ * - a `never_writes` entry that no `writes` of the same role covers. It
17
+ * subtracts from nothing, so it is either a typo in the path or a rule
18
+ * that stopped applying when the territory moved. Either way it reads as
19
+ * protection and is not.
20
+ *
21
+ * Silent when `never_writes` is absent. Absent is valid; a check that asks for
22
+ * the setting would turn an optional subtraction into a required migration.
23
+ */
24
+ const onlyTartaros = (config) => {
25
+ const names = config?.sandbox?.provider ?? [];
26
+ return names.length > 0 && names.every((n) => n === "tartaros");
27
+ };
28
+
29
+ export function roleKeyWarnings(roles, config = null) {
30
+ const warnings = [];
31
+ for (const r of roles) {
32
+ const enforced = new Set(enforcedNeverWrites(config, r));
33
+ const skipped = (r.neverWritesDeclared ?? r.neverWrites ?? []).filter((g) => !enforced.has(g));
34
+ if (skipped.length)
35
+ warnings.push({
36
+ kind: "never-writes-not-enforced-here",
37
+ headline: `${r.name}: never_writes ${skipped.join(" ")} — not enforced on Linux until it exists`,
38
+ detail: "bubblewrap denies a path by mounting over it, and would create a missing one on the host " +
39
+ "for the whole run — for a lock file, locking everyone else out, and for good if the role is " +
40
+ "killed. seisin skips those, and explain says allowed for them, so the sentence matches the kernel.",
41
+ });
42
+ for (const k of r.unknownKeys ?? []) {
43
+ const near = nearest(k, ROLE_KEYS);
44
+ warnings.push({
45
+ kind: "unknown-role-key",
46
+ headline: `${r.name}: unknown setting "${k}" in [roles.${r.name}] — ignored` +
47
+ (near ? `. Did you mean "${near}"?` : ""),
48
+ detail: `Known settings: ${ROLE_KEYS.join(", ")}. ` +
49
+ (near === "never_writes"
50
+ ? "As written this subtracts nothing: the role can still write every path it lists."
51
+ : "Nothing in it takes effect."),
52
+ });
53
+ }
54
+ warnings.push(...territoryWarnings(config, r));
55
+ // A family handed to a role is a hole in the protection on purpose, and
56
+ // said on every check so it stays a decision somebody can see.
57
+ for (const f of r.controlFiles ?? [])
58
+ warnings.push({
59
+ kind: "control-files-handed",
60
+ headline: f === "ide"
61
+ ? `${r.name}: control_files = ["ide"] — may edit .vscode/, .cursor/ and .windsurf/ in its territory`
62
+ : `${r.name}: control_files = ["instructions"] — may edit CLAUDE.md, AGENTS.md and their kin in its territory`,
63
+ detail: f === "ide"
64
+ ? "Your editor applies what it writes there — tasks, settings, auto-approval, MCP servers — outside the sandbox. " +
65
+ "Hand it only to a role that maintains those files, and review its changes to them."
66
+ : "The next agent session reads what it writes there as its instructions, whichever role that session is.",
67
+ });
68
+ // macOS only, and not by omission: on Linux the runtime removes the network
69
+ // namespace, so every role already has a loopback of its own — it can serve
70
+ // and reach its own server with or without the key, and reaches nothing on
71
+ // the host either way (measured in Docker, Debian 12, bwrap 0.8.0).
72
+ if (r.localBinding && process.platform === "darwin")
73
+ warnings.push({
74
+ kind: "local-binding-reaches-localhost",
75
+ headline: `${r.name}: local_binding lets it connect to every port on localhost, not only listen`,
76
+ detail: "The runtime grants bind on any interface, inbound, and outbound to localhost:*. " +
77
+ "Anything listening on this machine without authentication — a local control API, a " +
78
+ "database, a dev server — is within this role's reach, outside its territory.",
79
+ });
80
+ // Both at once is a config that says "these ports" and grants "every port".
81
+ // macOS only: on Linux local_binding reaches nothing on the host, so there
82
+ // local_ports is the only one of the two that does anything.
83
+ if (r.localBinding && r.localPorts?.length && process.platform === "darwin")
84
+ warnings.push({
85
+ kind: "local-ports-moot",
86
+ headline: `${r.name}: local_ports names ${r.localPorts.join(", ")}, but local_binding already opens every localhost port`,
87
+ detail: "local_binding grants outbound to localhost:* in the kernel profile, so the port list " +
88
+ "restricts nothing for this role. Drop local_binding if the role only needs to reach " +
89
+ "those ports; keep it only if the role has to listen.",
90
+ });
91
+ // The port list lives in the runtime's proxy, not in the kernel: a client
92
+ // that dials 127.0.0.1:N itself is refused even on a listed port. Said on
93
+ // every check, because the clients people list ports for — databases,
94
+ // caches — are mostly that kind. See docs/decisions.md. Not under tartaros
95
+ // alone: its profile holds the port list, and a direct dial reaches a listed port.
96
+ else if (r.localPorts?.length && !onlyTartaros(config))
97
+ warnings.push({
98
+ kind: "local-ports-proxy-only",
99
+ headline: `${r.name}: local_ports ${r.localPorts.join(", ")} reaches only clients that use HTTP_PROXY`,
100
+ detail: "The port list is enforced by the sandbox's proxy; the kernel has no rule for one localhost port. " +
101
+ "curl, Python's urllib and requests, and Node's fetch (22+) go through it. Database drivers, " +
102
+ "psql, mysql and redis-cli dial directly and are denied. To tell, run it with " +
103
+ `\`seisin run ${r.name} -- <client>\`: on macOS a \`connect tcp:<port>\` in \`seisin log\` means it ` +
104
+ "skipped the proxy; on Linux it fails with ECONNREFUSED.",
105
+ });
106
+ // trustd is a macOS service; on Linux the key changes nothing.
107
+ if (r.trustd && process.platform === "darwin")
108
+ warnings.push({
109
+ kind: "trustd-open",
110
+ headline: `${r.name}: trustd = true opens com.apple.trustd.agent, a path out that the domain list does not see`,
111
+ detail: "trustd verifies certificates for Go (before 1.27) and Dart on macOS, and to do it fetches, " +
112
+ "outside the sandbox, the URLs a certificate carries. The runtime calls it an exfiltration vector; " +
113
+ "no public demonstration exists. Go 1.27+ does not need it: seisin sets SSL_CERT_FILE.",
114
+ });
115
+ for (const g of r.neverWrites ?? []) {
116
+ if (r.writes.some((w) => covers(w, g) || covers(w, g.replace(/\/\*\*$/, ""))))
117
+ continue;
118
+ warnings.push({
119
+ kind: "never-writes-subtracts-nothing",
120
+ headline: `${r.name}: never_writes "${g}" is not inside any of its writes`,
121
+ detail: "It denies something that was already denied, so it protects nothing. " +
122
+ "Check the path, or drop the entry if the territory moved.",
123
+ });
124
+ }
125
+ }
126
+ return warnings;
127
+ }
128
+
129
+ /** The candidate within edit distance 3 of `word`, or null. */
130
+ function nearest(word, candidates) {
131
+ // Spellings of the same idea in another vocabulary, checked first because
132
+ // letters mislead here: `no_writes` is three edits from `writes`, and
133
+ // suggesting the grant for a misspelt subtraction is the worst answer.
134
+ if (/deny|never|no_?write/i.test(word)) return "never_writes";
135
+ return closest(word, candidates);
136
+ }
@@ -0,0 +1,37 @@
1
+ import { ownersOf } from "../owners.js";
2
+
3
+ /**
4
+ * Paths more than one role claims.
5
+ *
6
+ * Overlap is allowed and sometimes right — a hotfix role deliberately reaches
7
+ * into the territory of the role that normally owns a repo. It is reported so
8
+ * that it stays a decision somebody made rather than a thing that drifted.
9
+ */
10
+ export function sharedPaths(config, roles = Object.values(config.roles)) {
11
+ // Asked once per distinct glob: a real policy repeats them (1,332 writes,
12
+ // 303 distinct in a multi-repo workspace), and each question is every glob of every
13
+ // role again.
14
+ const seen = new Set();
15
+ const asked = new Map();
16
+ for (const r of roles)
17
+ for (const glob of r.writes) {
18
+ let many = asked.get(glob);
19
+ if (many === undefined) asked.set(glob, (many = ownersOf(config, glob.replace(/\/\*\*$/, "")).length > 1));
20
+ if (many) seen.add(glob);
21
+ }
22
+ /**
23
+ * A shared database counts once, not four times.
24
+ *
25
+ * `writes` carries the sidecar expansion so that the grant and the sentence
26
+ * agree (see config.js), and this list read straight off it turned nine
27
+ * shared things into thirty-three — the same name with three suffixes,
28
+ * announced as if it were three more decisions to make. The expansion exists
29
+ * to stop a policy saying one thing four times; a warning that undoes that
30
+ * is the feature leaking into the place it was meant to clean up.
31
+ *
32
+ * A sidecar shared WITHOUT its database still counts on its own, because
33
+ * that is a real and odd thing to have.
34
+ */
35
+ const todas = new Set(seen);
36
+ return [...seen].filter((p) => !(/-(wal|shm|journal)$/.test(p) && todas.has(p.replace(/-(wal|shm|journal)$/, ""))));
37
+ }
@@ -0,0 +1,58 @@
1
+ import { sharedPaths } from "./shared.js";
2
+ import { homeReachWarning } from "./home.js";
3
+ import { codexSignInWarnings } from "./codex.js";
4
+ import { roleKeyWarnings } from "./role-keys.js";
5
+ import { enforceWarnings } from "./enforce.js";
6
+ import { providerInTerritoryWarnings, providerMissingWarnings } from "./providers.js";
7
+ import { relativePathWarnings, scratchWarnings, hookWarnings } from "./machine.js";
8
+ import { modelEndpointWarnings } from "./network.js";
9
+ import { layoutWarnings } from "./layout.js";
10
+ import { envWarnings, keyFloorWarnings } from "./keys.js";
11
+ import { injectWarnings } from "./inject.js";
12
+
13
+ /** Every way this policy does not hold, each with what to do about it. */
14
+ export function warningsFor(config, roles, shared = sharedPaths(config, roles), { scoped = false } = {}) {
15
+ const warnings = [];
16
+
17
+ warnings.push(...enforceWarnings(config, roles));
18
+ warnings.push(...roleKeyWarnings(roles, config));
19
+
20
+ warnings.push(...providerInTerritoryWarnings(config));
21
+
22
+ warnings.push(...relativePathWarnings());
23
+ if (shared.length)
24
+ warnings.push({
25
+ kind: "shared",
26
+ headline: `${shared.length} path(s) claimed by more than one role: ${shared.join(" ")}`,
27
+ detail: "Overlap is allowed — seisin will name every owner. It is listed so it stays a decision.",
28
+ });
29
+
30
+ warnings.push(...scratchWarnings(config));
31
+
32
+ warnings.push(...modelEndpointWarnings(config, roles));
33
+
34
+ warnings.push(...codexSignInWarnings(config, roles));
35
+
36
+ // Without the hook, half the record is missing rather than all of it: on macOS
37
+ // `seisin run` reads the kernel's own denials, so they still land and
38
+ // still queue a request. What goes unrecorded is everything the kernel
39
+ // allowed — which is the half `init --from-observations` is built out of, and
40
+ // the half that tells "nothing was denied" apart from "nobody was watching".
41
+ // The one limit that is about YOUR machine rather than about the repo.
42
+ const reach = homeReachWarning(config);
43
+ if (reach) warnings.push(reach);
44
+
45
+ warnings.push(...hookWarnings(config));
46
+
47
+ warnings.push(...layoutWarnings(config, roles, scoped));
48
+
49
+ warnings.push(...envWarnings(roles));
50
+
51
+ warnings.push(...injectWarnings(config, roles));
52
+
53
+ warnings.push(...keyFloorWarnings(config));
54
+
55
+ warnings.push(...providerMissingWarnings(config));
56
+
57
+ return warnings;
58
+ }