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.
Files changed (82) hide show
  1. package/README.md +506 -162
  2. package/package.json +14 -6
  3. package/src/browsers.js +52 -0
  4. package/src/canary.js +149 -0
  5. package/src/cli.js +175 -52
  6. package/src/commands/check.js +157 -5
  7. package/src/commands/explain.js +31 -13
  8. package/src/commands/hook.js +20 -6
  9. package/src/commands/init.js +183 -88
  10. package/src/commands/log.js +48 -9
  11. package/src/commands/requests.js +122 -70
  12. package/src/commands/review.js +73 -21
  13. package/src/commands/run.js +422 -78
  14. package/src/commands/scan.js +18 -3
  15. package/src/commands/ui.js +18 -5
  16. package/src/commands/walls.js +62 -11
  17. package/src/commands/whose.js +9 -7
  18. package/src/commands/wire.js +156 -17
  19. package/src/config.js +505 -37
  20. package/src/controls.js +244 -0
  21. package/src/diagnose.js +102 -49
  22. package/src/env.js +14 -8
  23. package/src/grants.js +122 -9
  24. package/src/handoff.js +285 -10
  25. package/src/help.js +179 -0
  26. package/src/hook.js +101 -42
  27. package/src/index.d.ts +217 -0
  28. package/src/index.js +24 -16
  29. package/src/inspect/codex.js +64 -0
  30. package/src/inspect/credentials.js +48 -0
  31. package/src/inspect/enforce.js +24 -0
  32. package/src/inspect/files.js +71 -0
  33. package/src/inspect/home.js +43 -0
  34. package/src/inspect/inject.js +68 -0
  35. package/src/inspect/keys.js +74 -0
  36. package/src/inspect/layout.js +258 -0
  37. package/src/inspect/limits.js +98 -0
  38. package/src/inspect/machine.js +107 -0
  39. package/src/inspect/network.js +37 -0
  40. package/src/inspect/providers.js +71 -0
  41. package/src/inspect/read-territory.js +105 -0
  42. package/src/inspect/role-keys.js +136 -0
  43. package/src/inspect/shared.js +37 -0
  44. package/src/inspect/warnings.js +58 -0
  45. package/src/inspect.js +20 -756
  46. package/src/intake.js +150 -35
  47. package/src/keys.js +231 -73
  48. package/src/kinds.js +249 -0
  49. package/src/lines.js +26 -0
  50. package/src/log.js +242 -52
  51. package/src/mcp.js +57 -58
  52. package/src/nested.js +12 -3
  53. package/src/notify.js +66 -20
  54. package/src/owners.js +358 -39
  55. package/src/paths.js +54 -2
  56. package/src/provider-run.js +81 -0
  57. package/src/redact.js +46 -13
  58. package/src/render.js +101 -17
  59. package/src/requests.js +506 -87
  60. package/src/review.js +50 -25
  61. package/src/rundir.js +33 -14
  62. package/src/sandboxes/command.js +93 -0
  63. package/src/sandboxes/index.js +113 -0
  64. package/src/sandboxes/mach-profile.js +31 -0
  65. package/src/sandboxes/srt-mach.mjs +43 -0
  66. package/src/sandboxes/srt.js +163 -0
  67. package/src/sandboxes/tartaros.js +80 -0
  68. package/src/scan.js +181 -19
  69. package/src/serve.js +396 -230
  70. package/src/spool.js +16 -45
  71. package/src/srt.js +296 -49
  72. package/src/suggest.js +58 -0
  73. package/src/surface.js +314 -54
  74. package/src/territory.js +261 -0
  75. package/src/trace.js +33 -0
  76. package/src/uilink.js +24 -9
  77. package/src/views.js +315 -0
  78. package/src/violations.js +247 -23
  79. package/src/walls.js +76 -46
  80. package/src/worktree.js +3 -11
  81. package/ui/index.html +1429 -538
  82. 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 stopped by, over and over?
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 permission file only ever grows, everywhere, and the reason is
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
- const entries = read(logPath(config.root), { limit: 0 });
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
- const dir = e.action === "read" || e.action === "connect" ? e.target : dirOf(e.target);
77
- const key = `${e.role} ${e.action} ${dir}`;
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
- const friction = repeated.filter((f) => f.kind !== "key" && f.kind !== "network");
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 refused; it has nothing to say about what went
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 permission file smaller.
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 turn a month that needs it — but a folder a
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
- for (const role of Object.values(config.roles))
138
- for (const glob of role.writes)
139
- if (role.name === e.role && covers(glob, e.target)) used.add(`${role.name} ${glob}`);
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
- const unowned = new Map();
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
- for (const e of entries) {
159
- if (!e.target) continue;
160
- if (standOf(e).kind !== "unowned") continue;
161
- const dir = dirOf(e.target);
162
- unowned.set(dir, (unowned.get(dir) ?? 0) + 1);
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.entries()]
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 `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
@@ -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), RUNS_NAME);
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 `scratch` keys on disk indefinitely — measured by
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
- if (pid && !alive(pid)) rmSync(dir, { recursive: true, force: true });
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 box (spool.js).
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, "settings.json");
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
+ };