seisin 0.4.2 → 0.5.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 (50) hide show
  1. package/README.md +142 -54
  2. package/package.json +3 -3
  3. package/src/cli.js +145 -46
  4. package/src/commands/check.js +3 -2
  5. package/src/commands/explain.js +34 -13
  6. package/src/commands/hook.js +14 -4
  7. package/src/commands/init.js +126 -38
  8. package/src/commands/log.js +48 -9
  9. package/src/commands/requests.js +109 -32
  10. package/src/commands/review.js +41 -16
  11. package/src/commands/run.js +159 -37
  12. package/src/commands/scan.js +18 -3
  13. package/src/commands/ui.js +18 -5
  14. package/src/commands/walls.js +25 -6
  15. package/src/commands/whose.js +7 -6
  16. package/src/commands/wire.js +156 -17
  17. package/src/config.js +187 -25
  18. package/src/controls.js +244 -0
  19. package/src/diagnose.js +35 -33
  20. package/src/env.js +11 -8
  21. package/src/grants.js +15 -7
  22. package/src/help.js +159 -0
  23. package/src/hook.js +49 -19
  24. package/src/index.js +4 -4
  25. package/src/inspect.js +137 -71
  26. package/src/intake.js +20 -15
  27. package/src/keys.js +13 -11
  28. package/src/kinds.js +207 -0
  29. package/src/log.js +236 -51
  30. package/src/mcp.js +42 -28
  31. package/src/notify.js +13 -8
  32. package/src/owners.js +198 -41
  33. package/src/paths.js +37 -1
  34. package/src/redact.js +19 -5
  35. package/src/render.js +68 -10
  36. package/src/requests.js +173 -72
  37. package/src/review.js +30 -17
  38. package/src/rundir.js +4 -6
  39. package/src/scan.js +177 -19
  40. package/src/serve.js +377 -229
  41. package/src/spool.js +9 -29
  42. package/src/srt.js +38 -21
  43. package/src/suggest.js +58 -0
  44. package/src/surface.js +191 -44
  45. package/src/uilink.js +10 -4
  46. package/src/views.js +289 -0
  47. package/src/violations.js +104 -16
  48. package/src/walls.js +19 -42
  49. package/src/worktree.js +3 -11
  50. package/ui/index.html +1356 -537
@@ -13,18 +13,76 @@
13
13
  * config: a permission tool that edits your machine's settings to install
14
14
  * itself has misread the room.
15
15
  */
16
- import { readFileSync, writeFileSync, mkdirSync, existsSync } from "node:fs";
17
- import { join } from "node:path";
16
+ import { readFileSync, writeFileSync, mkdirSync, existsSync, realpathSync } from "node:fs";
17
+ import { join, dirname, delimiter } from "node:path";
18
+ import { fileURLToPath } from "node:url";
19
+ import { spawnSync } from "node:child_process";
18
20
  import { C, out } from "../render.js";
21
+ import { TOOL_MATCHER } from "../hook.js";
19
22
 
20
23
  const SETTINGS = join(".claude", "settings.json");
24
+ const CLI = join(dirname(fileURLToPath(import.meta.url)), "..", "cli.js");
21
25
 
22
- /** Where the PreToolUse entry lives, and what it has to say. */
23
- export function hookEntry(command = "seisin hook") {
24
- return {
25
- matcher: "*",
26
- hooks: [{ type: "command", command }],
27
- };
26
+ /** A hook command that is seisin's, however it was spelled when it was wired. */
27
+ const isOurs = (c) => typeof c === "string" && /\bhook"?\s*$/.test(c) && (c.includes("seisin") || runsSeisinScript(c));
28
+
29
+ /**
30
+ * A hook written by its full path (`"<node>" "<…>/src/cli.js" hook`) names no
31
+ * `seisin` when the checkout sits in a folder called something else. It is
32
+ * ours when the script it runs belongs to a package named seisin.
33
+ */
34
+ function runsSeisinScript(command) {
35
+ const script = /"([^"]+\.js)"\s+hook\s*$/.exec(command)?.[1];
36
+ if (!script) return false;
37
+ try { return JSON.parse(readFileSync(join(dirname(script), "..", "package.json"), "utf8")).name === "seisin"; } catch { return false; }
38
+ }
39
+
40
+ /**
41
+ * The command that reaches THIS seisin from a hook, and how it was found.
42
+ *
43
+ * `seisin hook` was written for everyone. With seisin installed in a project
44
+ * rather than globally there is no `seisin` on PATH, the hook exits 127, and
45
+ * Claude Code carries on without it — nothing on screen, nothing in the log.
46
+ * So: the one on PATH when it is this version; the project's own
47
+ * node_modules/.bin when there is one; otherwise this file, by absolute path.
48
+ */
49
+ export function hookCommand(root, { path = process.env.PATH, cli = CLI, version = ownVersion() } = {}) {
50
+ const found = (path ?? "").split(delimiter).map((d) => d && join(d, "seisin")).find((p) => p && existsSync(p));
51
+ let onPath = null;
52
+ if (found) {
53
+ const same = realOrSelf(found) === realOrSelf(cli);
54
+ const v = same ? version : spawnSync(found, ["--version"], { encoding: "utf8", timeout: 5000 }).stdout?.trim();
55
+ onPath = { at: found, version: v };
56
+ if (v === version) return { command: "seisin hook", via: "path", onPath };
57
+ }
58
+ const local = join(root, "node_modules", ".bin", "seisin");
59
+ if (existsSync(local)) return { command: '"$CLAUDE_PROJECT_DIR"/node_modules/.bin/seisin hook', via: "local", onPath };
60
+ return { command: `"${process.execPath}" "${realOrSelf(cli)}" hook`, via: "absolute", onPath };
61
+ }
62
+
63
+ function ownVersion() {
64
+ try { return JSON.parse(readFileSync(join(dirname(CLI), "..", "package.json"), "utf8")).version; } catch { return null; }
65
+ }
66
+
67
+ /** Is `dir` inside a git checkout? Kept here so wire does not load the scanner. */
68
+ function insideRepo(dir) {
69
+ for (let d = dir; ; d = dirname(d)) {
70
+ if (existsSync(join(d, ".git"))) return true;
71
+ if (dirname(d) === d) return false;
72
+ }
73
+ }
74
+
75
+ function realOrSelf(p) {
76
+ try { return realpathSync(p); } catch { return p; }
77
+ }
78
+
79
+ /** One sentence: which seisin the hook runs, and why that one. */
80
+ function saysWhich(h) {
81
+ const other = h.onPath && h.onPath.version !== ownVersion()
82
+ ? ` (the seisin on your PATH is ${h.onPath.version || "another version"}, not ${ownVersion()})` : "";
83
+ if (h.via === "path") return `the hook runs "seisin hook" — seisin ${ownVersion()} is on your PATH`;
84
+ if (h.via === "local") return `the hook runs this project's node_modules/.bin/seisin${other || " — seisin is not on your PATH"}`;
85
+ return `the hook runs this seisin by its full path${other || " — seisin is not on your PATH"}`;
28
86
  }
29
87
 
30
88
  /**
@@ -39,7 +97,7 @@ export function hookEntry(command = "seisin hook") {
39
97
  export function hookEntries(command = "seisin hook") {
40
98
  const h = [{ type: "command", command }];
41
99
  return {
42
- PreToolUse: [{ matcher: "*", hooks: h }],
100
+ PreToolUse: [{ matcher: TOOL_MATCHER, hooks: h }],
43
101
  PostToolUse: [{ matcher: "Bash", hooks: h }],
44
102
  PostToolUseFailure: [{ matcher: "*", hooks: h }],
45
103
  SessionStart: [{ matcher: "startup|resume|compact", hooks: h }],
@@ -55,14 +113,59 @@ export function wired(root) {
55
113
  // session-start hooks existed has only PreToolUse, and "already wired" there
56
114
  // would keep it from ever getting the rest.
57
115
  const hooks = JSON.parse(readFileSync(file, "utf8")).hooks ?? {};
58
- return Object.keys(hookEntries()).every((ev) => JSON.stringify(hooks[ev] ?? []).includes("seisin hook"));
116
+ return Object.keys(hookEntries()).every((ev) =>
117
+ (hooks[ev] ?? []).some((e) => (e?.hooks ?? []).some((h) => isOurs(h?.command))));
59
118
  } catch {
60
119
  return false; // unreadable settings is not "wired"
61
120
  }
62
121
  }
63
122
 
123
+ /**
124
+ * Narrows seisin's own `"*"` PreToolUse entry to {@link TOOL_MATCHER}. True if
125
+ * it changed anything.
126
+ *
127
+ * Only an entry that holds nothing but `seisin hook`: a `"*"` entry that also
128
+ * runs somebody else's command is theirs as much as ours, and narrowing it
129
+ * would silently stop their hook on every other tool.
130
+ */
131
+ function narrow(settings) {
132
+ let changed = false;
133
+ for (const entry of settings.hooks?.PreToolUse ?? []) {
134
+ if (broadOurs(entry)) {
135
+ entry.matcher = TOOL_MATCHER;
136
+ changed = true;
137
+ }
138
+ }
139
+ return changed;
140
+ }
141
+
142
+ /** A PreToolUse entry that runs only `seisin hook`, on every tool. */
143
+ function broadOurs(entry) {
144
+ const ours = Array.isArray(entry?.hooks) && entry.hooks.length > 0 &&
145
+ entry.hooks.every((h) => isOurs(h?.command));
146
+ return ours && (entry.matcher === "*" || entry.matcher === "" || entry.matcher === undefined);
147
+ }
148
+
149
+ /**
150
+ * Does this repo still run the hook on every tool call — the `"*"` matcher that
151
+ * versions before 0.5.0 installed? Correct, just slow: every Read, Glob and
152
+ * TodoWrite pays for a process start that answers nothing. `seisin wire` narrows
153
+ * it in place; this is how `check` knows to say so.
154
+ */
155
+ export function broadlyWired(root) {
156
+ const file = join(root, SETTINGS);
157
+ if (!existsSync(file)) return false;
158
+ try {
159
+ const pre = JSON.parse(readFileSync(file, "utf8")).hooks?.PreToolUse;
160
+ return Array.isArray(pre) && pre.some(broadOurs);
161
+ } catch {
162
+ return false; // unreadable settings is reported elsewhere, if at all
163
+ }
164
+ }
165
+
64
166
  export function wire(config) {
65
167
  const file = join(config.root, SETTINGS);
168
+ const how = hookCommand(config.root);
66
169
 
67
170
  // Merged, never replaced. Someone's hooks are their own and a tool that
68
171
  // overwrites them to add itself does not get a second chance.
@@ -76,24 +179,60 @@ export function wire(config) {
76
179
  }
77
180
 
78
181
  if (wired(config.root)) {
79
- out(`\n ${C.dim}already wired — ${SETTINGS} runs "seisin hook"${C.off}\n\n`);
182
+ // A bare `seisin hook` where no seisin is on PATH fails on every call
183
+ // without a word. Rewritten in place to the command that works here.
184
+ // Both repairs in one pass: an install from before 0.5.0 on a machine
185
+ // without seisin on PATH needs the command repointed AND the matcher
186
+ // narrowed, and returning after the first left it half-fixed.
187
+ const repointed = how.via !== "path" && repoint(settings, how.command);
188
+ // Wired before the matcher was narrowed: still correct, just slow, so
189
+ // `wired()` keeps saying yes and nobody is told to redo anything. Running
190
+ // `wire` again is the way to pick up the narrower one, in place.
191
+ const narrowed = narrow(settings);
192
+ if (repointed || narrowed) {
193
+ writeFileSync(file, JSON.stringify(settings, null, 2) + "\n");
194
+ if (repointed)
195
+ out(`\n ${C.green}repointed${C.off} ${SETTINGS}\n ${C.dim}"seisin hook" is not on your PATH, so it never ran. ${saysWhich(how)}.${C.off}\n\n`);
196
+ if (narrowed)
197
+ out(`\n ${C.green}narrowed${C.off} ${SETTINGS}\n` +
198
+ ` ${C.dim}PreToolUse now runs seisin's hook only for the tools it reads, not for every tool${C.off}\n\n`);
199
+ return { changed: true, file, ...(repointed && { how }) };
200
+ }
201
+ out(`\n ${C.dim}already wired — ${SETTINGS} runs seisin's hook${C.off}\n\n`);
80
202
  return { changed: false, file };
81
203
  }
82
204
 
83
205
  settings.hooks ??= {};
84
- for (const [ev, entries] of Object.entries(hookEntries())) {
206
+ for (const [ev, entries] of Object.entries(hookEntries(how.command))) {
85
207
  settings.hooks[ev] ??= [];
86
- if (!JSON.stringify(settings.hooks[ev]).includes("seisin hook")) settings.hooks[ev].push(...entries);
208
+ if (!settings.hooks[ev].some((e) => (e?.hooks ?? []).some((h) => isOurs(h?.command)))) settings.hooks[ev].push(...entries);
87
209
  }
210
+ narrow(settings); // an older PreToolUse entry that was kept
88
211
 
89
212
  mkdirSync(join(config.root, ".claude"), { recursive: true });
90
213
  writeFileSync(file, JSON.stringify(settings, null, 2) + "\n");
91
214
 
92
215
  out(
93
216
  `\n ${C.green}wired${C.off} ${SETTINGS}\n` +
94
- ` ${C.dim}the agent now runs "seisin hook" before each tool call, which is what fills${C.off}\n` +
95
- ` ${C.dim}the log. Without it the boundary still holds — you just cannot see it work.${C.off}\n\n` +
96
- ` ${C.dim}Commit this file if the rest of your team should get it too.${C.off}\n\n`
217
+ ` ${C.dim}the agent now runs seisin's hook before each tool call, which is what fills${C.off}\n` +
218
+ ` ${C.dim}the log. Without it the boundary still holds — you just cannot see it work.${C.off}\n` +
219
+ ` ${C.dim}${saysWhich(how)}.${C.off}\n\n` +
220
+ // Only in a repository: outside one there is nothing to commit it to.
221
+ (insideRepo(config.root)
222
+ ? how.via === "absolute"
223
+ ? ` ${C.dim}The command names a path on this machine; teammates should run seisin wire themselves.${C.off}\n\n`
224
+ : ` ${C.dim}Commit this file if the rest of your team should get it too.${C.off}\n\n`
225
+ : "")
97
226
  );
98
- return { changed: true, file };
227
+ return { changed: true, file, how };
228
+ }
229
+
230
+ /** Replaces a bare `seisin hook` command with `command`. True if it changed anything. */
231
+ function repoint(settings, command) {
232
+ let changed = false;
233
+ for (const entries of Object.values(settings.hooks ?? {}))
234
+ for (const e of Array.isArray(entries) ? entries : [])
235
+ for (const h of e?.hooks ?? [])
236
+ if (h?.command === "seisin hook") { h.command = command; changed = true; }
237
+ return changed;
99
238
  }
package/src/config.js CHANGED
@@ -31,7 +31,6 @@ export function findConfig(from = process.cwd()) {
31
31
  const RE_TABLE = /^\[([A-Za-z0-9_.\-]+)\]$/;
32
32
  const RE_PAIR = /^([A-Za-z0-9_\-]+)\s*=\s*(.+)$/;
33
33
 
34
- /** Minimal TOML subset -> plain object. Throws with a line number on bad input. */
35
34
  /**
36
35
  * Names a table or key may not have.
37
36
  *
@@ -59,8 +58,20 @@ export function own(obj, key) {
59
58
  return obj && Object.prototype.hasOwnProperty.call(obj, key) ? obj[key] : undefined;
60
59
  }
61
60
 
61
+ /**
62
+ * Where each table and key of a parsed file was written, by dotted name.
63
+ *
64
+ * Kept beside the result rather than in it, so the object stays exactly the
65
+ * data that was written; loadConfig reads it to put a line number on a name it
66
+ * does not know.
67
+ */
68
+ const LINES = new WeakMap();
69
+
70
+ /** Minimal TOML subset -> plain object. Throws with a line number on bad input. */
62
71
  export function parseToml(text) {
63
72
  const out = dict();
73
+ const where = new Map();
74
+ LINES.set(out, where);
64
75
  let table = out;
65
76
  let tableName = "";
66
77
  // "Last one wins" is TOML-ish and wrong for a permission file. A policy can
@@ -87,9 +98,13 @@ export function parseToml(text) {
87
98
  if (seenTables.has(tableName))
88
99
  throw new Error(
89
100
  `${CONFIG_NAME}:${i + 1}: [${tableName}] appears twice. ` +
90
- `A later block would silently override the earlier one — put every setting for ` +
101
+ `A later table would silently override the earlier one — put every setting for ` +
91
102
  `${tableName} in one place.`);
92
103
  seenTables.add(tableName);
104
+ tableName.split(".").forEach((_, k, parts) => {
105
+ const name = parts.slice(0, k + 1).join(".");
106
+ if (!where.has(name)) where.set(name, i + 1);
107
+ });
93
108
  table = tableName.split(".").reduce((node, key, k, parts) => {
94
109
  if (!Object.prototype.hasOwnProperty.call(node, key)) node[key] = dict();
95
110
  else if (!isTable(node[key]))
@@ -105,16 +120,18 @@ export function parseToml(text) {
105
120
  if (!pair) throw new Error(`${CONFIG_NAME}:${i + 1}: cannot read "${raw.trim()}"`);
106
121
 
107
122
  let [, key, value] = pair;
123
+ const at = i + 1;
108
124
  // An array may span lines; keep pulling until the brackets balance. Only
109
125
  // the new line is searched: searching the whole growing value made an
110
- // unclosed array of 100k lines take 20 s to be refused.
111
- if (value.startsWith("[") && !value.includes("]")) {
126
+ // unclosed array of 100k lines take 20 s to be refused. A `]` inside a
127
+ // quoted item does not close it — `["a]b",` used to end the array there.
128
+ if (value.startsWith("[") && !closesArray(value)) {
112
129
  const parts = [value];
113
130
  let closed = false;
114
131
  while (!closed && i + 1 < lines.length) {
115
132
  const next = stripComment(lines[++i]).trim();
116
133
  parts.push(next);
117
- closed = next.includes("]");
134
+ closed = closesArray(next);
118
135
  }
119
136
  value = parts.join(" ");
120
137
  }
@@ -126,6 +143,7 @@ export function parseToml(text) {
126
143
  `${CONFIG_NAME}:${i + 1}: "${key}" is set twice in [${tableName || "the root"}]. ` +
127
144
  `The second would win, which is not a thing a permission file should do quietly.`);
128
145
  seenKeys.add(seen);
146
+ where.set(tableName ? `${tableName}.${key}` : key, at);
129
147
  /**
130
148
  * A key may not be a table that is declared elsewhere, in either order.
131
149
  *
@@ -145,8 +163,19 @@ export function parseToml(text) {
145
163
  return out;
146
164
  }
147
165
 
166
+ /** Is there a `]` on this line outside a quoted string? Strings do not span lines. */
167
+ export function closesArray(line) {
168
+ let quoted = false;
169
+ for (let i = 0; i < line.length; i++) {
170
+ const c = line[i];
171
+ if (c === '"') quoted = !quoted;
172
+ else if (c === "]" && !quoted) return true;
173
+ }
174
+ return false;
175
+ }
176
+
148
177
  /** Strips a trailing `#` comment, but not one inside a quoted string. */
149
- function stripComment(line) {
178
+ export function stripComment(line) {
150
179
  let quoted = false;
151
180
  for (let i = 0; i < line.length; i++) {
152
181
  const c = line[i];
@@ -241,14 +270,6 @@ function readValue(value, lineNo) {
241
270
  bad("value must be a string, an array of strings, a whole number, or a boolean");
242
271
  }
243
272
 
244
- /**
245
- * Turns the parsed file into the shape the rest of the program uses, and
246
- * rejects anything ambiguous.
247
- *
248
- * Rejecting is the point. The sandbox runtime refuses to start on an invalid
249
- * settings file rather than falling back to a permissive default, and seisin
250
- * matches that: a permission tool that guesses is worse than no permission tool.
251
- */
252
273
  /**
253
274
  * `[runtime] isolate` — one setting, two levels, because they were one and
254
275
  * should not have been.
@@ -290,8 +311,19 @@ export function readIsolate(v) {
290
311
  `An unknown value cannot silently turn credential isolation off.`);
291
312
  }
292
313
 
293
- export function loadConfig(path) {
294
- const parsed = parseToml(readFileSync(path, "utf8"));
314
+ /**
315
+ * Turns the parsed file into the shape the rest of the program uses, and
316
+ * rejects anything ambiguous.
317
+ *
318
+ * Rejecting is the point. The sandbox runtime refuses to start on an invalid
319
+ * settings file rather than falling back to a permissive default, and seisin
320
+ * matches that: a permission tool that guesses is worse than no permission tool.
321
+ */
322
+ export function loadConfig(path, text = readFileSync(path, "utf8")) {
323
+ // `text` lets a caller validate an edit before writing it — the console
324
+ // previews a policy that does not exist on disk yet, against the real root.
325
+ const parsed = parseToml(text);
326
+ checkShape(parsed, SHAPE, "", path, LINES.get(parsed));
295
327
  // Read only what the file actually declared. The parser refuses the names
296
328
  // that make inheritance possible, and this is the second half of the same
297
329
  // rule: if one of them ever gets through, a role still cannot pick up a
@@ -353,7 +385,11 @@ export function loadConfig(path) {
353
385
  // than one kind of secret, and making people flatten them to satisfy the tool
354
386
  // is how a tool gets kept out.
355
387
  const keyDir = own(own(parsed, "keys"), "dir");
356
- const keyDirs = keyDir === undefined ? [] : asArray(keyDir, "keys.dir");
388
+ // `.secrets/` and `.secrets` are one directory, and every consumer compares
389
+ // them as prefixes: with the slash kept, `scan` walked into the protected
390
+ // directory and a bare key resolved to `.secrets//x`, which matched nothing.
391
+ const keyDirs = (keyDir === undefined ? [] : asArray(keyDir, "keys.dir"))
392
+ .map((d) => d.replace(/\/+$/, "") || d);
357
393
  const keyProviders = readProviders(own(own(parsed, "keys"), "providers"), path);
358
394
  // `undefined` means "use the defaults"; an explicit empty array means "none".
359
395
  // The difference matters: one is a user who has not thought about it, the
@@ -363,14 +399,17 @@ export function loadConfig(path) {
363
399
 
364
400
  const out = {
365
401
  root: dirname(path), path, keyDirs, keyProviders,
366
- allowedDomains: own(own(parsed, "network"), "allow") ?? [],
402
+ // Through asArray like every other list: `allow = "github.com"` was kept as
403
+ // a string, and a string iterated is its characters.
404
+ allowedDomains: asArray(own(own(parsed, "network"), "allow"), "network.allow"),
367
405
  runtimeWrites: runtimeWrites === undefined ? undefined : asArray(runtimeWrites, "runtime.writes"),
368
406
  // `[runtime] isolate` — how much of your home a role stops reaching. See
369
407
  // srt.js for what each level emits, and readIsolate() below for why there
370
408
  // are two of them.
371
409
  isolate: readIsolate(own(runtime, "isolate")),
372
- redact: own(runtime, "redact"),
410
+ redact: readBoolean(own(runtime, "redact"), `${path}: runtime.redact`),
373
411
  scanIgnore: asArray(own(own(parsed, "scan"), "ignore"), "scan.ignore"),
412
+ protect: readProtect(own(parsed, "protect"), path),
374
413
  roles: {},
375
414
  };
376
415
 
@@ -432,6 +471,7 @@ export function loadConfig(path) {
432
471
  throw new Error(`${path}: roles.${name}.local_binding must be true or false, not ${JSON.stringify(localBinding)}`);
433
472
  const localPorts = readLocalPorts(own(r, "local_ports"), `${path}: roles.${name}.local_ports`);
434
473
  const mcp = readMcp(own(r, "mcp"), `${path}: roles.${name}.mcp`);
474
+ const controlFiles = readControlFiles(own(r, "control_files"), `${path}: roles.${name}.control_files`);
435
475
  out.roles[name] = {
436
476
  name,
437
477
  /**
@@ -505,6 +545,13 @@ export function loadConfig(path) {
505
545
  * the role says so. See srt.js for what it costs.
506
546
  */
507
547
  trustd: trustd === true,
548
+ /**
549
+ * The families of control files this role may edit inside its own
550
+ * territory: "ide" (`.vscode/`, `.cursor/`, `.windsurf/`) and
551
+ * "instructions" (`CLAUDE.md`, `AGENTS.md`…). surface.js says what each
552
+ * is and why only these two can be handed out. Absent is none.
553
+ */
554
+ controlFiles,
508
555
  // Kept so `check` can name a misspelt key instead of ignoring it. An
509
556
  // unknown key in a role table used to be dropped silently, and for a
510
557
  // subtraction that is failing open: `never_write` would read as a rule
@@ -564,7 +611,7 @@ function readProviders(table, path) {
564
611
  /**
565
612
  * A SQLite database is four files, and a policy should say it once.
566
613
  *
567
- * `bitacora/x/equipo.sqlite` is not the database — it is one of the files the
614
+ * `data/x/app.sqlite` is not the database — it is one of the files the
568
615
  * database is made of. Writing to it in WAL mode also writes `-wal` and
569
616
  * `-shm`; in rollback mode, `-journal`. So "this role writes this database"
570
617
  * takes four lines, and a policy with eight databases and thirty-one roles
@@ -579,7 +626,7 @@ function readProviders(table, path) {
579
626
  * it, or corrupt it. The sidecar is not a second resource — it is the same one.
580
627
  *
581
628
  * It is also not a glob. The kernel grants a path and everything under it, so
582
- * `equipo.sqlite*` is unenforceable and `seisin check` refuses it; these are
629
+ * `app.sqlite*` is unenforceable and `seisin check` refuses it; these are
583
630
  * emitted as literal paths, exactly as if they had been typed.
584
631
  *
585
632
  * **The suffix list is closed on purpose** and this is the line that keeps the
@@ -603,8 +650,123 @@ export function withSidecars(paths) {
603
650
  return [...new Set(out)];
604
651
  }
605
652
 
653
+ /**
654
+ * Every table and key a policy may have outside `[roles.*]`. `true` is a value,
655
+ * an object is a table, and `*` stands for any name (a provider's).
656
+ *
657
+ * Anything else is refused at load, with its line. The roles table reports an
658
+ * unknown key through `check` because a role's typo is usually one extra
659
+ * grant; here the settings are global, and the one that made this necessary
660
+ * was `isolaet = true` under [runtime] — read as nothing, so isolation stayed
661
+ * off for every role and nothing on screen said so. The same "fail open by
662
+ * typo" that was closed for roles, closed harder because it covers them all.
663
+ */
664
+ const SHAPE = {
665
+ roles: null, // validated where roles are read
666
+ keys: { dir: true, providers: { "*": { command: true, mode: true } } },
667
+ network: { allow: true },
668
+ runtime: { writes: true, isolate: true, redact: true },
669
+ scan: { ignore: true },
670
+ protect: { instructions: true },
671
+ notify: { url: true, url_file: true, format: true },
672
+ };
673
+
674
+ function checkShape(table, shape, prefix, path, lines) {
675
+ const known = Object.keys(shape).filter((k) => k !== "*");
676
+ for (const key of Object.keys(table)) {
677
+ const name = prefix ? `${prefix}.${key}` : key;
678
+ const at = `${path}:${lines?.get(name) ?? "?"}`;
679
+ const rule = Object.prototype.hasOwnProperty.call(shape, key) ? shape[key] : shape["*"];
680
+ if (rule === undefined) {
681
+ const near = closest(key, known);
682
+ throw new Error(
683
+ `${at}: ${prefix ? `[${prefix}] ` : ""}"${key}" is not a setting` +
684
+ `${prefix ? "" : " or a table"} seisin knows${near ? ` — did you mean "${near}"?` : "."}\n` +
685
+ ` Known${prefix ? ` in [${prefix}]` : " at the top level"}: ${known.join(", ")}. ` +
686
+ `An unknown name is refused rather than ignored: ignored, a misspelt setting ` +
687
+ `reads as a rule and does nothing.`);
688
+ }
689
+ if (rule === null) continue;
690
+ const value = table[key];
691
+ if (rule === true && isTable(value))
692
+ throw new Error(`${at}: [${name}] is a table, but ${name} is a setting — write ${key} = … under [${prefix}].`);
693
+ if (typeof rule === "object" && !isTable(value))
694
+ throw new Error(`${at}: ${name} = ${JSON.stringify(value)} — ${name} is a table, [${name}], with its settings under it.`);
695
+ if (typeof rule === "object") checkShape(value, rule, name, path, lines);
696
+ }
697
+ }
698
+
699
+ /** The candidate within edit distance 3 of `word`, or null. Shared with `check`. */
700
+ export function closest(word, candidates) {
701
+ let best = null, bestD = 4;
702
+ for (const c of candidates) {
703
+ const d = distance(word.toLowerCase(), c);
704
+ if (d < bestD) { best = c; bestD = d; }
705
+ }
706
+ return best;
707
+ }
708
+
709
+ /** Levenshtein distance. */
710
+ function distance(a, b) {
711
+ const row = Array.from({ length: b.length + 1 }, (_, i) => i);
712
+ for (let i = 1; i <= a.length; i++) {
713
+ let prev = row[0]; row[0] = i;
714
+ for (let j = 1; j <= b.length; j++) {
715
+ const cur = row[j];
716
+ row[j] = Math.min(row[j] + 1, row[j - 1] + 1, prev + (a[i - 1] === b[j - 1] ? 0 : 1));
717
+ prev = cur;
718
+ }
719
+ }
720
+ return row[b.length];
721
+ }
722
+
723
+ /** true, false, or absent (undefined). Anything else is a mistake, said as one. */
724
+ function readBoolean(value, where) {
725
+ if (value === undefined || typeof value === "boolean") return value;
726
+ throw new Error(`${where} must be true or false, not ${JSON.stringify(value)}`);
727
+ }
728
+
606
729
  /** The keys a `[roles.<name>]` table can hold. Anything else is reported by `check`. */
607
- export const ROLE_KEYS = ["writes", "keys", "key_mode", "env", "network", "never_writes", "local_binding", "local_ports", "mcp", "trustd"];
730
+ export const ROLE_KEYS = ["writes", "keys", "key_mode", "env", "network", "never_writes", "local_binding", "local_ports", "mcp", "trustd", "control_files"];
731
+
732
+ /**
733
+ * `control_files`: which families of control files a role may edit. Refused at
734
+ * load when it names anything else — `.claude`, git hooks and the rest are not
735
+ * on the list on purpose, and a word the reader does not know must not load as
736
+ * a permission it thinks it wrote.
737
+ */
738
+ export const CONTROL_FAMILIES = ["ide", "instructions"];
739
+
740
+ function readControlFiles(value, where) {
741
+ if (value === undefined) return [];
742
+ const out = [];
743
+ for (const f of asArray(value, where)) {
744
+ if (!CONTROL_FAMILIES.includes(f))
745
+ throw new Error(`${where}: ${JSON.stringify(f)} is not a family of control files. ` +
746
+ `Known: ${CONTROL_FAMILIES.map((x) => JSON.stringify(x)).join(", ")}. ` +
747
+ `The others (.claude, git hooks, .mcp.json, .envrc) are never handed to a role.`);
748
+ if (!out.includes(f)) out.push(f);
749
+ }
750
+ return out;
751
+ }
752
+
753
+ /**
754
+ * `[protect]` — what is protected beyond the defaults. One key today:
755
+ * `instructions = true` protects `CLAUDE.md`, `AGENTS.md` and their kin from
756
+ * every role that is not handed them. Anything else in the table is refused.
757
+ */
758
+ function readProtect(value, path) {
759
+ if (value === undefined) return { instructions: false };
760
+ if (!value || typeof value !== "object" || Array.isArray(value))
761
+ throw new Error(`${path}: [protect] must be a table`);
762
+ for (const k of Object.keys(value))
763
+ if (k !== "instructions")
764
+ throw new Error(`${path}: [protect] ${k} is not a setting. Known: instructions.`);
765
+ const v = own(value, "instructions");
766
+ if (v !== undefined && typeof v !== "boolean")
767
+ throw new Error(`${path}: [protect] instructions must be true or false, not ${JSON.stringify(v)}`);
768
+ return { instructions: v === true };
769
+ }
608
770
 
609
771
  /**
610
772
  * `mcp`: names of MCP servers, as the CLI's config calls them. Absent is null,
@@ -672,9 +834,9 @@ function readNeverWrites(value, where) {
672
834
  * for a path on the machine and make `explain` (which reads absolute as outside
673
835
  * the repo) disagree with what the profile grants — see the CODEOWNERS `/apps/`
674
836
  * case. `..` is allowed on purpose: a role legitimately writes a sibling
675
- * directory (`../bitacora/...`), and it is resolved relative to the root, so a
676
- * territory that climbs above it grants what it resolves to, by the operator's
677
- * choice.
837
+ * directory (`../shared/...`), and it is resolved relative to the root, so a
838
+ * territory that climbs above it grants what it resolves to, by the choice of
839
+ * whoever wrote the policy.
678
840
  */
679
841
  function assertNotAbsolute(list, where) {
680
842
  for (const g of list) {