seisin 0.5.0 → 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 (76) hide show
  1. package/README.md +385 -129
  2. package/package.json +13 -5
  3. package/src/browsers.js +52 -0
  4. package/src/canary.js +149 -0
  5. package/src/cli.js +47 -23
  6. package/src/commands/check.js +154 -3
  7. package/src/commands/explain.js +2 -5
  8. package/src/commands/hook.js +7 -3
  9. package/src/commands/init.js +57 -50
  10. package/src/commands/requests.js +31 -56
  11. package/src/commands/review.js +33 -6
  12. package/src/commands/run.js +332 -110
  13. package/src/commands/walls.js +39 -7
  14. package/src/commands/whose.js +2 -1
  15. package/src/config.js +321 -15
  16. package/src/controls.js +5 -5
  17. package/src/diagnose.js +67 -16
  18. package/src/env.js +3 -0
  19. package/src/grants.js +107 -2
  20. package/src/handoff.js +285 -10
  21. package/src/help.js +39 -19
  22. package/src/hook.js +59 -30
  23. package/src/index.d.ts +217 -0
  24. package/src/index.js +20 -12
  25. package/src/inspect/codex.js +64 -0
  26. package/src/inspect/credentials.js +48 -0
  27. package/src/inspect/enforce.js +24 -0
  28. package/src/inspect/files.js +71 -0
  29. package/src/inspect/home.js +43 -0
  30. package/src/inspect/inject.js +68 -0
  31. package/src/inspect/keys.js +74 -0
  32. package/src/inspect/layout.js +258 -0
  33. package/src/inspect/limits.js +98 -0
  34. package/src/inspect/machine.js +107 -0
  35. package/src/inspect/network.js +37 -0
  36. package/src/inspect/providers.js +71 -0
  37. package/src/inspect/read-territory.js +105 -0
  38. package/src/inspect/role-keys.js +136 -0
  39. package/src/inspect/shared.js +37 -0
  40. package/src/inspect/warnings.js +58 -0
  41. package/src/inspect.js +13 -815
  42. package/src/intake.js +131 -21
  43. package/src/keys.js +223 -67
  44. package/src/kinds.js +42 -0
  45. package/src/lines.js +26 -0
  46. package/src/log.js +7 -2
  47. package/src/mcp.js +28 -43
  48. package/src/nested.js +12 -3
  49. package/src/notify.js +53 -12
  50. package/src/owners.js +165 -3
  51. package/src/paths.js +17 -1
  52. package/src/provider-run.js +81 -0
  53. package/src/redact.js +34 -15
  54. package/src/render.js +35 -9
  55. package/src/requests.js +363 -45
  56. package/src/review.js +21 -9
  57. package/src/rundir.js +29 -8
  58. package/src/sandboxes/command.js +93 -0
  59. package/src/sandboxes/index.js +113 -0
  60. package/src/sandboxes/mach-profile.js +31 -0
  61. package/src/sandboxes/srt-mach.mjs +43 -0
  62. package/src/sandboxes/srt.js +163 -0
  63. package/src/sandboxes/tartaros.js +80 -0
  64. package/src/scan.js +7 -3
  65. package/src/serve.js +35 -17
  66. package/src/spool.js +8 -17
  67. package/src/srt.js +263 -33
  68. package/src/surface.js +129 -16
  69. package/src/territory.js +261 -0
  70. package/src/trace.js +33 -0
  71. package/src/uilink.js +15 -6
  72. package/src/views.js +40 -14
  73. package/src/violations.js +143 -7
  74. package/src/walls.js +62 -9
  75. package/ui/index.html +85 -13
  76. package/ui/favicon.svg +0 -1
package/src/redact.js CHANGED
@@ -59,6 +59,10 @@ export function secretsOf(settings, readFile) {
59
59
  * half of a surrogate pair on either side.
60
60
  */
61
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);
62
66
  if (secrets.length === 0) return null;
63
67
  const longest = secrets[0].length;
64
68
  const decoder = new StringDecoder("utf8");
@@ -66,21 +70,22 @@ export function redactor(secrets, label = "redacted") {
66
70
 
67
71
  return new Transform({
68
72
  transform(chunk, _enc, done) {
69
- // Mask the WHOLE accumulated text first, then cut. Doing it the other way
70
- // — masking only the part about to be emitted — lets a secret that
71
- // straddles the cut through in two innocent halves. Written that way
72
- // first; the split-buffer test caught it on the first run.
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.
73
76
  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);
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;
79
83
  done();
80
84
  },
81
85
  flush(done) {
82
- const rest = mask(tail + decoder.end(), secrets, label);
83
- if (rest) this.push(rest);
86
+ const text = tail + decoder.end();
87
+ const result = maskPrefix(text, text.length, secrets, label);
88
+ if (result.output) this.push(result.output);
84
89
  tail = "";
85
90
  done();
86
91
  },
@@ -89,8 +94,22 @@ export function redactor(secrets, label = "redacted") {
89
94
 
90
95
  const isHighSurrogate = (c) => c >= 0xd800 && c <= 0xdbff;
91
96
 
92
- function mask(text, secrets, label) {
93
- let out = text;
94
- for (const s of secrets) out = out.split(s).join(`‹${label}›`);
95
- return out;
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) };
96
115
  }
package/src/render.js CHANGED
@@ -86,6 +86,8 @@ export function renderReport(report, { verbose = true } = {}) {
86
86
  lines.push(` ${C.b}${r.name.padEnd(width)}${C.off} ${C.blue}writes${C.off} ${writes}\n`);
87
87
  // Only when present: absent is the default, and a "never_writes nothing" line
88
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`);
89
91
  if (r.neverWrites?.length)
90
92
  lines.push(` ${" ".repeat(width)} ${C.yellow}never${C.off} ${collapseSidecars(r.neverWrites).join(" ")}\n`);
91
93
  if (r.trustd)
@@ -96,6 +98,12 @@ export function renderReport(report, { verbose = true } = {}) {
96
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`);
97
99
  if (r.localPorts?.length)
98
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`);
99
107
  lines.push(` ${" ".repeat(width)} ${C.green}keys${C.off} ${keys}\n\n`);
100
108
  }
101
109
 
@@ -129,28 +137,33 @@ export function renderReport(report, { verbose = true } = {}) {
129
137
  const more = [
130
138
  report.protected?.length ? `${report.protected.length} protected path(s)` : "",
131
139
  report.warnings.some((w) => w.detail) ? "why each warning matters" : "",
132
- report.limits?.length ? `${report.limits.length} standing limit(s)` : "",
133
140
  ].filter(Boolean);
134
- if (more.length) lines.push(` ${C.dim}seisin check --verbose: ${more.join(", ")}${C.off}\n\n`);
141
+ if (more.length) lines.push(` ${C.dim}seisin check --verbose: ${more.join(", ")}${C.off}\n`);
142
+ lines.push(limitsLine(report), "\n");
135
143
  return lines.join("");
136
144
  }
137
145
 
138
146
  for (const w of report.warnings) {
139
147
  lines.push(` ${C.yellow}${w.headline}${C.off}\n`);
140
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`);
141
151
  lines.push("\n");
142
152
  }
143
153
 
144
- // Dim, and last: a standing limit is not an alarm, but it is not a footnote
145
- // either — it is the part of the map that is blank.
146
- for (const l of report.limits ?? []) {
147
- lines.push(` ${C.dim}${l.headline}${C.off}\n`);
148
- if (l.detail) lines.push(` ${C.dim}${l.detail}${C.off}\n`);
149
- lines.push("\n");
150
- }
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");
151
158
  return lines.join("");
152
159
  }
153
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
+
154
167
  /* ── one verdict ──────────────────────────────────────────────────────── */
155
168
 
156
169
  /** The `explain` answer, as text. Takes what `owners.explain()` returns. */
@@ -169,6 +182,19 @@ export function renderVerdict(role, action, target, verdict) {
169
182
  * people to stop reading the colour.
170
183
  */
171
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
+ }
172
198
  const mark =
173
199
  e.verdict === "denied" ? `${C.yellow}denied ${C.off}`
174
200
  : e.verdict === "observed" ? `${C.dim}seen ${C.off}`