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
@@ -0,0 +1,81 @@
1
+ /**
2
+ * Runs one key provider and reports what it printed, as JSON on stdout.
3
+ *
4
+ * A separate process because `spawnSync` cannot do two things a provider
5
+ * needs, and `resolveRef` is synchronous by contract (keys.js):
6
+ *
7
+ * - **Take the answer when the provider exits.** `spawnSync` returns when
8
+ * the child has exited AND every copy of its stdout is closed. A provider
9
+ * that printed the value, exited 0 and left a background process holding
10
+ * the pipe made seisin wait the whole timeout and then say the provider
11
+ * "did not answer". Here the answer is what was read by the time the
12
+ * provider exited, plus a short drain for bytes still in the pipe.
13
+ * - **Stop it for good.** A timeout is SIGKILL, not SIGTERM: a provider that
14
+ * ignored SIGTERM held the run forever. And it goes to every descendant
15
+ * too, so a child the provider was waiting on dies with it instead of
16
+ * living on. Found by walking `ps`, not by a process group of its own: a
17
+ * new session would take the provider's terminal away, and a pinentry
18
+ * needs it. A provider that exits on its own keeps whatever it left
19
+ * running: an agent it starts (gpg-agent, an op daemon) is its business.
20
+ *
21
+ * stderr is read and counted, never kept: a provider may print the value
22
+ * there, and nothing downstream shows it.
23
+ *
24
+ * argv: <timeoutMs> <maxBytes> <drainMs> <command> [args…]
25
+ */
26
+ import { spawn, execFileSync } from "node:child_process";
27
+
28
+ const [timeoutMs, maxBytes, drainMs] = process.argv.slice(2, 5).map(Number);
29
+ const argv = process.argv.slice(5);
30
+
31
+ const chunks = [];
32
+ let size = 0, errBytes = 0, timedOut = false, tooBig = false, done = false;
33
+
34
+ function finish(result) {
35
+ if (done) return;
36
+ done = true;
37
+ clearTimeout(timer);
38
+ const out = tooBig ? "" : Buffer.concat(chunks).toString("base64");
39
+ process.stdout.write(JSON.stringify({ ...result, out, errBytes, timedOut, tooBig }), () => process.exit(0));
40
+ }
41
+
42
+ /** The provider and every process below it. */
43
+ function tree(root) {
44
+ const kids = new Map();
45
+ try {
46
+ for (const line of execFileSync("ps", ["-A", "-o", "pid=,ppid="], { encoding: "utf8" }).split("\n")) {
47
+ const [pid, ppid] = line.trim().split(/\s+/).map(Number);
48
+ if (pid && ppid) kids.set(ppid, [...(kids.get(ppid) ?? []), pid]);
49
+ }
50
+ } catch {}
51
+ const all = [root];
52
+ for (let i = 0; i < all.length; i++) all.push(...(kids.get(all[i]) ?? []));
53
+ return all;
54
+ }
55
+
56
+ const child = spawn(argv[0], argv.slice(1), { stdio: ["ignore", "pipe", "pipe"] });
57
+ const kill = () => {
58
+ if (!child.pid) return;
59
+ // Stopped first, so nothing forks between the listing and the kill.
60
+ const pids = tree(child.pid);
61
+ for (const pid of pids) try { process.kill(pid, "SIGSTOP"); } catch {}
62
+ for (const pid of tree(child.pid)) if (!pids.includes(pid)) pids.push(pid);
63
+ for (const pid of pids) try { process.kill(pid, "SIGKILL"); } catch {}
64
+ };
65
+ const timer = setTimeout(() => { timedOut = true; kill(); }, timeoutMs);
66
+
67
+ child.stdout.on("data", (c) => {
68
+ size += c.length;
69
+ if (size > maxBytes) { tooBig = true; chunks.length = 0; kill(); return; }
70
+ if (!tooBig) chunks.push(c);
71
+ });
72
+ child.stderr.on("data", (c) => { errBytes += c.length; });
73
+ child.on("error", (e) => finish({ error: e.code ?? e.message }));
74
+ child.on("exit", (status, signal) => {
75
+ // What the provider wrote before exiting may still be in the pipe. Wait for
76
+ // the end of it, but not for a descendant that kept the pipe open.
77
+ const drained = () => finish({ status, signal });
78
+ if (child.stdout.readableEnded) return drained();
79
+ child.stdout.once("end", drained);
80
+ setTimeout(drained, drainMs).unref();
81
+ });
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,34 +50,66 @@ 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") {
62
+ // Embedders use this export directly; neither order nor empty values may
63
+ // decide how much of a split credential is retained between chunks.
64
+ secrets = [...new Set(secrets.filter((s) => typeof s === "string" && s.length > 0))]
65
+ .sort((a, b) => b.length - a.length);
54
66
  if (secrets.length === 0) return null;
55
67
  const longest = secrets[0].length;
68
+ const decoder = new StringDecoder("utf8");
56
69
  let tail = "";
57
70
 
58
71
  return new Transform({
59
72
  transform(chunk, _enc, done) {
60
- // Mask the WHOLE accumulated text first, then cut. Doing it the other way
61
- // — masking only the part about to be emitted — lets a secret that
62
- // straddles the cut through in two innocent halves. Written that way
63
- // 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
+ // Retain original input, not replacement text. Replacing a shorter
74
+ // credential before a longer one finishes destroys their shared prefix;
75
+ // carrying a marker forward also lets another secret replace its label.
76
+ const text = typeof chunk === "string" ? chunk : decoder.write(chunk);
77
+ const buffered = tail + text;
78
+ let cut = Math.max(0, buffered.length - longest + 1);
79
+ if (cut > 0 && isHighSurrogate(buffered.charCodeAt(cut - 1))) cut--;
80
+ const result = maskPrefix(buffered, cut, secrets, label);
81
+ if (result.output) this.push(result.output);
82
+ tail = result.tail;
68
83
  done();
69
84
  },
70
85
  flush(done) {
71
- this.push(mask(tail, secrets, label));
86
+ const text = tail + decoder.end();
87
+ const result = maskPrefix(text, text.length, secrets, label);
88
+ if (result.output) this.push(result.output);
72
89
  tail = "";
73
90
  done();
74
91
  },
75
92
  });
76
93
  }
77
94
 
78
- function mask(text, secrets, label) {
79
- let out = text;
80
- for (const s of secrets) out = out.split(s).join(`‹${label}›`);
81
- return out;
95
+ const isHighSurrogate = (c) => c >= 0xd800 && c <= 0xdbff;
96
+
97
+ /** Only match starts with enough input to decide the longest credential. */
98
+ function maskPrefix(text, cut, secrets, label) {
99
+ const next = secrets.map((s) => text.indexOf(s));
100
+ let from = 0, output = "";
101
+ while (from < cut) {
102
+ let at = Infinity, chosen = -1;
103
+ for (let i = 0; i < next.length; i++) {
104
+ if (next[i] >= 0 && next[i] < at) { at = next[i]; chosen = i; }
105
+ }
106
+ if (at >= cut) { output += text.slice(from, cut); from = cut; break; }
107
+ output += text.slice(from, at) + `‹${label}›`;
108
+ from = at + secrets[chosen].length;
109
+ // Keep each search moving forward. In particular, a value absent from
110
+ // this buffer is not searched again for every occurrence of another.
111
+ for (let i = 0; i < next.length; i++)
112
+ if (next[i] >= 0 && next[i] < from) next[i] = text.indexOf(secrets[i], from);
113
+ }
114
+ return { output, tail: text.slice(from) };
82
115
  }
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
 
@@ -78,6 +86,8 @@ export function renderReport(report) {
78
86
  lines.push(` ${C.b}${r.name.padEnd(width)}${C.off} ${C.blue}writes${C.off} ${writes}\n`);
79
87
  // Only when present: absent is the default, and a "never_writes nothing" line
80
88
  // on every role is noise that teaches the reader to skip the one that matters.
89
+ if (r.private?.length)
90
+ lines.push(` ${" ".repeat(width)} ${C.yellow}private${C.off} ${r.private.join(" ")} ${C.dim}(only this role reads or writes it)${C.off}\n`);
81
91
  if (r.neverWrites?.length)
82
92
  lines.push(` ${" ".repeat(width)} ${C.yellow}never${C.off} ${collapseSidecars(r.neverWrites).join(" ")}\n`);
83
93
  if (r.trustd)
@@ -88,6 +98,12 @@ export function renderReport(report) {
88
98
  lines.push(` ${" ".repeat(width)} ${C.yellow}mcp${C.off} ${r.mcp.length ? r.mcp.join(" ") : "none"} ${C.dim}(declared; the launcher enforces it)${C.off}\n`);
89
99
  if (r.localPorts?.length)
90
100
  lines.push(` ${" ".repeat(width)} ${C.yellow}reach${C.off} localhost ${r.localPorts.join(" ")} ${C.dim}(local_ports, through the proxy)${C.off}\n`);
101
+ if (r.reads?.length)
102
+ lines.push(` ${" ".repeat(width)} ${C.blue}reads${C.off} ${r.reads.join(" ")}\n`);
103
+ if (r.toolchain?.length)
104
+ lines.push(` ${" ".repeat(width)} ${C.blue}tools${C.off} ${r.toolchain.join(" ")}\n`);
105
+ if (r.verify)
106
+ lines.push(` ${" ".repeat(width)} ${C.blue}verify${C.off} ${r.verify.join(" ")}\n`);
91
107
  lines.push(` ${" ".repeat(width)} ${C.green}keys${C.off} ${keys}\n\n`);
92
108
  }
93
109
 
@@ -101,10 +117,10 @@ export function renderReport(report) {
101
117
  // What the kernel refuses inside a territory, and why. Shown because each one
102
118
  // takes something from a territory as written; capped because a monorepo has
103
119
  // a hooks directory per package and the reader needs the shape, not all 200.
104
- if (report.protected?.length) {
120
+ if (report.protected?.length && verbose) {
105
121
  const root = report.where ? report.where.replace(/\/[^/]*$/, "/") : "";
106
122
  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`);
123
+ 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
124
  const LIMIT = 12;
109
125
  for (const e of report.protected.slice(0, LIMIT)) {
110
126
  const who = e.roles.length > 3 ? `${e.roles.slice(0, 3).join(", ")} +${e.roles.length - 3}` : e.roles.join(", ");
@@ -115,22 +131,39 @@ export function renderReport(report) {
115
131
  lines.push("\n");
116
132
  }
117
133
 
134
+ if (!verbose) {
135
+ for (const w of report.warnings) lines.push(` ${C.yellow}${w.headline}${C.off}\n`);
136
+ if (report.warnings.length) lines.push("\n");
137
+ const more = [
138
+ report.protected?.length ? `${report.protected.length} protected path(s)` : "",
139
+ report.warnings.some((w) => w.detail) ? "why each warning matters" : "",
140
+ ].filter(Boolean);
141
+ if (more.length) lines.push(` ${C.dim}seisin check --verbose: ${more.join(", ")}${C.off}\n`);
142
+ lines.push(limitsLine(report), "\n");
143
+ return lines.join("");
144
+ }
145
+
118
146
  for (const w of report.warnings) {
119
147
  lines.push(` ${C.yellow}${w.headline}${C.off}\n`);
120
148
  if (w.detail) lines.push(` ${C.dim}${w.detail}${C.off}\n`);
149
+ // A list is a list: one entry per line, not a sentence of twenty paths.
150
+ for (const item of w.items ?? []) lines.push(` ${C.dim}${item}${C.off}\n`);
121
151
  lines.push("\n");
122
152
  }
123
153
 
124
- // Dim, and last: a standing limit is not an alarm, but it is not a footnote
125
- // either — it is the part of the map that is blank.
126
- for (const l of report.limits ?? []) {
127
- lines.push(` ${C.dim}${l.headline}${C.off}\n`);
128
- if (l.detail) lines.push(` ${C.dim}${l.detail}${C.off}\n`);
129
- lines.push("\n");
130
- }
154
+ // The standing limits are the same in every repo, so they are one line here
155
+ // and the paragraphs are `seisin help limits` — six of them closed every
156
+ // verbose report, under the part that was about this repo.
157
+ lines.push(limitsLine(report), "\n");
131
158
  return lines.join("");
132
159
  }
133
160
 
161
+ /** Where the standing limits are, or nothing when the report carries none. */
162
+ function limitsLine(report) {
163
+ const n = report.limits?.length ?? 0;
164
+ return n ? ` ${C.dim}${n} standing limit(s) apply to every repo: seisin help limits${C.off}\n` : "";
165
+ }
166
+
134
167
  /* ── one verdict ──────────────────────────────────────────────────────── */
135
168
 
136
169
  /** The `explain` answer, as text. Takes what `owners.explain()` returns. */
@@ -149,6 +182,19 @@ export function renderVerdict(role, action, target, verdict) {
149
182
  * people to stop reading the colour.
150
183
  */
151
184
  export function renderEntry(e) {
185
+ if (e.event === "canary") {
186
+ const mark = e.confined ? `${C.green}canary passed${C.off}` : `${C.red}canary failed${C.off}`;
187
+ const why = e.reasons?.length ? ` ${C.dim}${e.reasons.join("; ")}${C.off}` : "";
188
+ return ` ${C.dim}${(e.at ?? "").slice(11, 19)}${C.off} ${mark} ${C.b}${e.role}${C.off}${why}\n`;
189
+ }
190
+ if (e.event === "log-dropped") {
191
+ const mark = `${C.yellow}log dropped${C.off}`;
192
+ return ` ${C.dim}${(e.at ?? "").slice(11, 19)}${C.off} ${mark} ${C.b}${e.role}${C.off} ${C.dim}${e.batches} batch(es) of system log messages lost — kernel denials may be missing${C.off}\n`;
193
+ }
194
+ if (e.event === "key") {
195
+ const mark = e.ok ? `${C.green}key resolved${C.off}` : `${C.red}key failed${C.off}`;
196
+ return ` ${C.dim}${(e.at ?? "").slice(11, 19)}${C.off} ${mark} ${C.b}${e.role}${C.off} ${safe(e.key)} ${C.dim}${e.mode}${C.off}\n`;
197
+ }
152
198
  const mark =
153
199
  e.verdict === "denied" ? `${C.yellow}denied ${C.off}`
154
200
  : e.verdict === "observed" ? `${C.dim}seen ${C.off}`
@@ -177,16 +223,40 @@ export function renderScan(result, keyDirs) {
177
223
  const { certain, review, skipped, truncated } = result;
178
224
  const links = result.links ?? [];
179
225
  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`];
226
+ const lines = [`\n ${C.dim}protected: ${where}${C.off}\n`];
227
+ // Scoped to the territories: say where it looked, and how to look everywhere.
228
+ if (result.scope) {
229
+ const shown = result.scope.slice(0, 10).map(safe).join(", ");
230
+ const more = result.scope.length > 10 ? ` and ${result.scope.length - 10} more` : "";
231
+ 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`);
232
+ }
233
+ lines.push("\n");
181
234
 
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`);
235
+ // A pruned checkout is part of what a clean result means, so it is said
236
+ // even when there is nothing else to say — in yellow, not dim: every role
237
+ // can still read it, and a dim line under a green verdict read as clean.
238
+ const paths = result.nestedPaths ?? [];
239
+ let nested = "";
240
+ if (skipped.nested) {
241
+ 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`;
242
+ for (const p of paths.slice(0, 15)) nested += ` ${safe(p)}\n`;
243
+ if (paths.length > 15) nested += ` ${C.dim}… and ${paths.length - 15} more${C.off}\n`;
244
+ nested += "\n";
245
+ }
246
+ const named = result.named ?? [];
247
+ if (certain.length === 0 && review.length === 0 && links.length === 0 && named.length === 0) {
248
+ lines.push(skipped.nested
249
+ ? ` nothing credential-shaped in what was scanned ${C.dim}(outside the declared directories)${C.off}\n\n`
250
+ : ` ${C.green}nothing credential-shaped outside the declared directories${C.off}\n\n`, nested);
184
251
  return lines.join("");
185
252
  }
186
253
 
187
254
  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`);
255
+ 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`);
256
+ for (const h of certain) {
257
+ const whose = h.owners?.length ? ` ${C.dim}in ${h.owners.map(safe).join(", ")}'s territory${C.off}` : "";
258
+ lines.push(` ${C.b}${safe(h.file)}${C.off}${C.dim}:${h.line}${C.off} ${h.shape}${whose}\n`);
259
+ }
190
260
  lines.push("\n");
191
261
  }
192
262
 
@@ -200,6 +270,18 @@ export function renderScan(result, keyDirs) {
200
270
  lines.push("\n");
201
271
  }
202
272
 
273
+ // By name, not by content, and said that way: a .env of DEBUG=1 is on this
274
+ // list too. What it is for is the .env nobody declared.
275
+ if (named.length) {
276
+ 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`);
277
+ for (const h of named.slice(0, 15)) {
278
+ const whose = h.owners?.length ? ` ${C.dim}in ${h.owners.map(safe).join(", ")}'s territory${C.off}` : "";
279
+ lines.push(` ${C.b}${safe(h.file)}${C.off}${whose}\n`);
280
+ }
281
+ if (named.length > 15) lines.push(` ${C.dim}… and ${named.length - 15} more${C.off}\n`);
282
+ lines.push("\n");
283
+ }
284
+
203
285
  // Links are their own finding, not a line with a secret-shaped name in it.
204
286
  // What is reported is that the link leaves the repo — the destination is
205
287
  // named and never opened, because the point is that it exists.
@@ -217,7 +299,9 @@ export function renderScan(result, keyDirs) {
217
299
  if (skipped.ignored) quiet.push(`${skipped.ignored} ignored path(s)`);
218
300
  if (skipped.protectedDirs) quiet.push(`${skipped.protectedDirs} inside declared key dir(s)`);
219
301
  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`);
302
+ // Only the ones to review are capped; a certain hit is always listed above.
303
+ 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`);
304
+ if (nested) lines.push(nested);
221
305
  lines.push(` ${C.dim}seisin does not move these. Where a credential lives is your call.${C.off}\n\n`);
222
306
  return lines.join("");
223
307
  }