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.
- package/README.md +385 -129
- package/package.json +13 -5
- package/src/browsers.js +52 -0
- package/src/canary.js +149 -0
- package/src/cli.js +47 -23
- package/src/commands/check.js +154 -3
- package/src/commands/explain.js +2 -5
- package/src/commands/hook.js +7 -3
- package/src/commands/init.js +57 -50
- package/src/commands/requests.js +31 -56
- package/src/commands/review.js +33 -6
- package/src/commands/run.js +332 -110
- package/src/commands/walls.js +39 -7
- package/src/commands/whose.js +2 -1
- package/src/config.js +321 -15
- package/src/controls.js +5 -5
- package/src/diagnose.js +67 -16
- package/src/env.js +3 -0
- package/src/grants.js +107 -2
- package/src/handoff.js +285 -10
- package/src/help.js +39 -19
- package/src/hook.js +59 -30
- package/src/index.d.ts +217 -0
- package/src/index.js +20 -12
- package/src/inspect/codex.js +64 -0
- package/src/inspect/credentials.js +48 -0
- package/src/inspect/enforce.js +24 -0
- package/src/inspect/files.js +71 -0
- package/src/inspect/home.js +43 -0
- package/src/inspect/inject.js +68 -0
- package/src/inspect/keys.js +74 -0
- package/src/inspect/layout.js +258 -0
- package/src/inspect/limits.js +98 -0
- package/src/inspect/machine.js +107 -0
- package/src/inspect/network.js +37 -0
- package/src/inspect/providers.js +71 -0
- package/src/inspect/read-territory.js +105 -0
- package/src/inspect/role-keys.js +136 -0
- package/src/inspect/shared.js +37 -0
- package/src/inspect/warnings.js +58 -0
- package/src/inspect.js +13 -815
- package/src/intake.js +131 -21
- package/src/keys.js +223 -67
- package/src/kinds.js +42 -0
- package/src/lines.js +26 -0
- package/src/log.js +7 -2
- package/src/mcp.js +28 -43
- package/src/nested.js +12 -3
- package/src/notify.js +53 -12
- package/src/owners.js +165 -3
- package/src/paths.js +17 -1
- package/src/provider-run.js +81 -0
- package/src/redact.js +34 -15
- package/src/render.js +35 -9
- package/src/requests.js +363 -45
- package/src/review.js +21 -9
- package/src/rundir.js +29 -8
- package/src/sandboxes/command.js +93 -0
- package/src/sandboxes/index.js +113 -0
- package/src/sandboxes/mach-profile.js +31 -0
- package/src/sandboxes/srt-mach.mjs +43 -0
- package/src/sandboxes/srt.js +163 -0
- package/src/sandboxes/tartaros.js +80 -0
- package/src/scan.js +7 -3
- package/src/serve.js +35 -17
- package/src/spool.js +8 -17
- package/src/srt.js +263 -33
- package/src/surface.js +129 -16
- package/src/territory.js +261 -0
- package/src/trace.js +33 -0
- package/src/uilink.js +15 -6
- package/src/views.js +40 -14
- package/src/violations.js +143 -7
- package/src/walls.js +62 -9
- package/ui/index.html +85 -13
- 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 {
|
|
29
|
-
import {
|
|
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
|
-
/**
|
|
36
|
-
export
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
217
|
-
|
|
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
|
-
|
|
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
|
+
}
|