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.
- package/README.md +506 -162
- package/package.json +14 -6
- package/src/browsers.js +52 -0
- package/src/canary.js +149 -0
- package/src/cli.js +175 -52
- package/src/commands/check.js +157 -5
- package/src/commands/explain.js +31 -13
- package/src/commands/hook.js +20 -6
- package/src/commands/init.js +183 -88
- package/src/commands/log.js +48 -9
- package/src/commands/requests.js +122 -70
- package/src/commands/review.js +73 -21
- package/src/commands/run.js +422 -78
- package/src/commands/scan.js +18 -3
- package/src/commands/ui.js +18 -5
- package/src/commands/walls.js +62 -11
- package/src/commands/whose.js +9 -7
- package/src/commands/wire.js +156 -17
- package/src/config.js +505 -37
- package/src/controls.js +244 -0
- package/src/diagnose.js +102 -49
- package/src/env.js +14 -8
- package/src/grants.js +122 -9
- package/src/handoff.js +285 -10
- package/src/help.js +179 -0
- package/src/hook.js +101 -42
- package/src/index.d.ts +217 -0
- package/src/index.js +24 -16
- 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 +20 -756
- package/src/intake.js +150 -35
- package/src/keys.js +231 -73
- package/src/kinds.js +249 -0
- package/src/lines.js +26 -0
- package/src/log.js +242 -52
- package/src/mcp.js +57 -58
- package/src/nested.js +12 -3
- package/src/notify.js +66 -20
- package/src/owners.js +358 -39
- package/src/paths.js +54 -2
- package/src/provider-run.js +81 -0
- package/src/redact.js +46 -13
- package/src/render.js +101 -17
- package/src/requests.js +506 -87
- package/src/review.js +50 -25
- package/src/rundir.js +33 -14
- 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 +181 -19
- package/src/serve.js +396 -230
- package/src/spool.js +16 -45
- package/src/srt.js +296 -49
- package/src/suggest.js +58 -0
- package/src/surface.js +314 -54
- package/src/territory.js +261 -0
- package/src/trace.js +33 -0
- package/src/uilink.js +24 -9
- package/src/views.js +315 -0
- package/src/violations.js +247 -23
- package/src/walls.js +76 -46
- package/src/worktree.js +3 -11
- package/ui/index.html +1429 -538
- 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
|
-
import {
|
|
27
|
-
import {
|
|
27
|
+
import { renderAdded } from "./requests.js";
|
|
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,30 +50,29 @@ const HERE = dirname(fileURLToPath(import.meta.url));
|
|
|
40
50
|
*/
|
|
41
51
|
const DRAIN_MS = 250;
|
|
42
52
|
|
|
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";
|
|
56
|
+
|
|
43
57
|
/**
|
|
44
|
-
*
|
|
45
|
-
*
|
|
46
|
-
*
|
|
47
|
-
*
|
|
48
|
-
* that refuses, because you keep trusting it.
|
|
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.
|
|
49
62
|
*/
|
|
50
|
-
export function
|
|
51
|
-
const
|
|
52
|
-
if (
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
if (existsSync(candidate)) return candidate;
|
|
62
|
-
}
|
|
63
|
-
return null;
|
|
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;
|
|
64
74
|
}
|
|
65
75
|
|
|
66
|
-
|
|
67
76
|
export async function run(config, argv) {
|
|
68
77
|
/**
|
|
69
78
|
* Ours before the `--`, theirs after it.
|
|
@@ -86,13 +95,14 @@ export async function run(config, argv) {
|
|
|
86
95
|
// `--observe` as seisin's and put the run in observe mode; now that flag
|
|
87
96
|
// belongs to `echo`, and observe needs `seisin run x --observe -- echo hi`.
|
|
88
97
|
let i = 1;
|
|
89
|
-
|
|
98
|
+
const values = flagsOf("run").values;
|
|
99
|
+
while (i < argv.length && argv[i].startsWith("-")) i += values.includes(argv[i]) ? 2 : 1;
|
|
90
100
|
mine = argv.slice(1, i);
|
|
91
101
|
cmd = argv.slice(i);
|
|
92
102
|
}
|
|
93
103
|
if (!role || cmd.length === 0) throw new Error("usage: seisin run <role> -- <command...>");
|
|
94
|
-
if (!config.roles[role])
|
|
95
|
-
|
|
104
|
+
if (!config.roles[role]) throw unknownRole(config, role);
|
|
105
|
+
const agent = agentFor(mine, cmd);
|
|
96
106
|
|
|
97
107
|
/**
|
|
98
108
|
* seisin does not run inside seisin, and says so here rather than later.
|
|
@@ -101,27 +111,30 @@ export async function run(config, argv) {
|
|
|
101
111
|
* than the outer one: the inner run dies in the runtime with rc=13 and a Node
|
|
102
112
|
* warning about an unsettled await — no role named, no boundary named, and it
|
|
103
113
|
* reads as seisin crashing rather than as a refusal. Before that it failed
|
|
104
|
-
* even earlier, on a $TMPDIR the outer
|
|
114
|
+
* even earlier, on a $TMPDIR the outer sandbox had already reshaped.
|
|
105
115
|
*
|
|
106
116
|
* This is the refusal, not a fix, because the shape it is usually reached for
|
|
107
|
-
* is wrong anyway: a dispatcher that starts roles from inside one role's
|
|
108
|
-
* makes every worker a descendant of the
|
|
117
|
+
* is wrong anyway: a dispatcher that starts roles from inside one role's sandbox
|
|
118
|
+
* makes every worker a descendant of the sandbox that should hold the least. Such
|
|
109
119
|
* a dispatcher belongs above the roles and gets asked, rather than running
|
|
110
120
|
* inside one of them.
|
|
111
121
|
*/
|
|
112
122
|
const outer = process.env.SEISIN_ROLE;
|
|
113
123
|
if (outer)
|
|
114
124
|
throw new Error(
|
|
115
|
-
`already inside
|
|
125
|
+
`already inside a seisin sandbox as "${outer}" — seisin does not nest.\n` +
|
|
116
126
|
` Starting "${role}" from in here does not give it its own territory: the run dies in the\n` +
|
|
117
|
-
` sandbox runtime, and what territory a second
|
|
127
|
+
` sandbox runtime, and what territory a second sandbox would even hold is undefined.\n` +
|
|
118
128
|
` Run roles as siblings from outside instead. Something that needs to start roles belongs\n` +
|
|
119
129
|
` above them — asked by the role that wants the work done, not run inside it.`,
|
|
120
130
|
);
|
|
121
131
|
|
|
132
|
+
const handed = handoffOf(config, role, mine);
|
|
133
|
+
const expectDenied = expectedDenials(mine);
|
|
134
|
+
|
|
122
135
|
/**
|
|
123
136
|
* The audit spool: this process holds the log and the queue, and the hook
|
|
124
|
-
* inside the
|
|
137
|
+
* inside the sandbox gets a socket instead of a directory. That is the whole
|
|
125
138
|
* reason `.seisin/` is no longer in anybody's allowWrite — see spool.js.
|
|
126
139
|
*
|
|
127
140
|
* Entries arrive with their own `at` already set by the sender, so what lands
|
|
@@ -129,24 +142,103 @@ export async function run(config, argv) {
|
|
|
129
142
|
*/
|
|
130
143
|
const observe = mine.includes("--observe");
|
|
131
144
|
// Built before the first request can arrive; the POST leaves from here, the
|
|
132
|
-
// parent, never from inside the
|
|
145
|
+
// parent, never from inside the sandbox (notify.js).
|
|
133
146
|
const notify = notifier(config);
|
|
134
|
-
// Everything that belongs to this run alone — socket, settings,
|
|
147
|
+
// Everything that belongs to this run alone — socket, settings, run-file keys
|
|
135
148
|
// — in one private directory, with one id. See rundir.js.
|
|
136
149
|
const theRun = openRun();
|
|
150
|
+
/**
|
|
151
|
+
* From here on, every way out removes what this run made.
|
|
152
|
+
*
|
|
153
|
+
* The happy path closed the spool, the kernel watcher and the directory; the
|
|
154
|
+
* failures did not. A key provider that failed, a spool that would not bind,
|
|
155
|
+
* a runtime that would not start — each left the directory behind with its
|
|
156
|
+
* socket and, past the key step, its `run-file` keys on disk until some later
|
|
157
|
+
* run swept it, and the `log stream` process running. An `exit` handler,
|
|
158
|
+
* because every one of those ends in `process.exit`, from cli.js or from here,
|
|
159
|
+
* and everything it has to do is synchronous. Idempotent: the normal exit
|
|
160
|
+
* below runs it first and the handler finds nothing left.
|
|
161
|
+
*/
|
|
162
|
+
let audit = null;
|
|
163
|
+
let denials = null;
|
|
164
|
+
let closed = false;
|
|
165
|
+
const cleanup = () => {
|
|
166
|
+
if (closed) return;
|
|
167
|
+
closed = true;
|
|
168
|
+
try { audit?.close(); } catch {}
|
|
169
|
+
try { denials?.close(); } catch {}
|
|
170
|
+
theRun.close();
|
|
171
|
+
};
|
|
172
|
+
process.once("exit", cleanup);
|
|
173
|
+
// What was already waiting, so the end of the run can say what it added.
|
|
174
|
+
const before = new Set(pending(requestsPath(config.root)).map((q) => q.key));
|
|
137
175
|
const sockPath = theRun.sock;
|
|
138
|
-
|
|
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 });
|
|
139
218
|
// Per run, never per role: `.seisin/<role>.json` was one file for every run
|
|
140
219
|
// of that role, and two at once wrote each other's socket path into it.
|
|
141
220
|
const file = theRun.writeSettings(settings);
|
|
142
221
|
|
|
143
222
|
// What arrives from the hook and from the kernel, checked against the policy
|
|
144
223
|
// this run started with and written with the run's id on it. See intake.js.
|
|
145
|
-
|
|
146
|
-
|
|
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 …).`);
|
|
235
|
+
audit = await spool(take.fromHook, sockPath);
|
|
147
236
|
|
|
148
|
-
|
|
149
|
-
|
|
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 });
|
|
150
242
|
|
|
151
243
|
// The hook runs inside the child and has to know which role it is. These are
|
|
152
244
|
// the only variables seisin injects, and none carries a secret.
|
|
@@ -154,13 +246,29 @@ export async function run(config, argv) {
|
|
|
154
246
|
env.SEISIN_ROLE = role;
|
|
155
247
|
env.SEISIN_CONFIG = config.path;
|
|
156
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
|
+
}
|
|
157
265
|
|
|
158
266
|
/**
|
|
159
267
|
* Reference keys: resolved here, by the parent, before anything is spawned.
|
|
160
268
|
*
|
|
161
269
|
* The ordering is the security property, not a convenience. The provider
|
|
162
270
|
* command holds the vault's own credential — the keychain prompt, the
|
|
163
|
-
* 1Password session — and running it inside the
|
|
271
|
+
* 1Password session — and running it inside the sandbox would mean putting that
|
|
164
272
|
* credential in there too, which is the thing this feature exists to avoid.
|
|
165
273
|
* The confined side cannot reach the parent; the parent can reach the vault;
|
|
166
274
|
* so the parent resolves and hands over the result.
|
|
@@ -169,18 +277,44 @@ export async function run(config, argv) {
|
|
|
169
277
|
* name, not a skipped key: a role that starts without a credential it
|
|
170
278
|
* declared fails later, somewhere else, looking like something it is not.
|
|
171
279
|
*/
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
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 });
|
|
175
297
|
for (const { entry, mode, value } of resolved) {
|
|
176
298
|
if (mode === "env") { env[entry.name] = value; continue; }
|
|
177
|
-
|
|
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
|
|
178
312
|
// makes for the spool and already removes on exit, so its lifetime is the
|
|
179
|
-
//
|
|
313
|
+
// run's by construction rather than by a cleanup somebody has to remember.
|
|
180
314
|
// The variable carries the PATH, which is the convention every tool that
|
|
181
315
|
// wants a secret from a file already reads (`_FILE`), and it means the
|
|
182
316
|
// value itself is not in the environment of a process that may print it.
|
|
183
|
-
const file = join(
|
|
317
|
+
const file = join(runFileKeys, entry.name);
|
|
184
318
|
writeFileSync(file, value, { mode: 0o600 });
|
|
185
319
|
/**
|
|
186
320
|
* Both names, because the world has two conventions and neither is ours.
|
|
@@ -195,7 +329,7 @@ export async function run(config, argv) {
|
|
|
195
329
|
* the file was there and correct, and the variable it was announced under
|
|
196
330
|
* was one kubectl has never heard of.
|
|
197
331
|
*
|
|
198
|
-
* Setting both costs nothing and leaks nothing: in `
|
|
332
|
+
* Setting both costs nothing and leaks nothing: in `run-file` mode the
|
|
199
333
|
* value is deliberately not in the environment, so both variables hold a
|
|
200
334
|
* path and neither holds a secret.
|
|
201
335
|
*/
|
|
@@ -229,14 +363,43 @@ export async function run(config, argv) {
|
|
|
229
363
|
env.XDG_STATE_HOME = join(home, ".local", "state");
|
|
230
364
|
env.XDG_CACHE_HOME = join(home, ".cache");
|
|
231
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");
|
|
232
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;
|
|
233
377
|
if (observe) env.SEISIN_OBSERVE = "1";
|
|
234
378
|
if (mine.includes("--debug-env")) err(`${C.dim}seisin: dropped ${dropped.join(" ")}${C.off}\n`);
|
|
235
379
|
|
|
380
|
+
/**
|
|
381
|
+
* A command the role cannot find, said as that — before the sandbox starts.
|
|
382
|
+
*
|
|
383
|
+
* It used to reach the runtime, which runs it through `env`, and the only
|
|
384
|
+
* word on screen was `env: nosuchcmd: No such file or directory`: seisin's
|
|
385
|
+
* own wrapper named as the thing that failed. 127, the shell's code for it.
|
|
386
|
+
*/
|
|
387
|
+
if (!onPath(cmd[0], env.PATH)) {
|
|
388
|
+
const e = new Error(`"${cmd[0]}" not found on the role's PATH`);
|
|
389
|
+
e.exitCode = 127;
|
|
390
|
+
throw e;
|
|
391
|
+
}
|
|
392
|
+
|
|
393
|
+
// What the role gets, in the policy's own words: `writes 8 path(s)` counted
|
|
394
|
+
// the runtime's scratch directories as territory and named none of it.
|
|
395
|
+
const r = config.roles[role];
|
|
396
|
+
const territory = observe ? "the whole repo" : r.writes.length ? r.writes.join(" ") : "nothing";
|
|
397
|
+
const scratch = settings.filesystem.allowWrite.length - (observe ? 1 : r.writes.length);
|
|
236
398
|
err(
|
|
237
|
-
`${C.dim}seisin: ${role} · writes ${
|
|
238
|
-
`
|
|
239
|
-
`
|
|
399
|
+
`${C.dim}seisin: ${role} · writes ${territory}${scratch > 0 ? ` (+${scratch} scratch)` : ""} · ` +
|
|
400
|
+
`keys ${r.keys.length ? r.keys.join(" ") : "none"} · ` +
|
|
401
|
+
`${agent ? `runs ${agent}` : "runs no agent seisin knows"} · ` +
|
|
402
|
+
`env ${Object.keys(env).length} kept, ${dropped.length} dropped · read ${config.read}` +
|
|
240
403
|
`${observe ? ` · ${C.yellow}OBSERVING — the repo is writable, the network is NOT${C.off}${C.dim}` : ""}${C.off}\n`
|
|
241
404
|
);
|
|
242
405
|
|
|
@@ -267,11 +430,11 @@ export async function run(config, argv) {
|
|
|
267
430
|
// `claude -c`, `claude --debug`. Found by passing --settings to claude and
|
|
268
431
|
// watching the sandbox reject it as its own malformed config.
|
|
269
432
|
/**
|
|
270
|
-
* The kernel's own
|
|
433
|
+
* The kernel's own denials, alongside the hook's.
|
|
271
434
|
*
|
|
272
435
|
* Started BEFORE the spawn, and that ordering is the whole difference between
|
|
273
436
|
* this working and this recording nothing. `log stream` is a separate process
|
|
274
|
-
* with its own startup; a `sh -c` that gets
|
|
437
|
+
* with its own startup; a `sh -c` that gets denied is over in single-digit
|
|
275
438
|
* milliseconds. Started after the spawn, the first version of this caught
|
|
276
439
|
* zero denials on exactly the short commands an agent runs most.
|
|
277
440
|
*
|
|
@@ -281,12 +444,12 @@ export async function run(config, argv) {
|
|
|
281
444
|
*
|
|
282
445
|
* What each denial becomes — a log line, a request, or a count — is decided
|
|
283
446
|
* in intake.js, beside the hook's lines: the kernel is the thing that
|
|
284
|
-
* actually
|
|
447
|
+
* actually denied, so when the two disagree, it is the one that is right.
|
|
285
448
|
*/
|
|
286
|
-
// What actually runs inside the
|
|
449
|
+
// What actually runs inside the sandbox, computed once and used by both ends. The
|
|
287
450
|
// watcher recognises a denial by comparing the runtime's command tag with this
|
|
288
451
|
// argv; handed `cmd` while `srt` was handed `env NO_PROXY=… cmd`, it recognised
|
|
289
|
-
// nothing for a role with `local_ports`, and every short
|
|
452
|
+
// nothing for a role with `local_ports`, and every short denial of those roles
|
|
290
453
|
// — writes included — left no line (measured: 0 of 5, and 5 of 5 without the key).
|
|
291
454
|
//
|
|
292
455
|
// And a nonce in front, because the tag alone does not tell runs apart: an
|
|
@@ -296,8 +459,73 @@ export async function run(config, argv) {
|
|
|
296
459
|
// one request per role, and a denial logged against the role that owns the
|
|
297
460
|
// path (measured: 3 lines and 3 requests for one write by one of 3 roles).
|
|
298
461
|
// The nonce makes each run's command unique, so the tag names one run.
|
|
299
|
-
|
|
300
|
-
|
|
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 });
|
|
301
529
|
|
|
302
530
|
// Deliberately NOT announced here. On Linux this branch is taken every time,
|
|
303
531
|
// so saying it per run puts a line the reader cannot act on in front of every
|
|
@@ -316,7 +544,8 @@ export async function run(config, argv) {
|
|
|
316
544
|
const nested = nestedSandboxWarning(cmd);
|
|
317
545
|
if (nested) err(`${C.yellow}seisin: ${nested}${C.off}\n`);
|
|
318
546
|
|
|
319
|
-
const
|
|
547
|
+
const [runner, ...runnerArgs] = wrap(boxed);
|
|
548
|
+
const child = spawn(runner, runnerArgs, {
|
|
320
549
|
stdio: outStream ? ["inherit", "pipe", "pipe"] : "inherit",
|
|
321
550
|
env,
|
|
322
551
|
});
|
|
@@ -372,8 +601,11 @@ export async function run(config, argv) {
|
|
|
372
601
|
* Seen once in a full suite run under load; by the end of the drains below
|
|
373
602
|
* the pending signal has been handled.
|
|
374
603
|
*/
|
|
604
|
+
// A runtime that itself dies of a signal (SIGKILL, the OOM killer) exits
|
|
605
|
+
// the shell's way, 128 + n, the same as a run seisin was told to stop — it
|
|
606
|
+
// was reported as 1, a plain failure, which hides that it was killed.
|
|
375
607
|
const statusOf = (code, signal) => stoppedBy ? 128 + osConstants.signals[stoppedBy]
|
|
376
|
-
: signal ?
|
|
608
|
+
: signal ? 128 + (osConstants.signals[signal] ?? 0)
|
|
377
609
|
: interrupted && !code ? 130
|
|
378
610
|
: code ?? 0;
|
|
379
611
|
|
|
@@ -387,32 +619,65 @@ export async function run(config, argv) {
|
|
|
387
619
|
// one entry behind is how a request goes unnoticed for a day.
|
|
388
620
|
audit.close();
|
|
389
621
|
// Same reason, one layer further out: the kernel writes its line through a
|
|
390
|
-
// separate process, so the last
|
|
622
|
+
// separate process, so the last denial of a run can still be in flight
|
|
391
623
|
// here. This is the only wait the mechanism adds and it is per run — it
|
|
392
|
-
// returns as soon as the stream goes quiet, so a run that was
|
|
624
|
+
// returns as soon as the stream goes quiet, so a run that was denied
|
|
393
625
|
// nothing pays a tick.
|
|
394
626
|
await denials.close({ drain: DRAIN_MS });
|
|
395
627
|
// The run made this directory, so the run removes it.
|
|
396
|
-
|
|
628
|
+
cleanup();
|
|
397
629
|
|
|
398
630
|
// Say what was dropped rather than only what was kept. A filter nobody can
|
|
399
631
|
// see is indistinguishable from a monitor that is not working, and this one
|
|
400
632
|
// drops the majority of what the kernel says on a busy run.
|
|
401
|
-
// Only when something was actually recorded. A run where every
|
|
633
|
+
// Only when something was actually recorded. A run where every denial was
|
|
402
634
|
// filtered out has nothing to contextualise, and `0 recorded, 1 outside the
|
|
403
635
|
// policy's paths` is a line that answers a question nobody asked — the
|
|
404
636
|
// counts exist to explain a filter, not to announce that one ran.
|
|
405
|
-
const { attributed,
|
|
406
|
-
|
|
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
|
+
}
|
|
407
664
|
const recorded = attributed - offPolicy - walks;
|
|
408
665
|
if (recorded > 0)
|
|
409
666
|
err(`${C.dim}seisin: ${recorded} kernel denial(s) recorded` +
|
|
410
667
|
`${walks ? `, ${walks} directory scan(s)` : ""}` +
|
|
411
|
-
`${offPolicy ? `, ${offPolicy} outside the policy's paths` : ""}` +
|
|
412
|
-
|
|
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` : ""));
|
|
413
674
|
|
|
414
|
-
|
|
415
|
-
|
|
675
|
+
// Only what this run added, with the numbers `grant` takes, and one line
|
|
676
|
+
// for the rest. The whole queue after every run buried the new request
|
|
677
|
+
// under the ones already seen; nothing new, nothing said.
|
|
678
|
+
const queue = queueOf(config);
|
|
679
|
+
const added = queue.map((q, i) => ({ ...q, n: i + 1 })).filter((q) => !before.has(q.key));
|
|
680
|
+
if (added.length) err(renderAdded(added, queue.length - added.length));
|
|
416
681
|
const failed = await notify.settle();
|
|
417
682
|
if (failed.length) err(`${C.yellow}seisin: could not notify about a new request (${failed[0]})${C.off}\n`);
|
|
418
683
|
// One more turn of the loop, so a signal that arrived with the child's exit
|
|
@@ -427,11 +692,90 @@ export async function run(config, argv) {
|
|
|
427
692
|
// Every run with a key paid it — measured 3.0 s against 1.2 s without keys.
|
|
428
693
|
// `finished()` settles at once for a stream that is already done.
|
|
429
694
|
const guard = setTimeout(() => process.exit(status), 2000);
|
|
430
|
-
|
|
431
|
-
|
|
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.
|
|
432
698
|
await Promise.all([finished(outStream), finished(errStream)]).catch(() => {});
|
|
433
699
|
clearTimeout(guard);
|
|
434
700
|
process.exit(status);
|
|
435
701
|
});
|
|
436
|
-
|
|
702
|
+
// Not a `throw`: in a listener it is an uncaught exception — exit 1, a
|
|
703
|
+
// stack trace, and none of the cleanup. Said the way cli.js says any other
|
|
704
|
+
// failure, with its code; the `exit` handler above removes the run.
|
|
705
|
+
child.on("error", (e) => {
|
|
706
|
+
err(`${C.red}seisin:${C.off} could not start the sandbox: ${e.message}\n`);
|
|
707
|
+
process.exit(2);
|
|
708
|
+
});
|
|
709
|
+
}
|
|
710
|
+
|
|
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) };
|
|
740
|
+
}
|
|
741
|
+
|
|
742
|
+
/**
|
|
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.
|
|
748
|
+
*
|
|
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.
|
|
752
|
+
*/
|
|
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)));
|
|
437
781
|
}
|