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/serve.js
CHANGED
|
@@ -18,9 +18,11 @@ import { fileURLToPath } from "node:url";
|
|
|
18
18
|
import { loadConfig } from "./config.js";
|
|
19
19
|
import { read, logPath, logSegments } from "./log.js";
|
|
20
20
|
import { settingsFor } from "./srt.js";
|
|
21
|
-
import {
|
|
21
|
+
import { describeHandoff } from "./handoff.js";
|
|
22
|
+
import { pending, requestsPath, settle, applyGrant, refuseIfBarred, editPolicy, cleanReason, asFileGrant, lastAgentOf, fileOnlyNote } from "./requests.js";
|
|
22
23
|
import { causesOf, describeWalls, parseSince, queue, verdicts, wallsByRole } from "./views.js";
|
|
23
24
|
import { planEdit, hashOf } from "./controls.js";
|
|
25
|
+
import { inspect } from "./inspect.js";
|
|
24
26
|
|
|
25
27
|
// Moved to views.js, which the MCP server shares; kept importable from here.
|
|
26
28
|
export { causesOf } from "./views.js";
|
|
@@ -135,11 +137,16 @@ export function state(configPath, { since = null, snap = snapshot(configPath) }
|
|
|
135
137
|
keys: r.keys,
|
|
136
138
|
env: r.env,
|
|
137
139
|
neverWrites: r.neverWritesDeclared ?? r.neverWrites ?? [],
|
|
140
|
+
private: r.private ?? [],
|
|
138
141
|
localBinding: r.localBinding === true,
|
|
139
142
|
localPorts: r.localPorts ?? [],
|
|
140
143
|
mcp: r.mcp ?? null,
|
|
141
144
|
trustd: r.trustd === true,
|
|
142
145
|
controlFiles: r.controlFiles ?? [],
|
|
146
|
+
reads: r.reads ?? [],
|
|
147
|
+
toolchain: r.toolchain ?? [],
|
|
148
|
+
verify: r.verify,
|
|
149
|
+
canary: [...all].reverse().find((e) => e.event === "canary" && e.role === r.name) ?? null,
|
|
143
150
|
// Counted over the same window as the causes, so the KPI and the screen it
|
|
144
151
|
// links to agree.
|
|
145
152
|
blocks: derived.blocks.get(r.name) ?? 0,
|
|
@@ -152,7 +159,16 @@ export function state(configPath, { since = null, snap = snapshot(configPath) }
|
|
|
152
159
|
// is not old just because the filter hides the runs that made it old. The
|
|
153
160
|
// whole log, every verdict, as `seisin requests` and the MCP read it — this
|
|
154
161
|
// counted runs over denials only, and marked differently from both.
|
|
155
|
-
|
|
162
|
+
// A write keyed by a folder can be granted as its one file instead; the
|
|
163
|
+
// line that choice carries is said here, once, so the page and the CLI
|
|
164
|
+
// say the same thing about Claude Code.
|
|
165
|
+
requests: queue(cfg, all).map((r) => ({
|
|
166
|
+
...r,
|
|
167
|
+
// Said by the server in the same words as the CLI, so the page never
|
|
168
|
+
// works out where a request goes on its own (handoff.js).
|
|
169
|
+
handoffNote: describeHandoff(r.handoff, r.attempt, r.chainStopped),
|
|
170
|
+
...(r.action === "write" && r.grant !== r.target ? { fileNote: fileOnlyNote(r.role, lastAgentOf(all, r.role)) } : {}),
|
|
171
|
+
})),
|
|
156
172
|
since,
|
|
157
173
|
// The file itself, not a regeneration of it. The page can render a policy
|
|
158
174
|
// from its own model, and that model has no comments — so showing it under
|
|
@@ -165,6 +181,9 @@ export function state(configPath, { since = null, snap = snapshot(configPath) }
|
|
|
165
181
|
// so a policy edited in between is not overwritten by a stale decision.
|
|
166
182
|
base: hashOf(text),
|
|
167
183
|
allowedDomains: cfg.allowedDomains,
|
|
184
|
+
read: cfg.read,
|
|
185
|
+
isolate: cfg.isolate,
|
|
186
|
+
warnings: inspect(cfg).warnings,
|
|
168
187
|
roles,
|
|
169
188
|
causes: derived.causes,
|
|
170
189
|
walls: derived.walls,
|
|
@@ -208,7 +227,7 @@ function settingsOf(configPath, role) {
|
|
|
208
227
|
* an exception for loopback. That is what lets the console hold the half the
|
|
209
228
|
* MCP server deliberately does not.
|
|
210
229
|
*/
|
|
211
|
-
function decide(configPath, { key, decision, reason }) {
|
|
230
|
+
function decide(configPath, { key, decision, reason, scope = "folder" }) {
|
|
212
231
|
/**
|
|
213
232
|
* By the request's id, never by its place in the queue.
|
|
214
233
|
*
|
|
@@ -220,13 +239,8 @@ function decide(configPath, { key, decision, reason }) {
|
|
|
220
239
|
* The CLI stopped doing this long ago; the console had not.
|
|
221
240
|
*/
|
|
222
241
|
if (typeof key !== "string" || !key) throw bad("key is required — the id of the request on screen");
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
* "denied" is still accepted from anything that scripted against the old
|
|
226
|
-
* value, and is what the queue stores either way — renaming a stored value
|
|
227
|
-
* is a breaking change, listed in docs/glossary.md, not made here.
|
|
228
|
-
*/
|
|
229
|
-
if (decision === "denied") decision = "declined";
|
|
242
|
+
// A person declines; the boundary denies (docs/glossary.md). One word each,
|
|
243
|
+
// here and in the queue.
|
|
230
244
|
if (decision !== "granted" && decision !== "declined")
|
|
231
245
|
throw bad(`decision must be granted or declined`);
|
|
232
246
|
/**
|
|
@@ -238,9 +252,11 @@ function decide(configPath, { key, decision, reason }) {
|
|
|
238
252
|
if (typeof reason !== "string" || !cleanReason(reason))
|
|
239
253
|
throw bad("a reason is required — it is written next to the decision");
|
|
240
254
|
// It grants the folder the request is keyed by (docs/api.md asked, docs/**
|
|
241
|
-
// granted), and the page says so before the click
|
|
242
|
-
//
|
|
243
|
-
//
|
|
255
|
+
// granted), and the page says so before the click — or, with scope "file",
|
|
256
|
+
// the one file that was asked for, as a literal path. The queue stays keyed
|
|
257
|
+
// by folder; the settle line records the file, so a later ask for a sibling
|
|
258
|
+
// reopens the request instead of folding into it (requests.js, reduce).
|
|
259
|
+
if (scope !== "folder" && scope !== "file") throw bad(`scope must be folder or file`);
|
|
244
260
|
const cfg = loadConfig(configPath);
|
|
245
261
|
const file = requestsPath(cfg.root);
|
|
246
262
|
|
|
@@ -259,12 +275,14 @@ function decide(configPath, { key, decision, reason }) {
|
|
|
259
275
|
let result = null;
|
|
260
276
|
try {
|
|
261
277
|
result = editPolicy(cfg, (before) => {
|
|
262
|
-
|
|
278
|
+
const current = loadConfig(configPath, before);
|
|
279
|
+
req = pending(file, { keyDirs: current.keyDirs }).find((r) => r.key === key);
|
|
263
280
|
if (!req) throw bad(`request ${key} is no longer pending — reload to see the queue as it is now`);
|
|
264
281
|
if (decision !== "granted") return { toml: before, changed: false };
|
|
265
|
-
|
|
282
|
+
if (scope === "file") req = asFileGrant(req);
|
|
283
|
+
refuseIfBarred(current, req);
|
|
266
284
|
return applyGrant(before, req, reason);
|
|
267
|
-
}, { after: () => settle(file, req.key, decision
|
|
285
|
+
}, { after: () => settle(file, req.key, decision, reason, { only: req.only }) });
|
|
268
286
|
} catch (e) {
|
|
269
287
|
if (e.code === "ELOCKED") throw Object.assign(new Error("another grant is in progress — try again in a moment"), { status: 503 });
|
|
270
288
|
throw e;
|
|
@@ -428,7 +446,7 @@ function declineAll(configPath, { keys, reason }) {
|
|
|
428
446
|
after: () => {
|
|
429
447
|
for (const req of pending(file)) {
|
|
430
448
|
if (!wanted.has(req.key)) continue;
|
|
431
|
-
settle(file, req.key, "
|
|
449
|
+
settle(file, req.key, "declined", reason);
|
|
432
450
|
declined++;
|
|
433
451
|
}
|
|
434
452
|
},
|
package/src/spool.js
CHANGED
|
@@ -29,6 +29,7 @@
|
|
|
29
29
|
import { createServer, createConnection, Socket } from "node:net";
|
|
30
30
|
import { unlinkSync, existsSync, openSync, writeSync, closeSync, constants } from "node:fs";
|
|
31
31
|
import { execFileSync } from "node:child_process";
|
|
32
|
+
import { lineReader as readLines } from "./lines.js";
|
|
32
33
|
|
|
33
34
|
/** The env var a confined hook looks for. Absent means "write the file". */
|
|
34
35
|
export const SOCK_ENV = "SEISIN_SPOOL";
|
|
@@ -62,24 +63,14 @@ const PIPE_BUF = 4096;
|
|
|
62
63
|
|
|
63
64
|
/** Parses newline-separated JSON messages and hands each to `sink`. */
|
|
64
65
|
function lineReader(sink, onTooLong) {
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
while ((nl = buffer.indexOf("\n")) !== -1) {
|
|
72
|
-
const line = buffer.slice(0, nl);
|
|
73
|
-
buffer = buffer.slice(nl + 1);
|
|
74
|
-
if (!line.trim()) continue;
|
|
75
|
-
try {
|
|
76
|
-
const msg = JSON.parse(line);
|
|
77
|
-
if (msg && (msg.to === "log" || msg.to === "requests")) sink(msg.to, msg.entry);
|
|
78
|
-
} catch {
|
|
79
|
-
// Garbage from inside the sandbox is not the parent's emergency.
|
|
80
|
-
}
|
|
66
|
+
return readLines((line) => {
|
|
67
|
+
try {
|
|
68
|
+
const msg = JSON.parse(line);
|
|
69
|
+
if (msg && (msg.to === "log" || msg.to === "requests")) sink(msg.to, msg.entry);
|
|
70
|
+
} catch {
|
|
71
|
+
// Garbage from inside the sandbox is not the parent's emergency.
|
|
81
72
|
}
|
|
82
|
-
};
|
|
73
|
+
}, { onTooLong });
|
|
83
74
|
}
|
|
84
75
|
|
|
85
76
|
/**
|
package/src/srt.js
CHANGED
|
@@ -14,24 +14,30 @@
|
|
|
14
14
|
* error, which is most of what this file is for.
|
|
15
15
|
*/
|
|
16
16
|
import { unknownRole } from "./suggest.js";
|
|
17
|
+
import { privateDir } from "./config.js";
|
|
17
18
|
import { existsSync, mkdirSync } from "node:fs";
|
|
18
19
|
import { join, dirname } from "node:path";
|
|
20
|
+
import { homedir } from "node:os";
|
|
19
21
|
import { STATE_DIR, CONFIG_NAME } from "./layout.js";
|
|
20
|
-
import { entriesOf } from "./keys.js";
|
|
22
|
+
import { entriesOf, injectKeysOf } from "./keys.js";
|
|
21
23
|
import { enforcedNeverWrites } from "./owners.js";
|
|
22
24
|
import { realAncestor } from "./paths.js";
|
|
23
25
|
import { denyFor } from "./surface.js";
|
|
24
|
-
import { runsRootOf } from "./rundir.js";
|
|
26
|
+
import { runsRootOf, SETTINGS_NAME, TMP_NAME } from "./rundir.js";
|
|
25
27
|
import { FIFO_SUFFIX } from "./spool.js";
|
|
28
|
+
import { machOf } from "./browsers.js";
|
|
29
|
+
import { territoryReads, outlastAllows, dataRoots, keptFor } from "./territory.js";
|
|
30
|
+
import { seccompDir } from "./sandboxes/srt.js";
|
|
26
31
|
import {
|
|
27
32
|
SOCKET_MAX, isLink, realOrSelf, expand, homeFits, roleHome, roleHomeRoot,
|
|
28
|
-
CREDENTIAL_HOMES, toWritePath, writePathsOf,
|
|
33
|
+
CREDENTIAL_HOMES, toWritePath, writePathsOf, otherAgentsCredentials,
|
|
29
34
|
} from "./grants.js";
|
|
30
35
|
|
|
31
36
|
// Re-exported: these were srt.js's exports before grants.js existed, and
|
|
32
37
|
// callers (the CLI, the tests, anyone embedding seisin) import them from here.
|
|
33
38
|
export {
|
|
34
39
|
expand, homeFits, roleHome, roleHomeRoot, CREDENTIAL_HOMES, RUNTIME_WRITES,
|
|
40
|
+
AGENTS, AGENT_HOMES, AGENT_CREDENTIALS, agentOf,
|
|
35
41
|
} from "./grants.js";
|
|
36
42
|
|
|
37
43
|
|
|
@@ -77,7 +83,7 @@ export const NO_PROXY_WITHOUT_LOOPBACK = "169.254.0.0/16,10.0.0.0/8,172.16.0.0/1
|
|
|
77
83
|
|
|
78
84
|
/**
|
|
79
85
|
* The command a role runs, with loopback routed through the proxy when it
|
|
80
|
-
* has `local_ports
|
|
86
|
+
* has `local_ports` and the sandbox is sandbox-runtime.
|
|
81
87
|
*
|
|
82
88
|
* An `env` in front and not a variable in the environment seisin passes: the
|
|
83
89
|
* runtime sets NO_PROXY itself, after seisin's environment, so the only place
|
|
@@ -86,14 +92,35 @@ export const NO_PROXY_WITHOUT_LOOPBACK = "169.254.0.0/16,10.0.0.0/8,172.16.0.0/1
|
|
|
86
92
|
* to be pointed at the proxy, or the port needs `local_binding`, which opens
|
|
87
93
|
* every port.
|
|
88
94
|
*/
|
|
89
|
-
export function loopbackVia(role, cmd) {
|
|
90
|
-
|
|
95
|
+
export function loopbackVia(role, cmd, sandbox = "srt") {
|
|
96
|
+
// tartaros holds the port list in the kernel's profile itself: a listed port
|
|
97
|
+
// is reached directly, any other refused, and its own NO_PROXY already exempts
|
|
98
|
+
// loopback. Its egress broker, made to carry loopback as a forward proxy, cut
|
|
99
|
+
// POST bodies (measured on 0.1.0-alpha.7: curl got no answer, and Claude Code
|
|
100
|
+
// retried until ECONNRESET), so under it the route is left as it sets it.
|
|
101
|
+
if (!role?.localPorts?.length || sandbox === "tartaros") return cmd;
|
|
91
102
|
return ["env", `NO_PROXY=${NO_PROXY_WITHOUT_LOOPBACK}`, `no_proxy=${NO_PROXY_WITHOUT_LOOPBACK}`, ...cmd];
|
|
92
103
|
}
|
|
93
104
|
|
|
94
|
-
|
|
105
|
+
/** The temp directory sandbox-runtime grants every sandbox it starts. See settingsFor. */
|
|
106
|
+
export const SHARED_TMP = { darwin: ["/tmp/claude", "/private/tmp/claude"], linux: ["/tmp/claude"] };
|
|
107
|
+
|
|
108
|
+
/**
|
|
109
|
+
* `agent` is what the run's command runs — `agentOf(cmd)`, or `--agent`. It
|
|
110
|
+
* decides which agent's home is scratch for this run and whose sign-in files
|
|
111
|
+
* are closed to it. Left out (check, console, explain: no command), the
|
|
112
|
+
* settings are the most the role can get, which is every agent's home.
|
|
113
|
+
*/
|
|
114
|
+
export function settingsFor(config, roleName, spool = null, observe = false, { agent, program, tmp, forRun = false } = {}) {
|
|
95
115
|
const role = config.roles[roleName];
|
|
96
116
|
if (!role) throw unknownRole(config, roleName);
|
|
117
|
+
const claudeGrant = role.writes.find((g) => /^(?:\.\/)?\.claude(?:\/|$)/.test(g));
|
|
118
|
+
if (claudeGrant)
|
|
119
|
+
throw new Error(
|
|
120
|
+
`roles.${roleName}.writes includes "${claudeGrant}", but a project's .claude cannot be granted.\n` +
|
|
121
|
+
" Claude Code reads its settings and hooks outside the sandbox. Remove this territory; " +
|
|
122
|
+
"a broad territory such as ** keeps working with .claude protected.",
|
|
123
|
+
);
|
|
97
124
|
|
|
98
125
|
const abs = (p) => (p.startsWith("/") ? p : join(config.root, p));
|
|
99
126
|
// "credentials" closes the places credentials live; "home" does that and
|
|
@@ -126,10 +153,25 @@ export function settingsFor(config, roleName, spool = null, observe = false) {
|
|
|
126
153
|
* any directory that exists. It is the directory Claude Code itself logs
|
|
127
154
|
* to; making it empty changes nothing for anyone.
|
|
128
155
|
*/
|
|
129
|
-
const scratch = writePathsOf(config, config.roles[roleName] ?? { name: roleName, writes: [] });
|
|
156
|
+
const scratch = writePathsOf(config, config.roles[roleName] ?? { name: roleName, writes: [] }, { agent });
|
|
130
157
|
const runtimeConvenience = ["~/.npm/_logs", "~/.claude/debug"]
|
|
131
158
|
.map(expand)
|
|
132
159
|
.filter((p) => process.platform !== "linux" || existsSync(p) || madeFirst(p, scratch));
|
|
160
|
+
/**
|
|
161
|
+
* The runtime's own temp directory, shared by every sandbox on the machine.
|
|
162
|
+
*
|
|
163
|
+
* sandbox-runtime grants `/tmp/claude` to every sandbox it starts, outside
|
|
164
|
+
* any allowWrite and "never left out", and hands it to the child as TMPDIR.
|
|
165
|
+
* Every Claude Code session on the machine keeps its own temp files there
|
|
166
|
+
* too, so a confined role could write beside them — caches, locks — with
|
|
167
|
+
* nothing in the policy saying so (measured: `echo x > /tmp/claude/…` from
|
|
168
|
+
* a role with `read = "territory"` and no scratch, rc=0). Only a denyWrite
|
|
169
|
+
* takes it back. The run gets a temp directory of its own instead
|
|
170
|
+
* (`tmp`, passed in, through CLAUDE_CODE_TMPDIR in commands/run.js), so
|
|
171
|
+
* nothing the runtime does needs the shared one.
|
|
172
|
+
*/
|
|
173
|
+
const sharedTmp = SHARED_TMP[process.platform === "linux" ? "linux" : "darwin"]
|
|
174
|
+
.filter((p) => process.platform !== "linux" || existsSync(p) || madeFirst(p, scratch));
|
|
133
175
|
|
|
134
176
|
/**
|
|
135
177
|
* Refuse before the runtime does, because the runtime's refusal says nothing.
|
|
@@ -195,9 +237,29 @@ export function settingsFor(config, roleName, spool = null, observe = false) {
|
|
|
195
237
|
* touches, gets one wrong, and fails as an unexplainable crash inside the
|
|
196
238
|
* agent. Naming the places credentials actually live is narrower than the
|
|
197
239
|
* ideal and it is a boundary that holds up in practice.
|
|
240
|
+
*
|
|
241
|
+
* For agents you would not trust there is `[runtime] read = "territory"`,
|
|
242
|
+
* below: not that allowlist, but its useful half — the places data lives are
|
|
243
|
+
* denied, the system stays readable, and what a toolchain needs from a home
|
|
244
|
+
* is declared and checked before the run. decisions.md has both halves.
|
|
198
245
|
*/
|
|
199
246
|
if (shielded) denyRead.push(...CREDENTIAL_HOMES.map(expand));
|
|
200
247
|
|
|
248
|
+
/**
|
|
249
|
+
* The other agents' sign-in, closed to this run both ways. See AGENT_HOMES
|
|
250
|
+
* in grants.js. Under `isolate = "home"` the agent signs in inside the role's
|
|
251
|
+
* own home, so that is where the files are looked for too.
|
|
252
|
+
*
|
|
253
|
+
* On Linux only the ones that exist: bubblewrap would otherwise put a file
|
|
254
|
+
* where the agent will later want to write its own. The directory around a
|
|
255
|
+
* missing one is not this run's to write (it is another agent's home), so
|
|
256
|
+
* nothing in the run can create it either — except under `isolate = "home"`,
|
|
257
|
+
* where the whole role home is writable and a missing one stays creatable.
|
|
258
|
+
*/
|
|
259
|
+
const notMine = otherAgentsCredentials(agent, isolated ? roleHome(config, roleName) : homedir())
|
|
260
|
+
.filter((p) => process.platform !== "linux" || existsSync(p));
|
|
261
|
+
denyRead.push(...notMine);
|
|
262
|
+
|
|
201
263
|
/**
|
|
202
264
|
* `~/.claude` and `~/.codex` are closed only at the `home` level, and the
|
|
203
265
|
* difference is the whole reason the two levels exist.
|
|
@@ -226,7 +288,7 @@ export function settingsFor(config, roleName, spool = null, observe = false) {
|
|
|
226
288
|
*
|
|
227
289
|
* A reference has no path, so there is nothing here to allow: it is resolved
|
|
228
290
|
* by the parent before the sandbox starts and handed over as an environment
|
|
229
|
-
* variable or as a
|
|
291
|
+
* variable or as a file in the run directory. See keys.js. Filtering here rather than
|
|
230
292
|
* upstream keeps the two kinds from having to know about each other — the
|
|
231
293
|
* settings this function emits are still, entirely, about the filesystem.
|
|
232
294
|
*/
|
|
@@ -262,9 +324,9 @@ export function settingsFor(config, roleName, spool = null, observe = false) {
|
|
|
262
324
|
/**
|
|
263
325
|
* Other runs' directories are unreadable; this run's is readable.
|
|
264
326
|
*
|
|
265
|
-
* Every run keeps its socket and `
|
|
327
|
+
* Every run keeps its socket and `run-file` keys under one root (rundir.js),
|
|
266
328
|
* and every role runs as the same user, so the directory mode protects
|
|
267
|
-
* nothing between roles. Measured: one role read another's
|
|
329
|
+
* nothing between roles. Measured: one role read another's run-file key while
|
|
268
330
|
* both ran. Same shape as the key directories: deny the parent, allow your own.
|
|
269
331
|
*/
|
|
270
332
|
const runs = runsRootOf(spool);
|
|
@@ -272,7 +334,101 @@ export function settingsFor(config, roleName, spool = null, observe = false) {
|
|
|
272
334
|
if (runs) {
|
|
273
335
|
denyRead.push(runs);
|
|
274
336
|
allowRead.push(dirname(spool));
|
|
337
|
+
/**
|
|
338
|
+
* And the temp directories runs are given when their role may write no
|
|
339
|
+
* other (rundir.js, TMP_NAME): denied as a whole, this run's own allowed
|
|
340
|
+
* back. Same shape again, and for the same reason — every run is the same
|
|
341
|
+
* user, so a run could otherwise read what another left in its temp.
|
|
342
|
+
*/
|
|
343
|
+
const tmps = join(dirname(runs), TMP_NAME);
|
|
344
|
+
denyRead.push(tmps);
|
|
345
|
+
if (tmp && (tmp === tmps || tmp.startsWith(tmps + "/"))) allowRead.push(tmp);
|
|
346
|
+
/**
|
|
347
|
+
* This run's settings are not this run's to read.
|
|
348
|
+
*
|
|
349
|
+
* They sit in the run's own directory, which the run reads for its run-file
|
|
350
|
+
* keys, so they were readable from inside: a map of every path left open
|
|
351
|
+
* and every one shut, handed to the process it confines. Not a defence on
|
|
352
|
+
* its own — the policy has to be right either way — but there is no reason
|
|
353
|
+
* to give an agent the list of doors to try. Named here, by the path
|
|
354
|
+
* rundir.js writes them to, because they do not exist yet.
|
|
355
|
+
*/
|
|
356
|
+
denyRead.push(join(dirname(spool), SETTINGS_NAME));
|
|
357
|
+
}
|
|
358
|
+
|
|
359
|
+
/**
|
|
360
|
+
* `[runtime] read = "territory"`: the data roots are denied whole and what
|
|
361
|
+
* this role needs is allowed back inside them. See territory.js for why
|
|
362
|
+
* allowed back and no longer carved.
|
|
363
|
+
*
|
|
364
|
+
* Allowed back: the repo (key directories inside it stay denied by name,
|
|
365
|
+
* above), what the role writes, and what it declares in `reads` and
|
|
366
|
+
* `toolchain` — except where a deny above already covers it. A `reads`
|
|
367
|
+
* entry under `~/.ssh` stays shut, as it did when the mode only denied.
|
|
368
|
+
*
|
|
369
|
+
* Not allowed back twice: what is already re-allowed above — this run's
|
|
370
|
+
* directory, the role's own home, the key files.
|
|
371
|
+
*/
|
|
372
|
+
/**
|
|
373
|
+
* Other roles' private folders: denied to read and to write, whatever this
|
|
374
|
+
* role's territory says and in either read mode.
|
|
375
|
+
*
|
|
376
|
+
* Deny-only, and never an allow for the owner: the owner reaches its folder
|
|
377
|
+
* the way it reaches the rest of its territory, and nobody else gets an
|
|
378
|
+
* allow anywhere near it. That is the shape that holds on both platforms —
|
|
379
|
+
* an allow around a deny is re-emitted after it on macOS, and a read allow
|
|
380
|
+
* is mounted over the writable mounts on Linux (territory.js).
|
|
381
|
+
*
|
|
382
|
+
* On Linux the folder is made first when it is missing: bubblewrap denies a
|
|
383
|
+
* missing path by putting something on the host in its place, and the owner
|
|
384
|
+
* would find a file where its folder should be. It is the owner's territory,
|
|
385
|
+
* so an empty folder there is what the policy already says. Only for a run
|
|
386
|
+
* (`forRun`, passed by `seisin run`): check, explain and the console ask for
|
|
387
|
+
* these settings too, and they write nothing to disk. For them a missing
|
|
388
|
+
* folder on Linux is left out — there is no run for it to be denied in.
|
|
389
|
+
*/
|
|
390
|
+
const shut = othersPrivate(config, roleName, abs, { make: forRun });
|
|
391
|
+
denyRead.push(...shut);
|
|
392
|
+
|
|
393
|
+
const allowWrite = [
|
|
394
|
+
// Observing means the kernel stops refusing, because otherwise there is
|
|
395
|
+
// nothing to observe. `--observe` used to relax only the hook, so the
|
|
396
|
+
// sandbox denied the write anyway and the banner said "nothing denied"
|
|
397
|
+
// over a transcript of denials — and `init --from-observations` builds
|
|
398
|
+
// a policy out of that transcript. It would read "the agent needs
|
|
399
|
+
// nothing outside its territory", which is the opposite of the truth.
|
|
400
|
+
//
|
|
401
|
+
// The repo, and only the repo. Everything in denyWrite below still
|
|
402
|
+
// holds: the config, the state directory and the key directories stay
|
|
403
|
+
// shut even here, because observing is not a reason to hand over the
|
|
404
|
+
// paperwork of the confinement.
|
|
405
|
+
// One function answers "what may this role write" for the settings and
|
|
406
|
+
// for surface.js, which needs the union over every role. Two copies of
|
|
407
|
+
// that arithmetic would be two answers the day one of them changes.
|
|
408
|
+
...writePathsOf(config, role, { observe, agent }),
|
|
409
|
+
...(isFifo ? [spool] : []),
|
|
410
|
+
...(tmp ? [tmp] : []),
|
|
411
|
+
];
|
|
412
|
+
|
|
413
|
+
if (config.read === "territory") {
|
|
414
|
+
const reopened = allowRead.map(realOrSelf);
|
|
415
|
+
const inReopened = (p) => { const r = realAncestor(p); return reopened.some((a) => r === a || r.startsWith(a + "/")); };
|
|
416
|
+
// On Linux the runtime's apply-seccomp runs inside: see seccompDir.
|
|
417
|
+
const seccomp = process.platform === "linux" ? seccompDir() : null;
|
|
418
|
+
const keep = [...keptFor(config, role, { observe, agent, program }), ...(seccomp ? [seccomp] : [])]
|
|
419
|
+
.filter((p) => !inReopened(p));
|
|
420
|
+
const { deny, allow } = territoryReads(dataRoots(), keep, { writes: allowWrite, denied: [...denyRead] });
|
|
421
|
+
denyRead.push(...deny);
|
|
422
|
+
allowRead.push(...allow);
|
|
275
423
|
}
|
|
424
|
+
// The key directory inside a repo that is now an allow: see outlastAllows.
|
|
425
|
+
const spelt = config.read === "territory" && process.platform === "darwin"
|
|
426
|
+
? outlastAllows(denyRead, allowRead, realAncestor) : denyRead;
|
|
427
|
+
|
|
428
|
+
const allowedDomains = role.localPorts?.length
|
|
429
|
+
? [...(role.network ?? config.allowedDomains ?? []), ...localPortDomains(role.localPorts)]
|
|
430
|
+
: role.network ?? config.allowedDomains;
|
|
431
|
+
const { credentials, tlsTerminate } = injectSettings(config, role, allowedDomains);
|
|
276
432
|
|
|
277
433
|
return {
|
|
278
434
|
network: {
|
|
@@ -280,38 +436,35 @@ export function settingsFor(config, roleName, spool = null, observe = false) {
|
|
|
280
436
|
// proxy is the one place that sees the destination PORT. See loopbackVia.
|
|
281
437
|
// Without ports the value is passed through untouched, `undefined` included: that
|
|
282
438
|
// is how a config with no network list has always read, and a spread would throw.
|
|
283
|
-
allowedDomains
|
|
284
|
-
? [...(role.network ?? config.allowedDomains ?? []), ...localPortDomains(role.localPorts)]
|
|
285
|
-
: role.network ?? config.allowedDomains,
|
|
439
|
+
allowedDomains,
|
|
286
440
|
deniedDomains: [],
|
|
441
|
+
// `[network] deny_addresses`: what an allowed name may not resolve to, on
|
|
442
|
+
// top of the runtime's own loopback / link-local / metadata set. Left out
|
|
443
|
+
// when empty, so a policy without it emits the settings it always did.
|
|
444
|
+
...(config.deniedAddresses?.length ? { deniedResolvedAddresses: config.deniedAddresses } : {}),
|
|
445
|
+
/**
|
|
446
|
+
* `inject` keys terminate TLS, because substituting a header needs reading
|
|
447
|
+
* it. Only the hosts that carry a masked credential are terminated; every
|
|
448
|
+
* other allowed domain is excluded, so the MITM is as narrow as the policy
|
|
449
|
+
* lets it be. A wildcard allow that covers an inject host is terminated
|
|
450
|
+
* whole — srt cannot terminate one host out of `*.x` — and `check` says so.
|
|
451
|
+
*/
|
|
452
|
+
...(tlsTerminate ? { tlsTerminate } : {}),
|
|
287
453
|
// Exactly one socket: the parent's audit spool, when there is a parent.
|
|
288
454
|
// Granted by path, not by turning unix sockets on.
|
|
289
455
|
allowUnixSockets: spool && !isFifo ? [spool] : [],
|
|
290
456
|
// Per role, `local_binding = true`. Off by default: listening is a
|
|
291
457
|
// capability, and most roles never need it.
|
|
292
458
|
allowLocalBinding: role.localBinding === true,
|
|
459
|
+
// `browsers = ["chromium"]`: the Mach services the browser's own
|
|
460
|
+
// processes look up to find each other, by prefix (browsers.js). The
|
|
461
|
+
// register half has no field here; srt-mach.mjs adds it.
|
|
462
|
+
...(machOf(role).lookup.length ? { allowMachLookup: machOf(role).lookup.map((p) => `${p}*`) } : {}),
|
|
293
463
|
},
|
|
294
464
|
filesystem: {
|
|
295
|
-
denyRead,
|
|
465
|
+
denyRead: spelt,
|
|
296
466
|
allowRead,
|
|
297
|
-
allowWrite
|
|
298
|
-
// Observing means the kernel stops refusing, because otherwise there is
|
|
299
|
-
// nothing to observe. `--observe` used to relax only the hook, so the
|
|
300
|
-
// sandbox denied the write anyway and the banner said "nothing denied"
|
|
301
|
-
// over a transcript of denials — and `init --from-observations` builds
|
|
302
|
-
// a policy out of that transcript. It would read "the agent needs
|
|
303
|
-
// nothing outside its territory", which is the opposite of the truth.
|
|
304
|
-
//
|
|
305
|
-
// The repo, and only the repo. Everything in denyWrite below still
|
|
306
|
-
// holds: the config, the state directory and the key directories stay
|
|
307
|
-
// shut even here, because observing is not a reason to hand over the
|
|
308
|
-
// paperwork of the confinement.
|
|
309
|
-
// One function answers "what may this role write" for the settings and
|
|
310
|
-
// for surface.js, which needs the union over every role. Two copies of
|
|
311
|
-
// that arithmetic would be two answers the day one of them changes.
|
|
312
|
-
...writePathsOf(config, role, { observe }),
|
|
313
|
-
...(isFifo ? [spool] : []),
|
|
314
|
-
],
|
|
467
|
+
allowWrite,
|
|
315
468
|
/**
|
|
316
469
|
* The confinement's own paperwork, never writable — not even by a role
|
|
317
470
|
* that was granted the whole repo.
|
|
@@ -329,6 +482,8 @@ export function settingsFor(config, roleName, spool = null, observe = false) {
|
|
|
329
482
|
*/
|
|
330
483
|
denyWrite: [
|
|
331
484
|
...runtimeConvenience,
|
|
485
|
+
...sharedTmp,
|
|
486
|
+
...notMine,
|
|
332
487
|
/**
|
|
333
488
|
* The role's own subtractions, `never_writes`.
|
|
334
489
|
*
|
|
@@ -353,6 +508,7 @@ export function settingsFor(config, roleName, spool = null, observe = false) {
|
|
|
353
508
|
* each member of the family is on it. `seisin check` prints it.
|
|
354
509
|
*/
|
|
355
510
|
...denyFor(config, role, { observe }).map((e) => e.path),
|
|
511
|
+
...shut,
|
|
356
512
|
// The audit socket, and the directory holding it. Denying only the
|
|
357
513
|
// socket left `mv /tmp/seisin-xxxx /tmp/gone` as a way to take the
|
|
358
514
|
// channel out without ever touching the file that was protected.
|
|
@@ -375,7 +531,81 @@ export function settingsFor(config, roleName, spool = null, observe = false) {
|
|
|
375
531
|
* and off by default, so the answer is written next to the role that needs it.
|
|
376
532
|
*/
|
|
377
533
|
...(role.trustd === true ? { enableWeakerNetworkIsolation: true } : {}),
|
|
534
|
+
/**
|
|
535
|
+
* Masked credentials: the real value reaches the runtime's own environment
|
|
536
|
+
* (run.js), never the sandbox, which sees `fake_value_…`. `mode: "mask"`
|
|
537
|
+
* and the per-credential `injectHosts` are all that is emitted — no
|
|
538
|
+
* `extract`, `decode`, files or SigV4, and never `allowPlaintextInject`,
|
|
539
|
+
* which over HTTPS carries the sentinel and so is deny with extra steps.
|
|
540
|
+
*/
|
|
541
|
+
...(credentials ? { credentials } : {}),
|
|
542
|
+
};
|
|
543
|
+
}
|
|
544
|
+
|
|
545
|
+
/**
|
|
546
|
+
* A domain pattern covers a host — srt's rule, narrowed to what seisin needs.
|
|
547
|
+
* `*` covers all; `*.base` covers any `x.base`; otherwise an exact, case-folded
|
|
548
|
+
* match. Mirrors `matchesDomainPattern` in the runtime's domain-pattern.js;
|
|
549
|
+
* written here rather than imported because seisin generates settings and does
|
|
550
|
+
* not embed the library, and exported so `inspect/inject.js` shares the one
|
|
551
|
+
* rule. Inject hosts are exact (config.js rejects a wildcard), so
|
|
552
|
+
* this is only ever asked "does this allowedDomains pattern cover this host".
|
|
553
|
+
*/
|
|
554
|
+
export function coversHost(pattern, host) {
|
|
555
|
+
const p = String(pattern).toLowerCase(), h = host.toLowerCase();
|
|
556
|
+
if (p === "*") return true;
|
|
557
|
+
if (p.startsWith("*.")) return h.endsWith("." + p.slice(2));
|
|
558
|
+
return h === p;
|
|
559
|
+
}
|
|
560
|
+
|
|
561
|
+
/**
|
|
562
|
+
* The `credentials` block and `tlsTerminate.excludeDomains` for a role's
|
|
563
|
+
* `inject` keys, or `{}` when it has none. One `envVars` entry per credential,
|
|
564
|
+
* `mode: "mask"` with its own `injectHosts`; TLS is terminated only for the
|
|
565
|
+
* domains that carry one, every other allowed domain excluded. Throws when an
|
|
566
|
+
* inject host is not reachable through the role's `allowedDomains` — a masked
|
|
567
|
+
* credential aimed at a host the role cannot connect to is a policy mistake,
|
|
568
|
+
* not a thing to emit silently.
|
|
569
|
+
*/
|
|
570
|
+
export function injectSettings(config, role, allowedDomains) {
|
|
571
|
+
const inject = injectKeysOf(config, role);
|
|
572
|
+
if (inject.length === 0) return {};
|
|
573
|
+
const allowed = allowedDomains ?? [];
|
|
574
|
+
for (const c of inject)
|
|
575
|
+
for (const h of c.hosts)
|
|
576
|
+
if (!allowed.some((p) => coversHost(p, h)))
|
|
577
|
+
throw new Error(
|
|
578
|
+
`roles.${role.name}: inject host ${h} (for ${c.name}) is not reachable.\n` +
|
|
579
|
+
` A masked credential is substituted only on egress to its host, and this role's ` +
|
|
580
|
+
`network does not allow ${h}. Add it: [roles.${role.name}] network = ["${h}", …], ` +
|
|
581
|
+
`or correct the host in inject_hosts.`);
|
|
582
|
+
const credentials = {
|
|
583
|
+
envVars: inject.map((c) => ({ name: c.name, mode: "mask", injectHosts: c.hosts })),
|
|
378
584
|
};
|
|
585
|
+
// Terminate a domain only if it carries an inject host; exclude the rest.
|
|
586
|
+
const targets = inject.flatMap((c) => c.hosts);
|
|
587
|
+
const excludeDomains = allowed.filter((d) => !targets.some((h) => coversHost(d, h)));
|
|
588
|
+
return { credentials, tlsTerminate: { excludeDomains } };
|
|
589
|
+
}
|
|
590
|
+
|
|
591
|
+
/**
|
|
592
|
+
* The private folders of every role but `roleName`, absolute, in both
|
|
593
|
+
* spellings (as written and as the kernel meets it through a symlink).
|
|
594
|
+
*/
|
|
595
|
+
export function othersPrivate(config, roleName, abs = (p) => (p.startsWith("/") ? p : join(config.root, p)), { make = false } = {}) {
|
|
596
|
+
const out = [];
|
|
597
|
+
for (const r of Object.values(config.roles))
|
|
598
|
+
if (r.name !== roleName)
|
|
599
|
+
for (const entry of r.private ?? []) {
|
|
600
|
+
const p = abs(privateDir(entry));
|
|
601
|
+
if (process.platform === "linux" && !existsSync(p)) {
|
|
602
|
+
if (!make) continue;
|
|
603
|
+
try { mkdirSync(p, { recursive: true }); } catch { continue; }
|
|
604
|
+
}
|
|
605
|
+
const real = realAncestor(p);
|
|
606
|
+
out.push(...(real === p ? [p] : [p, real]));
|
|
607
|
+
}
|
|
608
|
+
return out;
|
|
379
609
|
}
|
|
380
610
|
|
|
381
611
|
/** Creates `p` when its parent exists and a role may write there; says whether it now exists. */
|