seisin 0.2.0 → 0.4.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.
package/src/mcp.js CHANGED
@@ -26,11 +26,12 @@
26
26
  * The cost of this choice is written down under PROTOCOLS below: we track the
27
27
  * spec by hand, and the spec moves.
28
28
  */
29
+ import { toRepoRelative } from "./paths.js";
29
30
  import { loadConfig, findConfig } from "./config.js";
30
31
  import { inspect } from "./inspect.js";
31
32
  import { explain, ownersOf } from "./owners.js";
32
33
  import { settingsFor } from "./srt.js";
33
- import { pending, requestsPath, grantFor } from "./requests.js";
34
+ import { pending, requestsPath, grantFor, refuseIfBarred, markStale } from "./requests.js";
34
35
  import { read, logPath } from "./log.js";
35
36
  import { causesOf } from "./serve.js";
36
37
  import { walls, wasted } from "./walls.js";
@@ -62,14 +63,14 @@ const TOOLS = [
62
63
  {
63
64
  name: "seisin_explain",
64
65
  description:
65
- "Whether a role may read or write a path, and whose it is if not. " +
66
- "Use this before suggesting an agent touch a file. Read-only.",
66
+ "Whether a role may read or write a path, and whose it is if not; or, with action mcp, " +
67
+ "whether it may load an MCP server. Use this before suggesting an agent touch a file. Read-only.",
67
68
  inputSchema: {
68
69
  type: "object",
69
70
  properties: {
70
71
  role: { type: "string" },
71
- action: { type: "string", enum: ["read", "write"] },
72
- target: { type: "string", description: "path, or key filename for a read" },
72
+ action: { type: "string", enum: ["read", "write", "mcp"] },
73
+ target: { type: "string", description: "path; key filename for a read; server name for mcp" },
73
74
  },
74
75
  required: ["role", "action", "target"],
75
76
  },
@@ -163,24 +164,27 @@ const HANDLERS = {
163
164
  // that was not "read" fell into the write branch — including a typo, which
164
165
  // would answer the wrong question confidently. A declared schema that is
165
166
  // not enforced is documentation.
166
- if (action !== "read" && action !== "write")
167
- throw new Error(`action must be "read" or "write", got ${JSON.stringify(action)}`);
167
+ if (action !== "read" && action !== "write" && action !== "mcp")
168
+ throw new Error(`action must be "read", "write" or "mcp", got ${JSON.stringify(action)}`);
168
169
  if (typeof target !== "string" || !target)
169
170
  throw new Error("target must be a non-empty path");
170
171
  const cfg = config();
171
172
  if (!cfg.roles[role])
172
173
  return { error: `unknown role "${role}"`, known: Object.keys(cfg.roles) };
173
- const verdict = explain(cfg, role, action, target);
174
- return { ...verdict, alsoOwnedBy: action === "write" ? ownersOf(cfg, target) : undefined };
174
+ if (action === "mcp") return explain(cfg, role, "mcp", target);
175
+ const rel = toRepoRelative(cfg, target);
176
+ const verdict = explain(cfg, role, action, rel);
177
+ return { ...verdict, alsoOwnedBy: action === "write" ? ownersOf(cfg, rel) : undefined };
175
178
  },
176
179
 
177
180
  seisin_requests() {
178
181
  const cfg = config();
179
- const queue = pending(requestsPath(cfg.root));
182
+ const queue = markStale(pending(requestsPath(cfg.root)), read(logPath(cfg.root)));
180
183
  return {
181
184
  pending: queue.map((r, i) => ({
182
185
  number: i + 1, role: r.role, action: r.action,
183
186
  wants: r.grant, ownedBy: r.owners, asked: r.times, lastAsked: r.last,
187
+ ...(r.stale ? { stale: `not asked again in ${r.stale.runs} runs of ${r.role} since` } : {}),
184
188
  })),
185
189
  // Said in the payload and not only in the tool description, because a
186
190
  // model reading this is deciding what to do next.
@@ -240,6 +244,20 @@ const HANDLERS = {
240
244
  const req = pending(requestsPath(cfg.root))[Number(number) - 1];
241
245
  if (!req) throw new Error(`no pending request #${number}`);
242
246
 
247
+ // The same refusal `seisin grant` and the console apply, asked here first.
248
+ // Without it this drafted, as approvable, a request that `never_writes`
249
+ // cancels — and `grant` then refused what the draft had offered.
250
+ try {
251
+ refuseIfBarred(cfg, req);
252
+ } catch (e) {
253
+ return {
254
+ barred: e.message,
255
+ ownedBy: req.owners,
256
+ applyWith: null,
257
+ note: "Not approvable as it stands: never_writes wins over writes. Decline it with " +
258
+ `\`seisin decline ${number}\`, or remove the never_writes entry if the subtraction is wrong.`,
259
+ };
260
+ }
243
261
  const field = req.action === "read" ? "keys" : "writes";
244
262
  return {
245
263
  wouldAdd: { role: req.role, field, value: req.grant },
package/src/notify.js ADDED
@@ -0,0 +1,146 @@
1
+ /**
2
+ * Tell a person when a request is filed.
3
+ *
4
+ * The queue is where a refusal becomes a handoff, and a queue only works if
5
+ * somebody looks at it. It was asynchronous with nothing to say it had moved:
6
+ * the handoff existed and the person did not know. This sends one message per
7
+ * new request — the role, the path, whose it is, and the command that answers
8
+ * it — to wherever the operator reads things (ntfy, Slack, a webhook).
9
+ *
10
+ * Approving is still a person at a terminal. This only notifies; it does not
11
+ * accept an answer, so it changes nothing about who decides. (Accepting one —
12
+ * nono's `approval-webhook-v1` — would, and is a separate decision.)
13
+ *
14
+ * Two rules, both from the review that found a role approving its own request
15
+ * through the console:
16
+ * - the POST leaves from the parent, never from the role: the role's process
17
+ * never holds the URL, and `SEISIN_NOTIFY_URL` is dropped from its
18
+ * environment even if the role names it;
19
+ * - the URL is not readable from any territory. A ntfy topic or a webhook is
20
+ * a credential — whoever can read it can send the person a fake "approve
21
+ * #3". So it lives in a key directory no role declares (`url_file`), or in
22
+ * the parent's environment, and never as a plain value in seisin.toml,
23
+ * which every role can read.
24
+ */
25
+ import { readFileSync } from "node:fs";
26
+ import { basename, join } from "node:path";
27
+ import { keyOf, pending, requestsPath, grantFor } from "./requests.js";
28
+
29
+ export const FORMATS = ["text", "json", "slack"];
30
+ export const ENV_URL = "SEISIN_NOTIFY_URL";
31
+
32
+ /** The URL to notify, or null. Read in the parent only. */
33
+ export function notifyUrl(config, env = process.env) {
34
+ if (env[ENV_URL]) return env[ENV_URL].trim();
35
+ if (!config.notify?.urlFile) return null;
36
+ try {
37
+ const url = readFileSync(join(config.root, config.notify.urlFile), "utf8").trim();
38
+ return url || null;
39
+ } catch {
40
+ return null;
41
+ }
42
+ }
43
+
44
+ /** The body for one request, in the chosen format. */
45
+ export function message(config, req, number, format = "text") {
46
+ const owner = req.owners?.length ? `It belongs to ${req.owners.join(", ")}.` : "Nobody owns it.";
47
+ const text =
48
+ `seisin (${basename(config.root)}): ${req.role} was refused ${req.action} on ${req.target}. ${owner} ` +
49
+ `Approve: seisin grant ${number} · decline: seisin decline ${number}`;
50
+ if (format === "slack") return { type: "application/json", body: JSON.stringify({ text }) };
51
+ if (format === "json")
52
+ return {
53
+ type: "application/json",
54
+ body: JSON.stringify({
55
+ text, repo: basename(config.root), number, role: req.role, action: req.action,
56
+ target: req.target, owners: req.owners ?? [], grant: grantFor(req),
57
+ }),
58
+ };
59
+ return { type: "text/plain; charset=utf-8", body: text };
60
+ }
61
+
62
+ /**
63
+ * One per run. `maybe(req)` after a request is recorded; `settle()` before the
64
+ * run exits, so a message still in flight is not cut off.
65
+ *
66
+ * Only the first time a request appears — the queue already counts repeats,
67
+ * and a person pinged on every retry stops reading the pings.
68
+ */
69
+ export function notifier(config, { env = process.env, fetchImpl = globalThis.fetch, timeoutMs = 3000 } = {}) {
70
+ const url = notifyUrl(config, env);
71
+ const format = env.SEISIN_NOTIFY_FORMAT && FORMATS.includes(env.SEISIN_NOTIFY_FORMAT)
72
+ ? env.SEISIN_NOTIFY_FORMAT
73
+ : config.notify?.format ?? "text";
74
+ const file = requestsPath(config.root);
75
+ const known = url ? new Set(pending(file).map((p) => p.key)) : new Set();
76
+ const inflight = [];
77
+ const failures = [];
78
+
79
+ function maybe(req) {
80
+ if (!url) return false;
81
+ const key = keyOf(req);
82
+ if (known.has(key)) return false;
83
+ known.add(key);
84
+ const queue = pending(file);
85
+ const number = queue.findIndex((p) => p.key === key) + 1;
86
+ if (number < 1) return false;
87
+ const { type, body } = message(config, { ...req, owners: req.owners ?? queue[number - 1].owners }, number, format);
88
+ inflight.push(
89
+ fetchImpl(url, {
90
+ method: "POST",
91
+ headers: { "content-type": type },
92
+ body,
93
+ // A redirect would send the request to a place nobody configured.
94
+ redirect: "error",
95
+ signal: AbortSignal.timeout(timeoutMs),
96
+ })
97
+ .then((r) => { if (!r.ok) failures.push(`HTTP ${r.status}`); })
98
+ .catch((e) => failures.push(e.name === "TimeoutError" ? "timed out" : e.message)),
99
+ );
100
+ return true;
101
+ }
102
+
103
+ async function settle(capMs = timeoutMs + 500) {
104
+ if (!inflight.length) return failures;
105
+ await Promise.race([Promise.allSettled(inflight), new Promise((ok) => setTimeout(ok, capMs))]);
106
+ return failures;
107
+ }
108
+
109
+ return { enabled: !!url, maybe, settle };
110
+ }
111
+
112
+ /**
113
+ * `[notify]` from seisin.toml, refused rather than guessed when it would leak.
114
+ * Called by the config loader.
115
+ */
116
+ export function readNotify(table, { path, root, keyDirs, roles, own }) {
117
+ if (table === undefined) return null;
118
+ if (own(table, "url") !== undefined)
119
+ throw new Error(
120
+ `${path}: [notify] url is readable by every role — seisin.toml is not a secret. ` +
121
+ `Put the URL in a file inside a key directory no role declares and name it with url_file, ` +
122
+ `or set ${ENV_URL} in the environment that starts seisin.`);
123
+ const urlFile = own(table, "url_file");
124
+ const format = own(table, "format") ?? "text";
125
+ if (!FORMATS.includes(format))
126
+ throw new Error(`${path}: [notify] format = "${format}" is not one of ${FORMATS.join(", ")}`);
127
+ if (urlFile === undefined) return { urlFile: null, format };
128
+ if (typeof urlFile !== "string" || !urlFile.trim() || urlFile.startsWith("/") || urlFile.split("/").includes(".."))
129
+ throw new Error(`${path}: [notify] url_file must be a path inside the repo`);
130
+ const inKeyDir = keyDirs.some((d) => urlFile.startsWith(d.replace(/\/+$/, "") + "/"));
131
+ if (!inKeyDir)
132
+ throw new Error(
133
+ `${path}: [notify] url_file "${urlFile}" is not inside a key directory, so every role can read ` +
134
+ `it. Move it under ${keyDirs[0] ?? "a [keys] dir"}.`);
135
+ // A file key is written bare (`netlify.txt`, meaning the first key directory)
136
+ // or with its directory (`shared/api.txt`) — the same resolution settingsFor uses.
137
+ const resolve = (raw) => (raw.includes("/") ? raw : join(keyDirs[0] ?? "", raw));
138
+ const holders = Object.entries(roles)
139
+ .filter(([, r]) => r.keyEntries?.some((k) => k.kind === "file" && resolve(k.raw) === urlFile))
140
+ .map(([n]) => n);
141
+ if (holders.length)
142
+ throw new Error(
143
+ `${path}: [notify] url_file "${urlFile}" is declared as a key by ${holders.join(", ")} — ` +
144
+ `that role could read it and send the person a fake "approve". No role may hold it.`);
145
+ return { urlFile, format };
146
+ }
package/src/owners.js CHANGED
@@ -10,6 +10,9 @@
10
10
  * agents have to read each other's code to do anything useful. Keys are the
11
11
  * exception, and they are handled separately below.
12
12
  */
13
+ import { existsSync } from "node:fs";
14
+ import { join } from "node:path";
15
+ import { protectedBy } from "./surface.js";
13
16
 
14
17
  /**
15
18
  * Does this glob cover this path? Supports `**`, `*`, `?` and a trailing `/`.
@@ -37,13 +40,47 @@
37
40
  * kernel is already enforcing it. What was missing was seisin saying so.
38
41
  */
39
42
  export function covers(glob, path) {
40
- const p = normalize(path);
41
- let g = normalize(glob);
42
- if (glob.endsWith("/")) g += "/**";
43
+ const p = normalized(path);
44
+ const m = matcherOf(glob);
43
45
  // The path itself, or anything beneath it. The `/` is load-bearing: without
44
46
  // it `src/api` would also cover `src/apifoo.ts`, which the kernel does not.
45
- if (!WILD.test(g)) return p === g || p.startsWith(g + "/");
46
- return toRegExp(g).test(p);
47
+ if (m.prefix !== null) return p === m.prefix || p.startsWith(m.prefix + "/");
48
+ return m.re.test(p);
49
+ }
50
+
51
+ /**
52
+ * The same glob and the same path, prepared once.
53
+ *
54
+ * `covers` is the inner loop of everything that asks "whose is this": owners of
55
+ * a path is every role's every glob against it, and the console asks that for
56
+ * every path of every role. On a real 32-role policy that was ~1.7M calls per
57
+ * refresh, each normalizing the glob again and compiling its regex again — 8 s
58
+ * of a console that polls, measured with a CPU profile (normalize alone 5 s).
59
+ * The answer never changes for the same string, so it is kept. Bounded: a
60
+ * server that runs for days sees paths from the log, and those keep coming.
61
+ */
62
+ const MAX_CACHED = 50_000;
63
+ const GLOBS = new Map();
64
+ const PATHS = new Map();
65
+
66
+ function matcherOf(glob) {
67
+ let m = GLOBS.get(glob);
68
+ if (m) return m;
69
+ let g = normalize(glob);
70
+ if (glob.endsWith("/")) g += "/**";
71
+ m = WILD.test(g) ? { prefix: null, re: toRegExp(g) } : { prefix: g, re: null };
72
+ if (GLOBS.size >= MAX_CACHED) GLOBS.clear();
73
+ GLOBS.set(glob, m);
74
+ return m;
75
+ }
76
+
77
+ function normalized(path) {
78
+ let p = PATHS.get(path);
79
+ if (p !== undefined) return p;
80
+ p = normalize(path);
81
+ if (PATHS.size >= MAX_CACHED) PATHS.clear();
82
+ PATHS.set(path, p);
83
+ return p;
47
84
  }
48
85
 
49
86
  /** The four characters toRegExp() treats as wildcards. */
@@ -103,13 +140,64 @@ function toRegExp(glob) {
103
140
  return new RegExp("^" + out + "$");
104
141
  }
105
142
 
143
+ /** Is this path inside a `.git` directory (git's metadata, not a working file)? */
144
+ export function isGitMetadata(path) {
145
+ return /(^|\/)\.git\//.test(normalize(path));
146
+ }
147
+
106
148
  /** Every role whose territory covers `path`. Usually one; zero is a finding. */
107
149
  export function ownersOf(config, path) {
150
+ /**
151
+ * An absolute path is outside the repo, or it is made relative first.
152
+ *
153
+ * The kernel reports paths as it meets them, and toRepoRelative leaves the
154
+ * ones outside the repo absolute on purpose. They then met `writes = ["**"]`,
155
+ * which matches any string, so a refused write to ~/.claude/plugins was
156
+ * logged as "owned by dev, wide" — two roles that own nothing outside the
157
+ * repo. Measured on the first run of Claude Code under the new denies.
158
+ */
159
+ if (path.startsWith("/")) {
160
+ const root = config.root?.endsWith("/") ? config.root : `${config.root}/`;
161
+ if (!config.root || !path.startsWith(root)) return [];
162
+ path = path.slice(root.length);
163
+ }
108
164
  return Object.values(config.roles)
109
- .filter((r) => r.writes.some((g) => covers(g, path)))
165
+ .filter((r) => r.writes.some((g) => covers(g, path)) && !neverWrites(r, path, config))
110
166
  .map((r) => r.name);
111
167
  }
112
168
 
169
+ /**
170
+ * The `never_writes` entries the kernel will actually be given for this role.
171
+ *
172
+ * On macOS, all of them. On Linux, only the ones that exist on disk right now,
173
+ * and that is the runtime's shape rather than a choice: bubblewrap denies a
174
+ * path by mounting over it, and to mount over a path that does not exist it
175
+ * creates it on the host — an empty, read-only file that stays there while the
176
+ * role runs and forever if the process is killed. For `.git/index.lock`, the
177
+ * case the key exists for, that locks every other user of the repo out.
178
+ * Measured in Docker (Debian 12, bwrap 0.8.0) on 2026-09-23: present during the
179
+ * run, gone after a clean exit, still there after SIGKILL.
180
+ *
181
+ * `explain` asks this same function, so the sentence and the kernel agree: a
182
+ * document stricter than the boundary is the direction this project refuses.
183
+ * `check` names the entries that are not enforced.
184
+ */
185
+ export function enforcedNeverWrites(config, role) {
186
+ const all = role?.neverWrites ?? [];
187
+ if (process.platform !== "linux" || !config?.root) return all;
188
+ return all.filter((g) => existsSync(join(config.root, g.replace(/\/\*\*$/, ""))));
189
+ }
190
+
191
+ /**
192
+ * The `never_writes` entry of `role` that covers `path`, or null.
193
+ *
194
+ * `?? []` because a caller embedding the library builds role objects itself,
195
+ * and one built before the key existed must keep meaning what it meant.
196
+ */
197
+ export function neverWrites(role, path, config = null) {
198
+ return enforcedNeverWrites(config, role).find((g) => covers(g, path)) ?? null;
199
+ }
200
+
113
201
  /** Every role allowed to read `key`, matched with or without its extension. */
114
202
  export function keyHolders(config, key) {
115
203
  const dirs = config.keyDirs ?? [];
@@ -149,7 +237,72 @@ export function keyHolders(config, key) {
149
237
  * not a permission problem, it is a hole in the map. Saying so is more useful
150
238
  * than denying quietly, and it is the only way the hole ever gets fixed.
151
239
  */
240
+ /**
241
+ * A connection the kernel refused: `tcp:<port>` or a unix socket path.
242
+ *
243
+ * The kernel does not say which host — its line is `remote:*:<port>` — so the
244
+ * sentence says the port and what the policy gives for it, and nothing it
245
+ * cannot know. Three answers, because they send a person to three places:
246
+ *
247
+ * - the port is not in `local_ports`: a policy question, add it or do not;
248
+ * - it is, and was refused anyway: the client dialled directly instead of
249
+ * through the proxy — a MySQL driver, Chromium, a raw socket — and adding
250
+ * the port again will not change that;
251
+ * - a unix socket: seisin grants none, on purpose. The one that comes up is
252
+ * Docker's, and a role that can reach Docker can mount the whole disk.
253
+ *
254
+ * Never an owner and never a request: a port is not anybody's territory.
255
+ */
256
+ function explainConnect(config, role, target) {
257
+ const r = config.roles[role];
258
+ const port = /^tcp:(\d+)$/.exec(target)?.[1];
259
+ if (port) {
260
+ const n = Number(port);
261
+ if (r?.localPorts?.includes(n))
262
+ return {
263
+ allowed: false, owners: [], network: true, listed: true,
264
+ reason: `port ${n} is in ${role}'s local_ports, but this client skipped the proxy. ` +
265
+ `curl, fetch and urllib use HTTP_PROXY on their own; a database driver or a browser ` +
266
+ `has to be pointed at it`,
267
+ };
268
+ // The kernel names no host, and every direct dial is refused — to an
269
+ // outside host as much as to localhost. So both readings, not a guess:
270
+ // advising `local_ports = [443]` for a client that skipped the proxy on
271
+ // its way to the internet opens a port and fixes nothing.
272
+ return {
273
+ allowed: false, owners: [], network: true,
274
+ reason: `port ${n}, host unknown. Local service: a person adds it to local_ports` +
275
+ `${r?.localPorts?.length ? ` (has ${r.localPorts.join(", ")})` : ""}. ` +
276
+ `Outside host: the client skipped HTTP_PROXY`,
277
+ };
278
+ }
279
+ return {
280
+ allowed: false, owners: [], network: true,
281
+ reason: `${target} is a unix socket, and roles get none` +
282
+ (/docker\.sock$/.test(target)
283
+ ? `. Docker's socket can mount any directory on this machine: start containers outside ` +
284
+ `the role and reach them by port`
285
+ : ""),
286
+ };
287
+ }
288
+
289
+ /** May this role load the MCP server `name`? A declaration, not something the kernel sees. */
290
+ function explainMcp(config, role, name) {
291
+ const list = config.roles[role]?.mcp;
292
+ if (list == null)
293
+ return { allowed: true, owners: [], mcp: true, declared: false,
294
+ reason: `${role} has no mcp list, so the policy does not limit its MCP servers` };
295
+ if (list.includes(name))
296
+ return { allowed: true, owners: [], mcp: true, declared: true, reason: `${role} declares mcp = [${list.map((n) => `"${n}"`).join(", ")}]` };
297
+ return { allowed: false, owners: [], mcp: true, declared: true,
298
+ reason: list.length
299
+ ? `${role} may load only ${list.join(", ")}. Adding ${name} is a change to its mcp list`
300
+ : `${role} declares mcp = [], no MCP servers` };
301
+ }
302
+
152
303
  export function explain(config, role, action, target) {
304
+ if (action === "connect") return explainConnect(config, role, target);
305
+ if (action === "mcp") return explainMcp(config, role, target);
153
306
  if (action === "read") {
154
307
  const holders = keyHolders(config, target);
155
308
  if (holders.includes(role)) return { allowed: true, owners: holders, reason: `${role} declares ${target}` };
@@ -159,7 +312,56 @@ export function explain(config, role, action, target) {
159
312
  }
160
313
 
161
314
  const owners = ownersOf(config, target);
315
+ /**
316
+ * Protected before anything else, because no territory reaches it.
317
+ *
318
+ * The policy, seisin's state, the key directories, what the parent executes
319
+ * or reads for a key, and the files that make git or Claude Code run
320
+ * something outside the box. The kernel denies all of them to every role —
321
+ * surface.js has the list and the reasons — so saying "belongs to dev" about
322
+ * `seisin.toml` sent a request to a person that no grant could satisfy.
323
+ */
324
+ const guard = protectedBy(config, target);
325
+ if (guard)
326
+ return {
327
+ allowed: false, owners, protected: guard.why,
328
+ reason: `${target} is protected (${guard.why}) — no role writes it, so there is nothing to grant`,
329
+ };
330
+ /**
331
+ * Named for what it is, and checked before "no owner".
332
+ *
333
+ * A path the role gave up through `never_writes` is not a permission it is
334
+ * missing. Said as "belongs to nobody", the agent files a request, a person
335
+ * approves it, and the subtraction is undone from the other side without
336
+ * anyone deciding to undo it.
337
+ */
338
+ const r = config.roles[role];
339
+ const barred = r && r.writes.some((g) => covers(g, target)) ? neverWrites(r, target, config) : null;
340
+ if (barred)
341
+ return {
342
+ allowed: false, owners, neverWrites: barred,
343
+ reason: `${target} is denied by never_writes of ${role} ("${barred}") — ` +
344
+ `a subtraction written into the policy, not a missing permission`,
345
+ };
162
346
  if (owners.includes(role)) return { allowed: true, owners, reason: `${target} is inside ${role}'s territory` };
347
+ /**
348
+ * Git's own bookkeeping in a repository this role does not write.
349
+ *
350
+ * Lock files, FETCH_HEAD, objects, refs, packed-refs: never a territory to
351
+ * hand over, because granting them is letting the role rewrite somebody
352
+ * else's history. Codex keeps `.git` read-only under a writable root for the
353
+ * same reason; nono and Gemini grant the whole of it. Here it stays refused
354
+ * and is said for what it is — not "belongs to X, ask them", which filed a
355
+ * request per lock file (327 of them for one index.lock on one portfolio)
356
+ * that nobody could sensibly approve. The requests side skips it too.
357
+ */
358
+ if (isGitMetadata(target))
359
+ return {
360
+ allowed: false, owners, gitMetadata: true,
361
+ reason: `${target} is git's own bookkeeping in a repository ${role} does not write — ` +
362
+ `not a permission to ask for. Read what you need (git log, git show, git ls-remote) ` +
363
+ `or work in a worktree of your own`,
364
+ };
163
365
  if (owners.length === 0)
164
366
  return { allowed: false, owners: [], reason: `${target} has no owner — no role can write it until one claims it` };
165
367
  return { allowed: false, owners, reason: `${target} belongs to ${owners.join(", ")}` };
package/src/paths.js ADDED
@@ -0,0 +1,50 @@
1
+ /**
2
+ * One way to turn whatever path a caller holds into the repo-relative path the
3
+ * policy is written in.
4
+ *
5
+ * It was copied by hand into four places (explain, whose, the hook, run) and a
6
+ * fifth, the MCP server, never had it: `seisin_explain` answered "has no owner"
7
+ * for an absolute path the CLI called allowed — the same defect decisions.md
8
+ * records as closed, closed in one surface only. And none of the copies
9
+ * resolved symlinks, so on macOS a path under `/tmp` (a link to `/private/tmp`)
10
+ * was refused by the sentence while the kernel, which sees the real path,
11
+ * allowed it: the document tighter than the boundary, the direction this
12
+ * project refuses. Found by the read-only review of 2026-09-22.
13
+ */
14
+ import { realpathSync } from "node:fs";
15
+ import { basename, dirname, isAbsolute, join, relative, sep } from "node:path";
16
+
17
+ /**
18
+ * `p` with its deepest existing ancestor resolved through symlinks and the rest
19
+ * appended as written. realpath alone fails on a path that does not exist yet,
20
+ * which is the shape of every file about to be created — a lock file included.
21
+ */
22
+ export function realAncestor(p) {
23
+ let head = p, tail = "";
24
+ while (head !== dirname(head)) {
25
+ try {
26
+ const real = realpathSync(head);
27
+ return tail ? join(real, tail) : real;
28
+ } catch {
29
+ tail = tail ? join(basename(head), tail) : basename(head);
30
+ head = dirname(head);
31
+ }
32
+ }
33
+ return p;
34
+ }
35
+
36
+ /**
37
+ * Repo-relative when `target` is inside the repo, spelled either way; exactly as
38
+ * written when it is relative already or lies outside. A path outside keeps its
39
+ * honest "no owner" — it is a question about somewhere else.
40
+ */
41
+ export function toRepoRelative(config, target) {
42
+ if (typeof target !== "string" || !isAbsolute(target)) return target;
43
+ const root = config.root;
44
+ const inside = (r, t) => t === r || t.startsWith(r + sep);
45
+ if (inside(root, target)) return relative(root, target) || ".";
46
+ const realRoot = realAncestor(root);
47
+ const realTarget = realAncestor(target);
48
+ if (inside(realRoot, realTarget)) return relative(realRoot, realTarget) || ".";
49
+ return target;
50
+ }
package/src/render.js CHANGED
@@ -65,6 +65,18 @@ export function renderReport(report) {
65
65
  const writes = collapseSidecars(r.writes).join(" ") || `${C.dim}nothing${C.off}`;
66
66
  const keys = r.keys.length ? r.keys.join(" ") : `${C.dim}none${C.off}`;
67
67
  lines.push(` ${C.b}${r.name.padEnd(width)}${C.off} ${C.blue}writes${C.off} ${writes}\n`);
68
+ // Only when present: absent is the default, and a "never_writes nothing" line
69
+ // on every role is noise that teaches the reader to skip the one that matters.
70
+ if (r.neverWrites?.length)
71
+ lines.push(` ${" ".repeat(width)} ${C.yellow}never${C.off} ${collapseSidecars(r.neverWrites).join(" ")}\n`);
72
+ if (r.trustd)
73
+ lines.push(` ${" ".repeat(width)} ${C.yellow}trustd${C.off} TLS through the system verifier (trustd)\n`);
74
+ if (r.localBinding)
75
+ lines.push(` ${" ".repeat(width)} ${C.yellow}listen${C.off} local ports (local_binding)\n`);
76
+ if (r.mcp)
77
+ lines.push(` ${" ".repeat(width)} ${C.yellow}mcp${C.off} ${r.mcp.length ? r.mcp.join(" ") : "none"} ${C.dim}(declared; the launcher enforces it)${C.off}\n`);
78
+ if (r.localPorts?.length)
79
+ lines.push(` ${" ".repeat(width)} ${C.yellow}reach${C.off} localhost ${r.localPorts.join(" ")} ${C.dim}(local_ports, through the proxy)${C.off}\n`);
68
80
  lines.push(` ${" ".repeat(width)} ${C.green}keys${C.off} ${keys}\n\n`);
69
81
  }
70
82
 
@@ -75,6 +87,23 @@ export function renderReport(report) {
75
87
  lines.push("\n");
76
88
  }
77
89
 
90
+ // What the kernel refuses inside a territory, and why. Shown because each one
91
+ // takes something from a territory as written; capped because a monorepo has
92
+ // a hooks directory per package and the reader needs the shape, not all 200.
93
+ if (report.protected?.length) {
94
+ const root = report.where ? report.where.replace(/\/[^/]*$/, "/") : "";
95
+ const show = (p) => (root && p.startsWith(root) ? p.slice(root.length) : p.replace(/^\/Users\/[^/]+|^\/home\/[^/]+/, "~"));
96
+ lines.push(` ${C.b}protected${C.off} ${C.dim}— inside a territory, refused anyway: something outside the box runs or reads it${C.off}\n`);
97
+ const LIMIT = 12;
98
+ for (const e of report.protected.slice(0, LIMIT)) {
99
+ const who = e.roles.length > 3 ? `${e.roles.slice(0, 3).join(", ")} +${e.roles.length - 3}` : e.roles.join(", ");
100
+ lines.push(` ${show(e.path)} ${C.dim}${e.why} · ${who}${C.off}\n`);
101
+ }
102
+ if (report.protected.length > LIMIT)
103
+ lines.push(` ${C.dim}… and ${report.protected.length - LIMIT} more${C.off}\n`);
104
+ lines.push("\n");
105
+ }
106
+
78
107
  for (const w of report.warnings) {
79
108
  lines.push(` ${C.yellow}${w.headline}${C.off}\n`);
80
109
  if (w.detail) lines.push(` ${C.dim}${w.detail}${C.off}\n`);