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/review.js
CHANGED
|
@@ -9,13 +9,13 @@
|
|
|
9
9
|
*
|
|
10
10
|
* Three questions, and the second is the one nothing else can answer.
|
|
11
11
|
*
|
|
12
|
-
* 1. What is a role being
|
|
12
|
+
* 1. What is a role being denied, over and over?
|
|
13
13
|
* Forty denials on one directory is not an agent misbehaving. It is a
|
|
14
14
|
* policy that is wrong, and today it reads as forty tidy amber lines
|
|
15
15
|
* nobody adds up.
|
|
16
16
|
*
|
|
17
17
|
* 2. What was granted and never used?
|
|
18
|
-
* Every
|
|
18
|
+
* Every allowlist only ever grows, everywhere, and the reason is
|
|
19
19
|
* always the same: nobody can prove a line is dead, so the safe move is to
|
|
20
20
|
* leave it. Here the log can prove it. This is the only direction that
|
|
21
21
|
* makes a policy smaller.
|
|
@@ -29,6 +29,7 @@
|
|
|
29
29
|
*/
|
|
30
30
|
import { read, logPath } from "./log.js";
|
|
31
31
|
import { covers, standingOf } from "./owners.js";
|
|
32
|
+
import { kindsOf, expectedOf } from "./kinds.js";
|
|
32
33
|
|
|
33
34
|
/**
|
|
34
35
|
* One reading of the log against the policy.
|
|
@@ -37,16 +38,18 @@ import { covers, standingOf } from "./owners.js";
|
|
|
37
38
|
* is low on purpose: the cost of mentioning it is a line, and the cost of
|
|
38
39
|
* missing it is a role that quietly cannot do its job.
|
|
39
40
|
*/
|
|
40
|
-
export function review(config, { minDenials = 3 } = {}) {
|
|
41
|
-
|
|
41
|
+
export function review(config, { minDenials = 3, since = null } = {}) {
|
|
42
|
+
// `since`: an ISO time (views.js parseSince). Without it the whole log, and
|
|
43
|
+
// on a log months long "denied repeatedly" is led by paths fixed long ago.
|
|
44
|
+
const entries = read(logPath(config.root), { limit: 0, ...(since && { since }) });
|
|
42
45
|
|
|
43
46
|
if (entries.length === 0)
|
|
44
|
-
return { window: null, entries: 0, friction: [], guarded: [], unused: [], unusedKnowable: false, unowned: [] };
|
|
47
|
+
return { window: null, ...(since && { since }), entries: 0, friction: [], expected: [], guarded: [], unused: [], unusedKnowable: false, unowned: [] };
|
|
45
48
|
|
|
46
49
|
const at = entries.map((e) => e.at).filter(Boolean).sort();
|
|
47
50
|
const window = { from: at[0], to: at[at.length - 1], entries: entries.length };
|
|
48
51
|
|
|
49
|
-
return { window, entries: entries.length, ...findings(config, entries, minDenials) };
|
|
52
|
+
return { window, ...(since && { since }), entries: entries.length, ...findings(config, entries, minDenials) };
|
|
50
53
|
}
|
|
51
54
|
|
|
52
55
|
function findings(config, entries, minDenials) {
|
|
@@ -73,15 +76,19 @@ function findings(config, entries, minDenials) {
|
|
|
73
76
|
const stopped = new Map(); // role + directory -> how often
|
|
74
77
|
for (const e of entries) {
|
|
75
78
|
if (e.verdict !== "denied" || !e.target) continue;
|
|
76
|
-
|
|
77
|
-
|
|
79
|
+
// The caller's own probe (`seisin run --expect-denied`): denied on purpose.
|
|
80
|
+
if (e.expected === true) continue;
|
|
81
|
+
const dir = e.action === "read" || e.action === "connect" || e.kind === "system" ? e.target : dirOf(e.target);
|
|
82
|
+
const expected = expectedOf(e.target);
|
|
83
|
+
const key = `${e.role} ${e.action} ${dir}${expected ? " expected" : ""}`;
|
|
78
84
|
const seen = stopped.get(key) ?? {
|
|
79
85
|
role: e.role, action: e.action, where: dir, times: 0,
|
|
80
86
|
owners: e.owners ?? [],
|
|
87
|
+
...(expected && { expected }),
|
|
81
88
|
// Carried by the log from both writers — the hook sets it from the config
|
|
82
89
|
// and so does the parent for a kernel denial. Recomputing it from the path
|
|
83
90
|
// here would be a second opinion on a settled question.
|
|
84
|
-
kind: e.kind === "key" || e.kind === "network" ? e.kind : "file",
|
|
91
|
+
kind: e.kind === "key" || e.kind === "network" || e.kind === "system" ? e.kind : "file",
|
|
85
92
|
};
|
|
86
93
|
seen.times++;
|
|
87
94
|
stopped.set(key, seen);
|
|
@@ -91,7 +98,12 @@ function findings(config, entries, minDenials) {
|
|
|
91
98
|
.filter((f) => f.times >= minDenials)
|
|
92
99
|
.sort((a, b) => b.times - a.times);
|
|
93
100
|
|
|
94
|
-
|
|
101
|
+
// Expected (kinds.js expectedOf): Claude Code's plugin updater, refused
|
|
102
|
+
// correctly. Repeated on every start, and not a policy that is wrong — so it
|
|
103
|
+
// is said apart and never counts as friction, which is what exits 1.
|
|
104
|
+
const expected = repeated.filter((f) => f.expected);
|
|
105
|
+
// A system service is once per run (intake.js) and never grantable: not friction.
|
|
106
|
+
const friction = repeated.filter((f) => !f.expected && f.kind !== "key" && f.kind !== "network" && f.kind !== "system");
|
|
95
107
|
const guarded = repeated.filter((f) => f.kind === "key");
|
|
96
108
|
// Ports and sockets: repeated, and not a thing `grant` can answer.
|
|
97
109
|
const connects = repeated.filter((f) => f.kind === "network");
|
|
@@ -103,11 +115,11 @@ function findings(config, entries, minDenials) {
|
|
|
103
115
|
* that refusal is the whole of the second finding.
|
|
104
116
|
*
|
|
105
117
|
* "Never used" is read off `allowed` lines, and **only the hook writes those**.
|
|
106
|
-
* The kernel reports what it
|
|
118
|
+
* The kernel reports what it denied; it has nothing to say about what went
|
|
107
119
|
* through. So a log with no hook behind it holds denials and nothing else,
|
|
108
120
|
* every grant falls through as unused, and this section reports that the whole
|
|
109
121
|
* policy is dead — under a heading calling itself the only evidence anyone
|
|
110
|
-
* will ever have for making a
|
|
122
|
+
* will ever have for making a policy smaller.
|
|
111
123
|
*
|
|
112
124
|
* Measured on a real repository with the hook not installed: every write
|
|
113
125
|
* permission of every role listed as never used, while those roles were
|
|
@@ -128,15 +140,21 @@ function findings(config, entries, minDenials) {
|
|
|
128
140
|
const knowable = entries.some((e) => e.verdict === "allowed" && !e.disputed);
|
|
129
141
|
|
|
130
142
|
// Only writes. A key that is declared and not read is ordinary — most roles
|
|
131
|
-
// hold a credential for the one
|
|
143
|
+
// hold a credential for the one run a month that needs it — but a folder a
|
|
132
144
|
// role owns and has never written to in the whole log is a line nobody would
|
|
133
145
|
// miss, and the only evidence anyone will ever have for removing it.
|
|
146
|
+
// Straight to the line's own role, and each (role, target) once: walking
|
|
147
|
+
// every role's globs for every line to keep only one role's was the cost.
|
|
134
148
|
const used = new Set();
|
|
149
|
+
const seen = new Set();
|
|
135
150
|
for (const e of entries) {
|
|
136
151
|
if (e.verdict !== "allowed" || e.disputed || e.action !== "write" || !e.target) continue;
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
152
|
+
if (!Object.prototype.hasOwnProperty.call(config.roles, e.role)) continue;
|
|
153
|
+
const k = `${e.role}\u0000${e.target}`;
|
|
154
|
+
if (seen.has(k)) continue;
|
|
155
|
+
seen.add(k);
|
|
156
|
+
for (const glob of config.roles[e.role].writes)
|
|
157
|
+
if (covers(glob, e.target)) used.add(`${e.role} ${glob}`);
|
|
140
158
|
}
|
|
141
159
|
|
|
142
160
|
const unused = [];
|
|
@@ -153,24 +171,31 @@ function findings(config, entries, minDenials) {
|
|
|
153
171
|
// the repository is outside every territory: none of them is a hole in the
|
|
154
172
|
// map. This used to skip keys and ports and count the rest, protected ones
|
|
155
173
|
// included, while the console counted all four — one word, two numbers.
|
|
156
|
-
|
|
174
|
+
//
|
|
175
|
+
// Each row also says what kind of thing it is (kinds.js), by directory and
|
|
176
|
+
// kind together: `.git` holding a lock and a folder holding a source file
|
|
177
|
+
// are different answers, and only `territory` is a hole somebody has to fill.
|
|
157
178
|
const standOf = standingOf(config);
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
179
|
+
const loose = entries.filter((e) => e.target && standOf(e).kind === "unowned");
|
|
180
|
+
const natures = kindsOf(loose.map((e) => e.target), { keyDirs: config.keyDirs ?? [] });
|
|
181
|
+
const unowned = new Map();
|
|
182
|
+
for (const e of loose) {
|
|
183
|
+
const { kind, hint } = natures.get(e.target);
|
|
184
|
+
const where = dirOf(e.target);
|
|
185
|
+
const k = `${kind}\u0000${where}`;
|
|
186
|
+
const row = unowned.get(k) ?? { where, times: 0, kind, hint };
|
|
187
|
+
row.times++;
|
|
188
|
+
unowned.set(k, row);
|
|
163
189
|
}
|
|
164
190
|
|
|
165
191
|
return {
|
|
166
192
|
friction,
|
|
193
|
+
expected,
|
|
167
194
|
guarded,
|
|
168
195
|
connects,
|
|
169
196
|
unused,
|
|
170
197
|
unusedKnowable: knowable,
|
|
171
|
-
unowned: [...unowned.
|
|
172
|
-
.map(([where, times]) => ({ where, times }))
|
|
173
|
-
.sort((a, b) => b.times - a.times),
|
|
198
|
+
unowned: [...unowned.values()].sort((a, b) => b.times - a.times),
|
|
174
199
|
};
|
|
175
200
|
}
|
|
176
201
|
|
package/src/rundir.js
CHANGED
|
@@ -1,15 +1,15 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* One private directory per run, for everything that belongs to that run only:
|
|
3
|
-
* the audit socket, the sandbox settings, and `
|
|
3
|
+
* the audit socket, the sandbox settings, and `run-file` keys.
|
|
4
4
|
*
|
|
5
5
|
* ── Why ──
|
|
6
6
|
* These lived in two places and both leaked. The settings were written to
|
|
7
7
|
* `.seisin/<role>.json`, one file per ROLE, so two runs of the same role — an
|
|
8
8
|
* orchestrator does that all day — wrote the same file with different socket
|
|
9
|
-
* paths, and one could start with the other's. The socket and the
|
|
9
|
+
* paths, and one could start with the other's. The socket and the run-file keys
|
|
10
10
|
* lived in `$TMPDIR/seisin-XXXX/`, private by mode, but every role runs as the
|
|
11
11
|
* same user and every role can read the temp dir: measured on 2026-09-23, one
|
|
12
|
-
* role read another's `
|
|
12
|
+
* role read another's `run-file` key while it ran (`cat …/keys/S` → the value).
|
|
13
13
|
*
|
|
14
14
|
* ── How ──
|
|
15
15
|
* All runs live under one root, `$TMPDIR/snr/`, and each role's profile denies
|
|
@@ -25,8 +25,17 @@ import { tmpdir } from "node:os";
|
|
|
25
25
|
import { randomUUID } from "node:crypto";
|
|
26
26
|
import { realOrSelf } from "./grants.js";
|
|
27
27
|
import { channelName } from "./spool.js";
|
|
28
|
+
import { alive } from "./log.js";
|
|
28
29
|
|
|
29
30
|
export const RUNS_NAME = "snr";
|
|
31
|
+
/**
|
|
32
|
+
* Where a run's temp directory lives when the role may write no other: beside
|
|
33
|
+
* the runs root, not inside it. The runs root is denied to writes as a whole
|
|
34
|
+
* (srt.js), and a deny wins over any grant beneath it.
|
|
35
|
+
*/
|
|
36
|
+
export const TMP_NAME = "snt";
|
|
37
|
+
/** The settings file inside a run's directory. srt.js denies it to the run by this name. */
|
|
38
|
+
export const SETTINGS_NAME = "settings.json";
|
|
30
39
|
|
|
31
40
|
/**
|
|
32
41
|
* The root every run lives under, created private, and checked.
|
|
@@ -35,8 +44,8 @@ export const RUNS_NAME = "snr";
|
|
|
35
44
|
* dir sits behind /var → /private/var. Refused rather than used when it is not
|
|
36
45
|
* a directory of this user's with nobody else's bits on it: it holds keys.
|
|
37
46
|
*/
|
|
38
|
-
export function runsRoot(base = tmpdir()) {
|
|
39
|
-
const root = join(realOrSelf(base),
|
|
47
|
+
export function runsRoot(base = tmpdir(), name = RUNS_NAME) {
|
|
48
|
+
const root = join(realOrSelf(base), name);
|
|
40
49
|
try { mkdirSync(root, { mode: 0o700 }); } catch (e) { if (e.code !== "EEXIST") throw e; }
|
|
41
50
|
const st = lstatSync(root);
|
|
42
51
|
const mine = typeof process.getuid !== "function" || st.uid === process.getuid();
|
|
@@ -48,16 +57,11 @@ export function runsRoot(base = tmpdir()) {
|
|
|
48
57
|
return root;
|
|
49
58
|
}
|
|
50
59
|
|
|
51
|
-
/** Is the process that owns a run directory still alive? */
|
|
52
|
-
function alive(pid) {
|
|
53
|
-
try { process.kill(pid, 0); return true; } catch (e) { return e.code === "EPERM"; }
|
|
54
|
-
}
|
|
55
|
-
|
|
56
60
|
/**
|
|
57
61
|
* Removes the directories of runs whose process is gone.
|
|
58
62
|
*
|
|
59
63
|
* A run cleans up after itself on exit. A run that is killed does not, and
|
|
60
|
-
* its directory keeps its `
|
|
64
|
+
* its directory keeps its `run-file` keys on disk indefinitely — measured by
|
|
61
65
|
* SIGKILL on a keyed run. Swept at the start of the next one; a directory
|
|
62
66
|
* without a pid file is younger than the write that creates it, and is left.
|
|
63
67
|
*/
|
|
@@ -66,7 +70,12 @@ function sweep(root) {
|
|
|
66
70
|
const dir = join(root, name);
|
|
67
71
|
let pid;
|
|
68
72
|
try { pid = Number(readFileSync(join(dir, "pid"), "utf8")); } catch { continue; }
|
|
69
|
-
|
|
73
|
+
// Only a process the kernel says is gone: a directory kept one sweep too
|
|
74
|
+
// long costs disk, one removed early takes a live run's socket and keys.
|
|
75
|
+
if (pid && !alive(pid)) {
|
|
76
|
+
rmSync(dir, { recursive: true, force: true });
|
|
77
|
+
rmSync(join(dirname(root), TMP_NAME, name), { recursive: true, force: true });
|
|
78
|
+
}
|
|
70
79
|
}
|
|
71
80
|
}
|
|
72
81
|
|
|
@@ -87,20 +96,30 @@ export function openRun({ base = tmpdir(), id = randomUUID() } = {}) {
|
|
|
87
96
|
dir,
|
|
88
97
|
root,
|
|
89
98
|
// A socket on macOS, a FIFO on Linux, where the runtime blocks every unix
|
|
90
|
-
// socket inside the
|
|
99
|
+
// socket inside the sandbox (spool.js).
|
|
91
100
|
sock: join(dir, channelName()),
|
|
92
101
|
keys: join(dir, "keys"),
|
|
102
|
+
/**
|
|
103
|
+
* This run's own temp directory, made on first call: only a run whose role
|
|
104
|
+
* may write no temp directory of its own needs one (commands/run.js).
|
|
105
|
+
*/
|
|
106
|
+
tmp() {
|
|
107
|
+
const at = join(runsRoot(base, TMP_NAME), basename(dir));
|
|
108
|
+
mkdirSync(at, { recursive: true, mode: 0o700 });
|
|
109
|
+
return at;
|
|
110
|
+
},
|
|
93
111
|
/**
|
|
94
112
|
* `wx`: created by this call or not at all. A settings file that already
|
|
95
113
|
* exists is not this run's, and the runtime would read whatever it says.
|
|
96
114
|
*/
|
|
97
115
|
writeSettings(settings) {
|
|
98
|
-
const file = join(dir,
|
|
116
|
+
const file = join(dir, SETTINGS_NAME);
|
|
99
117
|
writeFileSync(file, JSON.stringify(settings, null, 2) + "\n", { flag: "wx", mode: 0o600 });
|
|
100
118
|
return file;
|
|
101
119
|
},
|
|
102
120
|
close() {
|
|
103
121
|
try { rmSync(dir, { recursive: true, force: true }); } catch {}
|
|
122
|
+
try { rmSync(join(dirname(root), TMP_NAME, basename(dir)), { recursive: true, force: true }); } catch {}
|
|
104
123
|
},
|
|
105
124
|
};
|
|
106
125
|
}
|
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* A sandbox declared in seisin.toml: a command with a placeholder.
|
|
3
|
+
*
|
|
4
|
+
* [sandbox.providers.mybox]
|
|
5
|
+
* command = ["mybox", "run", "--policy", "{policy}", "--"]
|
|
6
|
+
*
|
|
7
|
+
* The same shape as a key provider (keys.js), for the same reason: adding a
|
|
8
|
+
* sandbox is three lines of TOML and no code. seisin replaces `{policy}` with
|
|
9
|
+
* the path of the run's settings file and appends the command to run. The
|
|
10
|
+
* contract the provider takes on is in docs/sandboxes.md; the short form:
|
|
11
|
+
*
|
|
12
|
+
* - the file is the settings seisin generates, in sandbox-runtime's schema;
|
|
13
|
+
* - the provider enforces every field of it, or exits non-zero before
|
|
14
|
+
* running anything — it never runs the command with less;
|
|
15
|
+
* - it passes the command's exit status through.
|
|
16
|
+
*
|
|
17
|
+
* seisin cannot see inside a provider, so it asks: with a `check` command
|
|
18
|
+
*
|
|
19
|
+
* check = ["mybox", "check", "--policy", "{policy}"]
|
|
20
|
+
*
|
|
21
|
+
* the provider says before anything runs whether it can hold a policy (exit 0)
|
|
22
|
+
* or not (anything else, the last line it prints saying why), and one that
|
|
23
|
+
* cannot is passed over like a built-in one. Without `check` there is nothing
|
|
24
|
+
* to ask, and `unmet` has nothing to say.
|
|
25
|
+
*
|
|
26
|
+
* Neither answer is taken on faith: every run starts with the canary
|
|
27
|
+
* (canary.js) under the same provider and the same file, and the agent starts
|
|
28
|
+
* only if the boundary held. A provider that ignores the file fails there.
|
|
29
|
+
*/
|
|
30
|
+
import { existsSync } from "node:fs";
|
|
31
|
+
import { spawnSync } from "node:child_process";
|
|
32
|
+
import { join, delimiter, isAbsolute, basename } from "node:path";
|
|
33
|
+
|
|
34
|
+
export const POLICY_PLACEHOLDER = "{policy}";
|
|
35
|
+
|
|
36
|
+
function onPath(name, path = process.env.PATH) {
|
|
37
|
+
if (name.includes("/")) return isAbsolute(name) && existsSync(name) ? name : null;
|
|
38
|
+
for (const dir of (path ?? "").split(delimiter)) if (dir && existsSync(join(dir, name))) return join(dir, name);
|
|
39
|
+
return null;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/** Long enough for a sandbox to read a file and say no; short enough that a hung one is found out. */
|
|
43
|
+
const CHECK_TIMEOUT_MS = 15000;
|
|
44
|
+
|
|
45
|
+
const withPolicy = (args, file) => args.map((a) => a.split(POLICY_PLACEHOLDER).join(file));
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* Runs a sandbox's own pre-check on the settings file.
|
|
49
|
+
*
|
|
50
|
+
* `unmet` is `[]` when it answered yes; otherwise one sentence, its own reason
|
|
51
|
+
* when it gave one. A check that cannot answer (not found, killed, timed out)
|
|
52
|
+
* is a no: an answer that never came is not a pass. `warnings` are the lines
|
|
53
|
+
* it printed that start with `warning:`, yes or no — what it holds, but not the
|
|
54
|
+
* way the role might expect (tartaros on Linux, a repo root it cannot let the
|
|
55
|
+
* role create entries in) — for `seisin check` to show.
|
|
56
|
+
*/
|
|
57
|
+
export function assessCheck(argv, file, label) {
|
|
58
|
+
const r = spawnSync(argv[0], withPolicy(argv.slice(1), file), { encoding: "utf8", timeout: CHECK_TIMEOUT_MS, stdio: ["ignore", "pipe", "pipe"] });
|
|
59
|
+
// Its own name off the front: "mybox: no domains" reads twice after "mybox says".
|
|
60
|
+
// stdout before stderr, so a report on stdout does not bury a refusal on stderr.
|
|
61
|
+
const self = `${basename(argv[0])}:`;
|
|
62
|
+
const said = `${r.stdout ?? ""}\n${r.stderr ?? ""}`.split("\n").map((l) => l.trim()).filter(Boolean)
|
|
63
|
+
.map((l) => (l.startsWith(self) ? l.slice(self.length).trim() : l));
|
|
64
|
+
const warnings = said.filter((l) => /^warning:/i.test(l)).map((l) => l.replace(/^warning:\s*/i, ""));
|
|
65
|
+
if (r.status === 0) return { unmet: [], warnings };
|
|
66
|
+
// The last line: what comes before it can be warnings about what it did hold
|
|
67
|
+
// (tartaros says which patterns its own set covers), and the no comes last.
|
|
68
|
+
const why = r.error?.code === "ETIMEDOUT" ? `it did not answer in ${CHECK_TIMEOUT_MS / 1000} s`
|
|
69
|
+
: r.error ? r.error.message
|
|
70
|
+
: said.length ? said.at(-1)
|
|
71
|
+
: r.signal ? `it was killed by ${r.signal}` : `it exited ${r.status} without saying why`;
|
|
72
|
+
return { unmet: [`${label} says it cannot hold this policy: ${why}`], warnings };
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/** Only the answer: `[]` for yes, the reason for no. */
|
|
76
|
+
export const askCheck = (argv, file, label) => assessCheck(argv, file, label).unmet;
|
|
77
|
+
|
|
78
|
+
export function commandSandbox(name, command, check = null, { tagsDenials = false } = {}) {
|
|
79
|
+
const assess = (_settings, { file } = {}) => (check ? assessCheck(check, file, `its check (${check[0]})`) : { unmet: [], warnings: [] });
|
|
80
|
+
return {
|
|
81
|
+
name,
|
|
82
|
+
locate: () => onPath(command[0]),
|
|
83
|
+
missing: () => `${command[0]} (the command of [sandbox.providers.${name}]) was not found${command[0].includes("/") ? "" : " on PATH"}.`,
|
|
84
|
+
asksFile: Boolean(check),
|
|
85
|
+
assess,
|
|
86
|
+
unmet: (settings, ctx) => assess(settings, ctx).unmet,
|
|
87
|
+
wrap: (bin, file, _settings, inner) => [bin, ...withPolicy(command.slice(1), file), ...inner],
|
|
88
|
+
// Declared, not detected: `tags_denials = true` says its Seatbelt denials
|
|
89
|
+
// carry sandbox-runtime's tag (docs/sandboxes.md). Without it, on macOS,
|
|
90
|
+
// none of the kernel's refusals under it reach the log.
|
|
91
|
+
tagsDenials,
|
|
92
|
+
};
|
|
93
|
+
}
|
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Which sandbox a role runs under.
|
|
3
|
+
*
|
|
4
|
+
* seisin decides the policy; a sandbox enforces it. There are three ways in:
|
|
5
|
+
*
|
|
6
|
+
* - `srt` (built in, the default): @anthropic-ai/sandbox-runtime;
|
|
7
|
+
* - `tartaros` (built in): reads the settings file itself (`--settings`);
|
|
8
|
+
* - any name declared under `[sandbox.providers.<name>]`: a command that
|
|
9
|
+
* takes the settings file (command.js, docs/sandboxes.md).
|
|
10
|
+
*
|
|
11
|
+
* `[sandbox] provider` names one, or several in order of preference:
|
|
12
|
+
*
|
|
13
|
+
* [sandbox]
|
|
14
|
+
* provider = ["tartaros", "srt"]
|
|
15
|
+
*
|
|
16
|
+
* The choice is a capability question, not a preference toggle
|
|
17
|
+
* (docs/decisions.md): each role gets the first one that is installed and
|
|
18
|
+
* enforces every part of that role's settings. One that would enforce less is
|
|
19
|
+
* passed over with the reason, and a role none of them can hold does not start.
|
|
20
|
+
* A single name means exactly that one, so a config that asks for tartaros
|
|
21
|
+
* never ends up somewhere else without having said so.
|
|
22
|
+
*/
|
|
23
|
+
import { mkdtempSync, writeFileSync, rmSync } from "node:fs";
|
|
24
|
+
import { join } from "node:path";
|
|
25
|
+
import { tmpdir } from "node:os";
|
|
26
|
+
import { srt } from "./srt.js";
|
|
27
|
+
import { machOf } from "../browsers.js";
|
|
28
|
+
import { tartaros } from "./tartaros.js";
|
|
29
|
+
import { commandSandbox } from "./command.js";
|
|
30
|
+
|
|
31
|
+
export const BUILTIN_SANDBOXES = { srt, tartaros };
|
|
32
|
+
export const DEFAULT_SANDBOX = "srt";
|
|
33
|
+
|
|
34
|
+
/** The sandboxes the config names, in its order, as provider objects. */
|
|
35
|
+
export function sandboxesOf(config) {
|
|
36
|
+
const names = config.sandbox?.provider ?? [DEFAULT_SANDBOX];
|
|
37
|
+
return names.map((n) => BUILTIN_SANDBOXES[n] ?? commandSandbox(n, config.sandbox.providers[n].command, config.sandbox.providers[n].check, { tagsDenials: config.sandbox.providers[n].tagsDenials }));
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* For each sandbox the config names, whether it could run these settings and
|
|
42
|
+
* why not. `chosen` is the first that can, or null.
|
|
43
|
+
*
|
|
44
|
+
* A sandbox that answers for itself (tartaros, a declared one with `check`) is
|
|
45
|
+
* asked about the file a run would hand it: `file` when the run has written
|
|
46
|
+
* one, otherwise a copy in a temp directory of its own, removed after. Only a
|
|
47
|
+
* sandbox that was found is asked; one that was not is passed over as missing.
|
|
48
|
+
*/
|
|
49
|
+
export function weigh(config, settings, { platform = process.platform, role = null, locate = true, file = null } = {}) {
|
|
50
|
+
const list = sandboxesOf(config);
|
|
51
|
+
const register = role ? machOf(config.roles?.[role]).register : [];
|
|
52
|
+
let scratch = null;
|
|
53
|
+
let copies = 0;
|
|
54
|
+
// The file a sandbox is asked about is the one it would be handed: the run's
|
|
55
|
+
// own when it takes the settings as they are, otherwise its own version of them.
|
|
56
|
+
const fileFor = (own) => {
|
|
57
|
+
if (file && own === settings) return file;
|
|
58
|
+
scratch ??= mkdtempSync(join(tmpdir(), "seisin-weigh-"));
|
|
59
|
+
const f = join(scratch, `settings-${copies++}.json`);
|
|
60
|
+
writeFileSync(f, JSON.stringify(own, null, 2), { mode: 0o600 });
|
|
61
|
+
return f;
|
|
62
|
+
};
|
|
63
|
+
try {
|
|
64
|
+
const options = list.map((sandbox) => {
|
|
65
|
+
const bin = locate ? sandbox.locate() : "(not looked up)";
|
|
66
|
+
const own = settingsOf(sandbox, settings, { machRegister: register });
|
|
67
|
+
const asked = sandbox.asksFile && locate && bin;
|
|
68
|
+
const ctx = { platform, role, bin, file: asked ? fileFor(own) : null };
|
|
69
|
+
const { unmet, warnings } = sandbox.asksFile && !asked ? { unmet: [], warnings: [] }
|
|
70
|
+
: sandbox.assess ? sandbox.assess(own, ctx) : { unmet: sandbox.unmet(own, ctx), warnings: [] };
|
|
71
|
+
// `browsers`: a Mach service to register. srt's schema has no field for it
|
|
72
|
+
// (srt-mach.mjs adds it); tartaros takes it as network.allowMachRegister
|
|
73
|
+
// (adapt). A sandbox that is given it neither way cannot hold the role.
|
|
74
|
+
const browser = register.length && !sandbox.machRegister
|
|
75
|
+
? [`a Mach service to register (${register.join(", ")}, for browsers): ${sandbox.name} is not given it, only srt and tartaros are`]
|
|
76
|
+
: [];
|
|
77
|
+
return { sandbox, bin, unmet: [...unmet, ...browser], warnings, missing: bin ? null : sandbox.missing() };
|
|
78
|
+
});
|
|
79
|
+
const chosen = options.find((o) => o.bin && o.unmet.length === 0) ?? null;
|
|
80
|
+
return { chosen, options };
|
|
81
|
+
} finally {
|
|
82
|
+
if (scratch) rmSync(scratch, { recursive: true, force: true });
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/**
|
|
87
|
+
* The settings as this sandbox is handed them: as generated, or with what it
|
|
88
|
+
* takes in its own field (tartaros: network.allowMachRegister). The same
|
|
89
|
+
* object when nothing changes, so a caller can tell.
|
|
90
|
+
*/
|
|
91
|
+
export function settingsOf(sandbox, settings, ctx) {
|
|
92
|
+
return sandbox.adapt ? sandbox.adapt(settings, ctx) : settings;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/** The sandbox and binary for one run, or an Error that says why each was passed over. */
|
|
96
|
+
export function chooseSandbox(config, settings, { platform = process.platform, role = null, file = null } = {}) {
|
|
97
|
+
const { chosen, options } = weigh(config, settings, { platform, role, file });
|
|
98
|
+
if (chosen) return { sandbox: chosen.sandbox, bin: chosen.bin, options };
|
|
99
|
+
// The one sandbox there is, missing: said exactly as it always was.
|
|
100
|
+
if (options.length === 1 && options[0].missing && options[0].sandbox.name === DEFAULT_SANDBOX)
|
|
101
|
+
return { error: new Error(options[0].missing), options };
|
|
102
|
+
return { error: new Error(refusal(role, options)), options };
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
export function refusal(role, options) {
|
|
106
|
+
const lines = options.map((o) => o.missing
|
|
107
|
+
? ` - ${o.sandbox.name}: ${o.missing}`
|
|
108
|
+
: ` - ${o.sandbox.name} cannot enforce all of it:\n${o.unmet.map((u) => ` ${u}`).join("\n")}`);
|
|
109
|
+
return `no sandbox in [sandbox] provider can hold ${role ? `role ${role}` : "this role"} as its policy is written, so it was not started:\n` +
|
|
110
|
+
`${lines.join("\n")}\n` +
|
|
111
|
+
` A sandbox that enforces less than the policy is never used in its place. Add one that can (provider = [..., "srt"]), ` +
|
|
112
|
+
`or change the role.`;
|
|
113
|
+
}
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The splice srt-mach.mjs makes into srt's profile, kept apart so it can be
|
|
3
|
+
* tested without loading into srt. See srt-mach.mjs for why it exists.
|
|
4
|
+
*/
|
|
5
|
+
import { MACH_PREFIX } from "../browsers.js";
|
|
6
|
+
|
|
7
|
+
export const MACH_FILE = "mach.json";
|
|
8
|
+
const ANCHOR = "/usr/bin/sandbox-exec -p '";
|
|
9
|
+
|
|
10
|
+
export function fail(why) {
|
|
11
|
+
throw new Error(`seisin: cannot add the browser's Mach rule to the sandbox profile: ${why}`);
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
/** The profile with the rules appended, or an Error: exported so the splice is tested without srt. */
|
|
15
|
+
export function withRules(line, register) {
|
|
16
|
+
for (const name of register) if (!MACH_PREFIX.test(name)) fail(`"${name}" is not a Mach name prefix`);
|
|
17
|
+
const at = line.indexOf(ANCHOR);
|
|
18
|
+
if (at === -1) fail("srt no longer runs sandbox-exec with an inline profile");
|
|
19
|
+
if (line.indexOf(ANCHOR, at + 1) !== -1) fail("the command holds more than one profile");
|
|
20
|
+
const start = at + ANCHOR.length;
|
|
21
|
+
// Single-quoted by srt's quote(), which writes a quote inside it as '"'"'
|
|
22
|
+
// (a comment in the profile says "doesn't"). The quote that ends it is the
|
|
23
|
+
// first one that does not open that sequence, and the shell's path follows.
|
|
24
|
+
let end = line.indexOf("'", start);
|
|
25
|
+
while (end !== -1 && line.startsWith(`'"'"'`, end)) end = line.indexOf("'", end + 5);
|
|
26
|
+
if (end === -1 || line[end + 1] !== " ") fail("the profile is not quoted the way it was");
|
|
27
|
+
if (!line.slice(start, end).startsWith("(version 1)")) fail("the profile does not start with (version 1)");
|
|
28
|
+
const rules = register.map((p) => `(allow mach-register (global-name-prefix "${p}"))`).join("\n");
|
|
29
|
+
return `${line.slice(0, end)}\n; seisin: browsers (src/browsers.js)\n${rules}\n${line.slice(end)}`;
|
|
30
|
+
}
|
|
31
|
+
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Loaded into the `srt` process with `node --import`, for a role whose
|
|
3
|
+
* `browsers` need a Mach service registered (browsers.js).
|
|
4
|
+
*
|
|
5
|
+
* sandbox-runtime lets a policy name Mach services to look up
|
|
6
|
+
* (`allowMachLookup`) and has no field for registering one, which is what a
|
|
7
|
+
* Chromium browser process does first. So for those roles, and only those,
|
|
8
|
+
* this adds the one rule the profile cannot otherwise carry:
|
|
9
|
+
*
|
|
10
|
+
* (allow mach-register (global-name-prefix "org.chromium.Chromium.MachPortRendezvousServer."))
|
|
11
|
+
*
|
|
12
|
+
* It wraps `SandboxManager.wrapWithSandbox` — the module instance srt's own CLI
|
|
13
|
+
* uses, loaded from beside it — and appends the rule to the profile that call
|
|
14
|
+
* returns, before srt runs it. Nothing else in the profile is touched, and
|
|
15
|
+
* every run still starts with the canary under the result.
|
|
16
|
+
*
|
|
17
|
+
* Fails closed. The names come from `mach.json` in the run's directory, which
|
|
18
|
+
* seisin writes; each is checked against the shape a prefix may have before
|
|
19
|
+
* it goes into the profile. And the profile must be where and what it was
|
|
20
|
+
* when this was written — `/usr/bin/sandbox-exec -p '(version 1)…'`, once. If
|
|
21
|
+
* a later srt builds its command another way, this throws, srt does not run
|
|
22
|
+
* the command, and the canary reports a sandbox that did not start (exit 86).
|
|
23
|
+
* A role that asked for a browser gets no browser; it never gets less sandbox.
|
|
24
|
+
*/
|
|
25
|
+
import { readFileSync, realpathSync } from "node:fs";
|
|
26
|
+
import { dirname, join } from "node:path";
|
|
27
|
+
import { pathToFileURL } from "node:url";
|
|
28
|
+
import { MACH_FILE, withRules, fail } from "./mach-profile.js";
|
|
29
|
+
|
|
30
|
+
const at = process.argv.indexOf("--settings");
|
|
31
|
+
const settings = at === -1 ? null : process.argv[at + 1];
|
|
32
|
+
if (!settings) fail("srt was not given --settings");
|
|
33
|
+
const { register } = JSON.parse(readFileSync(join(dirname(settings), MACH_FILE), "utf8"));
|
|
34
|
+
if (!Array.isArray(register) || register.length === 0) fail(`${MACH_FILE} names nothing to register`);
|
|
35
|
+
|
|
36
|
+
// The same file srt's CLI imports as "./index.js", so the same module instance.
|
|
37
|
+
const cli = realpathSync(process.argv[1]);
|
|
38
|
+
const { SandboxManager } = await import(pathToFileURL(join(dirname(cli), "index.js")).href);
|
|
39
|
+
if (typeof SandboxManager?.wrapWithSandbox !== "function") fail("srt has no SandboxManager.wrapWithSandbox");
|
|
40
|
+
const wrap = SandboxManager.wrapWithSandbox;
|
|
41
|
+
SandboxManager.wrapWithSandbox = async function (...args) {
|
|
42
|
+
return withRules(await wrap.apply(this, args), register);
|
|
43
|
+
};
|