seisin 0.4.0 → 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 +151 -67
  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 -30
  8. package/src/commands/log.js +55 -10
  9. package/src/commands/requests.js +124 -39
  10. package/src/commands/review.js +41 -16
  11. package/src/commands/run.js +174 -39
  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 +261 -27
  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 +24 -16
  27. package/src/keys.js +13 -11
  28. package/src/kinds.js +207 -0
  29. package/src/log.js +312 -55
  30. package/src/mcp.js +96 -43
  31. package/src/notify.js +15 -10
  32. package/src/owners.js +323 -41
  33. package/src/paths.js +37 -1
  34. package/src/redact.js +19 -5
  35. package/src/render.js +81 -12
  36. package/src/requests.js +250 -72
  37. package/src/review.js +42 -21
  38. package/src/rundir.js +4 -6
  39. package/src/scan.js +177 -19
  40. package/src/serve.js +395 -205
  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 +203 -42
  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 +1366 -529
@@ -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
  *
@@ -52,13 +51,27 @@ function dict() {
52
51
  return Object.create(null);
53
52
  }
54
53
 
54
+ const isTable = (v) => v !== null && typeof v === "object" && !Array.isArray(v);
55
+
55
56
  /** Read a property only if the object actually has it. */
56
57
  export function own(obj, key) {
57
58
  return obj && Object.prototype.hasOwnProperty.call(obj, key) ? obj[key] : undefined;
58
59
  }
59
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. */
60
71
  export function parseToml(text) {
61
72
  const out = dict();
73
+ const where = new Map();
74
+ LINES.set(out, where);
62
75
  let table = out;
63
76
  let tableName = "";
64
77
  // "Last one wins" is TOML-ish and wrong for a permission file. A policy can
@@ -85,11 +98,19 @@ export function parseToml(text) {
85
98
  if (seenTables.has(tableName))
86
99
  throw new Error(
87
100
  `${CONFIG_NAME}:${i + 1}: [${tableName}] appears twice. ` +
88
- `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 ` +
89
102
  `${tableName} in one place.`);
90
103
  seenTables.add(tableName);
91
- table = tableName.split(".").reduce((node, key) => {
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
+ });
108
+ table = tableName.split(".").reduce((node, key, k, parts) => {
92
109
  if (!Object.prototype.hasOwnProperty.call(node, key)) node[key] = dict();
110
+ else if (!isTable(node[key]))
111
+ throw new Error(
112
+ `${CONFIG_NAME}:${i + 1}: [${tableName}] — "${parts.slice(0, k + 1).join(".")}" is already ` +
113
+ `a value, not a table. One name cannot be both.`);
93
114
  return node[key];
94
115
  }, out);
95
116
  continue;
@@ -99,9 +120,20 @@ export function parseToml(text) {
99
120
  if (!pair) throw new Error(`${CONFIG_NAME}:${i + 1}: cannot read "${raw.trim()}"`);
100
121
 
101
122
  let [, key, value] = pair;
102
- // An array may span lines; keep pulling until the brackets balance.
103
- if (value.startsWith("[") && !value.includes("]")) {
104
- while (!value.includes("]") && i + 1 < lines.length) value += " " + stripComment(lines[++i]).trim();
123
+ const at = i + 1;
124
+ // An array may span lines; keep pulling until the brackets balance. Only
125
+ // the new line is searched: searching the whole growing value made an
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)) {
129
+ const parts = [value];
130
+ let closed = false;
131
+ while (!closed && i + 1 < lines.length) {
132
+ const next = stripComment(lines[++i]).trim();
133
+ parts.push(next);
134
+ closed = closesArray(next);
135
+ }
136
+ value = parts.join(" ");
105
137
  }
106
138
  if (FORBIDDEN.has(key))
107
139
  throw new Error(`${CONFIG_NAME}:${i + 1}: "${key}" is a reserved name and cannot be a setting.`);
@@ -111,13 +143,39 @@ export function parseToml(text) {
111
143
  `${CONFIG_NAME}:${i + 1}: "${key}" is set twice in [${tableName || "the root"}]. ` +
112
144
  `The second would win, which is not a thing a permission file should do quietly.`);
113
145
  seenKeys.add(seen);
146
+ where.set(tableName ? `${tableName}.${key}` : key, at);
147
+ /**
148
+ * A key may not be a table that is declared elsewhere, in either order.
149
+ *
150
+ * `[roles.a]` with `network = []`, then `[roles]` and `a = true` forty lines
151
+ * down, used to replace the whole role with `true`: its `network = []` came
152
+ * back as "not set", which means the global allow list. `check` said
153
+ * nothing. It is the "last one wins" this parser refuses everywhere else,
154
+ * arriving by a different spelling.
155
+ */
156
+ if (Object.prototype.hasOwnProperty.call(table, key))
157
+ throw new Error(
158
+ `${CONFIG_NAME}:${i + 1}: "${key}" in [${tableName || "the root"}] is already the table ` +
159
+ `[${tableName ? tableName + "." : ""}${key}]. One name cannot be both — the value would replace ` +
160
+ `everything that table says.`);
114
161
  table[key] = readValue(value, i + 1);
115
162
  }
116
163
  return out;
117
164
  }
118
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
+
119
177
  /** Strips a trailing `#` comment, but not one inside a quoted string. */
120
- function stripComment(line) {
178
+ export function stripComment(line) {
121
179
  let quoted = false;
122
180
  for (let i = 0; i < line.length; i++) {
123
181
  const c = line[i];
@@ -212,14 +270,6 @@ function readValue(value, lineNo) {
212
270
  bad("value must be a string, an array of strings, a whole number, or a boolean");
213
271
  }
214
272
 
215
- /**
216
- * Turns the parsed file into the shape the rest of the program uses, and
217
- * rejects anything ambiguous.
218
- *
219
- * Rejecting is the point. The sandbox runtime refuses to start on an invalid
220
- * settings file rather than falling back to a permissive default, and seisin
221
- * matches that: a permission tool that guesses is worse than no permission tool.
222
- */
223
273
  /**
224
274
  * `[runtime] isolate` — one setting, two levels, because they were one and
225
275
  * should not have been.
@@ -255,13 +305,25 @@ function readValue(value, lineNo) {
255
305
  export function readIsolate(v) {
256
306
  if (v === true || v === "home") return "home";
257
307
  if (v === "credentials") return "credentials";
258
- // Anything else is off, including a misspelling. A permission tool must not
259
- // read a word it does not know as a stronger setting than the reader meant.
260
- return false;
308
+ if (v === false || v === undefined) return false;
309
+ throw new Error(
310
+ `runtime.isolate must be false, true, "credentials", or "home", not ${JSON.stringify(v)}. ` +
311
+ `An unknown value cannot silently turn credential isolation off.`);
261
312
  }
262
313
 
263
- export function loadConfig(path) {
264
- 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));
265
327
  // Read only what the file actually declared. The parser refuses the names
266
328
  // that make inheritance possible, and this is the second half of the same
267
329
  // rule: if one of them ever gets through, a role still cannot pick up a
@@ -269,6 +331,16 @@ export function loadConfig(path) {
269
331
  // they prevent is invisible in review — the config reads one way and the
270
332
  // policy is another.
271
333
  const roles = own(parsed, "roles") ?? {};
334
+ // `roles = "x"` or `roles = [...]` at the root, or `[roles]` with `a = true`,
335
+ // would read as roles named "0", "1" or "a" with no settings of their own —
336
+ // and a role with nothing set gets the global defaults.
337
+ if (!isTable(roles))
338
+ throw new Error(`${path}: "roles" must be tables — [roles.<name>] — not ${JSON.stringify(roles)}`);
339
+ for (const [name, r] of Object.entries(roles))
340
+ if (!isTable(r))
341
+ throw new Error(
342
+ `${path}: roles.${name} = ${JSON.stringify(r)} — a role is a table, [roles.${name}], ` +
343
+ `with its settings under it.`);
272
344
 
273
345
  /**
274
346
  * A role is a leaf. `[roles.a.b]` is a typo, with certainty.
@@ -295,12 +367,29 @@ export function loadConfig(path) {
295
367
  }
296
368
  const names = Object.keys(roles);
297
369
  if (names.length === 0) throw new Error(`${path}: no [roles.<name>] sections found`);
370
+ // Case-colliding names would share a HOME under `isolate = "home"` on a
371
+ // case-insensitive disk (APFS), so `dev` and `Dev` would read each other's
372
+ // secrets while `check` said nothing. Refuse the ambiguity at load.
373
+ {
374
+ const byLower = new Map();
375
+ for (const n of names) {
376
+ const k = n.toLowerCase();
377
+ if (byLower.has(k))
378
+ throw new Error(`${path}: roles "${byLower.get(k)}" and "${n}" differ only in case. ` +
379
+ `On a case-insensitive disk they would share an isolated HOME. Rename one.`);
380
+ byLower.set(k, n);
381
+ }
382
+ }
298
383
 
299
384
  // One directory or several. Several is the common case once a repo has more
300
385
  // than one kind of secret, and making people flatten them to satisfy the tool
301
386
  // is how a tool gets kept out.
302
387
  const keyDir = own(own(parsed, "keys"), "dir");
303
- 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);
304
393
  const keyProviders = readProviders(own(own(parsed, "keys"), "providers"), path);
305
394
  // `undefined` means "use the defaults"; an explicit empty array means "none".
306
395
  // The difference matters: one is a user who has not thought about it, the
@@ -310,20 +399,23 @@ export function loadConfig(path) {
310
399
 
311
400
  const out = {
312
401
  root: dirname(path), path, keyDirs, keyProviders,
313
- 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"),
314
405
  runtimeWrites: runtimeWrites === undefined ? undefined : asArray(runtimeWrites, "runtime.writes"),
315
406
  // `[runtime] isolate` — how much of your home a role stops reaching. See
316
407
  // srt.js for what each level emits, and readIsolate() below for why there
317
408
  // are two of them.
318
409
  isolate: readIsolate(own(runtime, "isolate")),
319
- redact: own(runtime, "redact"),
410
+ redact: readBoolean(own(runtime, "redact"), `${path}: runtime.redact`),
320
411
  scanIgnore: asArray(own(own(parsed, "scan"), "ignore"), "scan.ignore"),
412
+ protect: readProtect(own(parsed, "protect"), path),
321
413
  roles: {},
322
414
  };
323
415
 
324
416
  for (const name of names) {
325
417
  const r = own(roles, name);
326
- const writes = asArray(own(r, "writes"), `roles.${name}.writes`);
418
+ const writes = assertNotAbsolute(asArray(own(r, "writes"), `${path}: roles.${name}.writes`), `${path}: roles.${name}.writes`);
327
419
  const keys = asArray(own(r, "keys"), `roles.${name}.keys`);
328
420
  // Read once, here, so every consumer sees the same answer about what an
329
421
  // entry is. `keys` itself stays the array of strings that was written: it
@@ -379,6 +471,7 @@ export function loadConfig(path) {
379
471
  throw new Error(`${path}: roles.${name}.local_binding must be true or false, not ${JSON.stringify(localBinding)}`);
380
472
  const localPorts = readLocalPorts(own(r, "local_ports"), `${path}: roles.${name}.local_ports`);
381
473
  const mcp = readMcp(own(r, "mcp"), `${path}: roles.${name}.mcp`);
474
+ const controlFiles = readControlFiles(own(r, "control_files"), `${path}: roles.${name}.control_files`);
382
475
  out.roles[name] = {
383
476
  name,
384
477
  /**
@@ -452,6 +545,13 @@ export function loadConfig(path) {
452
545
  * the role says so. See srt.js for what it costs.
453
546
  */
454
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,
455
555
  // Kept so `check` can name a misspelt key instead of ignoring it. An
456
556
  // unknown key in a role table used to be dropped silently, and for a
457
557
  // subtraction that is failing open: `never_write` would read as a rule
@@ -511,7 +611,7 @@ function readProviders(table, path) {
511
611
  /**
512
612
  * A SQLite database is four files, and a policy should say it once.
513
613
  *
514
- * `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
515
615
  * database is made of. Writing to it in WAL mode also writes `-wal` and
516
616
  * `-shm`; in rollback mode, `-journal`. So "this role writes this database"
517
617
  * takes four lines, and a policy with eight databases and thirty-one roles
@@ -526,7 +626,7 @@ function readProviders(table, path) {
526
626
  * it, or corrupt it. The sidecar is not a second resource — it is the same one.
527
627
  *
528
628
  * It is also not a glob. The kernel grants a path and everything under it, so
529
- * `equipo.sqlite*` is unenforceable and `seisin check` refuses it; these are
629
+ * `app.sqlite*` is unenforceable and `seisin check` refuses it; these are
530
630
  * emitted as literal paths, exactly as if they had been typed.
531
631
  *
532
632
  * **The suffix list is closed on purpose** and this is the line that keeps the
@@ -550,8 +650,123 @@ export function withSidecars(paths) {
550
650
  return [...new Set(out)];
551
651
  }
552
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
+
553
729
  /** The keys a `[roles.<name>]` table can hold. Anything else is reported by `check`. */
554
- 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
+ }
555
770
 
556
771
  /**
557
772
  * `mcp`: names of MCP servers, as the CLI's config calls them. Absent is null,
@@ -614,6 +829,25 @@ function readNeverWrites(value, where) {
614
829
  return list;
615
830
  }
616
831
 
832
+ /**
833
+ * A territory is relative to the repo root. An absolute one would ask the kernel
834
+ * for a path on the machine and make `explain` (which reads absolute as outside
835
+ * the repo) disagree with what the profile grants — see the CODEOWNERS `/apps/`
836
+ * case. `..` is allowed on purpose: a role legitimately writes a sibling
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.
840
+ */
841
+ function assertNotAbsolute(list, where) {
842
+ for (const g of list) {
843
+ if (typeof g !== "string" || g.trim() === "")
844
+ throw new Error(`${where}: every entry must be a non-empty string`);
845
+ if (g.startsWith("/"))
846
+ throw new Error(`${where}: "${g}" is absolute. Territories are relative to the repo root.`);
847
+ }
848
+ return list;
849
+ }
850
+
617
851
  function asArray(value, where) {
618
852
  if (value === undefined) return [];
619
853
  if (typeof value === "string") return [value];