seisin 0.4.2 → 0.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (50) hide show
  1. package/README.md +142 -54
  2. package/package.json +3 -3
  3. package/src/cli.js +145 -46
  4. package/src/commands/check.js +3 -2
  5. package/src/commands/explain.js +34 -13
  6. package/src/commands/hook.js +14 -4
  7. package/src/commands/init.js +126 -38
  8. package/src/commands/log.js +48 -9
  9. package/src/commands/requests.js +109 -32
  10. package/src/commands/review.js +41 -16
  11. package/src/commands/run.js +159 -37
  12. package/src/commands/scan.js +18 -3
  13. package/src/commands/ui.js +18 -5
  14. package/src/commands/walls.js +25 -6
  15. package/src/commands/whose.js +7 -6
  16. package/src/commands/wire.js +156 -17
  17. package/src/config.js +187 -25
  18. package/src/controls.js +244 -0
  19. package/src/diagnose.js +35 -33
  20. package/src/env.js +11 -8
  21. package/src/grants.js +15 -7
  22. package/src/help.js +159 -0
  23. package/src/hook.js +49 -19
  24. package/src/index.js +4 -4
  25. package/src/inspect.js +137 -71
  26. package/src/intake.js +20 -15
  27. package/src/keys.js +13 -11
  28. package/src/kinds.js +207 -0
  29. package/src/log.js +236 -51
  30. package/src/mcp.js +42 -28
  31. package/src/notify.js +13 -8
  32. package/src/owners.js +198 -41
  33. package/src/paths.js +37 -1
  34. package/src/redact.js +19 -5
  35. package/src/render.js +68 -10
  36. package/src/requests.js +173 -72
  37. package/src/review.js +30 -17
  38. package/src/rundir.js +4 -6
  39. package/src/scan.js +177 -19
  40. package/src/serve.js +377 -229
  41. package/src/spool.js +9 -29
  42. package/src/srt.js +38 -21
  43. package/src/suggest.js +58 -0
  44. package/src/surface.js +191 -44
  45. package/src/uilink.js +10 -4
  46. package/src/views.js +289 -0
  47. package/src/violations.js +104 -16
  48. package/src/walls.js +19 -42
  49. package/src/worktree.js +3 -11
  50. package/ui/index.html +1356 -537
package/src/owners.js CHANGED
@@ -11,9 +11,11 @@
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 } from "./keys.js";
18
+ import { WILD, fromCwd, toRepoRelative } from "./paths.js";
17
19
 
18
20
  /**
19
21
  * Does this glob cover this path? Supports `**`, `*`, `?` and a trailing `/`.
@@ -43,9 +45,23 @@ import { protectedBy } from "./surface.js";
43
45
  export function covers(glob, path) {
44
46
  const p = normalized(path);
45
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;
46
58
  // The path itself, or anything beneath it. The `/` is load-bearing: without
47
59
  // it `src/api` would also cover `src/apifoo.ts`, which the kernel does not.
48
60
  if (m.prefix !== null) return p === m.prefix || p.startsWith(m.prefix + "/");
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;
49
65
  return matchGlob(m.glob, p);
50
66
  }
51
67
 
@@ -69,7 +85,8 @@ function matcherOf(glob) {
69
85
  if (m) return m;
70
86
  let g = normalize(glob);
71
87
  if (glob.endsWith("/")) g += "/**";
72
- m = WILD.test(g) ? { prefix: null, glob: g } : { prefix: g, glob: null };
88
+ const wild = WILD.test(g);
89
+ m = { prefix: wild ? null : g, glob: wild ? g : null, climbs: climbs(g), head: wild ? headOf(g) : "" };
73
90
  if (GLOBS.size >= MAX_CACHED) GLOBS.clear();
74
91
  GLOBS.set(glob, m);
75
92
  return m;
@@ -84,8 +101,15 @@ function normalized(path) {
84
101
  return p;
85
102
  }
86
103
 
87
- /** The four characters toRegExp() treats as wildcards. */
88
- 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("../");
89
113
 
90
114
  /**
91
115
  * One spelling per file, before anybody decides anything about it.
@@ -97,8 +121,10 @@ const WILD = /[*?[\]]/;
97
121
  * every sentence seisin said about it was wrong, which is the half of the tool
98
122
  * that is actually ours.
99
123
  *
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.
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.
102
128
  */
103
129
  export function normalize(s) {
104
130
  const flat = s.replace(/\\/g, "/").replace(/\/+/g, "/").replace(/\/+$/, "");
@@ -111,22 +137,9 @@ export function normalize(s) {
111
137
  return up.join("/");
112
138
  }
113
139
 
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
- */
140
+ /** matchGlob's memo, shared between calls (see there). */
141
+ let MEMO = new Uint8Array(4096);
142
+
130
143
  /**
131
144
  * Does `glob` cover `path`? A linear matcher, not a regular expression.
132
145
  *
@@ -141,11 +154,31 @@ export function normalize(s) {
141
154
  * Same wildcards as before: `?` one non-`/`, `*` a run of non-`/`, `**` any run
142
155
  * including `/`, `**\/` zero or more whole directories, a trailing `/**` the
143
156
  * subtree. Characters are compared literally; nothing is treated as regex.
157
+ *
158
+ * `src/api/**` also matches `src/api` itself. Without that, the directory a
159
+ * role was just granted comes back ownerless, which reads as a bug every time.
160
+ *
161
+ * Dotfiles are matched like any other name: `*.env` covers `.env`. Shell globs
162
+ * hide them by default, and most glob libraries copy that. A permission tool
163
+ * must not: covering one file too many costs an argument, covering one too few
164
+ * is the hole. When the two readings disagree, this one takes the wider set.
144
165
  */
145
166
  function matchGlob(glob, path) {
146
167
  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));
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
+ }
149
182
  const at = (gi, pi) => {
150
183
  for (;;) {
151
184
  const key = gi * (P + 1) + pi;
@@ -241,6 +274,12 @@ export function standing(config, entry) {
241
274
  if (entry.kind === "key")
242
275
  return { kind: "outside", owners: [], why: "a key directory, closed on purpose" };
243
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
+ }
244
283
  if (guard) return { kind: "protected", owners: [], why: guard.why };
245
284
  if (target.startsWith("/")) {
246
285
  const root = config.root?.endsWith("/") ? config.root : `${config.root}/`;
@@ -323,6 +362,92 @@ export function neverWrites(role, path, config = null) {
323
362
  return enforcedNeverWrites(config, role).find((g) => covers(g, path)) ?? null;
324
363
  }
325
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
+
326
451
  /** Every role allowed to read `key`, matched with or without its extension. */
327
452
  export function keyHolders(config, key) {
328
453
  const dirs = config.keyDirs ?? [];
@@ -349,19 +474,30 @@ export function keyHolders(config, key) {
349
474
  // netlify` — so it stays, and only for an unqualified question.
350
475
  const loose = !key.includes("/");
351
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
+ */
352
484
  return Object.values(config.roles)
353
- .filter((r) => r.keys.some((k) =>
485
+ .filter((r) => fileKeys(r).some((k) =>
354
486
  resolve(k) === wanted || (loose && bare(k) === bare(key))))
355
487
  .map((r) => r.name);
356
488
  }
357
489
 
358
- /**
359
- * The sentence a blocked agent should read.
360
- *
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.
364
- */
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
+
365
501
  /**
366
502
  * A connection the kernel refused: `tcp:<port>` or a unix socket path.
367
503
  *
@@ -425,14 +561,28 @@ function explainMcp(config, role, name) {
425
561
  : `${role} declares mcp = [], no MCP servers` };
426
562
  }
427
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
+ */
428
571
  export function explain(config, role, action, target) {
429
572
  if (action === "connect") return explainConnect(config, role, target);
430
573
  if (action === "mcp") return explainMcp(config, role, target);
431
574
  if (action === "read") {
432
575
  const holders = keyHolders(config, target);
433
576
  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` };
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
+ }
436
586
  return { allowed: false, owners: holders, reason: `${target} belongs to ${holders.join(", ")}` };
437
587
  }
438
588
 
@@ -442,11 +592,18 @@ export function explain(config, role, action, target) {
442
592
  *
443
593
  * The policy, seisin's state, the key directories, what the parent executes
444
594
  * 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 —
595
+ * something outside the sandbox. The kernel denies all of them to every role —
446
596
  * surface.js has the list and the reasons — so saying "belongs to dev" about
447
597
  * `seisin.toml` sent a request to a person that no grant could satisfy.
448
598
  */
449
- 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
+ };
450
607
  if (guard)
451
608
  return {
452
609
  allowed: false, owners, protected: guard.why,
@@ -466,7 +623,7 @@ export function explain(config, role, action, target) {
466
623
  return {
467
624
  allowed: false, owners, neverWrites: barred,
468
625
  reason: `${target} is denied by never_writes of ${role} ("${barred}") — ` +
469
- `a subtraction written into the policy, not a missing permission`,
626
+ `a subtraction written into the policy, not a missing grant`,
470
627
  };
471
628
  if (owners.includes(role)) return { allowed: true, owners, reason: `${target} is inside ${role}'s territory` };
472
629
  /**
@@ -477,14 +634,14 @@ export function explain(config, role, action, target) {
477
634
  * else's history. Codex keeps `.git` read-only under a writable root for the
478
635
  * same reason; nono and Gemini grant the whole of it. Here it stays refused
479
636
  * 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.
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.
482
639
  */
483
640
  if (isGitMetadata(target))
484
641
  return {
485
642
  allowed: false, owners, gitMetadata: true,
486
643
  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) ` +
644
+ `not something to request. Read what you need (git log, git show, git ls-remote) ` +
488
645
  `or work in a worktree of your own`,
489
646
  };
490
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}›`);
package/src/render.js CHANGED
@@ -67,8 +67,16 @@ export function collapseSidecars(paths) {
67
67
  return out;
68
68
  }
69
69
 
70
- /** The `check` report, as text. Takes the object `inspect()` returns. */
71
- export function renderReport(report) {
70
+ /**
71
+ * The `check` report, as text. Takes the object `inspect()` returns.
72
+ *
73
+ * The map and one line per warning first; the paragraphs behind `verbose`.
74
+ * On a three-role repo the full report ran to forty lines of explanation under
75
+ * a six-line map, and the one warning a person could act on sat in the middle.
76
+ * Nothing is dropped: the protected paths, each warning's reason and the
77
+ * standing limits are one flag away, and the footer says how many.
78
+ */
79
+ export function renderReport(report, { verbose = true } = {}) {
72
80
  const lines = [`\n${C.b}${report.where}${C.off}\n\n`];
73
81
  const width = Math.max(...report.roles.map((r) => r.name.length), 4);
74
82
 
@@ -101,10 +109,10 @@ export function renderReport(report) {
101
109
  // What the kernel refuses inside a territory, and why. Shown because each one
102
110
  // takes something from a territory as written; capped because a monorepo has
103
111
  // a hooks directory per package and the reader needs the shape, not all 200.
104
- if (report.protected?.length) {
112
+ if (report.protected?.length && verbose) {
105
113
  const root = report.where ? report.where.replace(/\/[^/]*$/, "/") : "";
106
114
  const show = (p) => (root && p.startsWith(root) ? p.slice(root.length) : p.replace(/^\/Users\/[^/]+|^\/home\/[^/]+/, "~"));
107
- 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`);
115
+ lines.push(` ${C.b}protected${C.off} ${C.dim}— inside a territory, denied anyway: something outside the sandbox runs or reads it${C.off}\n`);
108
116
  const LIMIT = 12;
109
117
  for (const e of report.protected.slice(0, LIMIT)) {
110
118
  const who = e.roles.length > 3 ? `${e.roles.slice(0, 3).join(", ")} +${e.roles.length - 3}` : e.roles.join(", ");
@@ -115,6 +123,18 @@ export function renderReport(report) {
115
123
  lines.push("\n");
116
124
  }
117
125
 
126
+ if (!verbose) {
127
+ for (const w of report.warnings) lines.push(` ${C.yellow}${w.headline}${C.off}\n`);
128
+ if (report.warnings.length) lines.push("\n");
129
+ const more = [
130
+ report.protected?.length ? `${report.protected.length} protected path(s)` : "",
131
+ report.warnings.some((w) => w.detail) ? "why each warning matters" : "",
132
+ report.limits?.length ? `${report.limits.length} standing limit(s)` : "",
133
+ ].filter(Boolean);
134
+ if (more.length) lines.push(` ${C.dim}seisin check --verbose: ${more.join(", ")}${C.off}\n\n`);
135
+ return lines.join("");
136
+ }
137
+
118
138
  for (const w of report.warnings) {
119
139
  lines.push(` ${C.yellow}${w.headline}${C.off}\n`);
120
140
  if (w.detail) lines.push(` ${C.dim}${w.detail}${C.off}\n`);
@@ -177,16 +197,40 @@ export function renderScan(result, keyDirs) {
177
197
  const { certain, review, skipped, truncated } = result;
178
198
  const links = result.links ?? [];
179
199
  const where = keyDirs.length ? keyDirs.join(", ") : `${C.yellow}nowhere — [keys] dir is unset${C.off}`;
180
- const lines = [`\n ${C.dim}protected: ${where}${C.off}\n\n`];
200
+ const lines = [`\n ${C.dim}protected: ${where}${C.off}\n`];
201
+ // Scoped to the territories: say where it looked, and how to look everywhere.
202
+ if (result.scope) {
203
+ const shown = result.scope.slice(0, 10).map(safe).join(", ");
204
+ const more = result.scope.length > 10 ? ` and ${result.scope.length - 10} more` : "";
205
+ lines.push(` ${C.dim}scanned: where the territories are — ${shown}${more}. ${C.b}seisin scan --all${C.off}${C.dim} walks the whole folder${C.off}\n`);
206
+ }
207
+ lines.push("\n");
181
208
 
182
- if (certain.length === 0 && review.length === 0 && links.length === 0) {
183
- lines.push(` ${C.green}nothing credential-shaped outside the declared directories${C.off}\n\n`);
209
+ // A pruned checkout is part of what a clean result means, so it is said
210
+ // even when there is nothing else to say — in yellow, not dim: every role
211
+ // can still read it, and a dim line under a green verdict read as clean.
212
+ const paths = result.nestedPaths ?? [];
213
+ let nested = "";
214
+ if (skipped.nested) {
215
+ nested = ` ${C.yellow}not scanned: ${skipped.nested} nested checkout(s)${C.off} — another repository each; every role can still read them. Scan one with ${C.b}cd <dir> && seisin scan${C.off}\n`;
216
+ for (const p of paths.slice(0, 15)) nested += ` ${safe(p)}\n`;
217
+ if (paths.length > 15) nested += ` ${C.dim}… and ${paths.length - 15} more${C.off}\n`;
218
+ nested += "\n";
219
+ }
220
+ const named = result.named ?? [];
221
+ if (certain.length === 0 && review.length === 0 && links.length === 0 && named.length === 0) {
222
+ lines.push(skipped.nested
223
+ ? ` nothing credential-shaped in what was scanned ${C.dim}(outside the declared directories)${C.off}\n\n`
224
+ : ` ${C.green}nothing credential-shaped outside the declared directories${C.off}\n\n`, nested);
184
225
  return lines.join("");
185
226
  }
186
227
 
187
228
  if (certain.length) {
188
- lines.push(` ${C.red}${certain.length} credential(s)${C.off} — these shapes are issued, not written by accident\n\n`);
189
- for (const h of certain) lines.push(` ${C.b}${h.file}${C.off}${C.dim}:${h.line}${C.off} ${h.shape}\n`);
229
+ lines.push(` ${C.red}${certain.length} credential(s)${C.off} — issued shapes, or a password in a URL: a credential, not a word that mentions one\n\n`);
230
+ for (const h of certain) {
231
+ const whose = h.owners?.length ? ` ${C.dim}in ${h.owners.map(safe).join(", ")}'s territory${C.off}` : "";
232
+ lines.push(` ${C.b}${safe(h.file)}${C.off}${C.dim}:${h.line}${C.off} ${h.shape}${whose}\n`);
233
+ }
190
234
  lines.push("\n");
191
235
  }
192
236
 
@@ -200,6 +244,18 @@ export function renderScan(result, keyDirs) {
200
244
  lines.push("\n");
201
245
  }
202
246
 
247
+ // By name, not by content, and said that way: a .env of DEBUG=1 is on this
248
+ // list too. What it is for is the .env nobody declared.
249
+ if (named.length) {
250
+ lines.push(` ${C.yellow}${named.length} file(s) that usually hold secrets${C.off} — by their name, not certain; every role reads them\n\n`);
251
+ for (const h of named.slice(0, 15)) {
252
+ const whose = h.owners?.length ? ` ${C.dim}in ${h.owners.map(safe).join(", ")}'s territory${C.off}` : "";
253
+ lines.push(` ${C.b}${safe(h.file)}${C.off}${whose}\n`);
254
+ }
255
+ if (named.length > 15) lines.push(` ${C.dim}… and ${named.length - 15} more${C.off}\n`);
256
+ lines.push("\n");
257
+ }
258
+
203
259
  // Links are their own finding, not a line with a secret-shaped name in it.
204
260
  // What is reported is that the link leaves the repo — the destination is
205
261
  // named and never opened, because the point is that it exists.
@@ -217,7 +273,9 @@ export function renderScan(result, keyDirs) {
217
273
  if (skipped.ignored) quiet.push(`${skipped.ignored} ignored path(s)`);
218
274
  if (skipped.protectedDirs) quiet.push(`${skipped.protectedDirs} inside declared key dir(s)`);
219
275
  if (quiet.length) lines.push(` ${C.dim}not reported: ${quiet.join(" · ")}${C.off}\n`);
220
- if (truncated) lines.push(` ${C.yellow}stopped at the limit — there are more${C.off}\n`);
276
+ // Only the ones to review are capped; a certain hit is always listed above.
277
+ if (truncated) lines.push(` ${C.yellow}${result.omitted ?? "more"} more to review were not listed — the cap applies to review, never to certain hits${C.off}\n`);
278
+ if (nested) lines.push(nested);
221
279
  lines.push(` ${C.dim}seisin does not move these. Where a credential lives is your call.${C.off}\n\n`);
222
280
  return lines.join("");
223
281
  }