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/spool.js
CHANGED
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
*
|
|
4
4
|
* ── Why this exists ──
|
|
5
5
|
* `.seisin/` used to be in every role's allowWrite, because the hook runs
|
|
6
|
-
* inside the
|
|
6
|
+
* inside the sandbox and has to record what it decided. That made the log and the
|
|
7
7
|
* request queue writable by the process they are a record of: an agent could
|
|
8
8
|
* truncate `log.jsonl`, rewrite a decision, or invent a request that had never
|
|
9
9
|
* been refused. The files said "append-only" in their own comments and nothing
|
|
@@ -27,34 +27,15 @@
|
|
|
27
27
|
* else did.
|
|
28
28
|
*/
|
|
29
29
|
import { createServer, createConnection, Socket } from "node:net";
|
|
30
|
-
import { unlinkSync, existsSync,
|
|
30
|
+
import { unlinkSync, existsSync, openSync, writeSync, closeSync, constants } from "node:fs";
|
|
31
31
|
import { execFileSync } from "node:child_process";
|
|
32
|
-
import {
|
|
33
|
-
import { join, dirname } from "node:path";
|
|
32
|
+
import { lineReader as readLines } from "./lines.js";
|
|
34
33
|
|
|
35
34
|
/** The env var a confined hook looks for. Absent means "write the file". */
|
|
36
35
|
export const SOCK_ENV = "SEISIN_SPOOL";
|
|
37
36
|
|
|
38
37
|
/**
|
|
39
|
-
*
|
|
40
|
-
*
|
|
41
|
-
* Not under the repo: the point is a path the role's territory does not cover.
|
|
42
|
-
* Kept short because a unix socket path is capped near 104 bytes on macOS and
|
|
43
|
-
* the failure when it is too long is an unhelpful EINVAL.
|
|
44
|
-
*/
|
|
45
|
-
export function spoolPath() {
|
|
46
|
-
// A random directory, 0700, rather than a name anyone can guess from the pid.
|
|
47
|
-
// The socket itself is already denyWrite so the agent cannot unlink it, but
|
|
48
|
-
// any other process running as this user could otherwise connect and add
|
|
49
|
-
// lines. That does not let them erase anything — forgery is the part that
|
|
50
|
-
// stays open — but a predictable path invites it for free.
|
|
51
|
-
const dir = mkdtempSync(join(tmpdir(), "seisin-"));
|
|
52
|
-
chmodSync(dir, 0o700);
|
|
53
|
-
return join(dir, "spool.sock");
|
|
54
|
-
}
|
|
55
|
-
|
|
56
|
-
/**
|
|
57
|
-
* Listens for lines from inside the box and hands each to `sink`.
|
|
38
|
+
* Listens for lines from inside the sandbox and hands each to `sink`.
|
|
58
39
|
*
|
|
59
40
|
* One line is one JSON object with a `to` field naming which file it belongs
|
|
60
41
|
* in. The parent decides what that means; the sender does not get to pick a
|
|
@@ -63,7 +44,7 @@ export function spoolPath() {
|
|
|
63
44
|
/**
|
|
64
45
|
* Which channel this platform can use: a unix socket, or a FIFO.
|
|
65
46
|
*
|
|
66
|
-
* On Linux the runtime blocks creating ANY unix socket inside the
|
|
47
|
+
* On Linux the runtime blocks creating ANY unix socket inside the sandbox with a
|
|
67
48
|
* seccomp filter — it cannot filter by path, so `allowUnixSockets` is ignored
|
|
68
49
|
* there (its README says so). The socket this file was built on therefore
|
|
69
50
|
* never carried a line on Linux: measured in Docker on 2026-09-23, with the
|
|
@@ -82,24 +63,14 @@ const PIPE_BUF = 4096;
|
|
|
82
63
|
|
|
83
64
|
/** Parses newline-separated JSON messages and hands each to `sink`. */
|
|
84
65
|
function lineReader(sink, onTooLong) {
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
while ((nl = buffer.indexOf("\n")) !== -1) {
|
|
92
|
-
const line = buffer.slice(0, nl);
|
|
93
|
-
buffer = buffer.slice(nl + 1);
|
|
94
|
-
if (!line.trim()) continue;
|
|
95
|
-
try {
|
|
96
|
-
const msg = JSON.parse(line);
|
|
97
|
-
if (msg && (msg.to === "log" || msg.to === "requests")) sink(msg.to, msg.entry);
|
|
98
|
-
} catch {
|
|
99
|
-
// Garbage from inside the box is not the parent's emergency.
|
|
100
|
-
}
|
|
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.
|
|
101
72
|
}
|
|
102
|
-
};
|
|
73
|
+
}, { onTooLong });
|
|
103
74
|
}
|
|
104
75
|
|
|
105
76
|
/**
|
|
@@ -125,7 +96,7 @@ function fifoSpool(sink, path) {
|
|
|
125
96
|
});
|
|
126
97
|
}
|
|
127
98
|
|
|
128
|
-
export function spool(sink, path
|
|
99
|
+
export function spool(sink, path) {
|
|
129
100
|
if (path.endsWith(FIFO_SUFFIX)) return fifoSpool(sink, path);
|
|
130
101
|
if (existsSync(path)) unlinkSync(path);
|
|
131
102
|
|
|
@@ -143,7 +114,7 @@ export function spool(sink, path = spoolPath()) {
|
|
|
143
114
|
* too long for a unix socket, a directory that is not there — left an awaited
|
|
144
115
|
* promise pending forever. What the user saw was Node's "Detected unsettled
|
|
145
116
|
* top-level await" and rc=13: no error, no path, nothing naming the spool.
|
|
146
|
-
* Measured while running seisin inside seisin, where the outer
|
|
117
|
+
* Measured while running seisin inside seisin, where the outer sandbox denies
|
|
147
118
|
* the inner bind; but nothing about it is particular to that case, which is
|
|
148
119
|
* the reason it is fixed here rather than beside the nesting check.
|
|
149
120
|
*
|
|
@@ -161,11 +132,11 @@ export function spool(sink, path = spoolPath()) {
|
|
|
161
132
|
close() {
|
|
162
133
|
server.close();
|
|
163
134
|
// Only the socket. The first version removed `dirname(path)` too,
|
|
164
|
-
// reasoning that
|
|
135
|
+
// reasoning that the spool had just made that directory — but a
|
|
165
136
|
// caller passing its own path makes dirname the system temp directory,
|
|
166
137
|
// and closing the spool deleted it. That is a caller's whole scratch
|
|
167
138
|
// space gone, for a cleanup. Whoever created the directory removes it:
|
|
168
|
-
// see `run`,
|
|
139
|
+
// see `run`, whose run directory (rundir.js) holds the socket.
|
|
169
140
|
try { unlinkSync(path); } catch {}
|
|
170
141
|
},
|
|
171
142
|
});
|
package/src/srt.js
CHANGED
|
@@ -13,23 +13,31 @@
|
|
|
13
13
|
* back to its defaults. Emitting the whole object removes that class of
|
|
14
14
|
* error, which is most of what this file is for.
|
|
15
15
|
*/
|
|
16
|
+
import { unknownRole } from "./suggest.js";
|
|
17
|
+
import { privateDir } from "./config.js";
|
|
18
|
+
import { existsSync, mkdirSync } from "node:fs";
|
|
16
19
|
import { join, dirname } from "node:path";
|
|
20
|
+
import { homedir } from "node:os";
|
|
17
21
|
import { STATE_DIR, CONFIG_NAME } from "./layout.js";
|
|
18
|
-
import { entriesOf } from "./keys.js";
|
|
22
|
+
import { entriesOf, injectKeysOf } from "./keys.js";
|
|
19
23
|
import { enforcedNeverWrites } from "./owners.js";
|
|
20
24
|
import { realAncestor } from "./paths.js";
|
|
21
25
|
import { denyFor } from "./surface.js";
|
|
22
|
-
import { runsRootOf } from "./rundir.js";
|
|
26
|
+
import { runsRootOf, SETTINGS_NAME, TMP_NAME } from "./rundir.js";
|
|
23
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";
|
|
24
31
|
import {
|
|
25
32
|
SOCKET_MAX, isLink, realOrSelf, expand, homeFits, roleHome, roleHomeRoot,
|
|
26
|
-
CREDENTIAL_HOMES, toWritePath, writePathsOf,
|
|
33
|
+
CREDENTIAL_HOMES, toWritePath, writePathsOf, otherAgentsCredentials,
|
|
27
34
|
} from "./grants.js";
|
|
28
35
|
|
|
29
36
|
// Re-exported: these were srt.js's exports before grants.js existed, and
|
|
30
37
|
// callers (the CLI, the tests, anyone embedding seisin) import them from here.
|
|
31
38
|
export {
|
|
32
39
|
expand, homeFits, roleHome, roleHomeRoot, CREDENTIAL_HOMES, RUNTIME_WRITES,
|
|
40
|
+
AGENTS, AGENT_HOMES, AGENT_CREDENTIALS, agentOf,
|
|
33
41
|
} from "./grants.js";
|
|
34
42
|
|
|
35
43
|
|
|
@@ -75,28 +83,49 @@ export const NO_PROXY_WITHOUT_LOOPBACK = "169.254.0.0/16,10.0.0.0/8,172.16.0.0/1
|
|
|
75
83
|
|
|
76
84
|
/**
|
|
77
85
|
* The command a role runs, with loopback routed through the proxy when it
|
|
78
|
-
* has `local_ports
|
|
86
|
+
* has `local_ports` and the sandbox is sandbox-runtime.
|
|
79
87
|
*
|
|
80
88
|
* An `env` in front and not a variable in the environment seisin passes: the
|
|
81
89
|
* runtime sets NO_PROXY itself, after seisin's environment, so the only place
|
|
82
|
-
* that wins is inside the
|
|
90
|
+
* that wins is inside the sandbox. Clients that ignore HTTP_PROXY — a MySQL driver,
|
|
83
91
|
* Chromium, which exempts loopback on its own — still get refused. They need
|
|
84
92
|
* to be pointed at the proxy, or the port needs `local_binding`, which opens
|
|
85
93
|
* every port.
|
|
86
94
|
*/
|
|
87
|
-
export function loopbackVia(role, cmd) {
|
|
88
|
-
|
|
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;
|
|
89
102
|
return ["env", `NO_PROXY=${NO_PROXY_WITHOUT_LOOPBACK}`, `no_proxy=${NO_PROXY_WITHOUT_LOOPBACK}`, ...cmd];
|
|
90
103
|
}
|
|
91
104
|
|
|
92
|
-
|
|
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 } = {}) {
|
|
93
115
|
const role = config.roles[roleName];
|
|
94
|
-
if (!role) throw
|
|
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
|
+
);
|
|
95
124
|
|
|
96
125
|
const abs = (p) => (p.startsWith("/") ? p : join(config.root, p));
|
|
97
126
|
// "credentials" closes the places credentials live; "home" does that and
|
|
98
127
|
// gives the role a home of its own. Only the second one needs a new HOME,
|
|
99
|
-
// and only the second one signs every CLI in the
|
|
128
|
+
// and only the second one signs every CLI in the sandbox out. See config.js.
|
|
100
129
|
//
|
|
101
130
|
// `true` is normalised here as well as in the config reader, because a caller
|
|
102
131
|
// embedding the library builds this object itself and `isolate: true` has
|
|
@@ -104,6 +133,45 @@ export function settingsFor(config, roleName, spool = null, observe = false) {
|
|
|
104
133
|
const level = config.isolate === true ? "home" : config.isolate;
|
|
105
134
|
const shielded = level === "credentials" || level === "home";
|
|
106
135
|
const isolated = level === "home";
|
|
136
|
+
/**
|
|
137
|
+
* `sandbox-runtime` silently makes these writable as conveniences even when
|
|
138
|
+
* they are absent from `allowWrite`. That gives every role a shared host
|
|
139
|
+
* directory outside its territory. A denyRead makes the runtime drop its
|
|
140
|
+
* implicit grant; denyWrite also wins over Seisin's explicit `~/.claude`
|
|
141
|
+
* runtime scratch grant for the nested debug directory.
|
|
142
|
+
*
|
|
143
|
+
* On macOS both are named whether they exist or not: the profile is a set of
|
|
144
|
+
* patterns, and a missing directory costs nothing. On Linux only the ones
|
|
145
|
+
* that exist are. bubblewrap denies a missing path by placing something on
|
|
146
|
+
* the host for the length of the run, in the user's home, where other tools
|
|
147
|
+
* see it — and a run that is killed leaves it behind. So a missing one is
|
|
148
|
+
* either not needed or made first:
|
|
149
|
+
* - its parent is not one a role may write (`~/.npm`): nothing can create
|
|
150
|
+
* it inside the sandbox, and there is nothing to deny;
|
|
151
|
+
* - its parent is (`~/.claude`, the runtime scratch every role gets): a role
|
|
152
|
+
* could create it, so it is created here, outside the sandbox, and denied like
|
|
153
|
+
* any directory that exists. It is the directory Claude Code itself logs
|
|
154
|
+
* to; making it empty changes nothing for anyone.
|
|
155
|
+
*/
|
|
156
|
+
const scratch = writePathsOf(config, config.roles[roleName] ?? { name: roleName, writes: [] }, { agent });
|
|
157
|
+
const runtimeConvenience = ["~/.npm/_logs", "~/.claude/debug"]
|
|
158
|
+
.map(expand)
|
|
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));
|
|
107
175
|
|
|
108
176
|
/**
|
|
109
177
|
* Refuse before the runtime does, because the runtime's refusal says nothing.
|
|
@@ -127,6 +195,7 @@ export function settingsFor(config, roleName, spool = null, observe = false) {
|
|
|
127
195
|
const home = isolated ? roleHome(config, roleName) : null;
|
|
128
196
|
const denyRead = [];
|
|
129
197
|
const allowRead = [];
|
|
198
|
+
denyRead.push(...runtimeConvenience);
|
|
130
199
|
|
|
131
200
|
// Every declared directory is denied, then each key the role names is
|
|
132
201
|
// re-allowed. A key written without a directory resolves against the first
|
|
@@ -168,9 +237,29 @@ export function settingsFor(config, roleName, spool = null, observe = false) {
|
|
|
168
237
|
* touches, gets one wrong, and fails as an unexplainable crash inside the
|
|
169
238
|
* agent. Naming the places credentials actually live is narrower than the
|
|
170
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.
|
|
171
245
|
*/
|
|
172
246
|
if (shielded) denyRead.push(...CREDENTIAL_HOMES.map(expand));
|
|
173
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
|
+
|
|
174
263
|
/**
|
|
175
264
|
* `~/.claude` and `~/.codex` are closed only at the `home` level, and the
|
|
176
265
|
* difference is the whole reason the two levels exist.
|
|
@@ -199,7 +288,7 @@ export function settingsFor(config, roleName, spool = null, observe = false) {
|
|
|
199
288
|
*
|
|
200
289
|
* A reference has no path, so there is nothing here to allow: it is resolved
|
|
201
290
|
* by the parent before the sandbox starts and handed over as an environment
|
|
202
|
-
* variable or as a
|
|
291
|
+
* variable or as a file in the run directory. See keys.js. Filtering here rather than
|
|
203
292
|
* upstream keeps the two kinds from having to know about each other — the
|
|
204
293
|
* settings this function emits are still, entirely, about the filesystem.
|
|
205
294
|
*/
|
|
@@ -235,9 +324,9 @@ export function settingsFor(config, roleName, spool = null, observe = false) {
|
|
|
235
324
|
/**
|
|
236
325
|
* Other runs' directories are unreadable; this run's is readable.
|
|
237
326
|
*
|
|
238
|
-
* Every run keeps its socket and `
|
|
327
|
+
* Every run keeps its socket and `run-file` keys under one root (rundir.js),
|
|
239
328
|
* and every role runs as the same user, so the directory mode protects
|
|
240
|
-
* nothing between roles. Measured: one role read another's
|
|
329
|
+
* nothing between roles. Measured: one role read another's run-file key while
|
|
241
330
|
* both ran. Same shape as the key directories: deny the parent, allow your own.
|
|
242
331
|
*/
|
|
243
332
|
const runs = runsRootOf(spool);
|
|
@@ -245,46 +334,137 @@ export function settingsFor(config, roleName, spool = null, observe = false) {
|
|
|
245
334
|
if (runs) {
|
|
246
335
|
denyRead.push(runs);
|
|
247
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));
|
|
248
357
|
}
|
|
249
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);
|
|
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);
|
|
432
|
+
|
|
250
433
|
return {
|
|
251
434
|
network: {
|
|
252
435
|
// `local_ports` rides the same allowlist as the domains: the runtime's
|
|
253
436
|
// proxy is the one place that sees the destination PORT. See loopbackVia.
|
|
254
437
|
// Without ports the value is passed through untouched, `undefined` included: that
|
|
255
438
|
// is how a config with no network list has always read, and a spread would throw.
|
|
256
|
-
allowedDomains
|
|
257
|
-
? [...(role.network ?? config.allowedDomains ?? []), ...localPortDomains(role.localPorts)]
|
|
258
|
-
: role.network ?? config.allowedDomains,
|
|
439
|
+
allowedDomains,
|
|
259
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 } : {}),
|
|
260
453
|
// Exactly one socket: the parent's audit spool, when there is a parent.
|
|
261
454
|
// Granted by path, not by turning unix sockets on.
|
|
262
455
|
allowUnixSockets: spool && !isFifo ? [spool] : [],
|
|
263
456
|
// Per role, `local_binding = true`. Off by default: listening is a
|
|
264
457
|
// capability, and most roles never need it.
|
|
265
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}*`) } : {}),
|
|
266
463
|
},
|
|
267
464
|
filesystem: {
|
|
268
|
-
denyRead,
|
|
465
|
+
denyRead: spelt,
|
|
269
466
|
allowRead,
|
|
270
|
-
allowWrite
|
|
271
|
-
// Observing means the kernel stops refusing, because otherwise there is
|
|
272
|
-
// nothing to observe. `--observe` used to relax only the hook, so the
|
|
273
|
-
// sandbox denied the write anyway and the banner said "nothing denied"
|
|
274
|
-
// over a transcript of denials — and `init --from-observations` builds
|
|
275
|
-
// a policy out of that transcript. It would read "the agent needs
|
|
276
|
-
// nothing outside its territory", which is the opposite of the truth.
|
|
277
|
-
//
|
|
278
|
-
// The repo, and only the repo. Everything in denyWrite below still
|
|
279
|
-
// holds: the config, the state directory and the key directories stay
|
|
280
|
-
// shut even here, because observing is not a reason to hand over the
|
|
281
|
-
// paperwork of the confinement.
|
|
282
|
-
// One function answers "what may this role write" for the settings and
|
|
283
|
-
// for surface.js, which needs the union over every role. Two copies of
|
|
284
|
-
// that arithmetic would be two answers the day one of them changes.
|
|
285
|
-
...writePathsOf(config, role, { observe }),
|
|
286
|
-
...(isFifo ? [spool] : []),
|
|
287
|
-
],
|
|
467
|
+
allowWrite,
|
|
288
468
|
/**
|
|
289
469
|
* The confinement's own paperwork, never writable — not even by a role
|
|
290
470
|
* that was granted the whole repo.
|
|
@@ -301,6 +481,9 @@ export function settingsFor(config, roleName, spool = null, observe = false) {
|
|
|
301
481
|
* relying on someone else's implementation detail staying put.
|
|
302
482
|
*/
|
|
303
483
|
denyWrite: [
|
|
484
|
+
...runtimeConvenience,
|
|
485
|
+
...sharedTmp,
|
|
486
|
+
...notMine,
|
|
304
487
|
/**
|
|
305
488
|
* The role's own subtractions, `never_writes`.
|
|
306
489
|
*
|
|
@@ -318,13 +501,14 @@ export function settingsFor(config, roleName, spool = null, observe = false) {
|
|
|
318
501
|
.flatMap((p) => { const r = realAncestor(p); return r === p ? [p] : [p, r]; }),
|
|
319
502
|
/**
|
|
320
503
|
* Everything the parent reads or executes, and every file that tells a
|
|
321
|
-
* program outside the
|
|
504
|
+
* program outside the sandbox what to run — the policy, `.seisin/`, the key
|
|
322
505
|
* directories, the key providers, `file://` targets, programs on PATH
|
|
323
506
|
* installed where a role writes, and each project's hooks and settings.
|
|
324
507
|
* One list with a reason per entry, built in surface.js, which says why
|
|
325
508
|
* each member of the family is on it. `seisin check` prints it.
|
|
326
509
|
*/
|
|
327
510
|
...denyFor(config, role, { observe }).map((e) => e.path),
|
|
511
|
+
...shut,
|
|
328
512
|
// The audit socket, and the directory holding it. Denying only the
|
|
329
513
|
// socket left `mv /tmp/seisin-xxxx /tmp/gone` as a way to take the
|
|
330
514
|
// channel out without ever touching the file that was protected.
|
|
@@ -343,27 +527,90 @@ export function settingsFor(config, roleName, spool = null, observe = false) {
|
|
|
343
527
|
* verify through the Security framework — Go before 1.27 (gh, kubectl,
|
|
344
528
|
* terraform) and every Dart/Flutter. The runtime ships it as
|
|
345
529
|
* `enableWeakerNetworkIsolation` and warns it is an exfiltration path:
|
|
346
|
-
* trustd fetches, outside the
|
|
530
|
+
* trustd fetches, outside the sandbox, the URLs a certificate carries. Per role
|
|
347
531
|
* and off by default, so the answer is written next to the role that needs it.
|
|
348
532
|
*/
|
|
349
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 } : {}),
|
|
350
542
|
};
|
|
351
543
|
}
|
|
352
544
|
|
|
353
545
|
/**
|
|
354
|
-
*
|
|
355
|
-
*
|
|
356
|
-
*
|
|
357
|
-
*
|
|
358
|
-
*
|
|
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 })),
|
|
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).
|
|
359
594
|
*/
|
|
360
|
-
export function
|
|
595
|
+
export function othersPrivate(config, roleName, abs = (p) => (p.startsWith("/") ? p : join(config.root, p)), { make = false } = {}) {
|
|
361
596
|
const out = [];
|
|
362
|
-
for (const
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
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;
|
|
368
609
|
}
|
|
369
610
|
|
|
611
|
+
/** Creates `p` when its parent exists and a role may write there; says whether it now exists. */
|
|
612
|
+
function madeFirst(p, writable) {
|
|
613
|
+
const parent = dirname(p);
|
|
614
|
+
if (!existsSync(parent) || !writable.some((w) => parent === w || parent.startsWith(w + "/"))) return false;
|
|
615
|
+
try { mkdirSync(p, { recursive: true }); return true; } catch { return false; }
|
|
616
|
+
}
|
package/src/suggest.js
ADDED
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* "Did you mean" — for a command, a role, a flag.
|
|
3
|
+
*
|
|
4
|
+
* One place, so every command answers a typo the same way: the name that was
|
|
5
|
+
* not found, what exists, and the nearest one when it is near enough to be a
|
|
6
|
+
* typo rather than a different word.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
/** Edit distance, small strings only. */
|
|
10
|
+
function distance(a, b) {
|
|
11
|
+
const d = Array.from({ length: a.length + 1 }, (_, i) => [i, ...Array(b.length).fill(0)]);
|
|
12
|
+
for (let j = 1; j <= b.length; j++) d[0][j] = j;
|
|
13
|
+
for (let i = 1; i <= a.length; i++)
|
|
14
|
+
for (let j = 1; j <= b.length; j++)
|
|
15
|
+
d[i][j] = Math.min(d[i - 1][j] + 1, d[i][j - 1] + 1, d[i - 1][j - 1] + (a[i - 1] === b[j - 1] ? 0 : 1));
|
|
16
|
+
return d[a.length][b.length];
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
/** The nearest of `known` to `word`, or null when nothing is close. */
|
|
20
|
+
export function nearest(word, known) {
|
|
21
|
+
let best = null, score = Infinity;
|
|
22
|
+
for (const k of known) {
|
|
23
|
+
const n = distance(String(word).toLowerCase(), k.toLowerCase());
|
|
24
|
+
if (n < score) { best = k; score = n; }
|
|
25
|
+
}
|
|
26
|
+
const room = Math.max(1, Math.floor(String(word).length / 3));
|
|
27
|
+
return score <= room || (best && best.startsWith(word) && word.length >= 2) ? best : null;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/** The error for a role the policy does not have, always with the list. */
|
|
31
|
+
export function unknownRole(config, role) {
|
|
32
|
+
const known = Object.keys(config.roles ?? {});
|
|
33
|
+
const near = nearest(role, known);
|
|
34
|
+
return new Error(
|
|
35
|
+
`unknown role "${role}"${near ? ` — did you mean "${near}"?` : ""}` +
|
|
36
|
+
`\n known roles: ${known.join(", ") || "none — add a [roles.<name>] section"}`);
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* Flags a command does not take are refused, never ignored. An ignored
|
|
41
|
+
* `--verbsoe` is a question answered as if it had not been asked.
|
|
42
|
+
* `values` are the flags that take the next word as their value.
|
|
43
|
+
*/
|
|
44
|
+
export function checkFlags(command, argv, known, values = []) {
|
|
45
|
+
const all = [...known, ...values];
|
|
46
|
+
for (let i = 0; i < argv.length; i++) {
|
|
47
|
+
const a = argv[i];
|
|
48
|
+
if (a === "--") break;
|
|
49
|
+
if (values.includes(a)) { i++; continue; }
|
|
50
|
+
if (!a.startsWith("-") || a === "-") continue;
|
|
51
|
+
if (all.includes(a)) continue;
|
|
52
|
+
const near = nearest(a, all);
|
|
53
|
+
const e = new Error(`unknown flag "${a}" for ${command}${near ? ` — did you mean "${near}"?` : ""}` +
|
|
54
|
+
`\n seisin ${command} --help lists its flags`);
|
|
55
|
+
e.usage = true;
|
|
56
|
+
throw e;
|
|
57
|
+
}
|
|
58
|
+
}
|