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.
Files changed (76) hide show
  1. package/README.md +385 -129
  2. package/package.json +13 -5
  3. package/src/browsers.js +52 -0
  4. package/src/canary.js +149 -0
  5. package/src/cli.js +47 -23
  6. package/src/commands/check.js +154 -3
  7. package/src/commands/explain.js +2 -5
  8. package/src/commands/hook.js +7 -3
  9. package/src/commands/init.js +57 -50
  10. package/src/commands/requests.js +31 -56
  11. package/src/commands/review.js +33 -6
  12. package/src/commands/run.js +332 -110
  13. package/src/commands/walls.js +39 -7
  14. package/src/commands/whose.js +2 -1
  15. package/src/config.js +321 -15
  16. package/src/controls.js +5 -5
  17. package/src/diagnose.js +67 -16
  18. package/src/env.js +3 -0
  19. package/src/grants.js +107 -2
  20. package/src/handoff.js +285 -10
  21. package/src/help.js +39 -19
  22. package/src/hook.js +59 -30
  23. package/src/index.d.ts +217 -0
  24. package/src/index.js +20 -12
  25. package/src/inspect/codex.js +64 -0
  26. package/src/inspect/credentials.js +48 -0
  27. package/src/inspect/enforce.js +24 -0
  28. package/src/inspect/files.js +71 -0
  29. package/src/inspect/home.js +43 -0
  30. package/src/inspect/inject.js +68 -0
  31. package/src/inspect/keys.js +74 -0
  32. package/src/inspect/layout.js +258 -0
  33. package/src/inspect/limits.js +98 -0
  34. package/src/inspect/machine.js +107 -0
  35. package/src/inspect/network.js +37 -0
  36. package/src/inspect/providers.js +71 -0
  37. package/src/inspect/read-territory.js +105 -0
  38. package/src/inspect/role-keys.js +136 -0
  39. package/src/inspect/shared.js +37 -0
  40. package/src/inspect/warnings.js +58 -0
  41. package/src/inspect.js +13 -815
  42. package/src/intake.js +131 -21
  43. package/src/keys.js +223 -67
  44. package/src/kinds.js +42 -0
  45. package/src/lines.js +26 -0
  46. package/src/log.js +7 -2
  47. package/src/mcp.js +28 -43
  48. package/src/nested.js +12 -3
  49. package/src/notify.js +53 -12
  50. package/src/owners.js +165 -3
  51. package/src/paths.js +17 -1
  52. package/src/provider-run.js +81 -0
  53. package/src/redact.js +34 -15
  54. package/src/render.js +35 -9
  55. package/src/requests.js +363 -45
  56. package/src/review.js +21 -9
  57. package/src/rundir.js +29 -8
  58. package/src/sandboxes/command.js +93 -0
  59. package/src/sandboxes/index.js +113 -0
  60. package/src/sandboxes/mach-profile.js +31 -0
  61. package/src/sandboxes/srt-mach.mjs +43 -0
  62. package/src/sandboxes/srt.js +163 -0
  63. package/src/sandboxes/tartaros.js +80 -0
  64. package/src/scan.js +7 -3
  65. package/src/serve.js +35 -17
  66. package/src/spool.js +8 -17
  67. package/src/srt.js +263 -33
  68. package/src/surface.js +129 -16
  69. package/src/territory.js +261 -0
  70. package/src/trace.js +33 -0
  71. package/src/uilink.js +15 -6
  72. package/src/views.js +40 -14
  73. package/src/violations.js +143 -7
  74. package/src/walls.js +62 -9
  75. package/ui/index.html +85 -13
  76. 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 `scratch` keys.
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 scratch keys
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 `scratch` key while it ran (`cat …/keys/S` → the value).
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), RUNS_NAME);
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 `scratch` keys on disk indefinitely — measured by
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)) rmSync(dir, { recursive: true, force: true });
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, "settings.json");
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) => join(root, d.replace(/\/+$/, "") || "."));
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
-