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/commands/run.js
CHANGED
|
@@ -10,25 +10,35 @@
|
|
|
10
10
|
import { spawn } from "node:child_process";
|
|
11
11
|
import { finished } from "node:stream/promises";
|
|
12
12
|
import { constants as osConstants } from "node:os";
|
|
13
|
-
import { mkdirSync, writeFileSync, existsSync, readFileSync } from "node:fs";
|
|
14
|
-
import { join, dirname, delimiter } from "node:path";
|
|
15
|
-
import {
|
|
16
|
-
import { settingsFor, roleHome, loopbackVia } from "../srt.js";
|
|
13
|
+
import { mkdirSync, writeFileSync, existsSync, readFileSync, statSync } from "node:fs";
|
|
14
|
+
import { join, dirname, delimiter, resolve } from "node:path";
|
|
15
|
+
import { settingsFor, roleHome, loopbackVia, agentOf, AGENTS, AGENT_HOMES } from "../srt.js";
|
|
17
16
|
import { buildEnv } from "../env.js";
|
|
17
|
+
import { traceFields } from "../trace.js";
|
|
18
18
|
import { resolveKeys } from "../keys.js";
|
|
19
19
|
import { nestedSandboxWarning } from "../nested.js";
|
|
20
|
-
import { spool, SOCK_ENV } from "../spool.js";
|
|
20
|
+
import { spool, SOCK_ENV, FIFO_SUFFIX } from "../spool.js";
|
|
21
21
|
import { openRun } from "../rundir.js";
|
|
22
22
|
import { secretsOf, redactor } from "../redact.js";
|
|
23
23
|
import { C, err } from "../render.js";
|
|
24
|
-
import { pending, requestsPath,
|
|
24
|
+
import { pending, requestsPath, queueOf, recordHandoff } from "../requests.js";
|
|
25
|
+
import { chainFrom, describeHandoff, MAX_DEPTH, CHAIN_ENV } from "../handoff.js";
|
|
25
26
|
import { notifier } from "../notify.js";
|
|
26
27
|
import { renderAdded } from "./requests.js";
|
|
27
28
|
import { unknownRole } from "../suggest.js";
|
|
28
29
|
import { watchDenials } from "../violations.js";
|
|
29
30
|
import { intake } from "../intake.js";
|
|
31
|
+
import { EXPECTED } from "../kinds.js";
|
|
32
|
+
import { toolchainBins } from "../territory.js";
|
|
33
|
+
import { writePathsOf, realOrSelf, SOCKET_MAX } from "../grants.js";
|
|
34
|
+
import { runCanary, CANARY_EXIT } from "../canary.js";
|
|
35
|
+
import { append, logPath } from "../log.js";
|
|
36
|
+
import { flagsOf } from "../help.js";
|
|
37
|
+
import { WILD } from "../paths.js";
|
|
38
|
+
import { chooseSandbox, settingsOf, DEFAULT_SANDBOX } from "../sandboxes/index.js";
|
|
39
|
+
import { machOf } from "../browsers.js";
|
|
40
|
+
import { MACH_FILE } from "../sandboxes/mach-profile.js";
|
|
30
41
|
|
|
31
|
-
const HERE = dirname(fileURLToPath(import.meta.url));
|
|
32
42
|
|
|
33
43
|
/**
|
|
34
44
|
* The ceiling on waiting for the kernel's last word when a run ends.
|
|
@@ -40,71 +50,27 @@ const HERE = dirname(fileURLToPath(import.meta.url));
|
|
|
40
50
|
*/
|
|
41
51
|
const DRAIN_MS = 250;
|
|
42
52
|
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
* "The one seisin depends on" is found the way Node finds a dependency — up
|
|
47
|
-
* from this file, through every `node_modules` — not at one spelled-out path.
|
|
48
|
-
* `<seisin>/node_modules/.bin/srt` is only where it lands in a checkout. In a
|
|
49
|
-
* project that installs seisin as a dependency, npm hoists the runtime to the
|
|
50
|
-
* project's own `node_modules`, and that path does not exist. Measured with
|
|
51
|
-
* 0.5.0 packed and installed into a project: `npx seisin` still worked, because
|
|
52
|
-
* npx puts the project's `.bin` on PATH; `./node_modules/.bin/seisin` and
|
|
53
|
-
* `node node_modules/seisin/src/cli.js` did not — they said "not found", or,
|
|
54
|
-
* with a global srt 0.0.75 installed, ran under THAT one instead of the 0.0.78
|
|
55
|
-
* this version pins, with none of its fixes.
|
|
56
|
-
*
|
|
57
|
-
* There is deliberately no fallback to running unsandboxed. A permission tool
|
|
58
|
-
* that quietly becomes a no-op when its enforcer is missing is worse than one
|
|
59
|
-
* that refuses, because you keep trusting it.
|
|
60
|
-
*/
|
|
61
|
-
export function resolveSrt({ from = HERE, path = process.env.PATH } = {}) {
|
|
62
|
-
const local = dependencySrt(from);
|
|
63
|
-
if (local) return local;
|
|
64
|
-
|
|
65
|
-
// Walk PATH rather than asking a shell. `spawnSync(..., { shell: true })`
|
|
66
|
-
// prints a deprecation warning on every single run — Node's DEP0190 — which
|
|
67
|
-
// is a line of noise in front of every user for the sake of finding one file.
|
|
68
|
-
// It is also the concatenation hazard the warning is about.
|
|
69
|
-
for (const dir of (path ?? "").split(delimiter)) {
|
|
70
|
-
if (!dir) continue;
|
|
71
|
-
const candidate = join(dir, "srt");
|
|
72
|
-
if (existsSync(candidate)) return candidate;
|
|
73
|
-
}
|
|
74
|
-
return null;
|
|
75
|
-
}
|
|
76
|
-
|
|
77
|
-
const RUNTIME = "@anthropic-ai/sandbox-runtime";
|
|
53
|
+
// Moved with the rest of what is particular to sandbox-runtime (sandboxes/srt.js);
|
|
54
|
+
// exported from here still, where callers and the tests have always found them.
|
|
55
|
+
export { resolveSrt, dependencySrt, installHint } from "../sandboxes/srt.js";
|
|
78
56
|
|
|
79
57
|
/**
|
|
80
|
-
*
|
|
81
|
-
*
|
|
82
|
-
*
|
|
83
|
-
*
|
|
84
|
-
* The package's `package.json` is found by walking up rather than through
|
|
85
|
-
* `require.resolve`, which a package's `exports` map is free to refuse.
|
|
58
|
+
* Which agent this run is: `--agent <name>` when given, else read off the
|
|
59
|
+
* command. It decides whose home is scratch and whose sign-in is closed (see
|
|
60
|
+
* AGENT_HOMES in grants.js). `--agent none` says "no agent", for a run that
|
|
61
|
+
* must get neither home whatever its command looks like.
|
|
86
62
|
*/
|
|
87
|
-
export function
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
if (rel) {
|
|
99
|
-
const target = join(nm, RUNTIME, rel);
|
|
100
|
-
if (existsSync(target)) return target;
|
|
101
|
-
}
|
|
102
|
-
} catch { /* an unreadable package.json is not a runtime */ }
|
|
103
|
-
}
|
|
104
|
-
const up = dirname(dir);
|
|
105
|
-
if (up === dir) return null;
|
|
106
|
-
dir = up;
|
|
107
|
-
}
|
|
63
|
+
export function agentFor(mine, cmd) {
|
|
64
|
+
const at = mine.lastIndexOf("--agent");
|
|
65
|
+
if (at === -1) return agentOf(cmd);
|
|
66
|
+
const given = mine[at + 1];
|
|
67
|
+
if (given === "none") return null;
|
|
68
|
+
if (!AGENTS.includes(given))
|
|
69
|
+
throw new Error(
|
|
70
|
+
`--agent ${given ?? ""}: seisin knows ${AGENTS.join(", ")} and none.\n` +
|
|
71
|
+
` It decides whose home a run may write (${AGENTS.map((a) => AGENT_HOMES[a]).join(", ")}) ` +
|
|
72
|
+
`and whose sign-in it cannot read.`);
|
|
73
|
+
return given;
|
|
108
74
|
}
|
|
109
75
|
|
|
110
76
|
export async function run(config, argv) {
|
|
@@ -129,12 +95,14 @@ export async function run(config, argv) {
|
|
|
129
95
|
// `--observe` as seisin's and put the run in observe mode; now that flag
|
|
130
96
|
// belongs to `echo`, and observe needs `seisin run x --observe -- echo hi`.
|
|
131
97
|
let i = 1;
|
|
132
|
-
|
|
98
|
+
const values = flagsOf("run").values;
|
|
99
|
+
while (i < argv.length && argv[i].startsWith("-")) i += values.includes(argv[i]) ? 2 : 1;
|
|
133
100
|
mine = argv.slice(1, i);
|
|
134
101
|
cmd = argv.slice(i);
|
|
135
102
|
}
|
|
136
103
|
if (!role || cmd.length === 0) throw new Error("usage: seisin run <role> -- <command...>");
|
|
137
104
|
if (!config.roles[role]) throw unknownRole(config, role);
|
|
105
|
+
const agent = agentFor(mine, cmd);
|
|
138
106
|
|
|
139
107
|
/**
|
|
140
108
|
* seisin does not run inside seisin, and says so here rather than later.
|
|
@@ -161,6 +129,9 @@ export async function run(config, argv) {
|
|
|
161
129
|
` above them — asked by the role that wants the work done, not run inside it.`,
|
|
162
130
|
);
|
|
163
131
|
|
|
132
|
+
const handed = handoffOf(config, role, mine);
|
|
133
|
+
const expectDenied = expectedDenials(mine);
|
|
134
|
+
|
|
164
135
|
/**
|
|
165
136
|
* The audit spool: this process holds the log and the queue, and the hook
|
|
166
137
|
* inside the sandbox gets a socket instead of a directory. That is the whole
|
|
@@ -173,7 +144,7 @@ export async function run(config, argv) {
|
|
|
173
144
|
// Built before the first request can arrive; the POST leaves from here, the
|
|
174
145
|
// parent, never from inside the sandbox (notify.js).
|
|
175
146
|
const notify = notifier(config);
|
|
176
|
-
// Everything that belongs to this run alone — socket, settings,
|
|
147
|
+
// Everything that belongs to this run alone — socket, settings, run-file keys
|
|
177
148
|
// — in one private directory, with one id. See rundir.js.
|
|
178
149
|
const theRun = openRun();
|
|
179
150
|
/**
|
|
@@ -182,7 +153,7 @@ export async function run(config, argv) {
|
|
|
182
153
|
* The happy path closed the spool, the kernel watcher and the directory; the
|
|
183
154
|
* failures did not. A key provider that failed, a spool that would not bind,
|
|
184
155
|
* a runtime that would not start — each left the directory behind with its
|
|
185
|
-
* socket and, past the key step, its `
|
|
156
|
+
* socket and, past the key step, its `run-file` keys on disk until some later
|
|
186
157
|
* run swept it, and the `log stream` process running. An `exit` handler,
|
|
187
158
|
* because every one of those ends in `process.exit`, from cli.js or from here,
|
|
188
159
|
* and everything it has to do is synchronous. Idempotent: the normal exit
|
|
@@ -202,18 +173,72 @@ export async function run(config, argv) {
|
|
|
202
173
|
// What was already waiting, so the end of the run can say what it added.
|
|
203
174
|
const before = new Set(pending(requestsPath(config.root)).map((q) => q.key));
|
|
204
175
|
const sockPath = theRun.sock;
|
|
205
|
-
|
|
176
|
+
// The program, as the role's PATH will find it: `read = "territory"` keeps
|
|
177
|
+
// that one file readable (territory.js). Same order run uses below — the
|
|
178
|
+
// declared toolchain first, then the parent's PATH.
|
|
179
|
+
const isDir = (d) => { try { return statSync(d).isDirectory(); } catch { return false; } };
|
|
180
|
+
const searchPath = config.read === "territory"
|
|
181
|
+
? [...toolchainBins(config, config.roles[role], isDir), process.env.PATH ?? ""].join(delimiter)
|
|
182
|
+
: process.env.PATH;
|
|
183
|
+
const program = whichOn(cmd[0], searchPath);
|
|
184
|
+
/**
|
|
185
|
+
* The run's temp directory: one the role may write, never the runtime's.
|
|
186
|
+
*
|
|
187
|
+
* sandbox-runtime hands every child `/tmp/claude` as TMPDIR, and that
|
|
188
|
+
* directory is now denied (srt.js, SHARED_TMP). It takes CLAUDE_CODE_TMPDIR
|
|
189
|
+
* instead when set, so that is where the run's goes: the role's own tmp under
|
|
190
|
+
* `isolate = "home"`, else the parent's TMPDIR when the role may already write
|
|
191
|
+
* it, else one made for this run beside the runs root, granted to it alone. A caller's
|
|
192
|
+
* own CLAUDE_CODE_TMPDIR is kept only if the role may write it: an
|
|
193
|
+
* environment variable is not a way to widen the policy.
|
|
194
|
+
*/
|
|
195
|
+
const granted = writePathsOf(config, config.roles[role], { observe, agent });
|
|
196
|
+
const writable = (p) => !!p && [p, realOrSelf(p)].some((q) =>
|
|
197
|
+
granted.some((g) => q === g || q === realOrSelf(g) || q.startsWith(g + "/") || q.startsWith(realOrSelf(g) + "/")));
|
|
198
|
+
const isolatedHome = config.isolate === "home" || config.isolate === true;
|
|
199
|
+
let runTmp = [process.env.CLAUDE_CODE_TMPDIR,
|
|
200
|
+
isolatedHome ? join(roleHome(config, role), "tmp") : process.env.TMPDIR].find(writable);
|
|
201
|
+
if (!runTmp) runTmp = theRun.tmp();
|
|
202
|
+
const generated = settingsFor(config, role, sockPath, observe, { agent, program, tmp: runTmp, forRun: true });
|
|
203
|
+
|
|
204
|
+
/**
|
|
205
|
+
* The sandbox this role runs under: the first one `[sandbox] provider` names
|
|
206
|
+
* that is installed and enforces every part of these settings
|
|
207
|
+
* (sandboxes/index.js). None is an error that names what each one lacked —
|
|
208
|
+
* never a run under one that enforces less. Chosen before the settings are
|
|
209
|
+
* written, because the file is the chosen sandbox's version of them
|
|
210
|
+
* (settingsOf: tartaros takes a browser's Mach names in a field of its own).
|
|
211
|
+
*/
|
|
212
|
+
// `browsers`: the Mach names to register (browsers.js).
|
|
213
|
+
const { register: machRegister } = machOf(config.roles[role]);
|
|
214
|
+
const box = chooseSandbox(config, generated, { role });
|
|
215
|
+
if (box.error) throw box.error;
|
|
216
|
+
const { sandbox, bin } = box;
|
|
217
|
+
const settings = settingsOf(sandbox, generated, { machRegister });
|
|
206
218
|
// Per run, never per role: `.seisin/<role>.json` was one file for every run
|
|
207
219
|
// of that role, and two at once wrote each other's socket path into it.
|
|
208
220
|
const file = theRun.writeSettings(settings);
|
|
209
221
|
|
|
210
222
|
// What arrives from the hook and from the kernel, checked against the policy
|
|
211
223
|
// this run started with and written with the run's id on it. See intake.js.
|
|
212
|
-
|
|
224
|
+
// The launcher's W3C trace, read from the parent's environment once: every line
|
|
225
|
+
// this run writes carries it, so the log joins the launcher's trace (trace.js).
|
|
226
|
+
const trace = traceFields(process.env);
|
|
227
|
+
const take = intake({ config, role, runId: theRun.id, observe, settings, notify, chain: handed?.chain ?? null, expectDenied, trace });
|
|
228
|
+
// The channel is a unix socket on macOS, and a socket path is capped. Past
|
|
229
|
+
// the cap the listen dies on `EINVAL` and a path, which says nothing about
|
|
230
|
+
// TMPDIR — the one thing that decides how long it is.
|
|
231
|
+
if (!sockPath.endsWith(FIFO_SUFFIX) && Buffer.byteLength(sockPath) >= SOCKET_MAX)
|
|
232
|
+
throw new Error(
|
|
233
|
+
`TMPDIR is too long for this run's socket: ${Buffer.byteLength(sockPath)} bytes, and a unix socket ` +
|
|
234
|
+
`path is capped near ${SOCKET_MAX}.\n Run seisin with a shorter TMPDIR (TMPDIR=/tmp/<short> seisin run …).`);
|
|
213
235
|
audit = await spool(take.fromHook, sockPath);
|
|
214
236
|
|
|
215
|
-
|
|
216
|
-
|
|
237
|
+
// srt's settings have no field for the Mach names to register: beside them,
|
|
238
|
+
// where srt-mach.mjs looks for them.
|
|
239
|
+
if (machRegister.length && sandbox.name === "srt")
|
|
240
|
+
writeFileSync(join(dirname(file), MACH_FILE), JSON.stringify({ register: machRegister }), { mode: 0o600 });
|
|
241
|
+
const wrap = (inner) => sandbox.wrap(bin, file, settings, inner, { role, machRegister });
|
|
217
242
|
|
|
218
243
|
// The hook runs inside the child and has to know which role it is. These are
|
|
219
244
|
// the only variables seisin injects, and none carries a secret.
|
|
@@ -221,6 +246,22 @@ export async function run(config, argv) {
|
|
|
221
246
|
env.SEISIN_ROLE = role;
|
|
222
247
|
env.SEISIN_CONFIG = config.path;
|
|
223
248
|
env[SOCK_ENV] = sockPath;
|
|
249
|
+
// The handoff chain, for the hook's words only (handoff.js, CHAIN_ENV): set
|
|
250
|
+
// from the decision checked above, and removed otherwise, so a value the
|
|
251
|
+
// parent happened to have never reaches a run that was not handed work.
|
|
252
|
+
delete env[CHAIN_ENV];
|
|
253
|
+
if (handed) env[CHAIN_ENV] = JSON.stringify(handed.chain);
|
|
254
|
+
/**
|
|
255
|
+
* The declared toolchain goes first on PATH, under `read = "territory"`.
|
|
256
|
+
*
|
|
257
|
+
* Declaring `~/.venvs/api` and then having `python` resolve to whatever the
|
|
258
|
+
* parent's PATH found first is not having the toolchain. Only in that mode:
|
|
259
|
+
* under "all" the PATH is the parent's, as it has always been.
|
|
260
|
+
*/
|
|
261
|
+
if (config.read === "territory") {
|
|
262
|
+
const bins = toolchainBins(config, config.roles[role], isDir);
|
|
263
|
+
if (bins.length) env.PATH = [...bins, ...(env.PATH ? [env.PATH] : [])].join(delimiter);
|
|
264
|
+
}
|
|
224
265
|
|
|
225
266
|
/**
|
|
226
267
|
* Reference keys: resolved here, by the parent, before anything is spawned.
|
|
@@ -236,18 +277,44 @@ export async function run(config, argv) {
|
|
|
236
277
|
* name, not a skipped key: a role that starts without a credential it
|
|
237
278
|
* declared fails later, somewhere else, looking like something it is not.
|
|
238
279
|
*/
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
280
|
+
/**
|
|
281
|
+
* Each resolution is a line in the log: which key, which provider, which
|
|
282
|
+
* mode, whether it resolved. Never the value — the log is the record of what
|
|
283
|
+
* the policy did, and a secret in it would be a second plaintext copy with
|
|
284
|
+
* worse access control than the first. Shaped like the canary's line, a
|
|
285
|
+
* run-level event beside the verdicts, so readers that count verdicts skip it.
|
|
286
|
+
*/
|
|
287
|
+
const resolved = resolveKeys(config, config.roles[role], {
|
|
288
|
+
record: ({ entry, mode, ok }) => append(logPath(config.root), {
|
|
289
|
+
// The variable's name and the provider, never the reference: a reference
|
|
290
|
+
// can carry what it points at (a provider that echoes it, a path that
|
|
291
|
+
// names the secret), and the log is read by more people than the policy.
|
|
292
|
+
event: "key", role, run: theRun.id.slice(0, 8), ...trace, key: entry.name, provider: entry.scheme, mode, ok,
|
|
293
|
+
}),
|
|
294
|
+
});
|
|
295
|
+
const runFileKeys = theRun.keys;
|
|
296
|
+
if (resolved.some((r) => r.mode === "run-file")) mkdirSync(runFileKeys, { recursive: true, mode: 0o700 });
|
|
242
297
|
for (const { entry, mode, value } of resolved) {
|
|
243
298
|
if (mode === "env") { env[entry.name] = value; continue; }
|
|
244
|
-
|
|
299
|
+
/**
|
|
300
|
+
* `inject`: the real value goes into the environment of `srt` — not the
|
|
301
|
+
* agent's. The settings this run already generated declare it under
|
|
302
|
+
* `credentials.envVars` with `mode: "mask"`, so the runtime reads the real
|
|
303
|
+
* value from its own environment, hands the sandboxed child a sentinel, and
|
|
304
|
+
* substitutes the real value back only on egress to the credential's hosts.
|
|
305
|
+
* `srt` runs outside the sandbox, and the sandboxed child gets the sentinel.
|
|
306
|
+
* This mode is Linux-only for now — see injectGate in keys.js. The value is
|
|
307
|
+
* still masked on stdout/stderr below, so a sentinel the agent reflects into
|
|
308
|
+
* its own output is covered too.
|
|
309
|
+
*/
|
|
310
|
+
if (mode === "inject") { env[entry.name] = value; continue; }
|
|
311
|
+
// `run-file`: the value lands in the per-run directory this process already
|
|
245
312
|
// makes for the spool and already removes on exit, so its lifetime is the
|
|
246
313
|
// run's by construction rather than by a cleanup somebody has to remember.
|
|
247
314
|
// The variable carries the PATH, which is the convention every tool that
|
|
248
315
|
// wants a secret from a file already reads (`_FILE`), and it means the
|
|
249
316
|
// value itself is not in the environment of a process that may print it.
|
|
250
|
-
const file = join(
|
|
317
|
+
const file = join(runFileKeys, entry.name);
|
|
251
318
|
writeFileSync(file, value, { mode: 0o600 });
|
|
252
319
|
/**
|
|
253
320
|
* Both names, because the world has two conventions and neither is ours.
|
|
@@ -262,7 +329,7 @@ export async function run(config, argv) {
|
|
|
262
329
|
* the file was there and correct, and the variable it was announced under
|
|
263
330
|
* was one kubectl has never heard of.
|
|
264
331
|
*
|
|
265
|
-
* Setting both costs nothing and leaks nothing: in `
|
|
332
|
+
* Setting both costs nothing and leaks nothing: in `run-file` mode the
|
|
266
333
|
* value is deliberately not in the environment, so both variables hold a
|
|
267
334
|
* path and neither holds a secret.
|
|
268
335
|
*/
|
|
@@ -296,7 +363,17 @@ export async function run(config, argv) {
|
|
|
296
363
|
env.XDG_STATE_HOME = join(home, ".local", "state");
|
|
297
364
|
env.XDG_CACHE_HOME = join(home, ".cache");
|
|
298
365
|
env.TMPDIR = join(home, "tmp");
|
|
366
|
+
// zsh writes its heredocs to $TMPPREFIX, not $TMPDIR, and defaults it to
|
|
367
|
+
// /tmp/zsh — which the role cannot write. Every heredoc in every zsh script
|
|
368
|
+
// failed with the role's own tmp a directory away (measured by an orchestrator running zsh scripts).
|
|
369
|
+
env.TMPPREFIX = join(home, "tmp", "zsh");
|
|
299
370
|
}
|
|
371
|
+
env.TMPDIR = runTmp;
|
|
372
|
+
env.CLAUDE_CODE_TMPDIR = runTmp;
|
|
373
|
+
// zsh writes heredocs to $TMPPREFIX (default /tmp/zsh), not $TMPDIR: see
|
|
374
|
+
// the isolated-home note above. Same answer for every run now.
|
|
375
|
+
env.TMPPREFIX = join(runTmp, "zsh");
|
|
376
|
+
delete env.CLAUDE_TMPDIR;
|
|
300
377
|
if (observe) env.SEISIN_OBSERVE = "1";
|
|
301
378
|
if (mine.includes("--debug-env")) err(`${C.dim}seisin: dropped ${dropped.join(" ")}${C.off}\n`);
|
|
302
379
|
|
|
@@ -321,7 +398,8 @@ export async function run(config, argv) {
|
|
|
321
398
|
err(
|
|
322
399
|
`${C.dim}seisin: ${role} · writes ${territory}${scratch > 0 ? ` (+${scratch} scratch)` : ""} · ` +
|
|
323
400
|
`keys ${r.keys.length ? r.keys.join(" ") : "none"} · ` +
|
|
324
|
-
`
|
|
401
|
+
`${agent ? `runs ${agent}` : "runs no agent seisin knows"} · ` +
|
|
402
|
+
`env ${Object.keys(env).length} kept, ${dropped.length} dropped · read ${config.read}` +
|
|
325
403
|
`${observe ? ` · ${C.yellow}OBSERVING — the repo is writable, the network is NOT${C.off}${C.dim}` : ""}${C.off}\n`
|
|
326
404
|
);
|
|
327
405
|
|
|
@@ -381,8 +459,73 @@ export async function run(config, argv) {
|
|
|
381
459
|
// one request per role, and a denial logged against the role that owns the
|
|
382
460
|
// path (measured: 3 lines and 3 requests for one write by one of 3 roles).
|
|
383
461
|
// The nonce makes each run's command unique, so the tag names one run.
|
|
384
|
-
|
|
385
|
-
|
|
462
|
+
/**
|
|
463
|
+
* Linux, `read = "territory"`: the runtime's own temp directory moves into
|
|
464
|
+
* this run's, and the agent gets its TMPDIR back.
|
|
465
|
+
*
|
|
466
|
+
* sandbox-runtime makes the sockets its in-sandbox bridge reaches the proxy
|
|
467
|
+
* through in `os.tmpdir()` — of the runtime's process. With /tmp a data root
|
|
468
|
+
* and denied, the bridge found nothing and the whole network died in
|
|
469
|
+
* silence: every allowed host and `local_ports` refused like the rest
|
|
470
|
+
* (measured in Docker, Debian 12, bwrap 0.8.0). The run's directory is
|
|
471
|
+
* already readable from inside, so the sockets go there; the `env` the
|
|
472
|
+
* command runs through puts the agent's TMPDIR back as it was.
|
|
473
|
+
*/
|
|
474
|
+
const agentTmp = env.TMPDIR;
|
|
475
|
+
const restoreTmp = [];
|
|
476
|
+
if (sandbox.name === "srt" && process.platform === "linux" && config.read === "territory") {
|
|
477
|
+
const srtTmp = join(theRun.dir, "srt");
|
|
478
|
+
mkdirSync(srtTmp, { recursive: true, mode: 0o700 });
|
|
479
|
+
env.TMPDIR = srtTmp;
|
|
480
|
+
restoreTmp.push(...(agentTmp ? [`TMPDIR=${agentTmp}`] : ["-u", "TMPDIR"]));
|
|
481
|
+
}
|
|
482
|
+
|
|
483
|
+
/**
|
|
484
|
+
* The canary, before anything of the agent's starts — and before the kernel
|
|
485
|
+
* watcher below, so its own denials are not recorded as the agent's. See
|
|
486
|
+
* canary.js. A failure stops the run with its own exit code and a line in
|
|
487
|
+
* the log; there is no flag to skip it.
|
|
488
|
+
*/
|
|
489
|
+
const probe = await runCanary({
|
|
490
|
+
wrap, settingsFile: file, settings, env, cwd: process.cwd(),
|
|
491
|
+
runDir: theRun.dir, runsRoot: theRun.root, role: config.roles[role],
|
|
492
|
+
granted: writePathsOf(config, config.roles[role], { observe, agent }),
|
|
493
|
+
});
|
|
494
|
+
if (!probe.ok) {
|
|
495
|
+
append(logPath(config.root), {
|
|
496
|
+
event: "canary", role, run: theRun.id.slice(0, 8), ...trace, agent: agent ?? null, confined: false, reasons: probe.failed,
|
|
497
|
+
...(sandbox.name === DEFAULT_SANDBOX ? {} : { sandbox: sandbox.name }),
|
|
498
|
+
});
|
|
499
|
+
err(`${C.red}seisin: ${role} was NOT started — the sandbox failed its canary:${C.off}\n` +
|
|
500
|
+
probe.failed.map((f) => ` - ${f}\n`).join("") +
|
|
501
|
+
` The policy as generated does not hold on this machine, so no agent runs under it. ` +
|
|
502
|
+
`This is recorded in the log (confined=false). Exit 86 means the boundary did not hold; ` +
|
|
503
|
+
`run seisin check, fix the reported policy or machine setup, and try again. There is no flag to skip it.\n`);
|
|
504
|
+
process.exit(CANARY_EXIT);
|
|
505
|
+
}
|
|
506
|
+
append(logPath(config.root), {
|
|
507
|
+
event: "canary", role, run: theRun.id.slice(0, 8), ...trace, agent: agent ?? null, confined: true, reasons: [],
|
|
508
|
+
...(sandbox.name === DEFAULT_SANDBOX ? {} : { sandbox: sandbox.name }),
|
|
509
|
+
});
|
|
510
|
+
// Said once, only when it is not the default: which sandbox holds this run
|
|
511
|
+
// is a fact a reader of the terminal would otherwise have to guess.
|
|
512
|
+
if (sandbox.name !== DEFAULT_SANDBOX) err(`${C.dim}seisin: ${role} runs under ${sandbox.name}${C.off}\n`);
|
|
513
|
+
// Said at the start, not discovered from a log that stayed empty.
|
|
514
|
+
if (!sandbox.tagsDenials && process.platform === "darwin")
|
|
515
|
+
err(`${C.yellow}seisin: ${sandbox.name} does not tag its denials, so none of the kernel's denials in this run are recorded ` +
|
|
516
|
+
`(tags_denials in docs/sandboxes.md)${C.off}\n`);
|
|
517
|
+
// Recorded once the receiver is confined and about to start, not before: a
|
|
518
|
+
// run that failed its canary did not take the work.
|
|
519
|
+
if (handed) {
|
|
520
|
+
recordHandoff(requestsPath(config.root), handed.request.key, handed.request.handoff);
|
|
521
|
+
err(`${C.dim}seisin: ${role} took ${handed.request.key} — handoff chain ${handed.chain.visited.join(" → ")}, ` +
|
|
522
|
+
`${handed.chain.depth} of ${MAX_DEPTH}${C.off}\n`);
|
|
523
|
+
}
|
|
524
|
+
|
|
525
|
+
const boxed = ["env", ...restoreTmp, `SEISIN_RUN_ID=${theRun.id}`, ...loopbackVia(config.roles[role], cmd, sandbox.name)];
|
|
526
|
+
// The tag is sandbox-runtime's: under any other sandbox there is none to
|
|
527
|
+
// match, and attribution goes by the process tree alone (violations.js).
|
|
528
|
+
denials = watchDenials(take.fromKernel, { argv: sandbox.tagsDenials ? boxed : null });
|
|
386
529
|
|
|
387
530
|
// Deliberately NOT announced here. On Linux this branch is taken every time,
|
|
388
531
|
// so saying it per run puts a line the reader cannot act on in front of every
|
|
@@ -401,7 +544,8 @@ export async function run(config, argv) {
|
|
|
401
544
|
const nested = nestedSandboxWarning(cmd);
|
|
402
545
|
if (nested) err(`${C.yellow}seisin: ${nested}${C.off}\n`);
|
|
403
546
|
|
|
404
|
-
const
|
|
547
|
+
const [runner, ...runnerArgs] = wrap(boxed);
|
|
548
|
+
const child = spawn(runner, runnerArgs, {
|
|
405
549
|
stdio: outStream ? ["inherit", "pipe", "pipe"] : "inherit",
|
|
406
550
|
env,
|
|
407
551
|
});
|
|
@@ -490,19 +634,48 @@ export async function run(config, argv) {
|
|
|
490
634
|
// filtered out has nothing to contextualise, and `0 recorded, 1 outside the
|
|
491
635
|
// policy's paths` is a line that answers a question nobody asked — the
|
|
492
636
|
// counts exist to explain a filter, not to announce that one ran.
|
|
493
|
-
const { attributed,
|
|
494
|
-
|
|
637
|
+
const { attributed, dropped } = denials.stats;
|
|
638
|
+
// The one loss the filter above does not explain: the system log could not
|
|
639
|
+
// keep up and threw lines away before seisin saw them. The boundary held;
|
|
640
|
+
// the record did not. Said every time, and written down, because a denial
|
|
641
|
+
// that never arrived files no request and nothing else would show it.
|
|
642
|
+
if (dropped) {
|
|
643
|
+
append(logPath(config.root), { event: "log-dropped", role, run: theRun.id.slice(0, 8), ...trace, batches: dropped });
|
|
644
|
+
err(`${C.yellow}seisin: the system log dropped ${dropped} batch(es) of messages during this run: ` +
|
|
645
|
+
`kernel denials may be missing from the log and the queue. ` +
|
|
646
|
+
`\`seisin check --system-log\` says what is flooding it.${C.off}\n`);
|
|
647
|
+
}
|
|
648
|
+
const { offPolicy, walks, expected, probes, atomic, browsers } = take.stats;
|
|
649
|
+
// A browser that died at start on its Mach rendezvous: the crash the agent
|
|
650
|
+
// saw does not say why, and this is the line that does.
|
|
651
|
+
for (const b of browsers)
|
|
652
|
+
err(`${C.yellow}seisin: ${role} started ${b} and the sandbox denied the Mach service it registers at start, so it ` +
|
|
653
|
+
`exited. Add browsers = ["${b}"] to [roles.${role}] in seisin.toml (docs/browsers.md).${C.off}\n`);
|
|
654
|
+
// A save refused at its temporary file, beside a file this role may write:
|
|
655
|
+
// the file was not saved, and nothing was queued because there is nothing
|
|
656
|
+
// to grant (the temporary name changes every time). Said whether or not
|
|
657
|
+
// anything else was recorded, because the agent's error said neither.
|
|
658
|
+
if (atomic.length) {
|
|
659
|
+
const files = [...new Set(atomic)];
|
|
660
|
+
err(`${C.yellow}seisin: ${files.length} file(s) ${role} may write were not saved: the save's temporary file beside ` +
|
|
661
|
+
`${files.slice(0, 3).join(", ")}${files.length > 3 ? ` and ${files.length - 3} more` : ""} was denied ` +
|
|
662
|
+
`(Claude Code's Edit and Write). \`seisin check ${role}\` says whether a grant can fix it.${C.off}\n`);
|
|
663
|
+
}
|
|
495
664
|
const recorded = attributed - offPolicy - walks;
|
|
496
665
|
if (recorded > 0)
|
|
497
666
|
err(`${C.dim}seisin: ${recorded} kernel denial(s) recorded` +
|
|
498
667
|
`${walks ? `, ${walks} directory scan(s)` : ""}` +
|
|
499
|
-
`${offPolicy ? `, ${offPolicy} outside the policy's paths` : ""}` +
|
|
500
|
-
|
|
668
|
+
`${offPolicy ? `, ${offPolicy} outside the policy's paths` : ""}${C.off}\n` +
|
|
669
|
+
// Claude Code's plugin updater, refused correctly: said as expected so
|
|
670
|
+
// it does not read as the run failing (kinds.js expectedOf).
|
|
671
|
+
(expected ? `${C.dim}seisin: ${expected} of them expected: ${EXPECTED.plugins}${C.off}\n` : "") +
|
|
672
|
+
// The caller's own probes: denied as it asked, and nothing to grant.
|
|
673
|
+
(probes ? `${C.dim}seisin: ${probes} of them expected by the caller (--expect-denied) — nothing queued${C.off}\n` : ""));
|
|
501
674
|
|
|
502
675
|
// Only what this run added, with the numbers `grant` takes, and one line
|
|
503
676
|
// for the rest. The whole queue after every run buried the new request
|
|
504
677
|
// under the ones already seen; nothing new, nothing said.
|
|
505
|
-
const queue =
|
|
678
|
+
const queue = queueOf(config);
|
|
506
679
|
const added = queue.map((q, i) => ({ ...q, n: i + 1 })).filter((q) => !before.has(q.key));
|
|
507
680
|
if (added.length) err(renderAdded(added, queue.length - added.length));
|
|
508
681
|
const failed = await notify.settle();
|
|
@@ -519,8 +692,9 @@ export async function run(config, argv) {
|
|
|
519
692
|
// Every run with a key paid it — measured 3.0 s against 1.2 s without keys.
|
|
520
693
|
// `finished()` settles at once for a stream that is already done.
|
|
521
694
|
const guard = setTimeout(() => process.exit(status), 2000);
|
|
522
|
-
|
|
523
|
-
|
|
695
|
+
// A child's exit is not its pipes' EOF. Let pipe() end the redactors
|
|
696
|
+
// after the remaining stdout/stderr arrives; ending them here lost that
|
|
697
|
+
// output. The guard still bounds a descendant that keeps a pipe open.
|
|
524
698
|
await Promise.all([finished(outStream), finished(errStream)]).catch(() => {});
|
|
525
699
|
clearTimeout(guard);
|
|
526
700
|
process.exit(status);
|
|
@@ -534,26 +708,74 @@ export async function run(config, argv) {
|
|
|
534
708
|
});
|
|
535
709
|
}
|
|
536
710
|
|
|
537
|
-
/**
|
|
538
|
-
|
|
539
|
-
|
|
540
|
-
|
|
541
|
-
|
|
711
|
+
/**
|
|
712
|
+
* `--handoff <id>`: this run is the role a pending request was handed to.
|
|
713
|
+
*
|
|
714
|
+
* The flag names a request, never a role. Which role takes it is the decision
|
|
715
|
+
* handoff.js makes from the policy as it stands, and a run of any other role
|
|
716
|
+
* is refused — so whatever starts roles can act on `handoff.role` but cannot
|
|
717
|
+
* substitute its own choice, and a model upstream of it choosing "backend"
|
|
718
|
+
* changes nothing. What the run carries forward is the chain the decision
|
|
719
|
+
* extends; every request it files is recorded in that chain, which is how a
|
|
720
|
+
* cycle back to a role the work came from, or a chain past its depth, is
|
|
721
|
+
* caught instead of routed.
|
|
722
|
+
*/
|
|
723
|
+
export function handoffOf(config, role, mine) {
|
|
724
|
+
const at = mine.indexOf("--handoff");
|
|
725
|
+
if (at === -1) return null;
|
|
726
|
+
// Said before the request is looked up: whatever the id, this role is not
|
|
727
|
+
// one the policy hands work to, and that is the answer.
|
|
728
|
+
if (config.roles[role]?.handoff === false)
|
|
729
|
+
throw new Error(`${role} does not take handed work (handoff = false in its role): no request is handed to it. Nothing was started.`);
|
|
730
|
+
const id = mine[at + 1];
|
|
731
|
+
if (!id || id.startsWith("-")) throw new Error("usage: seisin run <role> --handoff <id> -- <command...> (the id from seisin requests)");
|
|
732
|
+
const request = queueOf(config).find((r) => r.key === id);
|
|
733
|
+
if (!request) throw new Error(`no pending request has the id "${id}" — seisin requests lists them`);
|
|
734
|
+
const d = request.handoff;
|
|
735
|
+
if (d.type !== "route")
|
|
736
|
+
throw new Error(`${id} is not handed to anybody: ${describeHandoff(d, request.attempt)}. Nothing was started.`);
|
|
737
|
+
if (d.role !== role)
|
|
738
|
+
throw new Error(`${id} is handed to ${d.role}, not ${role}: the policy decides who takes it. Nothing was started.`);
|
|
739
|
+
return { request, chain: chainFrom(d) };
|
|
542
740
|
}
|
|
543
741
|
|
|
544
742
|
/**
|
|
545
|
-
*
|
|
743
|
+
* `--expect-denied <path>`, as many times as given: the paths whose denial the
|
|
744
|
+
* caller is waiting for (intake.js). Read from this process's argv and nowhere
|
|
745
|
+
* else — not the environment, not a file in the repo — because everything
|
|
746
|
+
* else is something the sandboxed side could reach, and a list it could add
|
|
747
|
+
* to would be a way to keep its own denials out of the queue.
|
|
546
748
|
*
|
|
547
|
-
*
|
|
548
|
-
*
|
|
549
|
-
*
|
|
550
|
-
* installs a second runtime somewhere seisin does not look first.
|
|
749
|
+
* A path, exactly. A glob is refused rather than read literally: `*` meaning
|
|
750
|
+
* "this one file" is not what anyone who typed it meant, and meaning "every
|
|
751
|
+
* file" would make a probe a way to silence a whole directory.
|
|
551
752
|
*/
|
|
552
|
-
export function
|
|
553
|
-
const
|
|
554
|
-
|
|
555
|
-
|
|
556
|
-
|
|
557
|
-
|
|
558
|
-
|
|
753
|
+
export function expectedDenials(mine) {
|
|
754
|
+
const out = [];
|
|
755
|
+
for (let i = 0; i < mine.length; i++) {
|
|
756
|
+
if (mine[i] !== "--expect-denied") continue;
|
|
757
|
+
const p = mine[++i];
|
|
758
|
+
if (!p || p.startsWith("-") || !p.trim())
|
|
759
|
+
throw new Error("usage: seisin run <role> --expect-denied <path> -- <command...> (a path, relative to the repo or absolute)");
|
|
760
|
+
if (WILD.test(p))
|
|
761
|
+
throw new Error(`--expect-denied ${p}: a path, not a pattern. Only a denial of exactly that path is expected; ` +
|
|
762
|
+
`name each one with its own --expect-denied.`);
|
|
763
|
+
out.push(p);
|
|
764
|
+
}
|
|
765
|
+
return out;
|
|
766
|
+
}
|
|
767
|
+
|
|
768
|
+
/** Is `name` a program the role can start with this PATH? */
|
|
769
|
+
/** Where `name` resolves on `path`, absolute, or null. */
|
|
770
|
+
function whichOn(name, path) {
|
|
771
|
+
if (!name) return null;
|
|
772
|
+
if (name.includes("/")) return existsSync(name) ? resolve(name) : null;
|
|
773
|
+
for (const d of (path ?? "").split(delimiter)) if (d && existsSync(join(d, name))) return join(d, name);
|
|
774
|
+
return null;
|
|
775
|
+
}
|
|
776
|
+
|
|
777
|
+
function onPath(name, path) {
|
|
778
|
+
if (!name) return false;
|
|
779
|
+
if (name.includes("/")) return existsSync(name);
|
|
780
|
+
return (path ?? "").split(delimiter).some((d) => d && existsSync(join(d, name)));
|
|
559
781
|
}
|