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
package/src/intake.js CHANGED
@@ -2,8 +2,8 @@
2
2
  * The parent's intake: what arrives during a run, checked, and written down.
3
3
  *
4
4
  * Two sources, and they are not equally trustworthy. The hook runs inside the
5
- * box, so everything it sends is a claim by the process being recorded. The
6
- * kernel is the thing that actually refused. Both end up as log lines and,
5
+ * sandbox, so everything it sends is a claim by the process being recorded. The
6
+ * kernel is the thing that actually denied. Both end up as log lines and,
7
7
  * sometimes, as requests in front of a person; this module is where each is
8
8
  * recomputed from the policy rather than believed.
9
9
  *
@@ -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 } 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,24 +42,55 @@ 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 };
54
+ // No `at` from here or from the sandbox: append() stamps the parent's clock and
55
+ // ignores any other. The confined process chose the time of its own lines —
56
+ // it could date them into or out of every `since` window, or make its
57
+ // requests look old enough to be passed over.
53
58
  const log = (entry) => append(logPath(config.root), { ...entry, ...stamp });
54
- 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); };
55
62
 
56
63
  const scope = scopeOf(settings, config.root);
57
64
  const keyDirs = (config.keyDirs ?? []).map((d) => (d.startsWith("/") ? d : join(config.root, d)));
58
65
  const connects = new Set(); // one line per refused target per run
59
- 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));
60
86
 
61
87
  /**
62
- * Nothing from inside the box is taken at its word.
88
+ * Nothing from inside the sandbox is taken at its word.
63
89
  *
64
90
  * The sender is the process being recorded, so every field it supplies is
65
91
  * a claim. Two of them matter. `role` decides whose request this is — left
66
- * alone, a frontend agent could file one as backend and wait for a human to
67
- * approve it. `owners` decides who the queue says it belongs to, and the
92
+ * alone, a frontend agent could file one as backend and wait for a person to
93
+ * grant it. `owners` decides who the queue says it belongs to, and the
68
94
  * parent can work that out itself from the policy.
69
95
  *
70
96
  * So the role is overwritten with the role of this run, the owners are
@@ -80,7 +106,7 @@ export function intake({ config, role, runId, observe = false, settings, notify
80
106
  * never queued — which servers a role may load is a change to its `mcp`
81
107
  * list, not a grant a person approves, the same shape as never_writes. The
82
108
  * verdict is recomputed here from the same list rather than believed from
83
- * inside the box: the server is parsed from the tool name and asked of the
109
+ * inside the sandbox: the server is parsed from the tool name and asked of the
84
110
  * policy, so a line claiming "allowed" for a server the role may not load
85
111
  * comes back marked `disputed`.
86
112
  */
@@ -91,7 +117,7 @@ export function intake({ config, role, runId, observe = false, settings, notify
91
117
  const v = server ? explain(config, role, "mcp", server) : { allowed: false };
92
118
  const expected = observe ? "observed" : v.allowed ? "allowed" : "denied";
93
119
  return void log({
94
- at: entry.at, role,
120
+ role,
95
121
  tool: String(entry.tool ?? "").slice(0, 40),
96
122
  action: "use", kind: "tool",
97
123
  target: entry.target.slice(0, 1000),
@@ -106,7 +132,7 @@ export function intake({ config, role, runId, observe = false, settings, notify
106
132
 
107
133
  if (to === "log") {
108
134
  // The same treatment the queue already got. The role was overwritten but
109
- // everything else was passed through, so a process inside the box could
135
+ // everything else was passed through, so a process inside the sandbox could
110
136
  // write "allowed" lines for paths it never touched — which would not move
111
137
  // the boundary, but would move `review`, and `init --from-observations`
112
138
  // builds a policy out of exactly this.
@@ -122,8 +148,10 @@ export function intake({ config, role, runId, observe = false, settings, notify
122
148
  const expected = observe
123
149
  ? "observed"
124
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);
125
154
  return void log({
126
- at: entry.at,
127
155
  role,
128
156
  tool: String(entry.tool ?? "").slice(0, 40),
129
157
  action: entry.action,
@@ -133,13 +161,14 @@ export function intake({ config, role, runId, observe = false, settings, notify
133
161
  owners: ownersOf(config, entry.target), // recomputed, never taken
134
162
  reason: String(entry.reason ?? "").slice(0, 500),
135
163
  ...(expected !== entry.verdict ? { disputed: expected } : {}),
164
+ ...(asked ? { expected: true } : {}),
136
165
  });
137
166
  }
138
167
 
139
168
  /**
140
169
  * The policy has to actually refuse it, or it does not belong in a queue.
141
170
  *
142
- * Nothing checked. A process inside the box could file a request for a path
171
+ * Nothing checked. A process inside the sandbox could file a request for a path
143
172
  * its own role already owns, and the queue would print *first refused on
144
173
  * …* about something that was never refused — a sentence placed in front of
145
174
  * a person for approval, describing an event that did not happen.
@@ -157,7 +186,10 @@ export function intake({ config, role, runId, observe = false, settings, notify
157
186
  * grant can open.
158
187
  */
159
188
  const v = explain(config, role, entry.action, entry.target);
160
- 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;
161
193
  // The owners explain found: for a key that is who declares it, not who
162
194
  // writes the directory it sits in — the queue said "owned by dev" about
163
195
  // backend's key because dev writes `**`.
@@ -177,12 +209,36 @@ export function intake({ config, role, runId, observe = false, settings, notify
177
209
  * is not a territory anyone owns, so there is nobody to hand it to — the
178
210
  * sentence says what the policy would have to change instead.
179
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
+ }
180
236
  if (d.action === "connect") {
181
237
  if (connects.has(d.path)) return;
182
238
  connects.add(d.path);
183
239
  const v = explain(config, role, "connect", d.path);
184
240
  log({
185
- at: new Date().toISOString(), role, tool: "kernel", source: "kernel",
241
+ role, tool: "kernel", source: "kernel",
186
242
  action: "connect", kind: "network", target: d.path, verdict: "denied",
187
243
  owners: [], reason: d.operation, ...(v.listed ? { listed: true } : {}),
188
244
  });
@@ -194,13 +250,18 @@ export function intake({ config, role, runId, observe = false, settings, notify
194
250
  // ~/.ssh under `isolate`, and "../../../.ssh/id_rsa" would be a worse
195
251
  // answer to "what was refused" than the path itself.
196
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++;
197
258
 
198
259
  /**
199
260
  * A refusal the hook never saw still leaves a request behind.
200
261
  *
201
262
  * Without this the two halves of the record disagree in the worst
202
263
  * direction: `review` would show a role stopped repeatedly on a directory
203
- * while the queue held nothing to approve, so the one refusal a person most
264
+ * while the queue held nothing to grant, so the one refusal a person most
204
265
  * needed to see — the one that escaped the hook — would be the one with no
205
266
  * way to act on it.
206
267
  *
@@ -210,22 +271,45 @@ export function intake({ config, role, runId, observe = false, settings, notify
210
271
  * reason this exists. Inside the repo only: `~/.ssh` is refused on purpose
211
272
  * under `isolate` and is not a territory anyone is meant to ask for.
212
273
  */
213
- const verdict = rel !== d.path && d.action === "write"
214
- ? 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)
215
278
  : null;
216
279
  const barred = verdict?.neverWrites ?? null;
217
- const unaskable = verdict?.gitMetadata === true || verdict?.protected != null;
280
+ const unaskable = verdict?.gitMetadata === true || verdict?.protected != null || verdict?.privateTo != null;
218
281
  // A request for something the policy already grants cannot be approved into
219
282
  // anything: it is misattribution or a kernel/policy mismatch, and either way
220
283
  // the line in the log is the evidence, not a question for a person. The hook
221
284
  // path has always had this check; the kernel path did not, which is how a
222
285
  // role ended up asking for its own territory.
223
286
  const granted = verdict?.allowed === true;
224
- if (rel !== d.path && !barred && !unaskable && !granted)
225
- ask({ role, action: d.action, target: rel, owners: ownersOf(config, rel) });
287
+ // A key is declared, not owned: who a write lookup finds for `.secrets/x`
288
+ // is always nobody, and the queue said "(unowned)" over another role's key.
289
+ const whose = d.action === "read" && inKeyDir(config, rel) ? keyHolders(config, rel) : ownersOf(config, rel);
290
+ const scratch = rel !== d.path && temporary(d.action, rel);
291
+ if (rel !== d.path && !barred && !unaskable && !granted && !scratch && !asked)
292
+ ask({ role, action: d.action, target: rel, owners: whose });
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
+ }
226
311
 
227
312
  log({
228
- at: new Date().toISOString(),
229
313
  role,
230
314
  tool: "kernel",
231
315
  source: "kernel",
@@ -239,8 +323,39 @@ export function intake({ config, role, runId, observe = false, settings, notify
239
323
  // and a protected file from a territory somebody could be asked for.
240
324
  ...(barred ? { neverWrites: barred } : {}),
241
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 } : {}),
242
333
  });
243
334
  }
244
335
 
245
336
  return { fromHook, fromKernel, stats };
246
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
+ }