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
package/src/hook.js CHANGED
@@ -28,6 +28,18 @@ const FILE_TOOLS = {
28
28
  Read: "read", NotebookRead: "read",
29
29
  };
30
30
 
31
+ /**
32
+ * The tools {@link targetsOf} has anything to say about, as a hook matcher.
33
+ *
34
+ * `seisin wire` used to install PreToolUse with `"*"`, so Claude Code started
35
+ * this hook for Grep, Glob and TodoWrite too — a whole Node start-up, measured
36
+ * at 218 ms, to return nothing. Built from the same table targetsOf reads, so
37
+ * a tool added there is matched here. Anchored, because Claude Code reads a
38
+ * matcher as a regular expression and an unanchored `Write` matches
39
+ * `TodoWrite`.
40
+ */
41
+ export const TOOL_MATCHER = `^(${[...Object.keys(FILE_TOOLS), "Bash"].join("|")}|mcp__.*)$`;
42
+
31
43
  /**
32
44
  * Paths a shell command is going to write.
33
45
  *
@@ -44,7 +56,7 @@ const WRITERS = /(?:^|\s|;|&&|\|\|)(?:cp|mv|install|tee|touch|mkdir|rm|rmdir|tru
44
56
  * The split is on the double underscore: a server name carries single ones
45
57
  * (`claude_ai_Gmail`) and so does a tool name (`execute_sql`), so the server is
46
58
  * everything between `mcp__` and the next `__`. Shared with the parent's intake,
47
- * which recomputes the same verdict rather than trusting the line from the box.
59
+ * which recomputes the same verdict rather than trusting the line from the sandbox.
48
60
  */
49
61
  export function mcpServer(tool) {
50
62
  const m = typeof tool === "string" && /^mcp__(.+?)__(.+)$/.exec(tool);
@@ -52,15 +64,19 @@ export function mcpServer(tool) {
52
64
  }
53
65
 
54
66
  export function targetsOf(tool, input = {}) {
67
+ // The event is whatever the agent's harness sent. `tool_input: null` or a
68
+ // path that is a number used to throw below, and a throwing hook exits 2 —
69
+ // which Claude Code reads as "block". Malformed means "nothing to say".
70
+ if (!input || typeof input !== "object") input = {};
55
71
  const kind = FILE_TOOLS[tool];
56
72
  if (kind) {
57
- const p = input.file_path ?? input.path ?? input.notebook_path;
58
- return p ? [{ action: kind, path: p }] : [];
73
+ const p = [input.file_path, input.path, input.notebook_path].find((x) => x != null);
74
+ return typeof p === "string" && p ? [{ action: kind, path: p }] : [];
59
75
  }
60
76
  /**
61
77
  * An MCP tool call names a resource, not a path: `mcp__<server>__<tool>`.
62
78
  *
63
- * The kernel never sees it — the MCP server runs outside the box — so a call
79
+ * The kernel never sees it — the MCP server runs outside the sandbox — so a call
64
80
  * that goes unrecorded here goes unrecorded everywhere. Measured 2026-09-24:
65
81
  * the event reaches PreToolUse with the tool's own arguments as `tool_input`,
66
82
  * which is exactly why the file-tool code above returns nothing for it. We
@@ -132,14 +148,14 @@ function kindOf(config, path) {
132
148
  }
133
149
 
134
150
  /**
135
- * The half of the refusal that nothing inside the box could otherwise know.
151
+ * The half of the refusal that nothing inside the sandbox could otherwise know.
136
152
  *
137
153
  * `ask()` has already filed a request by the time this sentence is built — and
138
154
  * until now it did so silently, so an agent could not tell the person who sent
139
155
  * it that anything was pending. It closes two loops the bare refusal leaves
140
156
  * open: retrying, because the queue deduplicates and a second attempt only
141
157
  * raises a counter; and waiting, because nothing inside the sandbox can
142
- * approve.
158
+ * grant.
143
159
  *
144
160
  * It deliberately says nothing about the MCP server. The hook cannot know
145
161
  * whether one is configured — `wire` writes `.claude/settings.json` and MCP
@@ -167,6 +183,15 @@ export function decide(config, role, event, { observe = false, now = append, ask
167
183
  const file = logPath(config.root);
168
184
  const queue = requestsPath(config.root);
169
185
  const verdicts = [];
186
+ // How many times the first refusal here was given BEFORE this attempt. Read
187
+ // before the entry is written, because where the entry goes decides whether
188
+ // a read afterwards sees it: to the file outside `seisin run`, but to the
189
+ // parent's socket inside it — and that line is not on disk until the parent
190
+ // gets to it, so counting afterwards came out one behind there.
191
+ let before = null;
192
+ const priorOnce = (action, target) => {
193
+ if (before === null) before = timesHit(file, role, action, target);
194
+ };
170
195
 
171
196
  for (const t of targets) {
172
197
  // A tool call, resolved to its MCP server. "Whose is it" is the question
@@ -179,6 +204,7 @@ export function decide(config, role, event, { observe = false, now = append, ask
179
204
  const v = t.server
180
205
  ? explain(config, role, "mcp", t.server)
181
206
  : { allowed: false, owners: [], mcp: true, reason: `${t.tool} is not a recognisable mcp__<server>__<tool> name` };
207
+ if (!observe && !v.allowed) priorOnce("use", t.tool);
182
208
  now(file, {
183
209
  role, tool: event.tool_name, action: "use", kind: "tool", target: t.tool,
184
210
  verdict: observe ? "observed" : v.allowed ? "allowed" : "denied",
@@ -202,6 +228,7 @@ export function decide(config, role, event, { observe = false, now = append, ask
202
228
  if (t.action === "read" && kind !== "key") continue;
203
229
 
204
230
  const v = explain(config, role, kind === "key" ? "read" : "write", rel);
231
+ if (!observe && !v.allowed) priorOnce(kind === "key" ? "read" : t.action, rel);
205
232
 
206
233
  now(file, {
207
234
  role,
@@ -215,13 +242,15 @@ export function decide(config, role, event, { observe = false, now = append, ask
215
242
  });
216
243
  // A denial already carries everything a request needs, so leave one behind:
217
244
  // the refusal stops being a dead end and becomes something a person can act
218
- // on in one command. Observing records nothing to approve — there was no
245
+ // on in one command. Observing records nothing to grant — there was no
219
246
  // denial to answer. See docs/permission-requests.md.
220
247
  //
221
248
  // Except a `never_writes` refusal. That one is the policy saying no on
222
249
  // purpose; a request for it asks a person to undo a subtraction somebody
223
250
  // wrote, and approving it is how the subtraction disappears unnoticed.
224
- if (!observe && !v.allowed && !v.neverWrites && !v.gitMetadata)
251
+ // Nor a protected file: no grant opens one (the kernel keeps refusing it),
252
+ // which is why `seisin run` never queued those either (intake.js).
253
+ if (!observe && !v.allowed && !v.neverWrites && !v.gitMetadata && !v.protected)
225
254
  ask(queue, { role, action: kind === "key" ? "read" : t.action, target: rel, owners: v.owners ?? [] });
226
255
 
227
256
  // The action as it was RECORDED, not as the tool named it: a read of a key
@@ -242,19 +271,20 @@ export function decide(config, role, event, { observe = false, now = append, ask
242
271
  * How many times this exact denial has already been handed to this role.
243
272
  *
244
273
  * The sentence below is correct every time and that has not been enough:
245
- * measured on a real team, 25% of all denials were a repeat of something the
246
- * same role had already been refused, one of them nineteen times. Correct
274
+ * measured on a multi-repo workspace, 25% of all denials were a repeat of something the
275
+ * same role had already been denied, one of them nineteen times. Correct
247
276
  * and *heard* are different properties, and only the first was being
248
- * measured. So the refusal carries its own history: the second time, it says
277
+ * measured. So the denial carries its own history: the second time, it says
249
278
  * it is the second time.
250
279
  *
251
- * Counted from the log that was just written, so the count includes this
252
- * attempt. Silent on the first — a counter that says "1×" on every first
253
- * refusal is noise on the turn where the sentence is already doing its job.
280
+ * The earlier refusals, read before this one was recorded, plus this one —
281
+ * the same number whether the entry went to the file or to the socket. Silent
282
+ * on the first — a counter that says "1×" on every first refusal is noise on
283
+ * the turn where the sentence is already doing its job.
254
284
  */
255
- const before = timesHit(logPath(config.root), role, denied.action, denied.target);
256
- const again = before > 1
257
- ? ` You have been denied this ${before} times now; it is not going to work on the ${ordinal(before + 1)} try.`
285
+ const times = (before ?? 0) + 1;
286
+ const again = times > 1
287
+ ? ` You have been denied this ${times} times now; it is not going to work on the ${ordinal(times + 1)} try.`
258
288
  : "";
259
289
 
260
290
  // The hook advises; it does not enforce. Returning `deny` here stops the call
@@ -271,10 +301,10 @@ export function decide(config, role, event, { observe = false, now = append, ask
271
301
  // told "no" retries; an agent told whose it is asks, or moves on.
272
302
  denied.neverWrites
273
303
  ? `${denied.reason}. Do not ask for it: nothing was queued, because the answer is already written down.` + again
274
- : denied.gitMetadata
304
+ : denied.gitMetadata || denied.protected
275
305
  ? `${denied.reason}. Nothing was queued.` + again
276
306
  : denied.mcp
277
- ? `${denied.reason}. Nothing was queued: which MCP servers a role may load is set in its mcp list, a policy change a person makes, not a grant they approve.` + again
307
+ ? `${denied.reason}. Nothing was queued: which MCP servers a role may load is set in its mcp list, a policy change a person makes, not something they grant from the queue.` + again
278
308
  : (denied.owners?.length
279
309
  ? wasRead
280
310
  ? `${denied.target} is declared for ${denied.owners.join(", ")}, not ${role}. Ask for what you need from it rather than reading the key.`
package/src/index.js CHANGED
@@ -18,7 +18,7 @@ export { findConfig, loadConfig, parseToml } from "./config.js";
18
18
  /** Where seisin's own files live. */
19
19
  export { CONFIG_NAME, STATE_DIR, LOG_NAME } from "./layout.js";
20
20
 
21
- /** Who owns a path, and the sentence to hand a blocked agent. */
21
+ /** Who owns a path, and the sentence to hand a denied agent. */
22
22
  export { covers, ownersOf, keyHolders, explain } from "./owners.js";
23
23
 
24
24
  /** A policy, resolved into sandbox settings and a child environment. */
@@ -34,7 +34,7 @@ export { buildEnv, BASE as BASE_ENV, DEFAULTS as DEFAULT_ENV } from "./env.js";
34
34
  */
35
35
  export { parseKey, defaultName, entriesOf, MODES, RESERVED_ENV } from "./keys.js";
36
36
 
37
- /** What a role keeps being refused, recomputed against the policy as it stands. */
37
+ /** What a role keeps being denied, recomputed against the policy as it stands. */
38
38
  export { walls, wasted, MIN_HITS } from "./walls.js";
39
39
 
40
40
  /** The report `check` prints, without the printing. */
@@ -47,11 +47,11 @@ export { scan, SHAPES, DEFAULT_IGNORE } from "./scan.js";
47
47
  export { read as readLog, logPath, observed, generalise } from "./log.js";
48
48
 
49
49
  /**
50
- * The queue of refusals waiting on a person.
50
+ * The queue of requests waiting on a person.
51
51
  *
52
52
  * `pending` and `grantFor` are public because reading the queue and drafting a
53
53
  * proposal are things a caller — including an MCP server — legitimately does.
54
- * `settle` and `applyGrant` are NOT exported: approving is not a tool call, and
54
+ * `settle` and `applyGrant` are NOT exported: granting is not a tool call, and
55
55
  * the surface should make that hard to get wrong rather than merely document
56
56
  * it. See docs/permission-requests.md.
57
57
  */
package/src/inspect.js CHANGED
@@ -8,14 +8,16 @@
8
8
  * does — each one is a way the policy silently does not hold — so they belong
9
9
  * where they can be exercised directly.
10
10
  */
11
+ import { unknownRole } from "./suggest.js";
11
12
  import { resolve, dirname, basename, relative, join, delimiter } from "node:path";
12
- import { realpathSync, lstatSync, readdirSync, existsSync, readFileSync, statSync } from "node:fs";
13
+ import { lstatSync, readdirSync, existsSync, readFileSync, statSync } from "node:fs";
13
14
  import { ownersOf, covers, enforcedNeverWrites } from "./owners.js";
14
- import { ROLE_KEYS } from "./config.js";
15
+ import { ROLE_KEYS, closest } from "./config.js";
15
16
  import { entriesOf } from "./keys.js";
16
17
  import { SHAPES } from "./scan.js";
17
- import { wired } from "./commands/wire.js";
18
+ import { wired, broadlyWired } from "./commands/wire.js";
18
19
  import { RUNTIME_WRITES, CREDENTIAL_HOMES, expand, settingsFor } from "./srt.js";
20
+ import { realOrSelf } from "./grants.js";
19
21
  import { protections, resolveExecutable } from "./surface.js";
20
22
 
21
23
  /**
@@ -26,8 +28,11 @@ import { protections, resolveExecutable } from "./surface.js";
26
28
  * answering it with silence is how a typo becomes a belief.
27
29
  */
28
30
  export function inspect(config, only = null, where = config.path) {
29
- if (only && !config.roles[only]) throw new Error(`unknown role "${only}"`);
31
+ if (only && !config.roles[only]) throw unknownRole(config, only);
30
32
  const roles = only ? [config.roles[only]] : Object.values(config.roles);
33
+ // Once: it is listed on its own and again as a warning, and it is the most
34
+ // expensive thing check computes.
35
+ const shared = sharedPaths(config, roles);
31
36
 
32
37
  return {
33
38
  where,
@@ -43,16 +48,16 @@ export function inspect(config, only = null, where = config.path) {
43
48
  providers: Object.values(config.keyProviders ?? {}).map((p) => ({
44
49
  name: p.name, command: p.command, mode: p.mode,
45
50
  })),
46
- shared: sharedPaths(config, roles),
51
+ shared,
47
52
  /**
48
- * What the kernel will refuse a role inside its own territory, and why:
53
+ * What the kernel will deny a role inside its own territory, and why:
49
54
  * programs installed where a role writes, the hooks and settings of each
50
55
  * project, provider scripts and `file://` targets. Each takes something
51
56
  * away from a territory as written, so it is shown rather than applied
52
57
  * quietly. See surface.js for the family and the measurements.
53
58
  */
54
59
  protected: protections(config, roles),
55
- warnings: warningsFor(config, roles),
60
+ warnings: warningsFor(config, roles, shared),
56
61
  limits: LIMITS,
57
62
  };
58
63
  }
@@ -61,8 +66,8 @@ export function inspect(config, only = null, where = config.path) {
61
66
  * What the tool cannot do at all — as opposed to `warnings`, which are things
62
67
  * wrong with *this* config.
63
68
  *
64
- * Kept apart on purpose. A limit printed as a warning reads as something the
65
- * operator could fix, and after the third run it reads as nothing at all.
69
+ * Kept apart on purpose. A limit printed as a warning reads as something a
70
+ * person could fix, and after the third run it reads as nothing at all.
66
71
  *
67
72
  * These are the sentences someone needs before they trust the map on screen
68
73
  * more than it deserves, so `check` prints them every time rather than behind a
@@ -72,11 +77,11 @@ export function inspect(config, only = null, where = config.path) {
72
77
  const LIMITS = [
73
78
  ...(process.platform === "darwin" ? [] : [{
74
79
  kind: "kernel-denials-unreadable",
75
- headline: "refusals by the kernel are not recorded on this platform",
80
+ headline: "denials by the kernel are not recorded on this platform",
76
81
  detail:
77
82
  "on macOS `seisin run` reads them out of the system log, so a denial the hook never " +
78
83
  "saw still lands in `seisin log` with its owner and still queues a request. There is no " +
79
- "equivalent to read here — bubblewrap does not log refusals, and the runtime's substitute " +
84
+ "equivalent to read here — bubblewrap does not log denials, and the runtime's substitute " +
80
85
  "is not reachable from outside it. The boundary holds exactly as well; what you lose is " +
81
86
  "seeing it work. Run `seisin wire` so the hook records what it can. The ask is upstream: " +
82
87
  "github.com/carlostapiacl/seisin/blob/main/docs/upstream/cli-violations.md",
@@ -95,7 +100,7 @@ const LIMITS = [
95
100
  headline: "`--observe` opens the filesystem, never the network",
96
101
  detail:
97
102
  "so it cannot tell you which domains an agent needs. The runtime does record every " +
98
- "refusal the proxy makes — host, port and reason — but only a caller that embeds the " +
103
+ "denial the proxy makes — host, port and reason — but only a caller that embeds the " +
99
104
  "library can read them; nothing reaches a caller that runs the binary, which is what " +
100
105
  "seisin does. So for an agent whose endpoints are not published, the options are to " +
101
106
  "find them another way or drop the network restriction for it. The ask is upstream: " +
@@ -108,14 +113,14 @@ const LIMITS = [
108
113
  "bubblewrap denies a path by mounting over it, and for a path that is not there it would " +
109
114
  "create it on your disk. So on Linux a role can still create a .git/hooks entry or a " +
110
115
  ".claude/settings.json in a project of its territory that had none. On macOS the kernel " +
111
- "refuses creating them too.",
116
+ "denies creating them too.",
112
117
  }] : []),
113
118
  {
114
119
  kind: "shared-scratch",
115
120
  headline: "roles share ~/.cache, ~/.local/share and the temp dir unless isolate = \"home\"",
116
121
  detail:
117
122
  "programs installed there that are on your PATH are protected, and so are Claude Code's " +
118
- "settings and plugins. Anything else a program outside the box reads from those places — a " +
123
+ "settings and plugins. Anything else a program outside the sandbox reads from those places — a " +
119
124
  "cache it executes, a tool not on PATH — is writable by every role. isolate = \"home\" " +
120
125
  "gives each role its own.",
121
126
  },
@@ -137,18 +142,18 @@ const LIMITS = [
137
142
  * Two things, both about `never_writes` and both ways a subtraction can look
138
143
  * written and not be:
139
144
  *
140
- * - a key seisin does not know. It used to be dropped without a word, which
145
+ * - a setting seisin does not know. It used to be dropped without a word, which
141
146
  * for `writes` is a role with less than it thinks, and for a misspelt
142
147
  * `never_writes` is a role with MORE than its owner thinks — the one
143
- * direction a permission file must not fail in. Named with the nearest
144
- * known key when there is one close enough to be the intended spelling.
148
+ * direction a policy must not fail in. Named with the nearest
149
+ * known setting when there is one close enough to be the intended spelling.
145
150
  * - a `never_writes` entry that no `writes` of the same role covers. It
146
151
  * subtracts from nothing, so it is either a typo in the path or a rule
147
152
  * that stopped applying when the territory moved. Either way it reads as
148
153
  * protection and is not.
149
154
  *
150
155
  * Silent when `never_writes` is absent. Absent is valid; a check that asks for
151
- * the key would turn an optional subtraction into a required migration.
156
+ * the setting would turn an optional subtraction into a required migration.
152
157
  */
153
158
  function roleKeyWarnings(roles, config = null) {
154
159
  const warnings = [];
@@ -167,14 +172,27 @@ function roleKeyWarnings(roles, config = null) {
167
172
  const near = nearest(k, ROLE_KEYS);
168
173
  warnings.push({
169
174
  kind: "unknown-role-key",
170
- headline: `${r.name}: unknown key "${k}" in [roles.${r.name}] — ignored` +
175
+ headline: `${r.name}: unknown setting "${k}" in [roles.${r.name}] — ignored` +
171
176
  (near ? `. Did you mean "${near}"?` : ""),
172
- detail: `Known keys: ${ROLE_KEYS.join(", ")}. ` +
177
+ detail: `Known settings: ${ROLE_KEYS.join(", ")}. ` +
173
178
  (near === "never_writes"
174
179
  ? "As written this subtracts nothing: the role can still write every path it lists."
175
180
  : "Nothing in it takes effect."),
176
181
  });
177
182
  }
183
+ // A family handed to a role is a hole in the protection on purpose, and
184
+ // said on every check so it stays a decision somebody can see.
185
+ for (const f of r.controlFiles ?? [])
186
+ warnings.push({
187
+ kind: "control-files-handed",
188
+ headline: f === "ide"
189
+ ? `${r.name}: control_files = ["ide"] — may edit .vscode/, .cursor/ and .windsurf/ in its territory`
190
+ : `${r.name}: control_files = ["instructions"] — may edit CLAUDE.md, AGENTS.md and their kin in its territory`,
191
+ detail: f === "ide"
192
+ ? "Your editor applies what it writes there — tasks, settings, auto-approval, MCP servers — outside the sandbox. " +
193
+ "Hand it only to a role that maintains those files, and review its changes to them."
194
+ : "The next agent session reads what it writes there as its instructions, whichever role that session is.",
195
+ });
178
196
  // macOS only, and not by omission: on Linux the runtime removes the network
179
197
  // namespace, so every role already has a loopback of its own — it can serve
180
198
  // and reach its own server with or without the key, and reaches nothing on
@@ -227,25 +245,7 @@ function nearest(word, candidates) {
227
245
  // letters mislead here: `no_writes` is three edits from `writes`, and
228
246
  // suggesting the grant for a misspelt subtraction is the worst answer.
229
247
  if (/deny|never|no_?write/i.test(word)) return "never_writes";
230
- let best = null, bestD = 4;
231
- for (const c of candidates) {
232
- const d = distance(word.toLowerCase(), c);
233
- if (d < bestD) { best = c; bestD = d; }
234
- }
235
- return best;
236
- }
237
-
238
- function distance(a, b) {
239
- const row = Array.from({ length: b.length + 1 }, (_, i) => i);
240
- for (let i = 1; i <= a.length; i++) {
241
- let prev = row[0]; row[0] = i;
242
- for (let j = 1; j <= b.length; j++) {
243
- const cur = row[j];
244
- row[j] = Math.min(row[j] + 1, row[j - 1] + 1, prev + (a[i - 1] === b[j - 1] ? 0 : 1));
245
- prev = cur;
246
- }
247
- }
248
- return row[b.length];
248
+ return closest(word, candidates);
249
249
  }
250
250
 
251
251
  /**
@@ -256,10 +256,17 @@ function distance(a, b) {
256
256
  * that it stays a decision somebody made rather than a thing that drifted.
257
257
  */
258
258
  export function sharedPaths(config, roles = Object.values(config.roles)) {
259
+ // Asked once per distinct glob: a real policy repeats them (1,332 writes,
260
+ // 303 distinct in a multi-repo workspace), and each question is every glob of every
261
+ // role again.
259
262
  const seen = new Set();
263
+ const asked = new Map();
260
264
  for (const r of roles)
261
- for (const glob of r.writes)
262
- if (ownersOf(config, glob.replace(/\/\*\*$/, "")).length > 1) seen.add(glob);
265
+ for (const glob of r.writes) {
266
+ let many = asked.get(glob);
267
+ if (many === undefined) asked.set(glob, (many = ownersOf(config, glob.replace(/\/\*\*$/, "")).length > 1));
268
+ if (many) seen.add(glob);
269
+ }
263
270
  /**
264
271
  * A shared database counts once, not four times.
265
272
  *
@@ -305,7 +312,7 @@ function homeReachWarning(config) {
305
312
  `isolate = "credentials" denies ${where} and the rest. What stays open is ` +
306
313
  "~/.claude and ~/.codex, which is what keeps the agent logged in, so the roles " +
307
314
  'share one session and can read each other. `isolate = "home"` closes those ' +
308
- "too, and then every CLI in the box will ask you to log in again.",
315
+ "too, and then every CLI in the sandbox will ask you to log in again.",
309
316
  }
310
317
  : {
311
318
  kind: "home-open",
@@ -318,7 +325,6 @@ function homeReachWarning(config) {
318
325
  }
319
326
 
320
327
 
321
- /** Every way this policy does not hold, each with what to do about it. */
322
328
  /**
323
329
  * Credential-shaped names at the top of the repository, and nothing deeper.
324
330
  *
@@ -364,19 +370,28 @@ function credentialish(root) {
364
370
  return [...found].sort().slice(0, 4);
365
371
  }
366
372
 
367
- /** Is this a command the shell would find? Walks PATH; asks no shell. */
368
- function onPath(cmd) {
369
- if (cmd.includes("/")) return existsSync(cmd);
370
- const dirs = (process.env.PATH ?? "").split(delimiter).filter(Boolean);
371
- return dirs.some((d) => existsSync(join(d, cmd)));
373
+ /**
374
+ * Would the parent find this command? The same lookup `run` makes
375
+ * (resolveExecutable), so check does not call "missing" a program that is
376
+ * only in a directory a role writes — that has its own warning, above.
377
+ */
378
+ function onPath(cmd, config) {
379
+ try {
380
+ // A path is resolved against the policy and not looked up, so it is there
381
+ // only if it exists.
382
+ return existsSync(resolveExecutable(cmd, config));
383
+ } catch (e) {
384
+ return /a role can write/.test(e.message);
385
+ }
372
386
  }
373
387
 
374
- function warningsFor(config, roles) {
388
+ /** Every way this policy does not hold, each with what to do about it. */
389
+ function warningsFor(config, roles, shared = sharedPaths(config, roles)) {
375
390
  const warnings = [];
376
391
 
377
392
  // A pattern the kernel cannot be given is not a policy, it is a sentence that
378
393
  // reads like one. `run` refuses it; `check` exists precisely so you find that
379
- // out while reading the map rather than mid-turn.
394
+ // out while reading the map rather than mid-run.
380
395
  for (const r of roles) {
381
396
  try {
382
397
  settingsFor(config, r.name);
@@ -411,17 +426,15 @@ function warningsFor(config, roles) {
411
426
 
412
427
  // PATH entries that are not absolute resolve against wherever the parent is
413
428
  // standing, which is the repo. They are ignored for every lookup seisin makes
414
- // outside the box; said here because a lookup that skips them can find a
429
+ // outside the sandbox; said here because a lookup that skips them can find a
415
430
  // different program than your shell does.
416
431
  const unanchored = (process.env.PATH ?? "").split(delimiter).filter((d) => !d || !d.startsWith("/"));
417
432
  if (unanchored.length)
418
433
  warnings.push({
419
434
  kind: "relative-path",
420
435
  headline: `PATH has ${unanchored.length} relative entr${unanchored.length === 1 ? "y" : "ies"} (${unanchored.map((d) => JSON.stringify(d)).join(", ")})`,
421
- detail: "They resolve inside the repo, where roles write, so seisin skips them when it looks for a program to run outside the box.",
436
+ detail: "They resolve inside the repo, where roles write, so seisin skips them when it looks for a program to run outside the sandbox.",
422
437
  });
423
- const shared = sharedPaths(config, roles);
424
-
425
438
  if (shared.length)
426
439
  warnings.push({
427
440
  kind: "shared",
@@ -484,7 +497,7 @@ function warningsFor(config, roles) {
484
497
  }
485
498
 
486
499
  // Without the hook, half the record is missing rather than all of it: on macOS
487
- // `seisin run` reads the kernel's own refusals, so denials still land and
500
+ // `seisin run` reads the kernel's own denials, so they still land and
488
501
  // still queue a request. What goes unrecorded is everything the kernel
489
502
  // allowed — which is the half `init --from-observations` is built out of, and
490
503
  // the half that tells "nothing was denied" apart from "nobody was watching".
@@ -498,16 +511,26 @@ function warningsFor(config, roles) {
498
511
  headline: "only denials are being recorded: the agent has not been told to run the hook",
499
512
  detail:
500
513
  process.platform === "darwin"
501
- ? "the boundary holds either way, and refusals are still logged and still queue a " +
514
+ ? "the boundary holds either way, and denials are still logged and still queue a " +
502
515
  "request — `seisin run` reads them from the kernel. What is missing is every " +
503
- "action that was allowed, so `review` cannot tell you which grants are dead and " +
516
+ "action that was allowed, so `review` cannot tell you which grants are unused and " +
504
517
  "`init --from-observations` has nothing to build from. Run `seisin wire` once in " +
505
518
  "this repo."
506
- : "the boundary holds either way — but on this platform the kernel's refusals are " +
519
+ : "the boundary holds either way — but on this platform the kernel's denials are " +
507
520
  "not readable either, so log, watch, requests, grant and review all read a record " +
508
521
  "that nobody is writing. Run `seisin wire` once in this repo.",
509
522
  });
510
523
 
524
+ else if (broadlyWired(config.root))
525
+ warnings.push({
526
+ kind: "hook-matcher-broad",
527
+ headline: "the hook runs on every tool call: it was wired before 0.5.0 with the matcher \"*\"",
528
+ detail:
529
+ "nothing is lost — every tool the hook reads is still covered — but each Read, Glob " +
530
+ "and TodoWrite starts a process that has nothing to say. Run `seisin wire` again: it " +
531
+ "narrows seisin's own entry in place and leaves everyone else's hooks alone.",
532
+ });
533
+
511
534
  // A territory that leaves the repo is supported — a role can own its handover
512
535
  // note one level up — but it is a different promise from "this repo, divided",
513
536
  // and it should be a sentence somebody chose rather than a line that drifted.
@@ -524,10 +547,63 @@ function warningsFor(config, roles) {
524
547
  });
525
548
  }
526
549
 
550
+ /**
551
+ * A territory that is not on disk.
552
+ *
553
+ * A proposal written for another repo — or one typo — reads as a working
554
+ * policy: `check` printed the map and the first sign of anything wrong was a
555
+ * denial inside the agent. Said per glob, as a warning and never a failure:
556
+ * a role can own a folder it is about to create.
557
+ */
558
+ for (const r of roles) {
559
+ for (const g of r.writes) {
560
+ if (g === "**" || g.startsWith("/") || g.startsWith("../")) continue;
561
+ const fixed = [];
562
+ for (const part of g.split("/")) {
563
+ if (/[*?[\]]/.test(part)) break;
564
+ fixed.push(part);
565
+ }
566
+ if (!fixed.length || existsSync(join(config.root, ...fixed))) continue;
567
+ warnings.push({
568
+ kind: "writes-match-nothing",
569
+ headline: `${r.name} writes ${g} — matches nothing in this repo`,
570
+ detail: "fine if the role is about to create it; otherwise the territory points at a folder that is not there.",
571
+ });
572
+ }
573
+ }
574
+
575
+ /**
576
+ * A territory inside a key directory.
577
+ *
578
+ * Seen in use: a role's whole territory sat under the folder declared as
579
+ * `[keys] dir`, which is closed to every role, so the role could write
580
+ * nothing at all — and `check` printed its territory as if it held. A key
581
+ * directory is read by the roles that declare a key in it and written by
582
+ * none; a folder a role must write belongs somewhere else.
583
+ */
584
+ const keyRoots = (config.keyDirs ?? []).map((d) => d.replace(/\/+$/, "")).filter(Boolean);
585
+ for (const r of roles) {
586
+ for (const g of r.writes) {
587
+ const fixed = [];
588
+ for (const part of g.split("/")) {
589
+ if (/[*?[\]]/.test(part)) break;
590
+ fixed.push(part);
591
+ }
592
+ const at = fixed.join("/");
593
+ const dir = at && keyRoots.find((d) => at === d || at.startsWith(d + "/"));
594
+ if (!dir) continue;
595
+ warnings.push({
596
+ kind: "territory-in-key-dir",
597
+ headline: `${r.name} writes ${g} — inside the key directory ${dir}, which no role can write`,
598
+ detail: "a key directory is read by the roles that declare its keys and written by none. Move what this role writes out of it, or drop the territory.",
599
+ });
600
+ }
601
+ }
602
+
527
603
  /**
528
604
  * A file granted by name does not come with its neighbours.
529
605
  *
530
- * Measured, in production, and it survived two human reviews of the policy:
606
+ * Measured, in production, and it survived two reviews of the policy by people:
531
607
  * a role was granted its database file and still could not write the
532
608
  * database. The database opens a second file beside the first — a journal —
533
609
  * and the sibling was outside the grant, so the write failed in the
@@ -584,7 +660,6 @@ function warningsFor(config, roles) {
584
660
  const unnamed = onDisk.filter((n) => !named.includes(n));
585
661
  if (!unnamed.length) continue;
586
662
  const shown = relative(resolve(config.root), dir);
587
- const more = unnamed.length > 8 ? ` … and ${unnamed.length - 8} more` : "";
588
663
  listed.push({ role: r.name, named: named.length, total: onDisk.length,
589
664
  where: shown ? shown + "/" : "the repo root", unnamed });
590
665
 
@@ -719,14 +794,14 @@ function warningsFor(config, roles) {
719
794
  for (const k of entriesOf(r)) if (k.kind === "ref") used.add(k.scheme);
720
795
  for (const scheme of [...used].sort()) {
721
796
  const provider = config.keyProviders?.[scheme];
722
- if (!provider || onPath(provider.command[0])) continue;
797
+ if (!provider || onPath(provider.command[0], config)) continue;
723
798
  warnings.push({
724
799
  kind: "provider-missing",
725
800
  headline: `the ${scheme} provider runs "${provider.command[0]}", which is not on PATH`,
726
801
  detail:
727
802
  "every key of that scheme will stop the run rather than resolve. Nothing is " +
728
803
  "substituted for a key that did not resolve, so this fails closed — but it fails " +
729
- "at the start of a turn, not here.",
804
+ "at the start of a run, not here.",
730
805
  });
731
806
  }
732
807
 
@@ -789,12 +864,3 @@ function lstatOrNull(p) {
789
864
  return null;
790
865
  }
791
866
  }
792
-
793
- /** The path with symlinks followed, or the path itself if it is not on disk. */
794
- function realOrSelf(p) {
795
- try {
796
- return realpathSync(p);
797
- } catch {
798
- return p;
799
- }
800
- }