seisin 0.6.3 → 0.6.5

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 (50) hide show
  1. package/README.md +143 -686
  2. package/package.json +2 -2
  3. package/src/canary.js +1 -1
  4. package/src/cli.js +47 -3
  5. package/src/commands/check.js +68 -12
  6. package/src/commands/hook.js +1 -1
  7. package/src/commands/log.js +21 -14
  8. package/src/commands/requests.js +10 -15
  9. package/src/commands/review.js +2 -1
  10. package/src/commands/run.js +331 -245
  11. package/src/commands/walls.js +12 -5
  12. package/src/config-fields.js +305 -0
  13. package/src/config.js +114 -534
  14. package/src/controls.js +1 -1
  15. package/src/decide-request.js +37 -0
  16. package/src/denial-attribution.js +338 -0
  17. package/src/grants.js +1 -1
  18. package/src/help.js +6 -4
  19. package/src/index.js +1 -1
  20. package/src/inspect/enforce.js +1 -1
  21. package/src/inspect/files.js +1 -1
  22. package/src/inspect/home.js +1 -1
  23. package/src/inspect/inject.js +1 -1
  24. package/src/intake.js +3 -2
  25. package/src/keys.js +2 -2
  26. package/src/kinds.js +19 -0
  27. package/src/layout.js +1 -1
  28. package/src/log.js +3 -2
  29. package/src/mcp.js +1 -1
  30. package/src/notify-config.js +58 -0
  31. package/src/notify.js +4 -52
  32. package/src/owners.js +2 -2
  33. package/src/requests.js +9 -4
  34. package/src/rundir.js +2 -2
  35. package/src/runs.js +29 -10
  36. package/src/sandboxes/command.js +0 -2
  37. package/src/sandboxes/index.js +15 -2
  38. package/src/sandboxes/srt.js +1 -1
  39. package/src/sandboxes/tartaros.js +34 -6
  40. package/src/serve.js +11 -22
  41. package/src/spool.js +1 -1
  42. package/src/{srt.js → srt-settings.js} +5 -2
  43. package/src/state.js +25 -0
  44. package/src/suggest.js +26 -1
  45. package/src/surface.js +3 -2
  46. package/src/territory.js +20 -1
  47. package/src/toml.js +250 -0
  48. package/src/violations.js +16 -315
  49. package/src/walls.js +4 -2
  50. package/ui/index.html +35 -9
@@ -1,5 +1,5 @@
1
1
  /** `seisin walls <role>` — what that role keeps being denied, and what it cost. */
2
- import { unknownRole } from "../suggest.js";
2
+ import { unknownRole, positiveInt } from "../suggest.js";
3
3
  import { walls, wasted } from "../walls.js";
4
4
  import { STALE_RUNS } from "../requests.js";
5
5
  import { logPath } from "../log.js";
@@ -8,6 +8,9 @@ import { kindsOf } from "../kinds.js";
8
8
  import { parseSince } from "../views.js";
9
9
  import { C, out, safe } from "../render.js";
10
10
 
11
+ /** How many walls a terminal shows without --all. */
12
+ const SHOWN = 6;
13
+
11
14
  export function wallsCommand(config, argv = []) {
12
15
  const role = argv[0];
13
16
  if (!role) throw new Error("usage: seisin walls <role> [--since <date|duration>] [--min <n>] [--all]");
@@ -22,21 +25,25 @@ export function wallsCommand(config, argv = []) {
22
25
  throw new Error(`--since takes a date (2026-10-01) or a duration back from now (7d, 12h, 2w), not "${safe(at("--since", "") ?? "")}"`);
23
26
  const every = walls(config, role, {
24
27
  file: logPath(config.root),
25
- min: Number(at("--min", 2)),
28
+ min: positiveInt("--min", at("--min", "2")),
26
29
  since,
27
30
  limit: 0,
28
31
  });
29
32
  const all = argv.includes("--all");
30
- const list = (all ? every : every.filter((w) => !w.stale)).slice(0, 6);
33
+ // Six at a terminal unless --all, and the rest counted: a cut nobody is told
34
+ // about reads as the whole list. The exit status is over everything selected.
35
+ const selected = withKinds(config, all ? every : every.filter((w) => !w.stale));
36
+ const shown = all ? selected : selected.slice(0, SHOWN);
31
37
  const old = every.filter((w) => w.stale).length;
32
- const shown = withKinds(config, list);
33
38
  if (shown.length) out(renderForPerson(role, shown));
39
+ if (selected.length > shown.length)
40
+ out(` (${selected.length - shown.length} more wall(s): seisin walls ${safe(role)} --all)\n`);
34
41
  else if (!every.length)
35
42
  out(`\n ${C.dim}${safe(role)} has no repeated denials in the recorded runs. A path becomes a wall after ${at("--min", 2)} denials.${C.off}\n\n`);
36
43
  else if (!all)
37
44
  out(`\n ${C.dim}${safe(role)} has no recent walls; older ones are hidden. Use seisin walls ${safe(role)} --all.${C.off}\n\n`);
38
45
  if (old && !all) out(` (${old} older wall(s) not hit in the last ${STALE_RUNS}+ runs: seisin walls ${role} --all)\n`);
39
- return shown;
46
+ return selected;
40
47
  }
41
48
 
42
49
  /**
@@ -0,0 +1,305 @@
1
+ /**
2
+ * The readers for each field of a role and of the runtime: one function per
3
+ * field, each refusing what it cannot read with the name and place it came from.
4
+ */
5
+ import { isIP } from "node:net";
6
+ import { join } from "node:path";
7
+ import { BROWSERS } from "./browsers.js";
8
+ import { own } from "./toml.js";
9
+
10
+ /** `a/b//` → `a/b`, in linear time (a `/\/+$/` regex backtracks on a long run of slashes). */
11
+ export const trimSlashes = (p) => {
12
+ let end = p.length;
13
+ while (end > 0 && p[end - 1] === "/") end--;
14
+ return p.slice(0, end);
15
+ };
16
+
17
+ // The directory above a path's first glob character: "src/a*/b" gives "src", "a*" gives "".
18
+ export const beforeGlob = (p) => {
19
+ const g = p.search(/[*?[\]{}]/);
20
+ if (g < 0) return p;
21
+ const slash = p.lastIndexOf("/", g);
22
+ return slash < 0 ? "" : p.slice(0, slash);
23
+ };
24
+
25
+ /** The candidate within edit distance 3 of `word`, or null. Shared with `check`. */
26
+ export function closest(word, candidates) {
27
+ let best = null, bestD = 4;
28
+ for (const c of candidates) {
29
+ const d = distance(word.toLowerCase(), c);
30
+ if (d < bestD) { best = c; bestD = d; }
31
+ }
32
+ return best;
33
+ }
34
+
35
+ /** Levenshtein distance. */
36
+ export function distance(a, b) {
37
+ const row = Array.from({ length: b.length + 1 }, (_, i) => i);
38
+ for (let i = 1; i <= a.length; i++) {
39
+ let prev = row[0]; row[0] = i;
40
+ for (let j = 1; j <= b.length; j++) {
41
+ const cur = row[j];
42
+ row[j] = Math.min(row[j] + 1, row[j - 1] + 1, prev + (a[i - 1] === b[j - 1] ? 0 : 1));
43
+ prev = cur;
44
+ }
45
+ }
46
+ return row[b.length];
47
+ }
48
+
49
+ /** true, false, or absent (undefined). Anything else is a mistake, said as one. */
50
+ export function readBoolean(value, where) {
51
+ if (value === undefined || typeof value === "boolean") return value;
52
+ throw new Error(`${where} must be true or false, not ${JSON.stringify(value)}`);
53
+ }
54
+
55
+ /** The keys a `[roles.<name>]` table can hold. Anything else is reported by `check`. */
56
+ export const ROLE_KEYS = ["writes", "private", "keys", "key_mode", "inject_hosts", "env", "network", "never_writes", "local_binding", "local_ports", "browsers", "mcp", "trustd", "control_files", "reads", "toolchain", "verify", "handoff", "handoff_exempt", "requests"];
57
+
58
+ /**
59
+ * `control_files`: which families of control files a role may edit. Refused at
60
+ * load when it names anything else — `.claude`, git hooks and the rest are not
61
+ * on the list on purpose, and a word the reader does not know must not load as
62
+ * a permission it thinks it wrote.
63
+ */
64
+ export const CONTROL_FAMILIES = ["ide", "instructions"];
65
+
66
+ export function readControlFiles(value, where) {
67
+ if (value === undefined) return [];
68
+ const out = [];
69
+ for (const f of asArray(value, where)) {
70
+ if (!CONTROL_FAMILIES.includes(f))
71
+ throw new Error(`${where}: ${JSON.stringify(f)} is not a family of control files. ` +
72
+ `Known: ${CONTROL_FAMILIES.map((x) => JSON.stringify(x)).join(", ")}. ` +
73
+ `The others (.claude, git hooks, .mcp.json, .envrc) are never handed to a role.`);
74
+ if (!out.includes(f)) out.push(f);
75
+ }
76
+ return out;
77
+ }
78
+
79
+ /**
80
+ * `[protect]` — what is protected beyond the defaults. One key today:
81
+ * `instructions = true` protects `CLAUDE.md`, `AGENTS.md` and their kin from
82
+ * every role that is not handed them. Anything else in the table is refused.
83
+ */
84
+ export function readProtect(value, path) {
85
+ if (value === undefined) return { instructions: false };
86
+ if (!value || typeof value !== "object" || Array.isArray(value))
87
+ throw new Error(`${path}: [protect] must be a table`);
88
+ for (const k of Object.keys(value))
89
+ if (k !== "instructions")
90
+ throw new Error(`${path}: [protect] ${k} is not a setting. Known: instructions.`);
91
+ const v = own(value, "instructions");
92
+ if (v !== undefined && typeof v !== "boolean")
93
+ throw new Error(`${path}: [protect] instructions must be true or false, not ${JSON.stringify(v)}`);
94
+ return { instructions: v === true };
95
+ }
96
+
97
+ /**
98
+ * `mcp`: names of MCP servers, as the CLI's config calls them. Absent is null,
99
+ * not [], so a policy that never mentions MCP changes nothing for a launcher
100
+ * that reads it; `mcp = []` is the explicit "none".
101
+ */
102
+ export function readMcp(value, where) {
103
+ if (value === undefined) return null;
104
+ const list = asArray(value, where);
105
+ const out = [];
106
+ for (const n of list) {
107
+ if (!/^[A-Za-z0-9][A-Za-z0-9._-]*$/.test(n))
108
+ throw new Error(`${where}: ${JSON.stringify(n)} is not an MCP server name. ` +
109
+ `Use the name from the CLI's config, for example mcp = ["playwright"].`);
110
+ if (!out.includes(n)) out.push(n);
111
+ }
112
+ return out;
113
+ }
114
+
115
+ /**
116
+ * `browsers`, refused rather than guessed: a name seisin has no preset for
117
+ * would read as a grant that was made, and grant nothing.
118
+ */
119
+ export function readBrowsers(value, where) {
120
+ if (value === undefined) return [];
121
+ if (!Array.isArray(value) || value.some((v) => typeof v !== "string"))
122
+ throw new Error(`${where} is a list of browser names, for example browsers = ["chromium"].`);
123
+ for (const b of value)
124
+ if (!Object.hasOwn(BROWSERS, b)) {
125
+ const near = closest(b, Object.keys(BROWSERS));
126
+ throw new Error(`${where}: "${b}" is not a browser seisin has a preset for${near ? ` — did you mean "${near}"?` : ""} ` +
127
+ `Known: ${Object.keys(BROWSERS).join(", ")}.`);
128
+ }
129
+ return [...new Set(value)];
130
+ }
131
+
132
+ /**
133
+ * `local_ports`, refused rather than guessed when it is not a list of ports.
134
+ *
135
+ * Refused at load for the same reason as `never_writes`: a port list that loads
136
+ * with a typo in it opens a port nobody meant, or silently fails to open the
137
+ * one they did. A string is accepted when it is only digits, because TOML
138
+ * written by hand says `"8001"` as often as `8001`.
139
+ */
140
+ export function readLocalPorts(value, where) {
141
+ if (value === undefined) return [];
142
+ const list = Array.isArray(value) ? value : [value];
143
+ const out = [];
144
+ for (const v of list) {
145
+ const n = typeof v === "string" && /^\d+$/.test(v) ? Number(v) : v;
146
+ if (!Number.isInteger(n) || n < 1 || n > 65535)
147
+ throw new Error(`${where}: ${JSON.stringify(v)} is not a port. Write numbers from 1 to 65535, ` +
148
+ `for example local_ports = [8001, 8081].`);
149
+ if (!out.includes(n)) out.push(n);
150
+ }
151
+ return out;
152
+ }
153
+
154
+ /**
155
+ * `inject_hosts`: for `key_mode = "inject"`, the hosts each masked credential
156
+ * is substituted into, keyed by the delivered variable name.
157
+ *
158
+ * [roles.frontend.inject_hosts]
159
+ * GH_TOKEN = ["api.github.com"]
160
+ *
161
+ * One list per credential and not one for the role, because the runtime gates
162
+ * substitution per credential: credential A's real value reaches only A's
163
+ * hosts, never B's. A host is an exact name — no wildcard — so `check` can say
164
+ * precisely which traffic is intercepted; the match against a wider wildcard in
165
+ * `allowedDomains` is computed later (srt-settings.js), where the terminated set is.
166
+ */
167
+ export function readInjectHosts(value, where) {
168
+ if (value === undefined) return null;
169
+ if (typeof value !== "object" || Array.isArray(value))
170
+ throw new Error(`${where}: inject_hosts is a table of VAR = ["host", …], ` +
171
+ `for example [roles.x.inject_hosts] GH_TOKEN = ["api.github.com"].`);
172
+ const out = {};
173
+ for (const [name, hosts] of Object.entries(value)) {
174
+ const list = asArray(hosts, `${where}.${name}`);
175
+ if (list.length === 0)
176
+ throw new Error(`${where}.${name}: a masked credential with no host injects nowhere. ` +
177
+ `List the host its token authenticates to, like ["api.github.com"].`);
178
+ for (const h of list) {
179
+ if (typeof h !== "string" || h.trim() === "")
180
+ throw new Error(`${where}.${name}: every host is a non-empty string`);
181
+ if (h.includes("*"))
182
+ throw new Error(`${where}.${name}: "${h}" is a wildcard. An inject host is an exact name, ` +
183
+ `so check can say which traffic is intercepted. A wildcard in allowedDomains still ` +
184
+ `covers it; that is reported, not declared here.`);
185
+ if (/^https?:\/\//.test(h) || h.includes("/"))
186
+ throw new Error(`${where}.${name}: "${h}" is not a host. Write the bare domain, like "api.github.com".`);
187
+ }
188
+ out[name] = [...new Set(list)];
189
+ }
190
+ return out;
191
+ }
192
+
193
+ /**
194
+ * `never_writes`, refused rather than guessed when it cannot mean what it says.
195
+ *
196
+ * Refused at load, not warned about: this is a list of things a role must not
197
+ * do, and a malformed entry that loads anyway fails open. An absolute path or a
198
+ * `..` would point outside the repo the rest of the policy is written against,
199
+ * so it cannot be the subtraction the person meant.
200
+ */
201
+ export function readNeverWrites(value, where) {
202
+ const list = asArray(value, where);
203
+ for (const g of list) {
204
+ if (typeof g !== "string" || g.trim() === "")
205
+ throw new Error(`${where}: every entry must be a non-empty string`);
206
+ if (g.startsWith("/"))
207
+ throw new Error(`${where}: "${g}" is absolute. never_writes is relative to the repo root, like writes.`);
208
+ if (g.split("/").includes(".."))
209
+ throw new Error(`${where}: "${g}" contains "..". never_writes names paths inside the repo.`);
210
+ }
211
+ return list;
212
+ }
213
+
214
+ /**
215
+ * `private`: folders inside the repo, written `dir` or `dir/**`.
216
+ *
217
+ * A folder and not a glob, because what is enforced is a deny on one path for
218
+ * every other role: `notes/*.md` would have to be widened to `notes` or frozen
219
+ * to today's files. Not absolute and no `..`, because the denies are given
220
+ * against the repo the policy describes and `explain` answers in it. Not the
221
+ * repo itself: private to one role, the whole repo is shut to all the others,
222
+ * which is a policy of one role written in the long way.
223
+ */
224
+ export function readPrivate(value, where) {
225
+ const list = value === undefined ? [] : asArray(value, where);
226
+ for (const p of list) {
227
+ const dir = trimSlashes(p.replace(/\/\*\*$/, "")).replace(/^\.\//, "");
228
+ if (p.startsWith("/"))
229
+ throw new Error(`${where}: "${p}" is absolute. A private folder is relative to the repo root, like writes.`);
230
+ if (dir.split("/").includes(".."))
231
+ throw new Error(`${where}: "${p}" contains "..". A private folder is inside the repo.`);
232
+ if (dir === "" || dir === "." || dir === "**")
233
+ throw new Error(`${where}: "${p}" is the whole repo. A private folder is a folder inside it — ` +
234
+ `"notes/**" — and every other role is denied it.`);
235
+ if (/[*?[\]{}]/.test(dir))
236
+ throw new Error(`${where}: "${p}" is a pattern. A private folder is one path and everything under it: ` +
237
+ `write "${beforeGlob(dir) || "dir"}/**".`);
238
+ }
239
+ return list;
240
+ }
241
+
242
+ /** `private` with its `/**` and `./` taken off: the folder itself. */
243
+ export const privateDir = (p) => trimSlashes(p.replace(/\/\*\*$/, "")).replace(/^\.\//, "");
244
+
245
+ /**
246
+ * One owner per private folder. Two roles naming the same folder, or one
247
+ * inside the other, would each be denied the other's — so neither could read
248
+ * its own. Refused at load, with both names.
249
+ */
250
+ export function checkPrivateOverlap(roles, path) {
251
+ const all = Object.values(roles).flatMap((r) => (r.private ?? []).map((p) => ({ role: r.name, p, dir: privateDir(p) })));
252
+ const inside = (a, b) => a === b || a.startsWith(b + "/");
253
+ for (let i = 0; i < all.length; i++)
254
+ for (let j = i + 1; j < all.length; j++) {
255
+ const a = all[i], b = all[j];
256
+ if (a.role === b.role || !(inside(a.dir, b.dir) || inside(b.dir, a.dir))) continue;
257
+ throw new Error(
258
+ `${path}: roles.${a.role}.private "${a.p}" and roles.${b.role}.private "${b.p}" ` +
259
+ `${a.dir === b.dir ? "are the same folder" : "overlap"}. A private folder has one owner: every ` +
260
+ `other role is denied it, so each of these two would be denied its own. Give it to one of them.`);
261
+ }
262
+ }
263
+
264
+ /**
265
+ * A territory is relative to the repo root. An absolute one would ask the kernel
266
+ * for a path on the machine and make `explain` (which reads absolute as outside
267
+ * the repo) disagree with what the profile grants — see the CODEOWNERS `/apps/`
268
+ * case. `..` is allowed on purpose: a role legitimately writes a sibling
269
+ * directory (`../shared/...`), and it is resolved relative to the root, so a
270
+ * territory that climbs above it grants what it resolves to, by the choice of
271
+ * whoever wrote the policy.
272
+ */
273
+ export function assertNotAbsolute(list, where) {
274
+ for (const g of list) {
275
+ if (typeof g !== "string" || g.trim() === "")
276
+ throw new Error(`${where}: every entry must be a non-empty string`);
277
+ if (g.startsWith("/"))
278
+ throw new Error(`${where}: "${g}" is absolute. Territories are relative to the repo root.`);
279
+ }
280
+ return list;
281
+ }
282
+
283
+ /**
284
+ * An IP address or a CIDR range: `10.0.0.0/8`, `192.168.1.10`, `fc00::/7`
285
+ * (IPv6 without brackets). Anything else is refused with what was written.
286
+ */
287
+ export function readAddresses(value, where) {
288
+ const list = asArray(value, where);
289
+ for (const v of list) {
290
+ const [addr, prefix, extra] = v.split("/");
291
+ const family = isIP(addr);
292
+ const max = family === 6 ? 128 : 32;
293
+ const okPrefix = prefix === undefined || (/^\d+$/.test(prefix) && Number(prefix) <= max);
294
+ if (!family || !okPrefix || extra !== undefined)
295
+ throw new Error(`${where}: "${v}" is not an IP address or a CIDR range (e.g. "10.0.0.0/8", "fc00::/7")`);
296
+ }
297
+ return list;
298
+ }
299
+
300
+ export function asArray(value, where) {
301
+ if (value === undefined) return [];
302
+ if (typeof value === "string") return [value];
303
+ if (Array.isArray(value) && value.every((v) => typeof v === "string")) return value;
304
+ throw new Error(`${where}: expected a string or an array of strings`);
305
+ }