seisin 0.4.2 → 0.6.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (82) hide show
  1. package/README.md +506 -162
  2. package/package.json +14 -6
  3. package/src/browsers.js +52 -0
  4. package/src/canary.js +149 -0
  5. package/src/cli.js +175 -52
  6. package/src/commands/check.js +157 -5
  7. package/src/commands/explain.js +31 -13
  8. package/src/commands/hook.js +20 -6
  9. package/src/commands/init.js +183 -88
  10. package/src/commands/log.js +48 -9
  11. package/src/commands/requests.js +122 -70
  12. package/src/commands/review.js +73 -21
  13. package/src/commands/run.js +422 -78
  14. package/src/commands/scan.js +18 -3
  15. package/src/commands/ui.js +18 -5
  16. package/src/commands/walls.js +62 -11
  17. package/src/commands/whose.js +9 -7
  18. package/src/commands/wire.js +156 -17
  19. package/src/config.js +505 -37
  20. package/src/controls.js +244 -0
  21. package/src/diagnose.js +102 -49
  22. package/src/env.js +14 -8
  23. package/src/grants.js +122 -9
  24. package/src/handoff.js +285 -10
  25. package/src/help.js +179 -0
  26. package/src/hook.js +101 -42
  27. package/src/index.d.ts +217 -0
  28. package/src/index.js +24 -16
  29. package/src/inspect/codex.js +64 -0
  30. package/src/inspect/credentials.js +48 -0
  31. package/src/inspect/enforce.js +24 -0
  32. package/src/inspect/files.js +71 -0
  33. package/src/inspect/home.js +43 -0
  34. package/src/inspect/inject.js +68 -0
  35. package/src/inspect/keys.js +74 -0
  36. package/src/inspect/layout.js +258 -0
  37. package/src/inspect/limits.js +98 -0
  38. package/src/inspect/machine.js +107 -0
  39. package/src/inspect/network.js +37 -0
  40. package/src/inspect/providers.js +71 -0
  41. package/src/inspect/read-territory.js +105 -0
  42. package/src/inspect/role-keys.js +136 -0
  43. package/src/inspect/shared.js +37 -0
  44. package/src/inspect/warnings.js +58 -0
  45. package/src/inspect.js +20 -756
  46. package/src/intake.js +150 -35
  47. package/src/keys.js +231 -73
  48. package/src/kinds.js +249 -0
  49. package/src/lines.js +26 -0
  50. package/src/log.js +242 -52
  51. package/src/mcp.js +57 -58
  52. package/src/nested.js +12 -3
  53. package/src/notify.js +66 -20
  54. package/src/owners.js +358 -39
  55. package/src/paths.js +54 -2
  56. package/src/provider-run.js +81 -0
  57. package/src/redact.js +46 -13
  58. package/src/render.js +101 -17
  59. package/src/requests.js +506 -87
  60. package/src/review.js +50 -25
  61. package/src/rundir.js +33 -14
  62. package/src/sandboxes/command.js +93 -0
  63. package/src/sandboxes/index.js +113 -0
  64. package/src/sandboxes/mach-profile.js +31 -0
  65. package/src/sandboxes/srt-mach.mjs +43 -0
  66. package/src/sandboxes/srt.js +163 -0
  67. package/src/sandboxes/tartaros.js +80 -0
  68. package/src/scan.js +181 -19
  69. package/src/serve.js +396 -230
  70. package/src/spool.js +16 -45
  71. package/src/srt.js +296 -49
  72. package/src/suggest.js +58 -0
  73. package/src/surface.js +314 -54
  74. package/src/territory.js +261 -0
  75. package/src/trace.js +33 -0
  76. package/src/uilink.js +24 -9
  77. package/src/views.js +315 -0
  78. package/src/violations.js +247 -23
  79. package/src/walls.js +76 -46
  80. package/src/worktree.js +3 -11
  81. package/ui/index.html +1429 -538
  82. package/ui/favicon.svg +0 -1
@@ -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
+ }