seisin 0.4.2 → 0.6.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 (82) hide show
  1. package/README.md +506 -162
  2. package/package.json +14 -6
  3. package/src/browsers.js +52 -0
  4. package/src/canary.js +149 -0
  5. package/src/cli.js +175 -52
  6. package/src/commands/check.js +157 -5
  7. package/src/commands/explain.js +31 -13
  8. package/src/commands/hook.js +20 -6
  9. package/src/commands/init.js +183 -88
  10. package/src/commands/log.js +48 -9
  11. package/src/commands/requests.js +122 -70
  12. package/src/commands/review.js +73 -21
  13. package/src/commands/run.js +422 -78
  14. package/src/commands/scan.js +18 -3
  15. package/src/commands/ui.js +18 -5
  16. package/src/commands/walls.js +62 -11
  17. package/src/commands/whose.js +9 -7
  18. package/src/commands/wire.js +156 -17
  19. package/src/config.js +505 -37
  20. package/src/controls.js +244 -0
  21. package/src/diagnose.js +102 -49
  22. package/src/env.js +14 -8
  23. package/src/grants.js +122 -9
  24. package/src/handoff.js +285 -10
  25. package/src/help.js +179 -0
  26. package/src/hook.js +101 -42
  27. package/src/index.d.ts +217 -0
  28. package/src/index.js +24 -16
  29. package/src/inspect/codex.js +64 -0
  30. package/src/inspect/credentials.js +48 -0
  31. package/src/inspect/enforce.js +24 -0
  32. package/src/inspect/files.js +71 -0
  33. package/src/inspect/home.js +43 -0
  34. package/src/inspect/inject.js +68 -0
  35. package/src/inspect/keys.js +74 -0
  36. package/src/inspect/layout.js +258 -0
  37. package/src/inspect/limits.js +98 -0
  38. package/src/inspect/machine.js +107 -0
  39. package/src/inspect/network.js +37 -0
  40. package/src/inspect/providers.js +71 -0
  41. package/src/inspect/read-territory.js +105 -0
  42. package/src/inspect/role-keys.js +136 -0
  43. package/src/inspect/shared.js +37 -0
  44. package/src/inspect/warnings.js +58 -0
  45. package/src/inspect.js +20 -756
  46. package/src/intake.js +150 -35
  47. package/src/keys.js +231 -73
  48. package/src/kinds.js +249 -0
  49. package/src/lines.js +26 -0
  50. package/src/log.js +242 -52
  51. package/src/mcp.js +57 -58
  52. package/src/nested.js +12 -3
  53. package/src/notify.js +66 -20
  54. package/src/owners.js +358 -39
  55. package/src/paths.js +54 -2
  56. package/src/provider-run.js +81 -0
  57. package/src/redact.js +46 -13
  58. package/src/render.js +101 -17
  59. package/src/requests.js +506 -87
  60. package/src/review.js +50 -25
  61. package/src/rundir.js +33 -14
  62. package/src/sandboxes/command.js +93 -0
  63. package/src/sandboxes/index.js +113 -0
  64. package/src/sandboxes/mach-profile.js +31 -0
  65. package/src/sandboxes/srt-mach.mjs +43 -0
  66. package/src/sandboxes/srt.js +163 -0
  67. package/src/sandboxes/tartaros.js +80 -0
  68. package/src/scan.js +181 -19
  69. package/src/serve.js +396 -230
  70. package/src/spool.js +16 -45
  71. package/src/srt.js +296 -49
  72. package/src/suggest.js +58 -0
  73. package/src/surface.js +314 -54
  74. package/src/territory.js +261 -0
  75. package/src/trace.js +33 -0
  76. package/src/uilink.js +24 -9
  77. package/src/views.js +315 -0
  78. package/src/violations.js +247 -23
  79. package/src/walls.js +76 -46
  80. package/src/worktree.js +3 -11
  81. package/ui/index.html +1429 -538
  82. package/ui/favicon.svg +0 -1
package/src/owners.js CHANGED
@@ -11,9 +11,13 @@
11
11
  * exception, and they are handled separately below.
12
12
  */
13
13
  import { createHash } from "node:crypto";
14
- import { existsSync, readFileSync } from "node:fs";
15
- import { join } from "node:path";
14
+ import { existsSync, readFileSync, readdirSync } from "node:fs";
15
+ import { isAbsolute, join } from "node:path";
16
16
  import { protectedBy } from "./surface.js";
17
+ import { entriesOf, parseKey, BUILTIN } from "./keys.js";
18
+ import { WILD, fromCwd, toRepoRelative } from "./paths.js";
19
+ import { AGENTS, AGENT_CREDENTIALS, CREDENTIAL_HOMES, expand } from "./grants.js";
20
+ import { keptFor, shutByTerritory } from "./territory.js";
17
21
 
18
22
  /**
19
23
  * Does this glob cover this path? Supports `**`, `*`, `?` and a trailing `/`.
@@ -43,9 +47,23 @@ import { protectedBy } from "./surface.js";
43
47
  export function covers(glob, path) {
44
48
  const p = normalized(path);
45
49
  const m = matcherOf(glob);
50
+ /**
51
+ * Above the root only for a glob that climbs there itself.
52
+ *
53
+ * `../../etc/passwd` is a path the kernel refuses to every role, and `**`
54
+ * matched it as a string, so `explain` answered "inside territory" for it.
55
+ * A territory reaches outside the repo only by saying so — the
56
+ * `../shared/lab/dev.md` a role writes beside its own directory — and
57
+ * that one still matches, because both sides start with the same `..`.
58
+ */
59
+ if (climbs(p) && !m.climbs) return false;
46
60
  // The path itself, or anything beneath it. The `/` is load-bearing: without
47
61
  // it `src/api` would also cover `src/apifoo.ts`, which the kernel does not.
48
62
  if (m.prefix !== null) return p === m.prefix || p.startsWith(m.prefix + "/");
63
+ // The literal head of the glob, before its first wildcard, has to be the
64
+ // head of the path too. Most pairs fail here, for the price of a
65
+ // startsWith — `check` asks ~400k of them on a real policy.
66
+ if (!p.startsWith(m.head)) return false;
49
67
  return matchGlob(m.glob, p);
50
68
  }
51
69
 
@@ -69,7 +87,8 @@ function matcherOf(glob) {
69
87
  if (m) return m;
70
88
  let g = normalize(glob);
71
89
  if (glob.endsWith("/")) g += "/**";
72
- m = WILD.test(g) ? { prefix: null, glob: g } : { prefix: g, glob: null };
90
+ const wild = WILD.test(g);
91
+ m = { prefix: wild ? null : g, glob: wild ? g : null, climbs: climbs(g), head: wild ? headOf(g) : "" };
73
92
  if (GLOBS.size >= MAX_CACHED) GLOBS.clear();
74
93
  GLOBS.set(glob, m);
75
94
  return m;
@@ -84,8 +103,15 @@ function normalized(path) {
84
103
  return p;
85
104
  }
86
105
 
87
- /** The four characters toRegExp() treats as wildcards. */
88
- const WILD = /[*?[\]]/;
106
+ /**
107
+ * What every path a glob matches starts with: its text up to the first
108
+ * wildcard, minus a final `/` — `src/api/**` matches `src/api` itself, and
109
+ * `a/**\/b` matches `a/b`.
110
+ */
111
+ const headOf = (g) => g.slice(0, g.search(WILD)).replace(/\/$/, "");
112
+
113
+ /** Does a normalised path start above the repo root? */
114
+ const climbs = (p) => p === ".." || p.startsWith("../");
89
115
 
90
116
  /**
91
117
  * One spelling per file, before anybody decides anything about it.
@@ -97,8 +123,10 @@ const WILD = /[*?[\]]/;
97
123
  * every sentence seisin said about it was wrong, which is the half of the tool
98
124
  * that is actually ours.
99
125
  *
100
- * A path that climbs above the repo root comes back as `..`, which owns
101
- * nothing and matches nothing. Outside the repo has no owner by definition.
126
+ * A path that climbs above the repo root keeps its leading `..` segments
127
+ * (`src/../../x` is `../x`). `covers` matches such a path only against a glob
128
+ * that climbs out too, so outside the repo has no owner unless a territory
129
+ * names that place explicitly.
102
130
  */
103
131
  export function normalize(s) {
104
132
  const flat = s.replace(/\\/g, "/").replace(/\/+/g, "/").replace(/\/+$/, "");
@@ -111,22 +139,9 @@ export function normalize(s) {
111
139
  return up.join("/");
112
140
  }
113
141
 
114
- /**
115
- * Glob to RegExp.
116
- *
117
- * The wildcards are parked on sentinels before `*` is expanded, because
118
- * expanding `**` first produces a `.*` whose own `*` the next pass would
119
- * rewrite again. Getting that order wrong is silent: the regex still compiles
120
- * and quietly matches the wrong set of files.
121
- *
122
- * `src/api/**` also matches `src/api` itself. Without that, the directory a
123
- * role was just granted comes back ownerless, which reads as a bug every time.
124
- *
125
- * Dotfiles are matched like any other name: `*.env` covers `.env`. Shell globs
126
- * hide them by default, and most glob libraries copy that. A permission tool
127
- * must not: covering one file too many costs an argument, covering one too few
128
- * is the hole. When the two readings disagree, this one takes the wider set.
129
- */
142
+ /** matchGlob's memo, shared between calls (see there). */
143
+ let MEMO = new Uint8Array(4096);
144
+
130
145
  /**
131
146
  * Does `glob` cover `path`? A linear matcher, not a regular expression.
132
147
  *
@@ -141,11 +156,31 @@ export function normalize(s) {
141
156
  * Same wildcards as before: `?` one non-`/`, `*` a run of non-`/`, `**` any run
142
157
  * including `/`, `**\/` zero or more whole directories, a trailing `/**` the
143
158
  * subtree. Characters are compared literally; nothing is treated as regex.
159
+ *
160
+ * `src/api/**` also matches `src/api` itself. Without that, the directory a
161
+ * role was just granted comes back ownerless, which reads as a bug every time.
162
+ *
163
+ * Dotfiles are matched like any other name: `*.env` covers `.env`. Shell globs
164
+ * hide them by default, and most glob libraries copy that. A permission tool
165
+ * must not: covering one file too many costs an argument, covering one too few
166
+ * is the hole. When the two readings disagree, this one takes the wider set.
144
167
  */
145
168
  function matchGlob(glob, path) {
146
169
  const G = glob.length, P = path.length;
147
- // memo[gi * (P+1) + pi]: 0 unknown, 1 true, 2 false. One flat array, reused.
148
- const memo = new Uint8Array((G + 1) * (P + 1));
170
+ // memo[gi * (P+1) + pi]: 0 unknown, 1 true, 2 false. One flat array, kept
171
+ // between calls and cleared over the part this call uses: allocating it
172
+ // fresh was most of the cost of a call that fails on its second character.
173
+ // Nothing re-enters matchGlob while it runs, so sharing it is safe.
174
+ // An outsized pair gets its own array, so one long path does not pin
175
+ // megabytes for the life of a server.
176
+ const size = (G + 1) * (P + 1);
177
+ let memo;
178
+ if (size > 1 << 20) memo = new Uint8Array(size);
179
+ else {
180
+ if (MEMO.length < size) MEMO = new Uint8Array(Math.max(size, MEMO.length * 2));
181
+ memo = MEMO;
182
+ memo.fill(0, 0, size);
183
+ }
149
184
  const at = (gi, pi) => {
150
185
  for (;;) {
151
186
  const key = gi * (P + 1) + pi;
@@ -210,11 +245,55 @@ export function ownersOf(config, path) {
210
245
  if (!config.root || !path.startsWith(root)) return [];
211
246
  path = path.slice(root.length);
212
247
  }
248
+ // A private folder has one owner, whatever else covers it: every other role
249
+ // is given a denyWrite on it (srt.js), and denyWrite wins over any territory.
250
+ const mine = privateOwner(config, path);
213
251
  return Object.values(config.roles)
214
252
  .filter((r) => r.writes.some((g) => covers(g, path)) && !neverWrites(r, path, config))
253
+ .filter((r) => !mine || r.name === mine.role)
215
254
  .map((r) => r.name);
216
255
  }
217
256
 
257
+ /**
258
+ * The private folder that holds `path` — `{ role, entry }` — or null.
259
+ *
260
+ * `path` is repo-relative, or absolute inside the repo. One answer for the
261
+ * settings, `explain`, the hook and `check`, so the denies the kernel is given
262
+ * and the sentence about them cannot drift apart.
263
+ */
264
+ export function privateOwner(config, path) {
265
+ let p = String(path ?? "");
266
+ if (p.startsWith("/")) {
267
+ const root = config.root?.endsWith("/") ? config.root : `${config.root}/`;
268
+ if (!config.root || !p.startsWith(root)) return null;
269
+ p = p.slice(root.length);
270
+ }
271
+ for (const r of Object.values(config.roles ?? {}))
272
+ for (const entry of r.private ?? [])
273
+ if (covers(entry.replace(/\/\*\*$/, "").replace(/\/+$/, ""), p)) return { role: r.name, entry };
274
+ return null;
275
+ }
276
+
277
+ /** The answer for `role` touching another role's private folder, or null. */
278
+ function privateAnswer(config, role, target, read = false) {
279
+ const hit = privateOwner(config, target);
280
+ if (!hit) return null;
281
+ // The owner's write goes on through the ordinary questions (never_writes
282
+ // still subtracts inside it); its read is answered here, because "every
283
+ // role reads it" is exactly what is no longer true.
284
+ if (hit.role === role)
285
+ return read
286
+ ? { allowed: true, owners: [role], privateTo: role,
287
+ reason: `${target} is in ${role}'s private folder (private = ["${hit.entry}"]): ${role} reads it, and no other role does` }
288
+ : null;
289
+ return {
290
+ allowed: false, owners: [hit.role], privateTo: hit.role,
291
+ reason: `${target} is in ${hit.role}'s private folder (private = ["${hit.entry}"]): only ${hit.role} ` +
292
+ `reads or writes it, and every other role is denied both, in any read mode. ` +
293
+ `There is nothing to grant — ask ${hit.role} for what you need from it`,
294
+ };
295
+ }
296
+
218
297
  /**
219
298
  * Where a refused target stands against the policy as it is now.
220
299
  *
@@ -238,9 +317,17 @@ export function standing(config, entry) {
238
317
  const target = entry.target;
239
318
  if (entry.kind === "network")
240
319
  return { kind: "outside", owners: [], why: "a port or a socket, which no role owns" };
320
+ if (entry.kind === "system")
321
+ return { kind: "outside", owners: [], why: "a system service, which no role owns" };
241
322
  if (entry.kind === "key")
242
323
  return { kind: "outside", owners: [], why: "a key directory, closed on purpose" };
243
324
  const guard = protectedBy(config, target);
325
+ // A family the policy hands to a role is protected from everybody else and
326
+ // owned by that role — if it is also that role's territory.
327
+ if (guard?.family) {
328
+ const holders = ownersOf(config, target).filter((o) => config.roles[o]?.controlFiles?.includes(guard.family));
329
+ if (holders.length) return { kind: "owned", owners: holders };
330
+ }
244
331
  if (guard) return { kind: "protected", owners: [], why: guard.why };
245
332
  if (target.startsWith("/")) {
246
333
  const root = config.root?.endsWith("/") ? config.root : `${config.root}/`;
@@ -323,6 +410,127 @@ export function neverWrites(role, path, config = null) {
323
410
  return enforcedNeverWrites(config, role).find((g) => covers(g, path)) ?? null;
324
411
  }
325
412
 
413
+ /** A key dir as the policy's paths spell it: no leading `./`, no trailing `/`. */
414
+ const dirSpelling = (d) => String(d).replace(/^\.\/+/, "").replace(/\/+$/, "");
415
+
416
+ /** Is the repo-relative `rel` one of the key directories, or inside one? */
417
+ export function inKeyDir(config, rel) {
418
+ return (config.keyDirs ?? []).some((d) => {
419
+ const k = dirSpelling(d);
420
+ const r = k.startsWith("/") ? relFromRoot(config, k) : k;
421
+ return r !== null && (rel === r || rel.startsWith(r + "/"));
422
+ });
423
+ }
424
+
425
+ function relFromRoot(config, abs) {
426
+ const root = config.root ?? "";
427
+ return abs === root ? "." : abs.startsWith(root + "/") ? abs.slice(root.length + 1) : null;
428
+ }
429
+
430
+ /**
431
+ * How a key is written in a role's `keys` list: relative to the first key
432
+ * directory when it lives there, with its directory otherwise — the same form
433
+ * settingsFor and keyHolders resolve back.
434
+ */
435
+ export function keyName(config, target) {
436
+ const t = String(target ?? "");
437
+ const first = config.keyDirs?.length ? dirSpelling(config.keyDirs[0]) : null;
438
+ return first && t.startsWith(first + "/") ? t.slice(first.length + 1) : t;
439
+ }
440
+
441
+ /**
442
+ * A read of a FILE — a path, not a key name — and whether the role gets it.
443
+ *
444
+ * Reads are open by design: every role reads everything outside the `[keys]`
445
+ * dirs (and, under `isolate`, outside the credential homes). `explain` used to
446
+ * send every read through the key question, so `explain dev read .env` said
447
+ * "denied — no role declares .env" about a file every role could read. A wrong
448
+ * no from the tool whose job is saying what the kernel will do. Now a path
449
+ * inside a key directory is a key question; anything else is answered as the
450
+ * open read it is, with how to make it a key.
451
+ */
452
+ /**
453
+ * An agent's sign-in file: closed to every run but a run of that agent, which
454
+ * is a fact about the command and not the role (AGENT_CREDENTIALS, grants.js).
455
+ * explain has no command, so it answers for the ordinary case and names the
456
+ * exception. Nothing to grant either way, and the sentence says so: the advice
457
+ * it replaced was "add it to a role's keys", which would hand a ChatGPT token
458
+ * to a role by policy.
459
+ */
460
+ function signInAnswer(role, target) {
461
+ if (!String(target ?? "").startsWith("/")) return null;
462
+ const agent = AGENTS.find((a) => AGENT_CREDENTIALS[a].map(expand).includes(target));
463
+ if (!agent) return null;
464
+ return {
465
+ allowed: false, owners: [], signIn: agent,
466
+ reason: `${target} is ${agent}'s sign-in: only a run whose command is ${agent} reads it ` +
467
+ `(seisin run ${role} -- ${agent} …), and the commands that run inside it. ` +
468
+ `Any other command run as ${role} is denied it, and there is nothing to grant`,
469
+ };
470
+ }
471
+
472
+ export function explainFileRead(config, role, rel, credentialHomes = []) {
473
+ // Only a key directory makes a key: settingsFor refuses a key declared
474
+ // anywhere else, so nothing outside one is denied by being a key.
475
+ if (inKeyDir(config, rel)) return explainPolicy(config, role, "read", rel);
476
+ const level = config.isolate === true ? "home" : config.isolate;
477
+ if ((level === "credentials" || level === "home") && rel.startsWith("/")) {
478
+ const hit = credentialHomes.find((h) => rel === h || rel.startsWith(h + "/"));
479
+ if (hit)
480
+ return {
481
+ allowed: false, owners: [],
482
+ reason: `${rel} is under ${hit}, which isolate = "${level}" closes to every role`,
483
+ };
484
+ }
485
+ const signIn = signInAnswer(role, rel);
486
+ if (signIn) return signIn;
487
+ const shut = privateAnswer(config, role, rel, true);
488
+ if (shut) return shut;
489
+ if (config.read === "territory" && rel.startsWith("/") &&
490
+ shutByTerritory(rel, keptFor(config, config.roles[role])))
491
+ return {
492
+ allowed: false, owners: [], readTerritory: true,
493
+ reason: `${rel} is outside what ${role} keeps readable under read = "territory". ` +
494
+ `Declare it in reads, or declare the tool that needs it in toolchain`,
495
+ };
496
+ const dirs = (config.keyDirs ?? []).map(dirSpelling);
497
+ return {
498
+ allowed: true, owners: [], open: true,
499
+ reason: dirs.length
500
+ ? `${rel} is outside every [keys] dir (${dirs.join(", ")}), so every role reads it. ` +
501
+ `Move it into ${dirs[0]}/ to make it a key`
502
+ : `no [keys] dir is declared, so every role reads every file. ` +
503
+ `Declare one under [keys] and move ${rel} into it to make it a key`,
504
+ };
505
+ }
506
+
507
+ /**
508
+ * What a read is about: a key, named the way the policy names keys, or a file.
509
+ *
510
+ * `explain dev read stripe` asks about a key — a bare name some role declares,
511
+ * or one sitting in a key directory. Everything else is a path, resolved from
512
+ * where you stand, and inside a key directory it is a key question again.
513
+ */
514
+ export function readTarget(config, target, cwd = process.cwd()) {
515
+ // A reference is a key by its spelling, never a path: resolving
516
+ // `keychain://x` from where you stand printed `keychain:/x` and answered
517
+ // a question about a file nobody asked about.
518
+ if (refOf(target)) return { key: true, target };
519
+ if (!target.includes("/") && !isAbsolute(target)) {
520
+ const declared = keyHolders(config, target).length > 0;
521
+ const bare = (s) => s.replace(/\.[^.]+$/, "");
522
+ const inDir = (config.keyDirs ?? []).some((d) => {
523
+ const dir = isAbsolute(d) ? d : join(config.root, d);
524
+ if (existsSync(join(dir, target))) return true;
525
+ try { return readdirSync(dir).some((f) => bare(f) === target); } catch { return false; }
526
+ });
527
+ const here = fromCwd(config, target, cwd);
528
+ const isFileHere = existsSync(here) && !inKeyDir(config, toRepoRelative(config, here));
529
+ if ((declared || inDir) && !isFileHere) return { key: true, target };
530
+ }
531
+ return { key: false, target: toRepoRelative(config, fromCwd(config, target, cwd)) };
532
+ }
533
+
326
534
  /** Every role allowed to read `key`, matched with or without its extension. */
327
535
  export function keyHolders(config, key) {
328
536
  const dirs = config.keyDirs ?? [];
@@ -349,19 +557,88 @@ export function keyHolders(config, key) {
349
557
  // netlify` — so it stays, and only for an unqualified question.
350
558
  const loose = !key.includes("/");
351
559
 
560
+ /**
561
+ * Only path keys. A reference — `keychain://x`, `NAME=file://.secrets/a#X` —
562
+ * is resolved by the parent and delivered as a value; the role is never
563
+ * given the file. Comparing the raw strings let `explain` say "declares
564
+ * a.env" about a role whose key merely pointed into it, a yes the kernel
565
+ * would refuse.
566
+ */
352
567
  return Object.values(config.roles)
353
- .filter((r) => r.keys.some((k) =>
568
+ .filter((r) => fileKeys(r).some((k) =>
354
569
  resolve(k) === wanted || (loose && bare(k) === bare(key))))
355
570
  .map((r) => r.name);
356
571
  }
357
572
 
573
+ /** `scheme://…`, optionally `NAME=scheme://…`, read as a key entry — or null. */
574
+ function refOf(target) {
575
+ try {
576
+ const e = parseKey(target);
577
+ return e.kind === "ref" ? e : null;
578
+ } catch {
579
+ return null;
580
+ }
581
+ }
582
+
358
583
  /**
359
- * The sentence a blocked agent should read.
584
+ * A reference key is a question about the policy, not about the disk.
360
585
  *
361
- * Three outcomes, and the third is the one worth having: a path nobody owns is
362
- * not a permission problem, it is a hole in the map. Saying so is more useful
363
- * than denying quietly, and it is the only way the hole ever gets fixed.
586
+ * Two facts answer it: whether the role lists the reference in its `keys`,
587
+ * and which provider turns it into a value. The role never reads where the
588
+ * secret lives — the parent resolves it before the sandbox starts and hands
589
+ * over the value — so there is no path to normalise and no key directory to
590
+ * be inside of. Matched on scheme and reference; the variable name in front is
591
+ * how it is delivered, not which secret it is.
364
592
  */
593
+ function explainRef(config, role, ref) {
594
+ const spelled = `${ref.scheme}://${ref.ref}`;
595
+ const declaring = (r) => {
596
+ try {
597
+ return entriesOf(r).find((e) => e.kind === "ref" && e.scheme === ref.scheme && e.ref === ref.ref);
598
+ } catch {
599
+ return undefined;
600
+ }
601
+ };
602
+ const holders = Object.values(config.roles).filter(declaring).map((r) => r.name);
603
+ const provider = BUILTIN.has(ref.scheme)
604
+ ? `seisin's built-in ${ref.scheme}:// provider`
605
+ : Object.hasOwn(config.keyProviders ?? {}, ref.scheme) ? `[keys.providers.${ref.scheme}]` : null;
606
+ if (!provider)
607
+ return {
608
+ allowed: false, owners: holders, key: "ref",
609
+ reason: `${spelled} uses the "${ref.scheme}" scheme and no [keys.providers.${ref.scheme}] is declared, ` +
610
+ `so nothing resolves it — declare the provider before a role can hold this key`,
611
+ };
612
+ const mine = config.roles[role] && declaring(config.roles[role]);
613
+ if (mine)
614
+ return {
615
+ allowed: true, owners: holders, key: "ref", provider,
616
+ reason: `${role} declares ${mine.raw}: the parent resolves it through ${provider} and hands ` +
617
+ `${role} the value as ${mine.name} — the role never reads where it is kept`,
618
+ };
619
+ if (!holders.length)
620
+ return {
621
+ allowed: false, owners: [], key: "ref", provider,
622
+ reason: `no role declares ${spelled} (${provider} would resolve it) — ` +
623
+ `add it to a role's keys: [roles.${role}] keys = ["${ref.raw}"]`,
624
+ };
625
+ return {
626
+ allowed: false, owners: holders, key: "ref", provider,
627
+ reason: `${spelled} belongs to ${holders.join(", ")}, through ${provider}`,
628
+ };
629
+ }
630
+
631
+ /** The keys of `role` that grant a read: the path ones. A malformed entry grants nothing. */
632
+ function fileKeys(role) {
633
+ let entries;
634
+ try {
635
+ entries = entriesOf(role);
636
+ } catch {
637
+ return [];
638
+ }
639
+ return entries.filter((e) => e.kind === "file").map((e) => e.raw);
640
+ }
641
+
365
642
  /**
366
643
  * A connection the kernel refused: `tcp:<port>` or a unix socket path.
367
644
  *
@@ -425,14 +702,32 @@ function explainMcp(config, role, name) {
425
702
  : `${role} declares mcp = [], no MCP servers` };
426
703
  }
427
704
 
428
- export function explain(config, role, action, target) {
705
+ /**
706
+ * The sentence a blocked agent should read.
707
+ *
708
+ * Three outcomes, and the third is the one worth having: a path nobody owns is
709
+ * not a permission problem, it is a hole in the map. Saying so is more useful
710
+ * than denying quietly, and it is the only way the hole ever gets fixed.
711
+ */
712
+ function explainPolicy(config, role, action, target) {
429
713
  if (action === "connect") return explainConnect(config, role, target);
430
714
  if (action === "mcp") return explainMcp(config, role, target);
431
715
  if (action === "read") {
716
+ const ref = refOf(target);
717
+ if (ref) return explainRef(config, role, ref);
718
+ const signIn = signInAnswer(role, target);
719
+ if (signIn) return signIn;
432
720
  const holders = keyHolders(config, target);
433
721
  if (holders.includes(role)) return { allowed: true, owners: holders, reason: `${role} declares ${target}` };
434
- if (holders.length === 0)
435
- return { allowed: false, owners: [], reason: `no role declares ${target} — add it under a [roles.<name>] keys list` };
722
+ if (holders.length === 0) {
723
+ // A key is written as a name relative to the key directory — `stripe.txt`,
724
+ // not `.secrets/stripe.txt` — so the advice says the name to write.
725
+ const name = keyName(config, target);
726
+ return {
727
+ allowed: false, owners: [],
728
+ reason: `no role declares ${target} — add "${name}" to a role's keys: [roles.<name>] keys = ["${name}"]`,
729
+ };
730
+ }
436
731
  return { allowed: false, owners: holders, reason: `${target} belongs to ${holders.join(", ")}` };
437
732
  }
438
733
 
@@ -442,11 +737,18 @@ export function explain(config, role, action, target) {
442
737
  *
443
738
  * The policy, seisin's state, the key directories, what the parent executes
444
739
  * or reads for a key, and the files that make git or Claude Code run
445
- * something outside the box. The kernel denies all of them to every role —
740
+ * something outside the sandbox. The kernel denies all of them to every role —
446
741
  * surface.js has the list and the reasons — so saying "belongs to dev" about
447
742
  * `seisin.toml` sent a request to a person that no grant could satisfy.
448
743
  */
449
- const guard = protectedBy(config, target);
744
+ const guard = protectedBy(config, target, { role });
745
+ if (guard?.family)
746
+ return {
747
+ allowed: false, owners, protected: guard.why, family: guard.family,
748
+ reason: `${target} is protected (${guard.why}). A role writes it only when the policy hands it ` +
749
+ `that family: control_files = ["${guard.family}"] under [roles.${role}], and inside its own ` +
750
+ `territory. That is a person's decision about the policy, not a grant to request`,
751
+ };
450
752
  if (guard)
451
753
  return {
452
754
  allowed: false, owners, protected: guard.why,
@@ -460,13 +762,15 @@ export function explain(config, role, action, target) {
460
762
  * approves it, and the subtraction is undone from the other side without
461
763
  * anyone deciding to undo it.
462
764
  */
765
+ const shut = privateAnswer(config, role, target);
766
+ if (shut) return shut;
463
767
  const r = config.roles[role];
464
768
  const barred = r && r.writes.some((g) => covers(g, target)) ? neverWrites(r, target, config) : null;
465
769
  if (barred)
466
770
  return {
467
771
  allowed: false, owners, neverWrites: barred,
468
772
  reason: `${target} is denied by never_writes of ${role} ("${barred}") — ` +
469
- `a subtraction written into the policy, not a missing permission`,
773
+ `a subtraction written into the policy, not a missing grant`,
470
774
  };
471
775
  if (owners.includes(role)) return { allowed: true, owners, reason: `${target} is inside ${role}'s territory` };
472
776
  /**
@@ -477,17 +781,32 @@ export function explain(config, role, action, target) {
477
781
  * else's history. Codex keeps `.git` read-only under a writable root for the
478
782
  * same reason; nono and Gemini grant the whole of it. Here it stays refused
479
783
  * and is said for what it is — not "belongs to X, ask them", which filed a
480
- * request per lock file (327 of them for one index.lock on one portfolio)
481
- * that nobody could sensibly approve. The requests side skips it too.
784
+ * request per lock file (327 of them for one index.lock in one multi-repo workspace)
785
+ * that nobody could sensibly grant. The requests side skips it too.
482
786
  */
483
787
  if (isGitMetadata(target))
484
788
  return {
485
789
  allowed: false, owners, gitMetadata: true,
486
790
  reason: `${target} is git's own bookkeeping in a repository ${role} does not write — ` +
487
- `not a permission to ask for. Read what you need (git log, git show, git ls-remote) ` +
791
+ `not something to request. Read what you need (git log, git show, git ls-remote) ` +
488
792
  `or work in a worktree of your own`,
489
793
  };
490
794
  if (owners.length === 0)
491
795
  return { allowed: false, owners: [], reason: `${target} has no owner — no role can write it until one claims it` };
492
796
  return { allowed: false, owners, reason: `${target} belongs to ${owners.join(", ")}` };
493
797
  }
798
+
799
+ /**
800
+ * One public answer for CLI, MCP and library callers.
801
+ *
802
+ * Reads first decide whether the target is a declared key or a file, then ask
803
+ * the same territory/isolation question. Callers may supply their cwd; servers
804
+ * use the policy root, while the CLI uses the directory it is standing in.
805
+ */
806
+ export function explain(config, role, action, target, cwd = config.root) {
807
+ if (action !== "read") return explainPolicy(config, role, action, target);
808
+ const asked = readTarget(config, target, cwd);
809
+ return asked.key
810
+ ? explainPolicy(config, role, "read", asked.target)
811
+ : explainFileRead(config, role, asked.target, CREDENTIAL_HOMES.map(expand));
812
+ }
package/src/paths.js CHANGED
@@ -12,7 +12,7 @@
12
12
  * project refuses. Found by the read-only review of 2026-09-22.
13
13
  */
14
14
  import { realpathSync } from "node:fs";
15
- import { basename, dirname, isAbsolute, join, relative, sep } from "node:path";
15
+ import { basename, dirname, isAbsolute, join, normalize, relative, sep } from "node:path";
16
16
 
17
17
  /**
18
18
  * `p` with its deepest existing ancestor resolved through symlinks and the rest
@@ -23,7 +23,8 @@ export function realAncestor(p) {
23
23
  let head = p, tail = "";
24
24
  while (head !== dirname(head)) {
25
25
  try {
26
- const real = realpathSync(head);
26
+ // `.native`: the disk's own case on APFS — see realOrSelf in grants.js.
27
+ const real = realpathSync.native(head);
27
28
  return tail ? join(real, tail) : real;
28
29
  } catch {
29
30
  tail = tail ? join(basename(head), tail) : basename(head);
@@ -33,6 +34,13 @@ export function realAncestor(p) {
33
34
  return p;
34
35
  }
35
36
 
37
+ /**
38
+ * The four characters the glob matcher treats as wildcards. Here, in a module
39
+ * that imports nothing of seisin's, so that owners.js and grants.js share one
40
+ * definition without importing each other.
41
+ */
42
+ export const WILD = /[*?[\]]/;
43
+
36
44
  /**
37
45
  * Repo-relative when `target` is inside the repo, spelled either way; exactly as
38
46
  * written when it is relative already or lies outside. A path outside keeps its
@@ -48,3 +56,47 @@ export function toRepoRelative(config, target) {
48
56
  if (inside(realRoot, realTarget)) return relative(realRoot, realTarget) || ".";
49
57
  return target;
50
58
  }
59
+
60
+ /**
61
+ * One spelling for a path, so two of them can be compared with `===`.
62
+ *
63
+ * Repo-relative and normalized inside the repo (`./a/`, `a/../a` and the
64
+ * absolute path through either symlinked spelling of the root are all `a`);
65
+ * absolute and resolved through its existing ancestors outside it. Used where
66
+ * a path a caller gave must match the path a denial names exactly — never as
67
+ * a prefix and never as a glob.
68
+ */
69
+ export function samePath(config, target) {
70
+ if (typeof target !== "string" || !target.trim()) return null;
71
+ const rel = toRepoRelative(config, isAbsolute(target) ? normalize(target) : target);
72
+ if (isAbsolute(rel)) return realAncestor(normalize(rel)).replace(/(.)\/+$/, "$1");
73
+ return normalize(rel).replace(/\/+$/, "") || ".";
74
+ }
75
+
76
+ /**
77
+ * A path typed at a prompt, made absolute the way a shell user means it:
78
+ * against the directory they are standing in. Relative arguments used to be
79
+ * read against the policy's root, so `seisin explain web write app.js` from
80
+ * web/src asked about ./app.js at the root and answered "no owner".
81
+ *
82
+ * Only when the working directory is inside the repo. From anywhere else —
83
+ * `SEISIN_CONFIG` pointing at a policy elsewhere, a test harness — a relative
84
+ * path keeps meaning what it always meant: relative to the root.
85
+ */
86
+ export function fromCwd(config, target, cwd = process.cwd()) {
87
+ if (typeof target !== "string" || isAbsolute(target)) return target;
88
+ const root = config.root;
89
+ const inside = (r, t) => t === r || t.startsWith(r + sep);
90
+ // The cwd and the root compared in the same spelling: as written, or both
91
+ // through their symlinks (on macOS /tmp is /private/tmp).
92
+ let base = root, here = cwd;
93
+ if (!inside(root, cwd)) {
94
+ base = realAncestor(root);
95
+ here = realAncestor(cwd);
96
+ if (!inside(base, here)) return target;
97
+ }
98
+ const rel = relative(base, join(here, target));
99
+ if (rel === "") return root;
100
+ if (rel === ".." || rel.startsWith(".." + sep)) return join(here, target);
101
+ return join(root, rel);
102
+ }