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/intake.js CHANGED
@@ -22,24 +22,19 @@
22
22
  * Pulled out of run.js, which held both handlers inline between the spawn and
23
23
  * the signal handling — 180 lines of policy arithmetic inside the launcher.
24
24
  */
25
- import { createHash } from "node:crypto";
26
- import { readFileSync } from "node:fs";
27
25
  import { join } from "node:path";
28
- import { toRepoRelative } from "./paths.js";
29
- import { explain, ownersOf, keyHolders, inKeyDir } from "./owners.js";
26
+ import { browserFor } from "./browsers.js";
27
+ import { toRepoRelative, samePath } from "./paths.js";
28
+ import { explain, ownersOf, keyHolders, inKeyDir, privateOwner } from "./owners.js";
30
29
  import { append, logPath } from "./log.js";
31
30
  import { record, requestsPath } from "./requests.js";
32
31
  import { inScope, scopeOf, reachedForContent } from "./violations.js";
33
32
  import { mcpServer } from "./hook.js";
33
+ import { policyId } from "./config.js";
34
+ import { expectedOf, kindOf } from "./kinds.js";
34
35
 
35
- /** A hash of the policy file as it is now, short: it names a version, it proves nothing. */
36
- export function policyId(config) {
37
- try {
38
- return createHash("sha256").update(readFileSync(config.path)).digest("hex").slice(0, 12);
39
- } catch {
40
- return null;
41
- }
42
- }
36
+ /** Moved to config.js, where the handoff decisions read it too; kept here for callers. */
37
+ export { policyId };
43
38
 
44
39
  /**
45
40
  * The handlers for one run.
@@ -47,20 +42,47 @@ export function policyId(config) {
47
42
  * `fromHook(to, entry)` is the spool's sink; `fromKernel(denial)` is
48
43
  * watchDenials' callback; `stats` counts what the kernel said that was left
49
44
  * out on purpose, for the line at the end of the run.
45
+ *
46
+ * `expectDenied` is the paths the caller said it expects to be denied
47
+ * (`seisin run --expect-denied`): taken from the parent's own argv, never from
48
+ * anything the sandbox can reach. See expectedByCaller below.
50
49
  */
51
- export function intake({ config, role, runId, observe = false, settings, notify }) {
52
- const stamp = { run: runId.slice(0, 8), policy: policyId(config) };
50
+ export function intake({ config, role, runId, observe = false, settings, notify, chain = null, expectDenied = [], trace = {} }) {
51
+ // `trace` is the launcher's (trace.js), read by the parent: in the stamp, so a
52
+ // line the sandbox sends cannot claim another run's trace.
53
+ const stamp = { run: runId.slice(0, 8), policy: policyId(config), ...trace };
53
54
  // No `at` from here or from the sandbox: append() stamps the parent's clock and
54
55
  // ignores any other. The confined process chose the time of its own lines —
55
56
  // it could date them into or out of every `since` window, or make its
56
57
  // requests look old enough to be passed over.
57
58
  const log = (entry) => append(logPath(config.root), { ...entry, ...stamp });
58
- const ask = (asked) => { record(requestsPath(config.root), asked); notify?.maybe(asked); };
59
+ // The chain is this run's (run.js, --handoff), the same for every request it
60
+ // files; nothing the sandbox sends can set or change it.
61
+ const ask = (asked) => { record(requestsPath(config.root), { ...asked, chain }); notify?.maybe(asked); };
59
62
 
60
63
  const scope = scopeOf(settings, config.root);
61
64
  const keyDirs = (config.keyDirs ?? []).map((d) => (d.startsWith("/") ? d : join(config.root, d)));
62
65
  const connects = new Set(); // one line per refused target per run
63
- const stats = { offPolicy: 0, walks: 0 };
66
+ const systems = new Set(); // one line per operation and service per run
67
+ const stats = { offPolicy: 0, walks: 0, expected: 0, probes: 0, atomic: [], browsers: [] };
68
+
69
+ /**
70
+ * A denial the caller asked for is recorded, and asked about by nobody.
71
+ *
72
+ * An orchestrator that probes the sandbox with a write it expects to be
73
+ * denied — its own canary, beside seisin's — got a request for it: a line in
74
+ * the queue, a notification, and a handoff decision that routed the probe to
75
+ * whichever role owns the repo root, which an orchestrator obeying the
76
+ * decision would then have started. The denial is the answer the caller
77
+ * wanted, so it stays in the log, marked `expected`, and files nothing.
78
+ *
79
+ * Exact paths, compared in one spelling (paths.js, samePath): a denial on any
80
+ * other path, the directory around it included, is still a request. The
81
+ * list is the parent's, from its own argv; the hook inside never suppresses
82
+ * anything, and nothing it sends can add to the list (docs/decisions.md).
83
+ */
84
+ const probes = new Set(expectDenied.map((p) => samePath(config, p)).filter(Boolean));
85
+ const expectedByCaller = (target) => probes.size > 0 && probes.has(samePath(config, target));
64
86
 
65
87
  /**
66
88
  * Nothing from inside the sandbox is taken at its word.
@@ -126,6 +148,9 @@ export function intake({ config, role, runId, observe = false, settings, notify
126
148
  const expected = observe
127
149
  ? "observed"
128
150
  : explain(config, role, entry.action, entry.target).allowed ? "allowed" : "denied";
151
+ // Marked only when the hook and the parent agree it was a denial: the
152
+ // hook's word alone does not make a line expected.
153
+ const asked = entry.verdict === "denied" && expected === "denied" && expectedByCaller(entry.target);
129
154
  return void log({
130
155
  role,
131
156
  tool: String(entry.tool ?? "").slice(0, 40),
@@ -136,6 +161,7 @@ export function intake({ config, role, runId, observe = false, settings, notify
136
161
  owners: ownersOf(config, entry.target), // recomputed, never taken
137
162
  reason: String(entry.reason ?? "").slice(0, 500),
138
163
  ...(expected !== entry.verdict ? { disputed: expected } : {}),
164
+ ...(asked ? { expected: true } : {}),
139
165
  });
140
166
  }
141
167
 
@@ -160,7 +186,10 @@ export function intake({ config, role, runId, observe = false, settings, notify
160
186
  * grant can open.
161
187
  */
162
188
  const v = explain(config, role, entry.action, entry.target);
163
- if (v.allowed || v.neverWrites || v.gitMetadata || v.protected) return;
189
+ if (v.allowed || v.neverWrites || v.gitMetadata || v.protected || v.privateTo) return;
190
+ if (temporary(entry.action, entry.target)) return;
191
+ // The caller's probe: its log line above says it was expected.
192
+ if (expectedByCaller(entry.target)) return;
164
193
  // The owners explain found: for a key that is who declares it, not who
165
194
  // writes the directory it sits in — the queue said "owned by dev" about
166
195
  // backend's key because dev writes `**`.
@@ -180,6 +209,30 @@ export function intake({ config, role, runId, observe = false, settings, notify
180
209
  * is not a territory anyone owns, so there is nobody to hand it to — the
181
210
  * sentence says what the policy would have to change instead.
182
211
  */
212
+ /**
213
+ * A refused Mach service, sysctl or the like: logged by operation and name,
214
+ * never queued — see SYSTEM in violations.js.
215
+ *
216
+ * Once per operation and name per run, and a trailing pid is folded into
217
+ * the name for that count: a browser whose children cannot find its
218
+ * rendezvous service ask for it a thousand times in forty seconds, each
219
+ * under the parent's pid, and the second line says nothing the first did not.
220
+ */
221
+ if (d.action === "system") {
222
+ const key = `${d.operation} ${d.path.replace(/\.\d+$/, ".<pid>")}`;
223
+ if (systems.has(key)) return;
224
+ systems.add(key);
225
+ // The one that kills a browser at start, and that a preset answers: named
226
+ // at the end of the run, because the agent saw only a crash.
227
+ const preset = d.operation === "mach-register" ? browserFor(d.path) : null;
228
+ if (preset && !stats.browsers.includes(preset)) stats.browsers.push(preset);
229
+ log({
230
+ role, tool: "kernel", source: "kernel",
231
+ action: d.operation, kind: "system", target: d.path.slice(0, 1000), verdict: "denied",
232
+ owners: [], reason: d.operation,
233
+ });
234
+ return;
235
+ }
183
236
  if (d.action === "connect") {
184
237
  if (connects.has(d.path)) return;
185
238
  connects.add(d.path);
@@ -197,6 +250,11 @@ export function intake({ config, role, runId, observe = false, settings, notify
197
250
  // ~/.ssh under `isolate`, and "../../../.ssh/id_rsa" would be a worse
198
251
  // answer to "what was refused" than the path itself.
199
252
  const rel = toRepoRelative(config, d.path);
253
+ // Recorded like any other, and counted apart, so the end of the run can
254
+ // say it was expected instead of leaving it to read as a failure.
255
+ if (expectedOf(rel)) stats.expected++;
256
+ const asked = expectedByCaller(rel);
257
+ if (asked) stats.probes++;
200
258
 
201
259
  /**
202
260
  * A refusal the hook never saw still leaves a request behind.
@@ -213,11 +271,13 @@ export function intake({ config, role, runId, observe = false, settings, notify
213
271
  * reason this exists. Inside the repo only: `~/.ssh` is refused on purpose
214
272
  * under `isolate` and is not a territory anyone is meant to ask for.
215
273
  */
216
- const verdict = rel !== d.path && d.action === "write"
217
- ? explain(config, role, "write", rel)
274
+ // Reads are asked too when they land in another role's private folder:
275
+ // that refusal is the policy, not a missing grant, so it is not queued.
276
+ const verdict = rel !== d.path && (d.action === "write" || (d.action === "read" && privateOwner(config, rel)))
277
+ ? explain(config, role, d.action === "write" ? "write" : "read", rel)
218
278
  : null;
219
279
  const barred = verdict?.neverWrites ?? null;
220
- const unaskable = verdict?.gitMetadata === true || verdict?.protected != null;
280
+ const unaskable = verdict?.gitMetadata === true || verdict?.protected != null || verdict?.privateTo != null;
221
281
  // A request for something the policy already grants cannot be approved into
222
282
  // anything: it is misattribution or a kernel/policy mismatch, and either way
223
283
  // the line in the log is the evidence, not a question for a person. The hook
@@ -227,9 +287,28 @@ export function intake({ config, role, runId, observe = false, settings, notify
227
287
  // A key is declared, not owned: who a write lookup finds for `.secrets/x`
228
288
  // is always nobody, and the queue said "(unowned)" over another role's key.
229
289
  const whose = d.action === "read" && inKeyDir(config, rel) ? keyHolders(config, rel) : ownersOf(config, rel);
230
- if (rel !== d.path && !barred && !unaskable && !granted)
290
+ const scratch = rel !== d.path && temporary(d.action, rel);
291
+ if (rel !== d.path && !barred && !unaskable && !granted && !scratch && !asked)
231
292
  ask({ role, action: d.action, target: rel, owners: whose });
232
293
 
294
+ /**
295
+ * An atomic save's temporary file stands for the file it replaces.
296
+ *
297
+ * Not queued as itself (temporary(), below), and until now not at all —
298
+ * measured: Write was refused at `health.ts.tmp.<pid>`, nothing was queued,
299
+ * and the agent concluded the directory was missing. The question a person
300
+ * can answer is the destination: another role's file is asked for like any
301
+ * write; a file this role may write is the runtime refusing the temporary
302
+ * file beside it, which no grant can name, and the end of the run says so.
303
+ */
304
+ const destination = scratch && !asked ? destinationOf(rel) : null;
305
+ if (destination) {
306
+ const v = explain(config, role, "write", destination);
307
+ if (v.allowed) stats.atomic.push(destination);
308
+ else if (!v.neverWrites && !v.gitMetadata && v.protected == null && v.privateTo == null)
309
+ ask({ role, action: "write", target: destination, owners: ownersOf(config, destination) });
310
+ }
311
+
233
312
  log({
234
313
  role,
235
314
  tool: "kernel",
@@ -244,8 +323,39 @@ export function intake({ config, role, runId, observe = false, settings, notify
244
323
  // and a protected file from a territory somebody could be asked for.
245
324
  ...(barred ? { neverWrites: barred } : {}),
246
325
  ...(verdict?.protected ? { protected: verdict.protected } : {}),
326
+ ...(verdict?.privateTo ? { privateTo: verdict.privateTo } : {}),
327
+ // Why no request was queued for it: a temporary file is not territory.
328
+ ...(scratch ? { nature: "temporary" } : {}),
329
+ // The file an atomic save was replacing, which is what was asked for.
330
+ ...(destination ? { destination } : {}),
331
+ // Why no request was queued for it: the caller said it would be denied.
332
+ ...(asked ? { expected: true } : {}),
247
333
  });
248
334
  }
249
335
 
250
336
  return { fromHook, fromKernel, stats };
251
337
  }
338
+
339
+ /**
340
+ * A write to a temporary file (kinds.js) is recorded and never queued.
341
+ *
342
+ * Its name changes on every run — `BACKLOG.md.tmp.32029.03c4…`, the atomic
343
+ * write of an editor or of Claude Code — so a grant of it opens nothing the
344
+ * next run will use, and the request's id is its directory: at the root of the
345
+ * repo, `dev:write:.`, which reads as asking for the whole repository. The
346
+ * answer is the folder of the file it replaces, or $TMPDIR, and `check` and
347
+ * `review` say so; a request in front of a person says the wrong thing.
348
+ */
349
+ function temporary(action, target) {
350
+ return action === "write" && kindOf(target).kind === "temporary";
351
+ }
352
+
353
+ /**
354
+ * The file a write-then-rename was replacing, from its temporary file's name:
355
+ * `src/health.ts.tmp.48213.3fa9c1d2e0b4` → `src/health.ts`. Claude Code's form,
356
+ * and the general `<file>.tmp.<anything>`; null for any other temporary file.
357
+ */
358
+ export function destinationOf(target) {
359
+ const m = /^(.+[^/])\.tmp\.[^/]+$/.exec(target);
360
+ return m ? m[1] : null;
361
+ }