seisin 0.4.0 → 0.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (50) hide show
  1. package/README.md +151 -67
  2. package/package.json +3 -3
  3. package/src/cli.js +145 -46
  4. package/src/commands/check.js +3 -2
  5. package/src/commands/explain.js +34 -13
  6. package/src/commands/hook.js +14 -4
  7. package/src/commands/init.js +126 -30
  8. package/src/commands/log.js +55 -10
  9. package/src/commands/requests.js +124 -39
  10. package/src/commands/review.js +41 -16
  11. package/src/commands/run.js +174 -39
  12. package/src/commands/scan.js +18 -3
  13. package/src/commands/ui.js +18 -5
  14. package/src/commands/walls.js +25 -6
  15. package/src/commands/whose.js +7 -6
  16. package/src/commands/wire.js +156 -17
  17. package/src/config.js +261 -27
  18. package/src/controls.js +244 -0
  19. package/src/diagnose.js +35 -33
  20. package/src/env.js +11 -8
  21. package/src/grants.js +15 -7
  22. package/src/help.js +159 -0
  23. package/src/hook.js +49 -19
  24. package/src/index.js +4 -4
  25. package/src/inspect.js +137 -71
  26. package/src/intake.js +24 -16
  27. package/src/keys.js +13 -11
  28. package/src/kinds.js +207 -0
  29. package/src/log.js +312 -55
  30. package/src/mcp.js +96 -43
  31. package/src/notify.js +15 -10
  32. package/src/owners.js +323 -41
  33. package/src/paths.js +37 -1
  34. package/src/redact.js +19 -5
  35. package/src/render.js +81 -12
  36. package/src/requests.js +250 -72
  37. package/src/review.js +42 -21
  38. package/src/rundir.js +4 -6
  39. package/src/scan.js +177 -19
  40. package/src/serve.js +395 -205
  41. package/src/spool.js +9 -29
  42. package/src/srt.js +38 -21
  43. package/src/suggest.js +58 -0
  44. package/src/surface.js +203 -42
  45. package/src/uilink.js +10 -4
  46. package/src/views.js +289 -0
  47. package/src/violations.js +104 -16
  48. package/src/walls.js +19 -42
  49. package/src/worktree.js +3 -11
  50. package/ui/index.html +1366 -529
package/src/owners.js CHANGED
@@ -10,9 +10,12 @@
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";
13
+ import { createHash } from "node:crypto";
14
+ import { existsSync, readFileSync, readdirSync } from "node:fs";
15
+ import { isAbsolute, join } from "node:path";
15
16
  import { protectedBy } from "./surface.js";
17
+ import { entriesOf } from "./keys.js";
18
+ import { WILD, fromCwd, toRepoRelative } from "./paths.js";
16
19
 
17
20
  /**
18
21
  * Does this glob cover this path? Supports `**`, `*`, `?` and a trailing `/`.
@@ -42,10 +45,24 @@ import { protectedBy } from "./surface.js";
42
45
  export function covers(glob, path) {
43
46
  const p = normalized(path);
44
47
  const m = matcherOf(glob);
48
+ /**
49
+ * Above the root only for a glob that climbs there itself.
50
+ *
51
+ * `../../etc/passwd` is a path the kernel refuses to every role, and `**`
52
+ * matched it as a string, so `explain` answered "inside territory" for it.
53
+ * A territory reaches outside the repo only by saying so — the
54
+ * `../shared/lab/dev.md` a role writes beside its own directory — and
55
+ * that one still matches, because both sides start with the same `..`.
56
+ */
57
+ if (climbs(p) && !m.climbs) return false;
45
58
  // The path itself, or anything beneath it. The `/` is load-bearing: without
46
59
  // it `src/api` would also cover `src/apifoo.ts`, which the kernel does not.
47
60
  if (m.prefix !== null) return p === m.prefix || p.startsWith(m.prefix + "/");
48
- return m.re.test(p);
61
+ // The literal head of the glob, before its first wildcard, has to be the
62
+ // head of the path too. Most pairs fail here, for the price of a
63
+ // startsWith — `check` asks ~400k of them on a real policy.
64
+ if (!p.startsWith(m.head)) return false;
65
+ return matchGlob(m.glob, p);
49
66
  }
50
67
 
51
68
  /**
@@ -68,7 +85,8 @@ function matcherOf(glob) {
68
85
  if (m) return m;
69
86
  let g = normalize(glob);
70
87
  if (glob.endsWith("/")) g += "/**";
71
- m = WILD.test(g) ? { prefix: null, re: toRegExp(g) } : { prefix: g, re: null };
88
+ const wild = WILD.test(g);
89
+ m = { prefix: wild ? null : g, glob: wild ? g : null, climbs: climbs(g), head: wild ? headOf(g) : "" };
72
90
  if (GLOBS.size >= MAX_CACHED) GLOBS.clear();
73
91
  GLOBS.set(glob, m);
74
92
  return m;
@@ -83,8 +101,15 @@ function normalized(path) {
83
101
  return p;
84
102
  }
85
103
 
86
- /** The four characters toRegExp() treats as wildcards. */
87
- const WILD = /[*?[\]]/;
104
+ /**
105
+ * What every path a glob matches starts with: its text up to the first
106
+ * wildcard, minus a final `/` — `src/api/**` matches `src/api` itself, and
107
+ * `a/**\/b` matches `a/b`.
108
+ */
109
+ const headOf = (g) => g.slice(0, g.search(WILD)).replace(/\/$/, "");
110
+
111
+ /** Does a normalised path start above the repo root? */
112
+ const climbs = (p) => p === ".." || p.startsWith("../");
88
113
 
89
114
  /**
90
115
  * One spelling per file, before anybody decides anything about it.
@@ -96,8 +121,10 @@ const WILD = /[*?[\]]/;
96
121
  * every sentence seisin said about it was wrong, which is the half of the tool
97
122
  * that is actually ours.
98
123
  *
99
- * A path that climbs above the repo root comes back as `..`, which owns
100
- * nothing and matches nothing. Outside the repo has no owner by definition.
124
+ * A path that climbs above the repo root keeps its leading `..` segments
125
+ * (`src/../../x` is `../x`). `covers` matches such a path only against a glob
126
+ * that climbs out too, so outside the repo has no owner unless a territory
127
+ * names that place explicitly.
101
128
  */
102
129
  export function normalize(s) {
103
130
  const flat = s.replace(/\\/g, "/").replace(/\/+/g, "/").replace(/\/+$/, "");
@@ -110,13 +137,23 @@ export function normalize(s) {
110
137
  return up.join("/");
111
138
  }
112
139
 
140
+ /** matchGlob's memo, shared between calls (see there). */
141
+ let MEMO = new Uint8Array(4096);
142
+
113
143
  /**
114
- * Glob to RegExp.
144
+ * Does `glob` cover `path`? A linear matcher, not a regular expression.
145
+ *
146
+ * The regex form (`**` → `.*`, `**\/` → `(?:.*\/)?`) put several unbounded `.*`
147
+ * next to literals, and on a long path that does not match, the engine
148
+ * backtracks catastrophically: measured on a crafted glob, 9 `**` took ~26 s
149
+ * and 11 hung for over a minute. Since a request's target — which an agent
150
+ * chooses — becomes a glob here (`grantFor`), that is a denial-of-service on the
151
+ * hook and on `explain`. This matcher is memoised on (glob index, path index),
152
+ * so its cost is bounded by their product and no input backtracks.
115
153
  *
116
- * The wildcards are parked on sentinels before `*` is expanded, because
117
- * expanding `**` first produces a `.*` whose own `*` the next pass would
118
- * rewrite again. Getting that order wrong is silent: the regex still compiles
119
- * and quietly matches the wrong set of files.
154
+ * Same wildcards as before: `?` one non-`/`, `*` a run of non-`/`, `**` any run
155
+ * including `/`, `**\/` zero or more whole directories, a trailing `/**` the
156
+ * subtree. Characters are compared literally; nothing is treated as regex.
120
157
  *
121
158
  * `src/api/**` also matches `src/api` itself. Without that, the directory a
122
159
  * role was just granted comes back ownerless, which reads as a bug every time.
@@ -126,18 +163,63 @@ export function normalize(s) {
126
163
  * must not: covering one file too many costs an argument, covering one too few
127
164
  * is the hole. When the two readings disagree, this one takes the wider set.
128
165
  */
129
- function toRegExp(glob) {
130
- const out = glob
131
- .replace(/[.+^${}()|[\]\\]/g, "\\$&")
132
- .replace(/\/\*\*$/, "\u0001")
133
- .replace(/\*\*\//g, "\u0002")
134
- .replace(/\*\*/g, "\u0003")
135
- .replace(/\*/g, "[^/]*")
136
- .replace(/\?/g, "[^/]")
137
- .replace(/\u0001/g, "(?:/.*)?")
138
- .replace(/\u0002/g, "(?:.*/)?")
139
- .replace(/\u0003/g, ".*");
140
- return new RegExp("^" + out + "$");
166
+ function matchGlob(glob, path) {
167
+ const G = glob.length, P = path.length;
168
+ // memo[gi * (P+1) + pi]: 0 unknown, 1 true, 2 false. One flat array, kept
169
+ // between calls and cleared over the part this call uses: allocating it
170
+ // fresh was most of the cost of a call that fails on its second character.
171
+ // Nothing re-enters matchGlob while it runs, so sharing it is safe.
172
+ // An outsized pair gets its own array, so one long path does not pin
173
+ // megabytes for the life of a server.
174
+ const size = (G + 1) * (P + 1);
175
+ let memo;
176
+ if (size > 1 << 20) memo = new Uint8Array(size);
177
+ else {
178
+ if (MEMO.length < size) MEMO = new Uint8Array(Math.max(size, MEMO.length * 2));
179
+ memo = MEMO;
180
+ memo.fill(0, 0, size);
181
+ }
182
+ const at = (gi, pi) => {
183
+ for (;;) {
184
+ const key = gi * (P + 1) + pi;
185
+ const seen = memo[key];
186
+ if (seen) return seen === 1;
187
+ let res;
188
+ if (gi === G) { res = pi === P; }
189
+ else if (glob[gi] === "*" && glob[gi + 1] === "*") {
190
+ if (glob[gi + 2] === "/") {
191
+ // `**/` = zero or more whole directories: empty, or any chars ending
192
+ // at a `/` then the rest.
193
+ if (at(gi + 3, pi)) res = true;
194
+ else if (pi < P) {
195
+ if (path[pi] === "/" && at(gi + 3, pi + 1)) res = true;
196
+ else { memo[key] = 2; pi++; continue; } // consume one char, stay on `**/`
197
+ } else res = false;
198
+ } else {
199
+ // `**` = any run including `/` (also covers `**` at the very end,
200
+ // where `at(gi+2, pi)` requires pi to reach the end).
201
+ if (at(gi + 2, pi)) res = true;
202
+ else if (pi < P) { memo[key] = 2; pi++; continue; } // consume one char, stay on `**`
203
+ else res = false;
204
+ }
205
+ } else if (glob[gi] === "/" && glob[gi + 1] === "*" && glob[gi + 2] === "*" && gi + 3 === G) {
206
+ // trailing `/**`: the directory itself, or anything beneath it.
207
+ res = pi === P || path[pi] === "/";
208
+ } else if (glob[gi] === "*") {
209
+ // `*` = a run of non-`/`.
210
+ if (at(gi + 1, pi)) res = true;
211
+ else if (pi < P && path[pi] !== "/") { memo[key] = 2; pi++; continue; }
212
+ else res = false;
213
+ } else if (glob[gi] === "?") {
214
+ res = pi < P && path[pi] !== "/" && at(gi + 1, pi + 1);
215
+ } else {
216
+ res = pi < P && glob[gi] === path[pi] && at(gi + 1, pi + 1);
217
+ }
218
+ memo[key] = res ? 1 : 2;
219
+ return res;
220
+ }
221
+ };
222
+ return at(0, 0);
141
223
  }
142
224
 
143
225
  /** Is this path inside a `.git` directory (git's metadata, not a working file)? */
@@ -166,6 +248,88 @@ export function ownersOf(config, path) {
166
248
  .map((r) => r.name);
167
249
  }
168
250
 
251
+ /**
252
+ * Where a refused target stands against the policy as it is now.
253
+ *
254
+ * One of four, and only the last one is a decision somebody has to make:
255
+ *
256
+ * outside not a place in the map at all — a port, a key directory, or a
257
+ * path outside the repository, which no `writes` reaches
258
+ * protected closed to every role on purpose (surface.js); the boundary
259
+ * working, with nothing to grant
260
+ * owned some role's territory today
261
+ * unowned in the repository, ownable, and nobody claims it
262
+ *
263
+ * Computed from the policy, never from the `owners` a log line carries. That
264
+ * value is what was true the day of the refusal; every grant made since makes
265
+ * it more wrong, so a screen reading it gets worse the more seisin is used the
266
+ * way it is meant to be. Measured on one deployment: the console said 1,178 of
267
+ * 1,486 refused paths had no owner; against the policy of that day it was 316.
268
+ * The rest had been claimed since, were protected, or were not paths at all.
269
+ */
270
+ export function standing(config, entry) {
271
+ const target = entry.target;
272
+ if (entry.kind === "network")
273
+ return { kind: "outside", owners: [], why: "a port or a socket, which no role owns" };
274
+ if (entry.kind === "key")
275
+ return { kind: "outside", owners: [], why: "a key directory, closed on purpose" };
276
+ const guard = protectedBy(config, target);
277
+ // A family the policy hands to a role is protected from everybody else and
278
+ // owned by that role — if it is also that role's territory.
279
+ if (guard?.family) {
280
+ const holders = ownersOf(config, target).filter((o) => config.roles[o]?.controlFiles?.includes(guard.family));
281
+ if (holders.length) return { kind: "owned", owners: holders };
282
+ }
283
+ if (guard) return { kind: "protected", owners: [], why: guard.why };
284
+ if (target.startsWith("/")) {
285
+ const root = config.root?.endsWith("/") ? config.root : `${config.root}/`;
286
+ if (!config.root || !target.startsWith(root))
287
+ return { kind: "outside", owners: [], why: "outside the repository, where no role's territory reaches" };
288
+ }
289
+ const owners = ownersOf(config, target);
290
+ return { kind: owners.length ? "owned" : "unowned", owners };
291
+ }
292
+
293
+ /**
294
+ * `standing`, remembered while the policy stays the same.
295
+ *
296
+ * The console asks for its whole state every two seconds, and a real log has
297
+ * well over a thousand distinct refused paths: classifying all of them costs
298
+ * most of a second each time (measured: ~400 ms in protectedBy, ~335 ms in
299
+ * ownersOf), on a call that already took about that long. What a path's
300
+ * standing depends on is the policy file, so that is the key. It also depends,
301
+ * a little, on the disk — on Linux a control file is protected only once it
302
+ * exists — so the memory is dropped after a minute as well, which bounds how
303
+ * stale an answer can be without paying for it on every poll.
304
+ *
305
+ * A config that was not read from a file (tests, embedders) gets a memory for
306
+ * this call only.
307
+ */
308
+ const STANDING_TTL = 60_000;
309
+ let standingMemo = { key: null, at: 0, map: new Map() };
310
+
311
+ export function standingOf(config, { now = Date.now() } = {}) {
312
+ let map;
313
+ let key = null;
314
+ try {
315
+ if (config.path) key = `${config.path}\u0000${createHash("sha256").update(readFileSync(config.path)).digest("hex")}`;
316
+ } catch {
317
+ key = null;
318
+ }
319
+ if (key === null) map = new Map();
320
+ else {
321
+ if (standingMemo.key !== key || now - standingMemo.at > STANDING_TTL)
322
+ standingMemo = { key, at: now, map: new Map() };
323
+ map = standingMemo.map;
324
+ }
325
+ return (entry) => {
326
+ const k = `${entry.kind ?? ""}\u0000${entry.target}`;
327
+ let s = map.get(k);
328
+ if (!s) map.set(k, (s = standing(config, entry)));
329
+ return s;
330
+ };
331
+ }
332
+
169
333
  /**
170
334
  * The `never_writes` entries the kernel will actually be given for this role.
171
335
  *
@@ -198,6 +362,92 @@ export function neverWrites(role, path, config = null) {
198
362
  return enforcedNeverWrites(config, role).find((g) => covers(g, path)) ?? null;
199
363
  }
200
364
 
365
+ /** A key dir as the policy's paths spell it: no leading `./`, no trailing `/`. */
366
+ const dirSpelling = (d) => String(d).replace(/^\.\/+/, "").replace(/\/+$/, "");
367
+
368
+ /** Is the repo-relative `rel` one of the key directories, or inside one? */
369
+ export function inKeyDir(config, rel) {
370
+ return (config.keyDirs ?? []).some((d) => {
371
+ const k = dirSpelling(d);
372
+ const r = k.startsWith("/") ? relFromRoot(config, k) : k;
373
+ return r !== null && (rel === r || rel.startsWith(r + "/"));
374
+ });
375
+ }
376
+
377
+ function relFromRoot(config, abs) {
378
+ const root = config.root ?? "";
379
+ return abs === root ? "." : abs.startsWith(root + "/") ? abs.slice(root.length + 1) : null;
380
+ }
381
+
382
+ /**
383
+ * How a key is written in a role's `keys` list: relative to the first key
384
+ * directory when it lives there, with its directory otherwise — the same form
385
+ * settingsFor and keyHolders resolve back.
386
+ */
387
+ export function keyName(config, target) {
388
+ const t = String(target ?? "");
389
+ const first = config.keyDirs?.length ? dirSpelling(config.keyDirs[0]) : null;
390
+ return first && t.startsWith(first + "/") ? t.slice(first.length + 1) : t;
391
+ }
392
+
393
+ /**
394
+ * A read of a FILE — a path, not a key name — and whether the role gets it.
395
+ *
396
+ * Reads are open by design: every role reads everything outside the `[keys]`
397
+ * dirs (and, under `isolate`, outside the credential homes). `explain` used to
398
+ * send every read through the key question, so `explain dev read .env` said
399
+ * "denied — no role declares .env" about a file every role could read. A wrong
400
+ * no from the tool whose job is saying what the kernel will do. Now a path
401
+ * inside a key directory is a key question; anything else is answered as the
402
+ * open read it is, with how to make it a key.
403
+ */
404
+ export function explainFileRead(config, role, rel, credentialHomes = []) {
405
+ // Only a key directory makes a key: settingsFor refuses a key declared
406
+ // anywhere else, so nothing outside one is denied by being a key.
407
+ if (inKeyDir(config, rel)) return explain(config, role, "read", rel);
408
+ const level = config.isolate === true ? "home" : config.isolate;
409
+ if ((level === "credentials" || level === "home") && rel.startsWith("/")) {
410
+ const hit = credentialHomes.find((h) => rel === h || rel.startsWith(h + "/"));
411
+ if (hit)
412
+ return {
413
+ allowed: false, owners: [],
414
+ reason: `${rel} is under ${hit}, which isolate = "${level}" closes to every role`,
415
+ };
416
+ }
417
+ const dirs = (config.keyDirs ?? []).map(dirSpelling);
418
+ return {
419
+ allowed: true, owners: [], open: true,
420
+ reason: dirs.length
421
+ ? `${rel} is outside every [keys] dir (${dirs.join(", ")}), so every role reads it. ` +
422
+ `Move it into ${dirs[0]}/ to make it a key`
423
+ : `no [keys] dir is declared, so every role reads every file. ` +
424
+ `Declare one under [keys] and move ${rel} into it to make it a key`,
425
+ };
426
+ }
427
+
428
+ /**
429
+ * What a read is about: a key, named the way the policy names keys, or a file.
430
+ *
431
+ * `explain dev read stripe` asks about a key — a bare name some role declares,
432
+ * or one sitting in a key directory. Everything else is a path, resolved from
433
+ * where you stand, and inside a key directory it is a key question again.
434
+ */
435
+ export function readTarget(config, target, cwd = process.cwd()) {
436
+ if (!target.includes("/") && !isAbsolute(target)) {
437
+ const declared = keyHolders(config, target).length > 0;
438
+ const bare = (s) => s.replace(/\.[^.]+$/, "");
439
+ const inDir = (config.keyDirs ?? []).some((d) => {
440
+ const dir = isAbsolute(d) ? d : join(config.root, d);
441
+ if (existsSync(join(dir, target))) return true;
442
+ try { return readdirSync(dir).some((f) => bare(f) === target); } catch { return false; }
443
+ });
444
+ const here = fromCwd(config, target, cwd);
445
+ const isFileHere = existsSync(here) && !inKeyDir(config, toRepoRelative(config, here));
446
+ if ((declared || inDir) && !isFileHere) return { key: true, target };
447
+ }
448
+ return { key: false, target: toRepoRelative(config, fromCwd(config, target, cwd)) };
449
+ }
450
+
201
451
  /** Every role allowed to read `key`, matched with or without its extension. */
202
452
  export function keyHolders(config, key) {
203
453
  const dirs = config.keyDirs ?? [];
@@ -224,19 +474,30 @@ export function keyHolders(config, key) {
224
474
  // netlify` — so it stays, and only for an unqualified question.
225
475
  const loose = !key.includes("/");
226
476
 
477
+ /**
478
+ * Only path keys. A reference — `keychain://x`, `NAME=file://.secrets/a#X` —
479
+ * is resolved by the parent and delivered as a value; the role is never
480
+ * given the file. Comparing the raw strings let `explain` say "declares
481
+ * a.env" about a role whose key merely pointed into it, a yes the kernel
482
+ * would refuse.
483
+ */
227
484
  return Object.values(config.roles)
228
- .filter((r) => r.keys.some((k) =>
485
+ .filter((r) => fileKeys(r).some((k) =>
229
486
  resolve(k) === wanted || (loose && bare(k) === bare(key))))
230
487
  .map((r) => r.name);
231
488
  }
232
489
 
233
- /**
234
- * The sentence a blocked agent should read.
235
- *
236
- * Three outcomes, and the third is the one worth having: a path nobody owns is
237
- * not a permission problem, it is a hole in the map. Saying so is more useful
238
- * than denying quietly, and it is the only way the hole ever gets fixed.
239
- */
490
+ /** The keys of `role` that grant a read: the path ones. A malformed entry grants nothing. */
491
+ function fileKeys(role) {
492
+ let entries;
493
+ try {
494
+ entries = entriesOf(role);
495
+ } catch {
496
+ return [];
497
+ }
498
+ return entries.filter((e) => e.kind === "file").map((e) => e.raw);
499
+ }
500
+
240
501
  /**
241
502
  * A connection the kernel refused: `tcp:<port>` or a unix socket path.
242
503
  *
@@ -300,14 +561,28 @@ function explainMcp(config, role, name) {
300
561
  : `${role} declares mcp = [], no MCP servers` };
301
562
  }
302
563
 
564
+ /**
565
+ * The sentence a blocked agent should read.
566
+ *
567
+ * Three outcomes, and the third is the one worth having: a path nobody owns is
568
+ * not a permission problem, it is a hole in the map. Saying so is more useful
569
+ * than denying quietly, and it is the only way the hole ever gets fixed.
570
+ */
303
571
  export function explain(config, role, action, target) {
304
572
  if (action === "connect") return explainConnect(config, role, target);
305
573
  if (action === "mcp") return explainMcp(config, role, target);
306
574
  if (action === "read") {
307
575
  const holders = keyHolders(config, target);
308
576
  if (holders.includes(role)) return { allowed: true, owners: holders, reason: `${role} declares ${target}` };
309
- if (holders.length === 0)
310
- return { allowed: false, owners: [], reason: `no role declares ${target} — add it under a [roles.<name>] keys list` };
577
+ if (holders.length === 0) {
578
+ // A key is written as a name relative to the key directory — `stripe.txt`,
579
+ // not `.secrets/stripe.txt` — so the advice says the name to write.
580
+ const name = keyName(config, target);
581
+ return {
582
+ allowed: false, owners: [],
583
+ reason: `no role declares ${target} — add "${name}" to a role's keys: [roles.<name>] keys = ["${name}"]`,
584
+ };
585
+ }
311
586
  return { allowed: false, owners: holders, reason: `${target} belongs to ${holders.join(", ")}` };
312
587
  }
313
588
 
@@ -317,11 +592,18 @@ export function explain(config, role, action, target) {
317
592
  *
318
593
  * The policy, seisin's state, the key directories, what the parent executes
319
594
  * 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 —
595
+ * something outside the sandbox. The kernel denies all of them to every role —
321
596
  * surface.js has the list and the reasons — so saying "belongs to dev" about
322
597
  * `seisin.toml` sent a request to a person that no grant could satisfy.
323
598
  */
324
- const guard = protectedBy(config, target);
599
+ const guard = protectedBy(config, target, { role });
600
+ if (guard?.family)
601
+ return {
602
+ allowed: false, owners, protected: guard.why, family: guard.family,
603
+ reason: `${target} is protected (${guard.why}). A role writes it only when the policy hands it ` +
604
+ `that family: control_files = ["${guard.family}"] under [roles.${role}], and inside its own ` +
605
+ `territory. That is a person's decision about the policy, not a grant to request`,
606
+ };
325
607
  if (guard)
326
608
  return {
327
609
  allowed: false, owners, protected: guard.why,
@@ -341,7 +623,7 @@ export function explain(config, role, action, target) {
341
623
  return {
342
624
  allowed: false, owners, neverWrites: barred,
343
625
  reason: `${target} is denied by never_writes of ${role} ("${barred}") — ` +
344
- `a subtraction written into the policy, not a missing permission`,
626
+ `a subtraction written into the policy, not a missing grant`,
345
627
  };
346
628
  if (owners.includes(role)) return { allowed: true, owners, reason: `${target} is inside ${role}'s territory` };
347
629
  /**
@@ -352,14 +634,14 @@ export function explain(config, role, action, target) {
352
634
  * else's history. Codex keeps `.git` read-only under a writable root for the
353
635
  * same reason; nono and Gemini grant the whole of it. Here it stays refused
354
636
  * 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.
637
+ * request per lock file (327 of them for one index.lock in one multi-repo workspace)
638
+ * that nobody could sensibly grant. The requests side skips it too.
357
639
  */
358
640
  if (isGitMetadata(target))
359
641
  return {
360
642
  allowed: false, owners, gitMetadata: true,
361
643
  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) ` +
644
+ `not something to request. Read what you need (git log, git show, git ls-remote) ` +
363
645
  `or work in a worktree of your own`,
364
646
  };
365
647
  if (owners.length === 0)
package/src/paths.js CHANGED
@@ -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,31 @@ export function toRepoRelative(config, target) {
48
56
  if (inside(realRoot, realTarget)) return relative(realRoot, realTarget) || ".";
49
57
  return target;
50
58
  }
59
+
60
+ /**
61
+ * A path typed at a prompt, made absolute the way a shell user means it:
62
+ * against the directory they are standing in. Relative arguments used to be
63
+ * read against the policy's root, so `seisin explain web write app.js` from
64
+ * web/src asked about ./app.js at the root and answered "no owner".
65
+ *
66
+ * Only when the working directory is inside the repo. From anywhere else —
67
+ * `SEISIN_CONFIG` pointing at a policy elsewhere, a test harness — a relative
68
+ * path keeps meaning what it always meant: relative to the root.
69
+ */
70
+ export function fromCwd(config, target, cwd = process.cwd()) {
71
+ if (typeof target !== "string" || isAbsolute(target)) return target;
72
+ const root = config.root;
73
+ const inside = (r, t) => t === r || t.startsWith(r + sep);
74
+ // The cwd and the root compared in the same spelling: as written, or both
75
+ // through their symlinks (on macOS /tmp is /private/tmp).
76
+ let base = root, here = cwd;
77
+ if (!inside(root, cwd)) {
78
+ base = realAncestor(root);
79
+ here = realAncestor(cwd);
80
+ if (!inside(base, here)) return target;
81
+ }
82
+ const rel = relative(base, join(here, target));
83
+ if (rel === "") return root;
84
+ if (rel === ".." || rel.startsWith(".." + sep)) return join(here, target);
85
+ return join(root, rel);
86
+ }
package/src/redact.js CHANGED
@@ -20,6 +20,7 @@
20
20
  * README spends its time avoiding.
21
21
  */
22
22
  import { Transform } from "node:stream";
23
+ import { StringDecoder } from "node:string_decoder";
23
24
 
24
25
  /** Reads the values this role may read. Unreadable or huge files are skipped. */
25
26
  export function secretsOf(settings, readFile) {
@@ -49,10 +50,18 @@ export function secretsOf(settings, readFile) {
49
50
  * It carries the tail of each chunk forward, because a value split across a
50
51
  * buffer boundary would otherwise sail through — which is the one case a
51
52
  * naive implementation always gets wrong and never notices.
53
+ *
54
+ * Bytes are decoded with a StringDecoder that lives as long as the stream. A
55
+ * chunk boundary falls wherever the pipe put it, including inside a multibyte
56
+ * character: decoding each chunk on its own turned `año` into `a��o`, and a
57
+ * secret with an `ñ` in it split that way was never seen whole, so it was
58
+ * never masked. The same care applies to the cut below, which must not leave
59
+ * half of a surrogate pair on either side.
52
60
  */
53
61
  export function redactor(secrets, label = "redacted") {
54
62
  if (secrets.length === 0) return null;
55
63
  const longest = secrets[0].length;
64
+ const decoder = new StringDecoder("utf8");
56
65
  let tail = "";
57
66
 
58
67
  return new Transform({
@@ -61,20 +70,25 @@ export function redactor(secrets, label = "redacted") {
61
70
  // — masking only the part about to be emitted — lets a secret that
62
71
  // straddles the cut through in two innocent halves. Written that way
63
72
  // first; the split-buffer test caught it on the first run.
64
- const masked = mask(tail + chunk.toString("utf8"), secrets, label);
65
- const keep = Math.min(longest - 1, masked.length);
66
- this.push(masked.slice(0, masked.length - keep));
67
- tail = masked.slice(masked.length - keep);
73
+ const text = typeof chunk === "string" ? chunk : decoder.write(chunk);
74
+ const masked = mask(tail + text, secrets, label);
75
+ let cut = masked.length - Math.min(longest - 1, masked.length);
76
+ if (cut > 0 && isHighSurrogate(masked.charCodeAt(cut - 1))) cut--;
77
+ if (cut > 0) this.push(masked.slice(0, cut));
78
+ tail = masked.slice(cut);
68
79
  done();
69
80
  },
70
81
  flush(done) {
71
- this.push(mask(tail, secrets, label));
82
+ const rest = mask(tail + decoder.end(), secrets, label);
83
+ if (rest) this.push(rest);
72
84
  tail = "";
73
85
  done();
74
86
  },
75
87
  });
76
88
  }
77
89
 
90
+ const isHighSurrogate = (c) => c >= 0xd800 && c <= 0xdbff;
91
+
78
92
  function mask(text, secrets, label) {
79
93
  let out = text;
80
94
  for (const s of secrets) out = out.split(s).join(`‹${label}›`);