seisin 0.2.0 → 0.3.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/src/surface.js ADDED
@@ -0,0 +1,518 @@
1
+ /**
2
+ * What the parent trusts, and why no role may write any of it.
3
+ *
4
+ * The sandbox confines the agent. It does not confine the process that decides
5
+ * what the agent gets: `seisin run` reads the policy, runs the key providers,
6
+ * reads `file://` secrets and starts the runtime, all of it outside the box and
7
+ * as you. Every file that process reads or executes is therefore part of the
8
+ * boundary, and a role that can write one of them does not need to escape — it
9
+ * waits for the next run, or for you to type the command yourself.
10
+ *
11
+ * That was fixed one file at a time: the policy, then `.seisin/`, then provider
12
+ * scripts with a path. Each fix was right and each left the next member of the
13
+ * family open. Measured on 2026-09-23 against the real kernel, with the fixes
14
+ * above in place:
15
+ *
16
+ * - a provider named `fakeprov` resolved through PATH; a role with a PATH
17
+ * directory in its territory planted its own, and the next run executed it
18
+ * unsandboxed (a marker file appeared outside every territory);
19
+ * - a role swapped a `file://` target for a symlink to another role's key,
20
+ * and the next run read that key and handed it over;
21
+ * - every role could create files in `~/.local/share/claude/versions/`, where
22
+ * the Claude Code binary you run outside the box lives, because the shared
23
+ * runtime scratch includes `~/.local/share`.
24
+ *
25
+ * So this module answers the question once, for the family: **which paths does
26
+ * the parent trust?** Anything on that list that falls inside some role's
27
+ * writable area is denied to every role, and `seisin check` prints the list
28
+ * with the reason for each entry.
29
+ */
30
+ import { readdirSync, statSync, accessSync, existsSync, realpathSync, constants } from "node:fs";
31
+ import { join, isAbsolute, delimiter, dirname, resolve, basename } from "node:path";
32
+ import { fileURLToPath } from "node:url";
33
+ import { homedir } from "node:os";
34
+ import { writePathsOf, realOrSelf } from "./grants.js";
35
+ import { STATE_DIR, CONFIG_NAME } from "./layout.js";
36
+
37
+ /** seisin's own package: the code the next `seisin run` executes. */
38
+ const SEISIN_HOME = realOrSelf(join(dirname(fileURLToPath(import.meta.url)), ".."));
39
+
40
+ const under = (p, root) => p === root || p.startsWith(root.endsWith("/") ? root : root + "/");
41
+
42
+ /**
43
+ * Every directory some role can write in some run, as real paths.
44
+ *
45
+ * The union over roles, not one role's view: a role that cannot write a
46
+ * provider script can still wait for the role that can. The repo root is in
47
+ * it because `--observe` grants the whole repo to whichever role it runs.
48
+ */
49
+ export function writableRoots(config) {
50
+ const root = realOrSelf(config.root);
51
+ const out = new Set([root]);
52
+ // A grant inside the repo is covered by the repo root already, so only the
53
+ // ones outside it are resolved. Resolving all of them was most of the cost:
54
+ // 301 paths for the portfolio's 32 roles, most of them sidecars that do not
55
+ // exist, 200 ms of failed realpath calls to learn nothing.
56
+ for (const role of Object.values(config.roles ?? {}))
57
+ for (const p of writePathsOf(config, role)) {
58
+ if (under(p, config.root)) continue;
59
+ out.add(realOrSelf(p));
60
+ }
61
+ // Nested roots add nothing either: ~/.local/share/x under ~/.local/share.
62
+ const all = [...out];
63
+ return all.filter((r) => !all.some((o) => o !== r && under(r, o)));
64
+ }
65
+
66
+ /**
67
+ * The PATH the parent uses for its own lookups: yours, minus every directory
68
+ * some role can write, minus relative entries.
69
+ *
70
+ * A relative entry (`.`, `bin`, an empty one between two colons) resolves
71
+ * against whatever directory the parent is standing in, which is the repo —
72
+ * the one place every role writes.
73
+ */
74
+ export function trustedPath(config, env = process.env, roots = writableRoots(config)) {
75
+ return (env.PATH ?? "").split(delimiter)
76
+ .filter((d) => d && isAbsolute(d) && !roots.some((r) => under(realOrSelf(d), r)));
77
+ }
78
+
79
+ function isExecutable(p) {
80
+ try {
81
+ if (!statSync(p).isFile()) return false;
82
+ accessSync(p, constants.X_OK);
83
+ return true;
84
+ } catch {
85
+ return false;
86
+ }
87
+ }
88
+
89
+ /**
90
+ * Where a command the parent runs lives, found only where no role can write.
91
+ *
92
+ * A command with a slash is a path, relative to the policy. A bare name is
93
+ * looked up on {@link trustedPath}. When the only match sits in a writable
94
+ * directory the lookup fails and says where it was found: running it would
95
+ * mean running whatever a role last put there, and dropping to the next match
96
+ * silently would run a different program than the one the policy's author
97
+ * tested with.
98
+ */
99
+ export function resolveExecutable(cmd, config, env = process.env) {
100
+ if (cmd.includes("/")) return isAbsolute(cmd) ? cmd : resolve(config.root ?? ".", cmd);
101
+ const roots = writableRoots(config);
102
+ for (const dir of trustedPath(config, env, roots)) {
103
+ const candidate = join(dir, cmd);
104
+ if (isExecutable(candidate)) return candidate;
105
+ }
106
+ const shadow = (env.PATH ?? "").split(delimiter).filter(Boolean)
107
+ .map((d) => join(isAbsolute(d) ? d : resolve(config.root ?? ".", d), cmd))
108
+ .find(isExecutable);
109
+ throw new Error(
110
+ shadow
111
+ ? `"${cmd}" is only found at ${shadow}, in a directory a role can write.\n` +
112
+ ` seisin runs it outside the sandbox, so it would run whatever a role last put ` +
113
+ `there. Install it outside every territory, or give its absolute path.`
114
+ : `"${cmd}" is not on PATH.`);
115
+ }
116
+
117
+ /** The target of a `file://` key, as written and resolved against the policy. */
118
+ function fileRefTargets(config) {
119
+ const out = [];
120
+ for (const role of Object.values(config.roles ?? {})) {
121
+ for (const raw of role.keys ?? []) {
122
+ const m = /^(?:[A-Za-z_][A-Za-z0-9_]*=)?file:\/\/(.+)$/.exec(raw);
123
+ if (!m) continue;
124
+ const hash = m[1].lastIndexOf("#");
125
+ const path = hash === -1 ? m[1] : m[1].slice(0, hash);
126
+ out.push({ path: isAbsolute(path) ? path : resolve(config.root, path), raw });
127
+ }
128
+ }
129
+ return out;
130
+ }
131
+
132
+ /**
133
+ * Where a program lives, as the directory to protect.
134
+ *
135
+ * The package, not the binary alone: a Python tool installed by uv runs
136
+ * `~/.local/share/uv/tools/ruff/bin/ruff`, and the code it executes is the rest
137
+ * of that tree. So the walk goes up from the binary to the first directory
138
+ * that looks like the root of an installation — one holding a `package.json`
139
+ * or a `pyvenv.cfg`, or the parent of a `bin/` — and stops at the writable
140
+ * root. With nothing recognisable on the way, the binary's own directory.
141
+ *
142
+ * The first version protected the top-level entry under the writable root
143
+ * instead, and on the portfolio that was `01-activos/`: every project, denied
144
+ * to every role, because seisin itself lives in one of them.
145
+ */
146
+ function packageRoot(p, root) {
147
+ let dir = dirname(p);
148
+ while (under(dir, root) && dir !== root) {
149
+ if (existsSync(join(dir, "package.json")) || existsSync(join(dir, "pyvenv.cfg"))) return dir;
150
+ if (basename(dir) === "bin") return dirname(dir);
151
+ dir = dirname(dir);
152
+ }
153
+ return dirname(p) === root ? p : dirname(p);
154
+ }
155
+
156
+ /**
157
+ * Programs that run outside the box and live where a role can write.
158
+ *
159
+ * Two ways in. A PATH directory inside a writable area: anything a role drops
160
+ * there shadows a real command the next time anybody types it. And a PATH
161
+ * entry that is a symlink into a writable area — `~/.local/bin/claude` points
162
+ * at `~/.local/share/claude/versions/…`, and that directory was writable by
163
+ * every role through the shared runtime scratch.
164
+ *
165
+ * Only symlinks are followed. A regular file in a directory no role writes is
166
+ * already out of reach; the cost is then one `readdir` per PATH entry and one
167
+ * `realpath` per link.
168
+ */
169
+ const SURFACES = new WeakMap(); // config -> PATH -> entries
170
+
171
+ export function executionSurface(config, env = process.env, roots = null) {
172
+ // Once per config and PATH: `check` asks for every role's denies, and the
173
+ // PATH scan is the same answer 32 times on the portfolio.
174
+ const byPath = SURFACES.get(config) ?? SURFACES.set(config, new Map()).get(config);
175
+ const key = env.PATH ?? "";
176
+ if (!roots && byPath.has(key)) return byPath.get(key);
177
+ const result = scanSurface(config, env, roots ?? writableRoots(config));
178
+ if (!roots) byPath.set(key, result);
179
+ return result;
180
+ }
181
+
182
+ function scanSurface(config, env, roots) {
183
+ const out = [];
184
+ const inWritable = (p) => roots.find((r) => under(p, r));
185
+ // Deduplicated: the PATH a shell hands down repeats itself (33 entries, 21
186
+ // distinct, on the machine this was measured on), and each repeat is a
187
+ // directory listing and its links resolved again.
188
+ for (const dir of new Set((env.PATH ?? "").split(delimiter))) {
189
+ if (!dir || !isAbsolute(dir)) continue;
190
+ const real = realOrSelf(dir);
191
+ if (inWritable(real)) {
192
+ out.push({ path: real, why: `on PATH (${dir}): a role could shadow any command there` });
193
+ continue;
194
+ }
195
+ let entries;
196
+ try { entries = readdirSync(real, { withFileTypes: true }); } catch { continue; }
197
+ for (const e of entries) {
198
+ if (!e.isSymbolicLink()) continue;
199
+ // `.native`: one realpath(3) call. The JS implementation lstats every
200
+ // component, and /usr/local/bin alone holds hundreds of links.
201
+ let target;
202
+ try { target = realpathSync.native(join(real, e.name)); } catch { continue; }
203
+ const root = inWritable(target);
204
+ if (root) out.push({ path: packageRoot(target, root), why: `${join(dir, e.name)} runs from here` });
205
+ }
206
+ }
207
+ // What the next `seisin run` itself executes: node, seisin, the runtime.
208
+ // Found in a writable place when node comes from a version manager that
209
+ // installs under ~/.local/share (fnm), or seisin is a devDependency of the
210
+ // repo it governs.
211
+ for (const [p, why] of [
212
+ [process.execPath, "the node that runs seisin"],
213
+ [SEISIN_HOME, "seisin itself"],
214
+ ]) {
215
+ const real = realOrSelf(p);
216
+ const root = inWritable(real);
217
+ if (root) out.push({ path: real === SEISIN_HOME ? real : packageRoot(real, root), why });
218
+ }
219
+ return dedupe(out);
220
+ }
221
+
222
+ /** One entry per path; the first reason given wins. */
223
+ function dedupe(entries) {
224
+ const seen = new Map();
225
+ for (const e of entries) if (!seen.has(e.path)) seen.set(e.path, e);
226
+ return [...seen.values()];
227
+ }
228
+
229
+ /**
230
+ * Everything the parent reads or executes, named by the policy, with the reason.
231
+ *
232
+ * Denied to every role whatever its territory, the way `seisin.toml` always
233
+ * was: these are cheap to list and a role that can write one of them has no
234
+ * policy.
235
+ */
236
+ export function parentInputs(config, { env = process.env } = {}) {
237
+ const abs = (p) => (isAbsolute(p) ? p : join(config.root, p));
238
+ const out = [
239
+ { path: abs(config.path ?? CONFIG_NAME), why: "the policy" },
240
+ { path: abs(STATE_DIR), why: "seisin's log and request queue" },
241
+ ...(config.keyDirs ?? []).map((d) => ({ path: abs(d), why: "a key directory" })),
242
+ ];
243
+ for (const [scheme, p] of Object.entries(config.keyProviders ?? {})) {
244
+ const cmd = p.command?.[0];
245
+ if (!cmd) continue;
246
+ let path;
247
+ // A provider that does not resolve here is not denied here: the run that
248
+ // needs it fails at resolution, with the message resolveExecutable gives.
249
+ try { path = resolveExecutable(cmd, config, env); } catch { continue; }
250
+ out.push({ path, why: `the ${scheme}:// key provider, run outside the box` });
251
+ }
252
+ for (const { path, raw } of fileRefTargets(config))
253
+ out.push({ path, why: `read by the parent for key "${raw}"` });
254
+
255
+ // Both spellings of every entry: the one written, and the one the kernel
256
+ // meets when a symlink sits on the way. A deny on the written path alone let
257
+ // writes through once already (never_writes, 2026-09-22).
258
+ return out.flatMap((e) => {
259
+ const real = realOrSelf(e.path);
260
+ return real === e.path ? [e] : [e, { ...e, path: real }];
261
+ });
262
+ }
263
+
264
+ /**
265
+ * Files that tell a program running outside the box what to execute.
266
+ *
267
+ * Git runs `.git/hooks/*` and whatever `.git/config` names (core.hooksPath,
268
+ * core.fsmonitor). Claude Code runs the hooks, plugins and skills its settings
269
+ * declare, and the MCP servers in `.mcp.json`. direnv runs `.envrc` on `cd`.
270
+ * Codex starts what `config.toml` lists. A role that writes one of these does
271
+ * not escape the box; it leaves a command for you to run the next time you
272
+ * open that project — measured on 2026-09-23: every role could open
273
+ * `~/.claude/settings.json` for writing through the shared runtime scratch.
274
+ *
275
+ * The runtime already covers part of this with patterns (`**\/.git/hooks/**`,
276
+ * `**\/.mcp.json`, `**\/.claude/commands`). Literal paths hold more than
277
+ * patterns do — the runtime also protects each literal path's ancestors — so
278
+ * every control file of every project inside a role's territory is named
279
+ * literally, and the patterns stay as a second layer.
280
+ */
281
+ export const CONTROL_FILES = [
282
+ ".git/hooks", ".git/config",
283
+ ".claude/settings.json", ".claude/settings.local.json",
284
+ ".claude/hooks", ".claude/plugins", ".claude/skills", ".claude/commands", ".claude/agents",
285
+ ".mcp.json", ".envrc", ".codex/config.toml",
286
+ ];
287
+
288
+ const RUNNER = (f) =>
289
+ f.startsWith(".git/") ? "git" : f === ".envrc" ? "direnv" : f.startsWith(".codex/") ? "Codex" : "Claude Code";
290
+
291
+ // Case-insensitive, because the disk is: on APFS `.CLAUDE/Settings.json` is
292
+ // the same file, and the kernel was measured refusing it.
293
+ const CONTROL_RE = new RegExp(
294
+ `(^|/)(${CONTROL_FILES.map((f) => f.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")).join("|")})(/|$)`, "i");
295
+
296
+ /** Does this path name a control file, at any depth? What `explain` asks. */
297
+ export const isControlPath = (p) => CONTROL_RE.test(p);
298
+
299
+ /** Where no control file is looked for: dependencies and build output. */
300
+ const PRUNE = new Set(["node_modules", ".git", "vendor", "dist", "build", ".venv", "venv",
301
+ "__pycache__", ".next", "target", ".turbo", ".cache", "Pods", ".gradle", ".dart_tool"]);
302
+
303
+ /** A directory holding one of these is where somebody opens a project. */
304
+ const MARKERS = new Set([".git", ".claude", ".mcp.json", ".envrc", ".codex"]);
305
+
306
+ /**
307
+ * How deep under a territory to look for projects.
308
+ *
309
+ * Measured on the portfolio over every territory at once: depth 3 finds 128
310
+ * project roots reading 2.6k directories (~0.4 s under load); depth 4 finds
311
+ * 130 reading 5.5k (0.9 s); depth 6, 132 and 2.1 s. The four below depth 3
312
+ * are `.claude` folders agents left inside source trees (`features/…`,
313
+ * `tests/Feature/Api`), not projects anybody opens — and on macOS the pattern
314
+ * layer still refuses writing into them. The whole portfolio at depth 8 is
315
+ * 23k directories, which is why the walk is per role and never over the repo.
316
+ */
317
+ const SCAN_DEPTH = 3;
318
+
319
+ function projectRoots(dir, depth = SCAN_DEPTH) {
320
+ const out = [];
321
+ const stack = [[dir, 0]];
322
+ while (stack.length) {
323
+ const [d, level] = stack.pop();
324
+ let entries;
325
+ try { entries = readdirSync(d, { withFileTypes: true }); } catch { continue; }
326
+ if (level === 0 || entries.some((e) => MARKERS.has(e.name))) out.push(d);
327
+ if (level >= depth) continue;
328
+ for (const e of entries)
329
+ if (e.isDirectory() && !e.isSymbolicLink() && !PRUNE.has(e.name)) stack.push([join(d, e.name), level + 1]);
330
+ }
331
+ return out;
332
+ }
333
+
334
+ /**
335
+ * The control files of one project directory, as literal paths.
336
+ *
337
+ * On Linux only the ones that exist: bubblewrap denies a path by mounting over
338
+ * it, and to do that for a path that is not there it creates it on the host
339
+ * (never_writes has the measurement). A `.git` that is a file is a worktree
340
+ * pointing at its repository, and the pointer is what gets protected.
341
+ */
342
+ function controlsOf(dir, platform) {
343
+ const out = [];
344
+ let git = null;
345
+ try { git = statSync(join(dir, ".git")); } catch {}
346
+ if (git?.isFile()) out.push({ path: join(dir, ".git"), why: "a worktree's pointer to its repository, read by git" });
347
+ if (git?.isDirectory())
348
+ for (const f of [".git/hooks", ".git/config"])
349
+ out.push({ path: join(dir, f), why: `${f}, run by git outside the box` });
350
+ /**
351
+ * `.claude` whole, as one entry, rather than its seven control files.
352
+ *
353
+ * Every literal path costs sandbox start-up — the runtime adds a rule for
354
+ * each of its ancestors. Measured with one real role's profile: 50 literals
355
+ * started in ~1.9 s like none at all, 150 in ~2.3 s, 400 in 4.8–7.7 s. One
356
+ * entry for the directory covers every file in it, its ancestors, and
357
+ * creating it where it did not exist. The cost is
358
+ * that a role cannot write a project's `.claude/` at all, which is the
359
+ * directory Claude Code reads its instructions to itself from.
360
+ */
361
+ if (platform !== "linux" || existsSync(join(dir, ".claude")))
362
+ out.push({ path: join(dir, ".claude"), why: ".claude, whose settings, hooks and skills Claude Code runs outside the box" });
363
+ // The single files only where they exist: where they do not, the patterns
364
+ // refuse creating them, and a literal each would be start-up for nothing.
365
+ for (const f of [".mcp.json", ".envrc", ".codex/config.toml"]) {
366
+ const p = join(dir, f);
367
+ if (existsSync(p)) out.push({ path: p, why: `${f}, run by ${RUNNER(f)} outside the box` });
368
+ }
369
+ return out;
370
+ }
371
+
372
+ /**
373
+ * The home's control files, one by one — never `~/.claude` whole.
374
+ *
375
+ * That directory is also where Claude Code keeps its sessions, todos and
376
+ * history, and it writes them from inside the box on every turn. Denying the
377
+ * directory would stop the agent; denying what it executes does not (checked
378
+ * with `claude -p` under the new profile: it answered, and what it was refused
379
+ * were lock and cache files under plugins/, which it tolerates).
380
+ */
381
+ function homeControls(platform) {
382
+ const home = homedir();
383
+ return [
384
+ ".claude/settings.json", ".claude/settings.local.json", ".claude/hooks", ".claude/plugins",
385
+ ".claude/skills", ".claude/commands", ".claude/agents", ".codex/config.toml",
386
+ ]
387
+ .map((f) => ({ path: join(home, f), why: `${f}, run by ${RUNNER(f)} outside the box` }))
388
+ .filter((e) => platform !== "linux" || existsSync(e.path));
389
+ }
390
+
391
+ /**
392
+ * Everything this role's profile must deny beyond its own `never_writes`.
393
+ *
394
+ * Per role, and that is not only a saving. The kernel profile is one role's,
395
+ * so the only protected paths that matter in it are the ones inside what that
396
+ * role can write; a provider script in `backend`'s territory is already out of
397
+ * `frontend`'s reach. The union over roles (writableRoots) is for the other
398
+ * question — which directories the parent may trust at all.
399
+ */
400
+ const DENIES = new WeakMap(); // config -> key -> entries
401
+ const ROOTS = new WeakMap(); // config -> dir -> project roots under it
402
+
403
+ export function denyFor(config, role, { env = process.env, platform = process.platform, observe = false } = {}) {
404
+ // Memoised per config: `check` builds every role's settings and then asks
405
+ // for the same list again to show it; on the portfolio that was 2.6 s twice.
406
+ const memo = DENIES.get(config) ?? DENIES.set(config, new Map()).get(config);
407
+ const key = `${role.name}\0${observe}\0${platform}\0${env.PATH ?? ""}`;
408
+ if (memo.has(key)) return memo.get(key);
409
+ const result = computeDenies(config, role, { env, platform, observe });
410
+ memo.set(key, result);
411
+ return result;
412
+ }
413
+
414
+ /** Project roots under `dir`, once per config: roles share directories. */
415
+ function rootsUnder(config, dir) {
416
+ const memo = ROOTS.get(config) ?? ROOTS.set(config, new Map()).get(config);
417
+ if (!memo.has(dir)) memo.set(dir, projectRoots(dir));
418
+ return memo.get(dir);
419
+ }
420
+
421
+ function computeDenies(config, role, { env, platform, observe }) {
422
+ const mine = writePathsOf(config, role, { observe });
423
+ const minePaths = [...new Set(mine.flatMap((p) => [p, realOrSelf(p)]))];
424
+ const inMine = (p) => minePaths.some((w) => under(p, w));
425
+
426
+ const surface = executionSurface(config, env).filter((e) => inMine(e.path));
427
+
428
+ const dirs = mine.filter((p) => { try { return statSync(p).isDirectory(); } catch { return false; } });
429
+ const repoDirs = dirs.filter((p) => under(p, config.root));
430
+ const controls = repoDirs.flatMap((d) => rootsUnder(config, d)).flatMap((d) => controlsOf(d, platform));
431
+ // The home is a project too, for Claude Code and Codex: ~/.claude/settings.json
432
+ // is read by every session. Not walked — only its own control files.
433
+ if (["~/.claude", "~/.codex"].some((h) => inMine(join(homedir(), h.slice(2)))))
434
+ controls.push(...homeControls(platform));
435
+
436
+ /**
437
+ * Patterns, macOS only (bubblewrap does not take them), and anchored to this
438
+ * role's own directories so that `.claude` means a project's and never the
439
+ * home's, where Claude Code keeps its sessions.
440
+ *
441
+ * Anchored patterns cost nothing at start-up (measured with 24: the same as
442
+ * none) and cover what the literals cannot: a `.claude` deeper than the walk,
443
+ * or one a role creates where there was none. The home's
444
+ * files stay literal (homeControls).
445
+ */
446
+ const globs = platform === "darwin"
447
+ ? repoDirs.flatMap((d) => [
448
+ `${d}/**/.claude`, `${d}/**/.claude/**`,
449
+ `${d}/**/.git/hooks/**`, `${d}/**/.git/config`,
450
+ `${d}/**/.mcp.json`, `${d}/**/.envrc`, `${d}/**/.codex/config.toml`,
451
+ ]).map((path) => ({ path, why: "any project's control files in this territory, by pattern" }))
452
+ : [];
453
+
454
+ // The policy, `.seisin/` and the key directories are denied whatever the
455
+ // territory, as they always were. What the providers and `file://` keys
456
+ // point at is denied where this role can write it: `/usr/bin/security` in
457
+ // every profile would be a rule about the machine, and no role reaches it.
458
+ const always = new Set(["the policy", "seisin's log and request queue", "a key directory"]);
459
+ const inputs = parentInputs(config, { env }).filter((e) => always.has(e.why) || inMine(e.path));
460
+
461
+ return dedupe([...inputs, ...surface, ...controls, ...globs]);
462
+ }
463
+
464
+ /**
465
+ * The protection covering a repo-relative path, or null — what `explain` asks.
466
+ *
467
+ * So the sentence and the kernel agree: a request to write `seisin.toml` used
468
+ * to be filed as "belongs to dev" for a role with `writes = ["**"]`, and
469
+ * granting it would have granted nothing.
470
+ */
471
+ export function protectedBy(config, target, { platform = process.platform } = {}) {
472
+ /**
473
+ * A project's `.claude/`, any file in it, and the other control files — the
474
+ * same rule the kernel is given. On Linux the kernel only holds the ones
475
+ * that exist, so only those are called protected there: a sentence stricter
476
+ * than the boundary is the direction this project refuses.
477
+ */
478
+ const f = /(^|\/)\.claude(\/|$)/i.test(target)
479
+ ? ".claude"
480
+ : CONTROL_FILES.find((c) => new RegExp(`(^|/)${c.replace(/\./g, "\\.")}(/|$)`, "i").test(target));
481
+ if (f && (platform !== "linux" || existsSync(isAbsolute(target) ? target : join(config.root, target)))) {
482
+ const why = f === ".claude"
483
+ ? ".claude, whose settings, hooks and skills Claude Code runs outside the box"
484
+ : `${f}, run by ${RUNNER(f)} outside the box`;
485
+ return { path: target, why };
486
+ }
487
+ const full = realOrSelf(isAbsolute(target) ? target : join(config.root, target));
488
+ return parentInputs(config)
489
+ .find((e) => under(full, realOrSelf(e.path)) || under(join(config.root, target), e.path)) ?? null;
490
+ }
491
+
492
+ /**
493
+ * What `seisin check` shows: every protection that takes something away from a
494
+ * role — a path inside its territory that it will not be able to write — with
495
+ * the reason and the roles it applies to.
496
+ *
497
+ * The policy, `.seisin/` and the key directories are left out: they have been
498
+ * closed to every role since the first release and saying so on every check
499
+ * is noise. Patterns are left out too; they are a second layer, listed in
500
+ * surface.js.
501
+ */
502
+ export function protections(config, roles = Object.values(config.roles), opts = {}) {
503
+ const always = new Set(["the policy", "seisin's log and request queue", "a key directory"]);
504
+ const byPath = new Map();
505
+ for (const role of roles) {
506
+ let entries;
507
+ try { entries = denyFor(config, role, opts); } catch { continue; }
508
+ for (const e of entries) {
509
+ if (always.has(e.why) || e.path.includes("*")) continue;
510
+ // Only what the role could otherwise write: a literal control-file path
511
+ // is listed for every project in the territory whether it exists or not,
512
+ // and the ones that do not exist are shown only if their project does.
513
+ const hit = byPath.get(e.path) ?? byPath.set(e.path, { path: e.path, why: e.why, roles: [] }).get(e.path);
514
+ hit.roles.push(role.name);
515
+ }
516
+ }
517
+ return [...byPath.values()].filter((e) => existsSync(e.path) || !e.why.includes(" run by "));
518
+ }
package/src/violations.js CHANGED
@@ -55,12 +55,14 @@ const DENY = /Sandbox:\s+(\S+)\((\d+)\)\s+deny\(\d+\)\s+(\S+)\s+(.+?)\s*$/;
55
55
  const TAG = /CMD64_(.*?)_END_(\S*_SBX)\s*$/;
56
56
 
57
57
  /**
58
- * A Seatbelt operation, as one of the two verbs the log speaks.
58
+ * A Seatbelt operation, as one of the three verbs the log speaks.
59
59
  *
60
- * Anything that is not a file operation — `mach-lookup`, `network-outbound`,
61
- * `sysctl-read` — returns null and is dropped. Those are the sandbox doing its
62
- * job against the machine, not a role reaching into someone else's territory,
63
- * and the network half already has its own path through the proxy.
60
+ * `network-outbound` is `connect` — a refused dial to a local port or socket,
61
+ * kept narrow by `connectTarget` below. Everything else that is not a file
62
+ * operation — `mach-lookup`, `sysctl-read` — returns null and is dropped: the
63
+ * sandbox doing its job against the machine, not a role reaching for anything
64
+ * a person could decide about. Refusals by the proxy (a domain not on the
65
+ * list) never reach this log at all; see docs/decisions.md.
64
66
  *
65
67
  * **`file-read-metadata` is dropped too, and that one was learned the hard
66
68
  * way.** It is a refused `stat()`, which is what any directory walk produces
@@ -79,6 +81,31 @@ const TAG = /CMD64_(.*?)_END_(\S*_SBX)\s*$/;
79
81
  export function actionOf(operation) {
80
82
  if (operation.startsWith("file-write")) return "write";
81
83
  if (operation === "file-read-data") return "read";
84
+ if (operation === "network-outbound") return "connect";
85
+ return null;
86
+ }
87
+
88
+ /**
89
+ * The target of a refused connection, or null when it is not one worth a line.
90
+ *
91
+ * `network-outbound` was dropped with the rest of the non-file operations, and
92
+ * that hid the one refusal an agent misreads the most: a role that cannot reach
93
+ * the test database gets `connect EPERM`, no path, and concludes the service is
94
+ * down (measured: a whole cell spent two rounds reporting Docker as broken
95
+ * while Docker was up). Kept narrow, because the lesson of
96
+ * `file-read-metadata` applies here as well:
97
+ *
98
+ * - `remote:*:<port>` — a TCP dial the profile refused. The kernel does not
99
+ * name the host, so the target is the port and nothing more.
100
+ * - a unix socket path — except under /var/run, where the system daemons
101
+ * live: every DNS lookup inside the box is a refused connect to
102
+ * mDNSResponder, and recording those would bury everything else.
103
+ * - anything else (an empty detail, which the kernel also writes) — dropped.
104
+ */
105
+ export function connectTarget(detail) {
106
+ const port = /^remote:\S*:(\d+)$/.exec(detail)?.[1];
107
+ if (port) return `tcp:${port}`;
108
+ if (detail.startsWith("/") && !/^\/(private\/)?var\/run\//.test(detail)) return detail;
82
109
  return null;
83
110
  }
84
111
 
@@ -107,6 +134,11 @@ export function parseChunk(chunk) {
107
134
  if (!deny) return null;
108
135
  const action = actionOf(deny.operation);
109
136
  if (!action) return null;
137
+ if (action === "connect") {
138
+ const target = connectTarget(deny.detail);
139
+ if (!target) return null;
140
+ return { ...deny, action, path: target, suffix: tag?.suffix ?? null, command: tag?.command ?? null };
141
+ }
110
142
  // Only an absolute path is a territory question. A relative or malformed
111
143
  // detail means the line was not what it looked like; dropping beats guessing.
112
144
  if (!deny.detail.startsWith("/")) return null;
package/src/walls.js CHANGED
@@ -36,6 +36,7 @@
36
36
  import { openSync, readSync, closeSync, statSync } from "node:fs";
37
37
  import { read } from "./log.js";
38
38
  import { explain } from "./owners.js";
39
+ import { STALE_RUNS, RUN_GAP_MS, runsAfter } from "./requests.js";
39
40
 
40
41
  /** A wall is something you hit more than once. Once is information; twice is a pattern. */
41
42
  export const MIN_HITS = 2;
@@ -48,8 +49,12 @@ export const MIN_HITS = 2;
48
49
  * different repositories are two walls, and merging them hides which one was
49
50
  * hit.
50
51
  */
51
- export function walls(config, role, { file, min = MIN_HITS, since = null, limit = 6 } = {}) {
52
- const denied = read(file, { role, verdict: "denied", ...(since ? { since } : {}) });
52
+ export function walls(config, role, { file, entries = null, min = MIN_HITS, since = null, limit = 6, fresh = false } = {}) {
53
+ // `entries`: the log already read, for a caller asking about every role at
54
+ // once (the console asks for 32 on a real policy) — one read instead of 32.
55
+ const denied = entries
56
+ ? entries.filter((e) => e.role === role && e.verdict === "denied" && (!since || e.at >= since))
57
+ : read(file, { role, verdict: "denied", ...(since ? { since } : {}) });
53
58
 
54
59
  const byKey = new Map();
55
60
  for (const e of denied) {
@@ -69,8 +74,16 @@ export function walls(config, role, { file, min = MIN_HITS, since = null, limit
69
74
  if (verdict.allowed) continue;
70
75
  out.push({ ...w, reason: verdict.reason, owners: verdict.owners });
71
76
  }
72
- out.sort((a, b) => b.times - a.times || a.target.localeCompare(b.target));
73
- return limit > 0 ? out.slice(0, limit) : out;
77
+ // A wall the role stopped hitting — it ran STALE_RUNS times since, refused
78
+ // elsewhere but not here — is still true and no longer worth saying. Same
79
+ // rule as an old request. `fresh` drops them; otherwise they are marked.
80
+ for (const w of out) {
81
+ const n = runsAfter(denied, Date.parse(w.lastAt), RUN_GAP_MS);
82
+ if (n >= STALE_RUNS) w.stale = { runs: n };
83
+ }
84
+ const kept = fresh ? out.filter((w) => !w.stale) : out;
85
+ kept.sort((a, b) => b.times - a.times || a.target.localeCompare(b.target));
86
+ return limit > 0 ? kept.slice(0, limit) : kept;
74
87
  }
75
88
 
76
89
  /**
@@ -160,7 +173,8 @@ export function render(list) {
160
173
  ];
161
174
  for (const w of list) {
162
175
  lines.push(` ${w.times}× ${w.action} ${w.target}`);
163
- lines.push(` ${w.reason}`);
176
+ // The reason for an owned path starts with the path again; the owner is the news.
177
+ lines.push(` ${w.owners?.length ? `belongs to ${w.owners.join(", ")}` : w.reason}`);
164
178
  }
165
179
  const n = wasted(list);
166
180
  // Said to the agent, not about it. One that can see what retrying cost has a
package/ui/favicon.svg CHANGED
@@ -1 +1 @@
1
- <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32"><rect width="32" height="32" rx="8" fill="#12161c"/><g clip-path="url(#p)"><clipPath id="p"><rect x="6" y="6" width="20" height="20" rx="4"/></clipPath><rect x="6" y="6" width="9" height="11" fill="#3fb950"/></g><rect x="6" y="6" width="20" height="20" rx="4" fill="none" stroke="#e6edf3" stroke-width="2.6"/><path d="M15 6v11M6 17h20" stroke="#e6edf3" stroke-width="2.6" stroke-linecap="square"/></svg>
1
+ <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32"><rect width="32" height="32" rx="7" fill="#1f2328"/><path d="M16 5.5C8 5.5 8 16 16 16S24 26.5 16 26.5H26.5V5.5Z" fill="#2da44e"/><path d="M16 5.5C8 5.5 8 16 16 16S24 26.5 16 26.5" fill="none" stroke="#fff" stroke-width="2.8" stroke-linecap="round"/></svg>