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/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
|
|
@@ -28,6 +28,14 @@ import { channelName } from "./spool.js";
|
|
|
28
28
|
import { alive } from "./log.js";
|
|
29
29
|
|
|
30
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";
|
|
31
39
|
|
|
32
40
|
/**
|
|
33
41
|
* The root every run lives under, created private, and checked.
|
|
@@ -36,8 +44,8 @@ export const RUNS_NAME = "snr";
|
|
|
36
44
|
* dir sits behind /var → /private/var. Refused rather than used when it is not
|
|
37
45
|
* a directory of this user's with nobody else's bits on it: it holds keys.
|
|
38
46
|
*/
|
|
39
|
-
export function runsRoot(base = tmpdir()) {
|
|
40
|
-
const root = join(realOrSelf(base),
|
|
47
|
+
export function runsRoot(base = tmpdir(), name = RUNS_NAME) {
|
|
48
|
+
const root = join(realOrSelf(base), name);
|
|
41
49
|
try { mkdirSync(root, { mode: 0o700 }); } catch (e) { if (e.code !== "EEXIST") throw e; }
|
|
42
50
|
const st = lstatSync(root);
|
|
43
51
|
const mine = typeof process.getuid !== "function" || st.uid === process.getuid();
|
|
@@ -53,7 +61,7 @@ export function runsRoot(base = tmpdir()) {
|
|
|
53
61
|
* Removes the directories of runs whose process is gone.
|
|
54
62
|
*
|
|
55
63
|
* A run cleans up after itself on exit. A run that is killed does not, and
|
|
56
|
-
* its directory keeps its `
|
|
64
|
+
* its directory keeps its `run-file` keys on disk indefinitely — measured by
|
|
57
65
|
* SIGKILL on a keyed run. Swept at the start of the next one; a directory
|
|
58
66
|
* without a pid file is younger than the write that creates it, and is left.
|
|
59
67
|
*/
|
|
@@ -64,7 +72,10 @@ function sweep(root) {
|
|
|
64
72
|
try { pid = Number(readFileSync(join(dir, "pid"), "utf8")); } catch { continue; }
|
|
65
73
|
// Only a process the kernel says is gone: a directory kept one sweep too
|
|
66
74
|
// long costs disk, one removed early takes a live run's socket and keys.
|
|
67
|
-
if (pid && !alive(pid))
|
|
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
|
+
}
|
|
68
79
|
}
|
|
69
80
|
}
|
|
70
81
|
|
|
@@ -88,17 +99,27 @@ export function openRun({ base = tmpdir(), id = randomUUID() } = {}) {
|
|
|
88
99
|
// socket inside the sandbox (spool.js).
|
|
89
100
|
sock: join(dir, channelName()),
|
|
90
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
|
+
},
|
|
91
111
|
/**
|
|
92
112
|
* `wx`: created by this call or not at all. A settings file that already
|
|
93
113
|
* exists is not this run's, and the runtime would read whatever it says.
|
|
94
114
|
*/
|
|
95
115
|
writeSettings(settings) {
|
|
96
|
-
const file = join(dir,
|
|
116
|
+
const file = join(dir, SETTINGS_NAME);
|
|
97
117
|
writeFileSync(file, JSON.stringify(settings, null, 2) + "\n", { flag: "wx", mode: 0o600 });
|
|
98
118
|
return file;
|
|
99
119
|
},
|
|
100
120
|
close() {
|
|
101
121
|
try { rmSync(dir, { recursive: true, force: true }); } catch {}
|
|
122
|
+
try { rmSync(join(dirname(root), TMP_NAME, basename(dir)), { recursive: true, force: true }); } catch {}
|
|
102
123
|
},
|
|
103
124
|
};
|
|
104
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
|
+
};
|
|
@@ -0,0 +1,163 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `srt`: @anthropic-ai/sandbox-runtime, the sandbox seisin was built on.
|
|
3
|
+
*
|
|
4
|
+
* The default, and the reference every other provider is measured against:
|
|
5
|
+
* the settings seisin generates (srt.js, one level up) ARE this runtime's
|
|
6
|
+
* settings file, so it enforces all of them by construction and `unmet` is
|
|
7
|
+
* always empty. What a run hands it is the file and the command, nothing else.
|
|
8
|
+
*/
|
|
9
|
+
import { existsSync, readFileSync, realpathSync } from "node:fs";
|
|
10
|
+
import { join, dirname, delimiter } from "node:path";
|
|
11
|
+
import { fileURLToPath } from "node:url";
|
|
12
|
+
|
|
13
|
+
const HERE = dirname(fileURLToPath(import.meta.url));
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* The runtime seisin depends on first, one on PATH second, and nothing third.
|
|
17
|
+
*
|
|
18
|
+
* "The one seisin depends on" is found the way Node finds a dependency — up
|
|
19
|
+
* from this file, through every `node_modules` — not at one spelled-out path.
|
|
20
|
+
* `<seisin>/node_modules/.bin/srt` is only where it lands in a checkout. In a
|
|
21
|
+
* project that installs seisin as a dependency, npm hoists the runtime to the
|
|
22
|
+
* project's own `node_modules`, and that path does not exist. Measured with
|
|
23
|
+
* 0.5.0 packed and installed into a project: `npx seisin` still worked, because
|
|
24
|
+
* npx puts the project's `.bin` on PATH; `./node_modules/.bin/seisin` and
|
|
25
|
+
* `node node_modules/seisin/src/cli.js` did not — they said "not found", or,
|
|
26
|
+
* with a global srt 0.0.75 installed, ran under THAT one instead of the 0.0.78
|
|
27
|
+
* this version pins, with none of its fixes.
|
|
28
|
+
*
|
|
29
|
+
* There is deliberately no fallback to running unsandboxed. A permission tool
|
|
30
|
+
* that quietly becomes a no-op when its enforcer is missing is worse than one
|
|
31
|
+
* that refuses, because you keep trusting it.
|
|
32
|
+
*/
|
|
33
|
+
export function resolveSrt({ from = HERE, path = process.env.PATH } = {}) {
|
|
34
|
+
const local = dependencySrt(from);
|
|
35
|
+
if (local) return local;
|
|
36
|
+
|
|
37
|
+
// Walk PATH rather than asking a shell. `spawnSync(..., { shell: true })`
|
|
38
|
+
// prints a deprecation warning on every single run — Node's DEP0190 — which
|
|
39
|
+
// is a line of noise in front of every user for the sake of finding one file.
|
|
40
|
+
// It is also the concatenation hazard the warning is about.
|
|
41
|
+
for (const dir of (path ?? "").split(delimiter)) {
|
|
42
|
+
if (!dir) continue;
|
|
43
|
+
const candidate = join(dir, "srt");
|
|
44
|
+
if (existsSync(candidate)) return candidate;
|
|
45
|
+
}
|
|
46
|
+
return null;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
const RUNTIME = "@anthropic-ai/sandbox-runtime";
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* The `srt` of the sandbox-runtime that resolves from `from`, or null.
|
|
53
|
+
*
|
|
54
|
+
* The `.bin/srt` npm linked beside the package when there is one — that is the
|
|
55
|
+
* file npm made executable — and otherwise the package's own `bin` entry.
|
|
56
|
+
* The package's `package.json` is found by walking up rather than through
|
|
57
|
+
* `require.resolve`, which a package's `exports` map is free to refuse.
|
|
58
|
+
*/
|
|
59
|
+
export function dependencySrt(from = HERE) {
|
|
60
|
+
let dir = from;
|
|
61
|
+
for (;;) {
|
|
62
|
+
const nm = join(dir, "node_modules");
|
|
63
|
+
const pkg = join(nm, RUNTIME, "package.json");
|
|
64
|
+
if (existsSync(pkg)) {
|
|
65
|
+
const linked = join(nm, ".bin", "srt");
|
|
66
|
+
if (existsSync(linked)) return linked;
|
|
67
|
+
try {
|
|
68
|
+
const bin = JSON.parse(readFileSync(pkg, "utf8")).bin;
|
|
69
|
+
const rel = typeof bin === "string" ? bin : bin?.srt;
|
|
70
|
+
if (rel) {
|
|
71
|
+
const target = join(nm, RUNTIME, rel);
|
|
72
|
+
if (existsSync(target)) return target;
|
|
73
|
+
}
|
|
74
|
+
} catch { /* an unreadable package.json is not a runtime */ }
|
|
75
|
+
}
|
|
76
|
+
const up = dirname(dir);
|
|
77
|
+
if (up === dir) return null;
|
|
78
|
+
dir = up;
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/**
|
|
83
|
+
* The directory of the runtime's own `apply-seccomp`, or null.
|
|
84
|
+
*
|
|
85
|
+
* On Linux the runtime runs that binary INSIDE the sandbox, from wherever the
|
|
86
|
+
* package is installed, before the command. With `read = "territory"` a home
|
|
87
|
+
* directory is shut, and seisin installed under it — nvm, a user npm prefix,
|
|
88
|
+
* a CI checkout in /home/runner — left the binary unmountable: every run died
|
|
89
|
+
* with `apply-seccomp: No such file or directory` before the canary (measured
|
|
90
|
+
* on ubuntu-latest and in Debian with the checkout in /root, srt 0.0.81).
|
|
91
|
+
* Found from the srt that runs (its real path, up to the package), so a
|
|
92
|
+
* global srt keeps its own.
|
|
93
|
+
*/
|
|
94
|
+
export function seccompDir(bin = resolveSrt()) {
|
|
95
|
+
if (!bin) return null;
|
|
96
|
+
let dir;
|
|
97
|
+
try { dir = dirname(realpathSync(bin)); } catch { return null; }
|
|
98
|
+
for (;;) {
|
|
99
|
+
const pkg = join(dir, "package.json");
|
|
100
|
+
if (existsSync(pkg)) {
|
|
101
|
+
try {
|
|
102
|
+
if (JSON.parse(readFileSync(pkg, "utf8")).name === RUNTIME) {
|
|
103
|
+
const vendor = join(dir, "vendor", "seccomp");
|
|
104
|
+
return existsSync(vendor) ? vendor : null;
|
|
105
|
+
}
|
|
106
|
+
} catch { /* not the runtime's */ }
|
|
107
|
+
}
|
|
108
|
+
const up = dirname(dir);
|
|
109
|
+
if (up === dir) return null;
|
|
110
|
+
dir = up;
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
/**
|
|
115
|
+
* How to put the runtime back, for the way this seisin was installed.
|
|
116
|
+
*
|
|
117
|
+
* It is a dependency, so it is missing only from a broken install — and the
|
|
118
|
+
* fix differs: a project's own node_modules, a global install, or a checkout.
|
|
119
|
+
* `npm i -g` was suggested to everyone, which for a project-local seisin
|
|
120
|
+
* installs a second runtime somewhere seisin does not look first.
|
|
121
|
+
*/
|
|
122
|
+
export function installHint(from = HERE) {
|
|
123
|
+
const at = from.split("/node_modules/seisin/")[0];
|
|
124
|
+
if (at === from) return `Install it in this checkout with: npm install (in ${dirname(dirname(from))})`;
|
|
125
|
+
// npm's global layout is <prefix>/lib/node_modules on unix, …/npm/node_modules on Windows.
|
|
126
|
+
if (/\/lib$/.test(at) || /[\\/]npm$/.test(at))
|
|
127
|
+
return "seisin is installed globally; reinstall it with: npm install -g seisin";
|
|
128
|
+
return `seisin is installed in ${at}; reinstall it there with: npm install seisin`;
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
const MACH_PRELOAD = join(HERE, "srt-mach.mjs");
|
|
132
|
+
|
|
133
|
+
/**
|
|
134
|
+
* srt with srt-mach.mjs loaded into it, for a role whose browsers register a
|
|
135
|
+
* Mach service. Run by this node, so the preload is in the process: the runtime
|
|
136
|
+
* is a node script, and one that is not (a binary on PATH) cannot take it —
|
|
137
|
+
* refused here rather than run without the rule the role asked for, which
|
|
138
|
+
* would start the browser just to watch it die.
|
|
139
|
+
*/
|
|
140
|
+
export function withMachPreload(bin, file, inner) {
|
|
141
|
+
const cli = realpathSync(bin);
|
|
142
|
+
if (!/\.(c|m)?js$/.test(cli))
|
|
143
|
+
throw new Error(`the sandbox runtime at ${bin} is not a node script, so the browser's Mach rule cannot be added to it ` +
|
|
144
|
+
`(browsers in seisin.toml). Use the runtime seisin installs with it.`);
|
|
145
|
+
return [process.execPath, "--import", MACH_PRELOAD, cli, "--settings", file, "--", ...inner];
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
export const srt = {
|
|
149
|
+
name: "srt",
|
|
150
|
+
locate: () => resolveSrt(),
|
|
151
|
+
missing: () => `sandbox runtime not found. ${installHint()}`,
|
|
152
|
+
unmet: () => [],
|
|
153
|
+
// `--` before the command, and it is not cosmetic. Without it the runtime's
|
|
154
|
+
// own argument parser reaches into what the agent was invoked with and eats
|
|
155
|
+
// anything resembling one of its flags: `claude --settings`, `claude -c`.
|
|
156
|
+
wrap: (bin, file, _settings, inner, { machRegister = [] } = {}) =>
|
|
157
|
+
machRegister.length ? withMachPreload(bin, file, inner) : [bin, "--settings", file, "--", ...inner],
|
|
158
|
+
// Takes the `browsers` Mach rule, through srt-mach.mjs.
|
|
159
|
+
machRegister: true,
|
|
160
|
+
// Each Seatbelt denial carries this runtime's tag with the wrapped command,
|
|
161
|
+
// which is how violations.js tells this run's denials from another's.
|
|
162
|
+
tagsDenials: true,
|
|
163
|
+
};
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `tartaros`: the Seatbelt / Landlock sandbox, driven by its own CLI.
|
|
3
|
+
*
|
|
4
|
+
* tartaros reads seisin's settings file itself (`--settings`, the schema of
|
|
5
|
+
* sandbox-runtime) and holds itself to the contract in docs/sandboxes.md:
|
|
6
|
+
* every field enforced, or it refuses to start. So seisin does not translate
|
|
7
|
+
* anything and does not keep its own list of what tartaros can do — a list
|
|
8
|
+
* like that is out of date the day tartaros learns something. It asks:
|
|
9
|
+
*
|
|
10
|
+
* tartaros explain --settings <file>
|
|
11
|
+
*
|
|
12
|
+
* reads the same file through the same code as `run` and exits 78, with the
|
|
13
|
+
* reason, on a field it cannot guarantee (2 before 0.1.0-alpha.6, which keeps
|
|
14
|
+
* 2 for a bad command line). seisin takes any exit but 0 as that no, so the
|
|
15
|
+
* code is not read. That answer is what `unmet` returns,
|
|
16
|
+
* so a role tartaros cannot hold is passed over before it starts (or `seisin
|
|
17
|
+
* check` says so), and the canary still checks every run that it can.
|
|
18
|
+
*/
|
|
19
|
+
import { existsSync } from "node:fs";
|
|
20
|
+
import { join, delimiter } from "node:path";
|
|
21
|
+
import { assessCheck } from "./command.js";
|
|
22
|
+
|
|
23
|
+
/** `tartaros` on PATH, or null. */
|
|
24
|
+
export function resolveTartaros(path = process.env.PATH) {
|
|
25
|
+
for (const dir of (path ?? "").split(delimiter)) {
|
|
26
|
+
if (!dir) continue;
|
|
27
|
+
const candidate = join(dir, "tartaros");
|
|
28
|
+
if (existsSync(candidate)) return candidate;
|
|
29
|
+
}
|
|
30
|
+
return null;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/** The command line for one run: the settings as they are, the role as a label. */
|
|
34
|
+
export function tartarosArgv(bin, file, inner, { role = null, machRegister = [] } = {}) {
|
|
35
|
+
// A browser role gets tartaros's browser grants — the shared memory, IOKit
|
|
36
|
+
// classes and sysctls a headless Chromium dies without (f7edae1) — and a
|
|
37
|
+
// run-private TMPDIR, the only place it may open a unix socket (bcb0c69):
|
|
38
|
+
// where @playwright/mcp publishes its browser.
|
|
39
|
+
const browser = machRegister.length ? ["--browser", "--private-tmp"] : [];
|
|
40
|
+
// --inherit-env: the environment seisin hands tartaros is already the one
|
|
41
|
+
// the role gets — built from the role's own list (env.js), with the hook's
|
|
42
|
+
// socket, the role's name and its env-mode keys. tartaros's own scrub
|
|
43
|
+
// (7f9925f) would drop those, and the hook would go silent.
|
|
44
|
+
return [bin, "run", ...(role ? ["--role", role] : []), "--inherit-env", ...browser, "--settings", file, "--", ...inner];
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/** What tartaros says of this file: what it cannot hold, and its warnings. Only asked of a binary that was found. */
|
|
48
|
+
export function tartarosAssess(bin, file, { platform = process.platform } = {}) {
|
|
49
|
+
if (platform !== "darwin" && platform !== "linux") return { unmet: [`tartaros runs on macOS and Linux, not ${platform}`], warnings: [] };
|
|
50
|
+
return assessCheck([bin, "explain", "--settings", "{policy}"], file, "tartaros");
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
export const tartarosUnmet = (bin, file, ctx) => tartarosAssess(bin, file, ctx).unmet;
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* `browsers`: tartaros takes the Mach names to register in its settings, as
|
|
57
|
+
* network.allowMachRegister with sandbox-runtime's trailing `*` for a prefix
|
|
58
|
+
* (tartaros 99b0117). Only when there is one: an unchanged role is handed the
|
|
59
|
+
* same file as before, and srt, whose schema is strict, never sees the field.
|
|
60
|
+
*/
|
|
61
|
+
export function tartarosSettings(settings, { machRegister = [] } = {}) {
|
|
62
|
+
if (!machRegister.length) return settings;
|
|
63
|
+
return { ...settings, network: { ...settings.network, allowMachRegister: machRegister.map((p) => `${p}*`) } };
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
export const tartaros = {
|
|
67
|
+
name: "tartaros",
|
|
68
|
+
locate: () => resolveTartaros(),
|
|
69
|
+
missing: () => "tartaros not found on PATH. Build it (cargo build --release -p tartaros-cli) and put its target directory on PATH.",
|
|
70
|
+
asksFile: true,
|
|
71
|
+
adapt: tartarosSettings,
|
|
72
|
+
machRegister: true,
|
|
73
|
+
assess: (_settings, { bin, file, platform } = {}) => tartarosAssess(bin, file, { platform }),
|
|
74
|
+
unmet: (_settings, { bin, file, platform } = {}) => tartarosUnmet(bin, file, { platform }),
|
|
75
|
+
wrap: (bin, file, _settings, inner, ctx) => tartarosArgv(bin, file, inner, ctx),
|
|
76
|
+
// Every deny in its Seatbelt profile carries sandbox-runtime's tag
|
|
77
|
+
// (`CMD64_<command>_END_<suffix>_SBX`), so violations.js attributes them
|
|
78
|
+
// exactly as it does srt's. Measured 2026-10-13 with tartaros ad85515.
|
|
79
|
+
tagsDenials: true,
|
|
80
|
+
};
|
package/src/scan.js
CHANGED
|
@@ -18,7 +18,7 @@
|
|
|
18
18
|
* to skip. Three things were wrong and all three are handled below.
|
|
19
19
|
*/
|
|
20
20
|
import { readdirSync, readFileSync, statSync, realpathSync, existsSync } from "node:fs";
|
|
21
|
-
import { join, relative, sep, dirname } from "node:path";
|
|
21
|
+
import { join, resolve, relative, sep, dirname } from "node:path";
|
|
22
22
|
import { covers } from "./owners.js";
|
|
23
23
|
import { toWritePath } from "./grants.js";
|
|
24
24
|
|
|
@@ -151,7 +151,12 @@ export function scan(root, protectedDirs = [], ignore = [], limit = 500, { roots
|
|
|
151
151
|
try { root = realpathSync.native(root); } catch { /* scanned as written */ }
|
|
152
152
|
// `.secrets/` and `.secrets` are one directory. With the slash kept, the
|
|
153
153
|
// prefix test below never matched and the protected directory was scanned.
|
|
154
|
-
const safe = protectedDirs.map((d) =>
|
|
154
|
+
const safe = protectedDirs.map((d) => {
|
|
155
|
+
const path = resolve(root, d);
|
|
156
|
+
// The root was canonicalised above. Match the same spelling for absolute
|
|
157
|
+
// directories, including macOS's /var -> /private/var ancestor.
|
|
158
|
+
try { return realpathSync.native(path); } catch { return path; }
|
|
159
|
+
});
|
|
155
160
|
const patterns = [...DEFAULT_IGNORE, ...ignore];
|
|
156
161
|
const hits = [];
|
|
157
162
|
const skipped = { ignored: 0, protectedDirs: 0, reference: 0, placeholder: 0, nested: 0 };
|
|
@@ -330,4 +335,3 @@ function repositoryOf(p, root) {
|
|
|
330
335
|
function isDir(p) {
|
|
331
336
|
try { return statSync(p).isDirectory(); } catch { return false; }
|
|
332
337
|
}
|
|
333
|
-
|