seisin 0.4.0 → 0.5.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 (50) hide show
  1. package/README.md +151 -67
  2. package/package.json +3 -3
  3. package/src/cli.js +145 -46
  4. package/src/commands/check.js +3 -2
  5. package/src/commands/explain.js +34 -13
  6. package/src/commands/hook.js +14 -4
  7. package/src/commands/init.js +126 -30
  8. package/src/commands/log.js +55 -10
  9. package/src/commands/requests.js +124 -39
  10. package/src/commands/review.js +41 -16
  11. package/src/commands/run.js +174 -39
  12. package/src/commands/scan.js +18 -3
  13. package/src/commands/ui.js +18 -5
  14. package/src/commands/walls.js +25 -6
  15. package/src/commands/whose.js +7 -6
  16. package/src/commands/wire.js +156 -17
  17. package/src/config.js +261 -27
  18. package/src/controls.js +244 -0
  19. package/src/diagnose.js +35 -33
  20. package/src/env.js +11 -8
  21. package/src/grants.js +15 -7
  22. package/src/help.js +159 -0
  23. package/src/hook.js +49 -19
  24. package/src/index.js +4 -4
  25. package/src/inspect.js +137 -71
  26. package/src/intake.js +24 -16
  27. package/src/keys.js +13 -11
  28. package/src/kinds.js +207 -0
  29. package/src/log.js +312 -55
  30. package/src/mcp.js +96 -43
  31. package/src/notify.js +15 -10
  32. package/src/owners.js +323 -41
  33. package/src/paths.js +37 -1
  34. package/src/redact.js +19 -5
  35. package/src/render.js +81 -12
  36. package/src/requests.js +250 -72
  37. package/src/review.js +42 -21
  38. package/src/rundir.js +4 -6
  39. package/src/scan.js +177 -19
  40. package/src/serve.js +395 -205
  41. package/src/spool.js +9 -29
  42. package/src/srt.js +38 -21
  43. package/src/suggest.js +58 -0
  44. package/src/surface.js +203 -42
  45. package/src/uilink.js +10 -4
  46. package/src/views.js +289 -0
  47. package/src/violations.js +104 -16
  48. package/src/walls.js +19 -42
  49. package/src/worktree.js +3 -11
  50. package/ui/index.html +1366 -529
package/src/surface.js CHANGED
@@ -3,7 +3,7 @@
3
3
  *
4
4
  * The sandbox confines the agent. It does not confine the process that decides
5
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
6
+ * reads `file://` secrets and starts the runtime, all of it outside the sandbox and
7
7
  * as you. Every file that process reads or executes is therefore part of the
8
8
  * boundary, and a role that can write one of them does not need to escape — it
9
9
  * waits for the next run, or for you to type the command yourself.
@@ -19,7 +19,7 @@
19
19
  * - a role swapped a `file://` target for a symlink to another role's key,
20
20
  * and the next run read that key and handed it over;
21
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
22
+ * the Claude Code binary you run outside the sandbox lives, because the shared
23
23
  * runtime scratch includes `~/.local/share`.
24
24
  *
25
25
  * So this module answers the question once, for the family: **which paths does
@@ -28,7 +28,7 @@
28
28
  * with the reason for each entry.
29
29
  */
30
30
  import { readdirSync, statSync, accessSync, existsSync, realpathSync, constants } from "node:fs";
31
- import { join, isAbsolute, delimiter, dirname, resolve, basename } from "node:path";
31
+ import { join, isAbsolute, delimiter, dirname, resolve, basename, sep } from "node:path";
32
32
  import { fileURLToPath } from "node:url";
33
33
  import { homedir } from "node:os";
34
34
  import { writePathsOf, realOrSelf } from "./grants.js";
@@ -46,12 +46,25 @@ const under = (p, root) => p === root || p.startsWith(root.endsWith("/") ? root
46
46
  * provider script can still wait for the role that can. The repo root is in
47
47
  * it because `--observe` grants the whole repo to whichever role it runs.
48
48
  */
49
+ const ROOTS_OF = new WeakMap(); // config -> writable roots
50
+
49
51
  export function writableRoots(config) {
52
+ // Once per config: protectedBy asks for it through parentInputs on every
53
+ // call, and on a multi-repo workspace that was 1.7 ms each — 50 ms for a hook call
54
+ // with two providers. A config is reloaded whenever the policy can have
55
+ // changed (hook, console, MCP), so a memory per object is never stale for
56
+ // longer than the object lives.
57
+ let roots = ROOTS_OF.get(config);
58
+ if (!roots) ROOTS_OF.set(config, (roots = computeWritableRoots(config)));
59
+ return roots;
60
+ }
61
+
62
+ function computeWritableRoots(config) {
50
63
  const root = realOrSelf(config.root);
51
64
  const out = new Set([root]);
52
65
  // A grant inside the repo is covered by the repo root already, so only the
53
66
  // 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
67
+ // 301 paths for a policy with 32 roles, most of them sidecars that do not
55
68
  // exist, 200 ms of failed realpath calls to learn nothing.
56
69
  for (const role of Object.values(config.roles ?? {}))
57
70
  for (const p of writePathsOf(config, role)) {
@@ -140,8 +153,8 @@ function fileRefTargets(config) {
140
153
  * root. With nothing recognisable on the way, the binary's own directory.
141
154
  *
142
155
  * 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.
156
+ * instead, and in a multi-repo workspace that was the folder holding every
157
+ * project: all of them, denied to every role, because seisin itself lives in one.
145
158
  */
146
159
  function packageRoot(p, root) {
147
160
  let dir = dirname(p);
@@ -154,7 +167,7 @@ function packageRoot(p, root) {
154
167
  }
155
168
 
156
169
  /**
157
- * Programs that run outside the box and live where a role can write.
170
+ * Programs that run outside the sandbox and live where a role can write.
158
171
  *
159
172
  * Two ways in. A PATH directory inside a writable area: anything a role drops
160
173
  * there shadows a real command the next time anybody types it. And a PATH
@@ -170,7 +183,7 @@ const SURFACES = new WeakMap(); // config -> PATH -> entries
170
183
 
171
184
  export function executionSurface(config, env = process.env, roots = null) {
172
185
  // 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.
186
+ // PATH scan is the same answer 32 times for a policy with 32 roles.
174
187
  const byPath = SURFACES.get(config) ?? SURFACES.set(config, new Map()).get(config);
175
188
  const key = env.PATH ?? "";
176
189
  if (!roots && byPath.has(key)) return byPath.get(key);
@@ -233,12 +246,32 @@ function dedupe(entries) {
233
246
  * was: these are cheap to list and a role that can write one of them has no
234
247
  * policy.
235
248
  */
249
+ const INPUTS = new WeakMap(); // config -> PATH -> entries
250
+
236
251
  export function parentInputs(config, { env = process.env } = {}) {
252
+ const byPath = INPUTS.get(config) ?? INPUTS.set(config, new Map()).get(config);
253
+ const key = env.PATH ?? "";
254
+ if (!byPath.has(key)) byPath.set(key, computeParentInputs(config, env));
255
+ return byPath.get(key);
256
+ }
257
+
258
+ /**
259
+ * `always` marks the three that are denied whatever the territory — the
260
+ * policy, `.seisin/` and the key directories. It is a field, not a comparison
261
+ * against the wording of `why`: that sentence is for people, and rewording it
262
+ * would have quietly moved a key directory out of every profile.
263
+ */
264
+ function computeParentInputs(config, env) {
237
265
  const abs = (p) => (isAbsolute(p) ? p : join(config.root, p));
266
+ const policy = abs(config.path ?? CONFIG_NAME);
238
267
  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" })),
268
+ { path: policy, why: "the policy", always: true },
269
+ // `seisin.toml.observed` is the proposal `init --from-observations` writes
270
+ // for a person to diff and move onto the policy; a role that could write it
271
+ // could plant a policy the operator then promotes in good faith.
272
+ { path: policy + ".observed", why: "the policy", always: true },
273
+ { path: abs(STATE_DIR), why: "seisin's log and request queue", always: true },
274
+ ...(config.keyDirs ?? []).map((d) => ({ path: abs(d), why: "a key directory", always: true })),
242
275
  ];
243
276
  for (const [scheme, p] of Object.entries(config.keyProviders ?? {})) {
244
277
  const cmd = p.command?.[0];
@@ -247,7 +280,7 @@ export function parentInputs(config, { env = process.env } = {}) {
247
280
  // A provider that does not resolve here is not denied here: the run that
248
281
  // needs it fails at resolution, with the message resolveExecutable gives.
249
282
  try { path = resolveExecutable(cmd, config, env); } catch { continue; }
250
- out.push({ path, why: `the ${scheme}:// key provider, run outside the box` });
283
+ out.push({ path, why: `the ${scheme}:// key provider, run outside the sandbox` });
251
284
  }
252
285
  for (const { path, raw } of fileRefTargets(config))
253
286
  out.push({ path, why: `read by the parent for key "${raw}"` });
@@ -262,13 +295,13 @@ export function parentInputs(config, { env = process.env } = {}) {
262
295
  }
263
296
 
264
297
  /**
265
- * Files that tell a program running outside the box what to execute.
298
+ * Files that tell a program running outside the sandbox what to execute.
266
299
  *
267
300
  * Git runs `.git/hooks/*` and whatever `.git/config` names (core.hooksPath,
268
301
  * core.fsmonitor). Claude Code runs the hooks, plugins and skills its settings
269
302
  * declare, and the MCP servers in `.mcp.json`. direnv runs `.envrc` on `cd`.
270
303
  * 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
304
+ * not escape the sandbox; it leaves a command for you to run the next time you
272
305
  * open that project — measured on 2026-09-23: every role could open
273
306
  * `~/.claude/settings.json` for writing through the shared runtime scratch.
274
307
  *
@@ -288,30 +321,97 @@ export const CONTROL_FILES = [
288
321
  const RUNNER = (f) =>
289
322
  f.startsWith(".git/") ? "git" : f === ".envrc" ? "direnv" : f.startsWith(".codex/") ? "Codex" : "Claude Code";
290
323
 
324
+ /** `s` as a literal inside a RegExp — every metacharacter, not only `.`. */
325
+ const literal = (s) => s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
326
+
327
+ /** `name` as a whole path segment run, anywhere in a path. */
328
+ const segment = (name, flags = "") => new RegExp(`(^|/)${literal(name)}(/|$)`, flags);
329
+
291
330
  // 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");
331
+ // the same file, and the kernel was measured refusing it. Compiled once: this
332
+ // used to build one RegExp per control file on every call to protectedBy.
333
+ const CLAUDE_DIR = segment(".claude", "i");
334
+ const CONTROL_RES = CONTROL_FILES.map((c) => [c, segment(c, "i")]);
295
335
 
296
- /** Does this path name a control file, at any depth? What `explain` asks. */
297
- export const isControlPath = (p) => CONTROL_RE.test(p);
336
+ /**
337
+ * Two more families of control files, which a role may be let to edit.
338
+ *
339
+ * The ones above make a program outside the sandbox *execute* something, and no
340
+ * role writes them. These two are softer, and that is why a policy can hand
341
+ * them to a role (`control_files = ["ide"]`, `["instructions"]`) where the
342
+ * others cannot be handed to anybody:
343
+ *
344
+ * - **ide** — `.vscode/`, `.cursor/`, `.windsurf/`. The editor applies them as
345
+ * soon as they change: tasks, terminal profiles, settings that turn on tool
346
+ * auto-approval or add an MCP server. GitHub Security Lab showed an injected
347
+ * agent doing exactly that to `.vscode/settings.json`; the editor reloads
348
+ * before a person can choose Undo. Protected by default — but a front-end
349
+ * role that maintains its project's launch configs has a real reason to edit
350
+ * them, and a boundary with no way to say so gets switched off whole.
351
+ * - **instructions** — `CLAUDE.md`, `AGENTS.md` and their kin. Nothing runs
352
+ * them; the next agent session *reads* them, as instructions. One role
353
+ * writing them is how it reaches another role's context. Off by default
354
+ * (`[protect] instructions = true` turns it on), because keeping them current
355
+ * is ordinary work for most teams, and git then fights the protection: a
356
+ * role's `git checkout` cannot update a file it may not write, git exits 0,
357
+ * and the old version stays behind as a modification.
358
+ */
359
+ export const FAMILIES = {
360
+ ide: [".vscode", ".cursor", ".windsurf"],
361
+ instructions: ["CLAUDE.md", "AGENTS.md", "GEMINI.md", ".cursorrules", ".windsurfrules",
362
+ ".github/copilot-instructions.md", ".github/instructions"],
363
+ };
364
+
365
+ const EDITOR = { ".vscode": "VS Code", ".cursor": "Cursor", ".windsurf": "Windsurf" };
366
+ // The families, compiled once per case rule (see protectedBy).
367
+ const FAMILY_RES = {
368
+ fold: Object.fromEntries(Object.entries(FAMILIES).map(([f, names]) => [f, names.map((n) => [n, segment(n, "i")])])),
369
+ exact: Object.fromEntries(Object.entries(FAMILIES).map(([f, names]) => [f, names.map((n) => [n, segment(n)])])),
370
+ };
371
+
372
+ const familyWhy = (family, name) => family === "ide"
373
+ ? `${name}, whose settings and tasks ${EDITOR[name]} applies outside the sandbox`
374
+ : `${name}, which the next agent session reads as its instructions`;
375
+
376
+ /** The families this role's profile protects: the defaults, minus what the policy hands it. */
377
+ export function familiesFor(config, role) {
378
+ const mine = new Set(role?.controlFiles ?? []);
379
+ const out = [];
380
+ if (!mine.has("ide")) out.push("ide");
381
+ if (config.protect?.instructions && !mine.has("instructions")) out.push("instructions");
382
+ return out;
383
+ }
384
+
385
+ /**
386
+ * A pattern that matches exactly `name` under `root`, and only there.
387
+ *
388
+ * `**\/.vscode` would also match inside `node_modules`, and npm would then
389
+ * drop those files from the packages it unpacks, with `EPERM` in a warning and
390
+ * exit 0. Anchored at the project root it cannot. The last character goes in a
391
+ * one-letter class so the runtime treats the entry as a pattern — which costs
392
+ * nothing at start-up — and not as a literal, which costs a rule for each of
393
+ * its ancestors and would be paid for every project whether the file is there
394
+ * or not.
395
+ */
396
+ const exactly = (root, name) => `${root}/${name.slice(0, -1)}[${name.slice(-1)}]`;
298
397
 
299
398
  /** Where no control file is looked for: dependencies and build output. */
300
399
  const PRUNE = new Set(["node_modules", ".git", "vendor", "dist", "build", ".venv", "venv",
301
400
  "__pycache__", ".next", "target", ".turbo", ".cache", "Pods", ".gradle", ".dart_tool"]);
302
401
 
303
402
  /** A directory holding one of these is where somebody opens a project. */
304
- const MARKERS = new Set([".git", ".claude", ".mcp.json", ".envrc", ".codex"]);
403
+ const MARKERS = new Set([".git", ".claude", ".mcp.json", ".envrc", ".codex",
404
+ ...FAMILIES.ide, ...FAMILIES.instructions.filter((f) => !f.includes("/"))]);
305
405
 
306
406
  /**
307
407
  * How deep under a territory to look for projects.
308
408
  *
309
- * Measured on the portfolio over every territory at once: depth 3 finds 128
409
+ * Measured on a multi-repo workspace over every territory at once: depth 3 finds 128
310
410
  * project roots reading 2.6k directories (~0.4 s under load); depth 4 finds
311
411
  * 130 reading 5.5k (0.9 s); depth 6, 132 and 2.1 s. The four below depth 3
312
412
  * are `.claude` folders agents left inside source trees (`features/…`,
313
413
  * `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
414
+ * layer still denies writing into them. The whole workspace at depth 8 is
315
415
  * 23k directories, which is why the walk is per role and never over the repo.
316
416
  */
317
417
  const SCAN_DEPTH = 3;
@@ -339,14 +439,14 @@ function projectRoots(dir, depth = SCAN_DEPTH) {
339
439
  * (never_writes has the measurement). A `.git` that is a file is a worktree
340
440
  * pointing at its repository, and the pointer is what gets protected.
341
441
  */
342
- function controlsOf(dir, platform) {
442
+ function controlsOf(dir, platform, families = []) {
343
443
  const out = [];
344
444
  let git = null;
345
445
  try { git = statSync(join(dir, ".git")); } catch {}
346
446
  if (git?.isFile()) out.push({ path: join(dir, ".git"), why: "a worktree's pointer to its repository, read by git" });
347
447
  if (git?.isDirectory())
348
448
  for (const f of [".git/hooks", ".git/config"])
349
- out.push({ path: join(dir, f), why: `${f}, run by git outside the box` });
449
+ out.push({ path: join(dir, f), why: `${f}, run by git outside the sandbox`, ifPresent: true });
350
450
  /**
351
451
  * `.claude` whole, as one entry, rather than its seven control files.
352
452
  *
@@ -359,13 +459,21 @@ function controlsOf(dir, platform) {
359
459
  * directory Claude Code reads its instructions to itself from.
360
460
  */
361
461
  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" });
462
+ out.push({ path: join(dir, ".claude"), why: ".claude, whose settings, hooks and skills Claude Code runs outside the sandbox" });
363
463
  // The single files only where they exist: where they do not, the patterns
364
464
  // refuse creating them, and a literal each would be start-up for nothing.
365
465
  for (const f of [".mcp.json", ".envrc", ".codex/config.toml"]) {
366
466
  const p = join(dir, f);
367
- if (existsSync(p)) out.push({ path: p, why: `${f}, run by ${RUNNER(f)} outside the box` });
467
+ if (existsSync(p)) out.push({ path: p, why: `${f}, run by ${RUNNER(f)} outside the sandbox`, ifPresent: true });
368
468
  }
469
+ // The families literally only where they exist, on every platform. Where
470
+ // they do not, macOS gets a pattern for creating them (computeDenies) and
471
+ // Linux gets nothing: bubblewrap would create the path to mount over it.
472
+ for (const family of families)
473
+ for (const f of FAMILIES[family]) {
474
+ const p = join(dir, f);
475
+ if (existsSync(p)) out.push({ path: p, why: familyWhy(family, f), family });
476
+ }
369
477
  return out;
370
478
  }
371
479
 
@@ -373,7 +481,7 @@ function controlsOf(dir, platform) {
373
481
  * The home's control files, one by one — never `~/.claude` whole.
374
482
  *
375
483
  * 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
484
+ * history, and it writes them from inside the sandbox on every turn. Denying the
377
485
  * directory would stop the agent; denying what it executes does not (checked
378
486
  * with `claude -p` under the new profile: it answered, and what it was refused
379
487
  * were lock and cache files under plugins/, which it tolerates).
@@ -384,7 +492,7 @@ function homeControls(platform) {
384
492
  ".claude/settings.json", ".claude/settings.local.json", ".claude/hooks", ".claude/plugins",
385
493
  ".claude/skills", ".claude/commands", ".claude/agents", ".codex/config.toml",
386
494
  ]
387
- .map((f) => ({ path: join(home, f), why: `${f}, run by ${RUNNER(f)} outside the box` }))
495
+ .map((f) => ({ path: join(home, f), why: `${f}, run by ${RUNNER(f)} outside the sandbox`, ifPresent: true }))
388
496
  .filter((e) => platform !== "linux" || existsSync(e.path));
389
497
  }
390
498
 
@@ -402,7 +510,7 @@ const ROOTS = new WeakMap(); // config -> dir -> project roots under
402
510
 
403
511
  export function denyFor(config, role, { env = process.env, platform = process.platform, observe = false } = {}) {
404
512
  // 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.
513
+ // for the same list again to show it; on a multi-repo workspace that was 2.6 s twice.
406
514
  const memo = DENIES.get(config) ?? DENIES.set(config, new Map()).get(config);
407
515
  const key = `${role.name}\0${observe}\0${platform}\0${env.PATH ?? ""}`;
408
516
  if (memo.has(key)) return memo.get(key);
@@ -427,7 +535,9 @@ function computeDenies(config, role, { env, platform, observe }) {
427
535
 
428
536
  const dirs = mine.filter((p) => { try { return statSync(p).isDirectory(); } catch { return false; } });
429
537
  const repoDirs = dirs.filter((p) => under(p, config.root));
430
- const controls = repoDirs.flatMap((d) => rootsUnder(config, d)).flatMap((d) => controlsOf(d, platform));
538
+ const families = familiesFor(config, role);
539
+ const roots = [...new Set(repoDirs.flatMap((d) => rootsUnder(config, d)))];
540
+ const controls = roots.flatMap((d) => controlsOf(d, platform, families));
431
541
  // The home is a project too, for Claude Code and Codex: ~/.claude/settings.json
432
542
  // is read by every session. Not walked — only its own control files.
433
543
  if (["~/.claude", "~/.codex"].some((h) => inMine(join(homedir(), h.slice(2)))))
@@ -447,16 +557,32 @@ function computeDenies(config, role, { env, platform, observe }) {
447
557
  ? repoDirs.flatMap((d) => [
448
558
  `${d}/**/.claude`, `${d}/**/.claude/**`,
449
559
  `${d}/**/.git/hooks/**`, `${d}/**/.git/config`,
560
+ // What git runs that lives beside, not in, `.git/config`: a submodule's
561
+ // own config and hooks, and the files that redirect a worktree to
562
+ // another gitdir or config. Ordinary commits never write these, so
563
+ // denying them costs a working role nothing; a role that could write
564
+ // one would leave a command for the next `git` outside the sandbox. The
565
+ // subtree of `.git` stays writable on purpose, so the agent can commit.
566
+ `${d}/**/.git/config.worktree`,
567
+ `${d}/**/.git/modules/**/config`, `${d}/**/.git/modules/**/hooks/**`,
568
+ `${d}/**/.git/worktrees/**/config.worktree`, `${d}/**/.git/worktrees/**/commondir`,
450
569
  `${d}/**/.mcp.json`, `${d}/**/.envrc`, `${d}/**/.codex/config.toml`,
451
570
  ]).map((path) => ({ path, why: "any project's control files in this territory, by pattern" }))
452
571
  : [];
572
+ // Creating one of the families where it is not there yet: one exact pattern
573
+ // per project root, never `**` (see `exactly`).
574
+ if (platform === "darwin")
575
+ for (const root of roots)
576
+ for (const family of families)
577
+ for (const f of FAMILIES[family])
578
+ if (!existsSync(join(root, f)))
579
+ globs.push({ path: exactly(root, f), why: `${familyWhy(family, f)}, if it is created`, family });
453
580
 
454
581
  // The policy, `.seisin/` and the key directories are denied whatever the
455
582
  // territory, as they always were. What the providers and `file://` keys
456
583
  // point at is denied where this role can write it: `/usr/bin/security` in
457
584
  // 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));
585
+ const inputs = parentInputs(config, { env }).filter((e) => e.always || inMine(e.path));
460
586
 
461
587
  return dedupe([...inputs, ...surface, ...controls, ...globs]);
462
588
  }
@@ -468,27 +594,60 @@ function computeDenies(config, role, { env, platform, observe }) {
468
594
  * to be filed as "belongs to dev" for a role with `writes = ["**"]`, and
469
595
  * granting it would have granted nothing.
470
596
  */
471
- export function protectedBy(config, target, { platform = process.platform } = {}) {
597
+ export function protectedBy(config, target, { platform = process.platform, role = null } = {}) {
472
598
  /**
473
599
  * A project's `.claude/`, any file in it, and the other control files — the
474
600
  * same rule the kernel is given. On Linux the kernel only holds the ones
475
601
  * that exist, so only those are called protected there: a sentence stricter
476
602
  * than the boundary is the direction this project refuses.
477
603
  */
478
- const f = /(^|\/)\.claude(\/|$)/i.test(target)
604
+ const f = CLAUDE_DIR.test(target)
479
605
  ? ".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)))) {
606
+ : CONTROL_RES.find(([, re]) => re.test(target))?.[0];
607
+ const re = f === ".claude" ? CLAUDE_DIR : CONTROL_RES.find(([name]) => name === f)?.[1];
608
+ if (f && (platform !== "linux" || heldOnLinux(config, target, re))) {
482
609
  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`;
610
+ ? ".claude, whose settings, hooks and skills Claude Code runs outside the sandbox"
611
+ : `${f}, run by ${RUNNER(f)} outside the sandbox`;
485
612
  return { path: target, why };
486
613
  }
614
+ /**
615
+ * The two families, as the kernel is given them. Case-insensitive on macOS
616
+ * only: APFS folds case and the kernel refuses `.VSCODE/`, ext4 does not, and
617
+ * saying "protected" about a spelling the kernel lets through is the one
618
+ * direction this project refuses. With a role, what the policy hands that
619
+ * role is not protected for it; without one, the answer carries the family
620
+ * so the caller can ask whose it is.
621
+ */
622
+ const active = role ? familiesFor(config, config.roles?.[role]) : familiesFor(config, null);
623
+ const compiled = platform === "darwin" ? FAMILY_RES.fold : FAMILY_RES.exact;
624
+ for (const family of active)
625
+ for (const [name, re] of compiled[family]) {
626
+ if (!re.test(target)) continue;
627
+ if (platform === "linux" && !heldOnLinux(config, target, re)) continue;
628
+ return { path: target, why: familyWhy(family, name), family };
629
+ }
487
630
  const full = realOrSelf(isAbsolute(target) ? target : join(config.root, target));
488
631
  return parentInputs(config)
489
632
  .find((e) => under(full, realOrSelf(e.path)) || under(join(config.root, target), e.path)) ?? null;
490
633
  }
491
634
 
635
+ /**
636
+ * Whether the Linux kernel holds `target`: the path itself, or a directory
637
+ * above it that the same rule names, exists — bubblewrap denies what exists,
638
+ * and a denied directory takes everything created inside it. Checking only the
639
+ * target called `web/.vscode/settings.json` unprotected while `web/.vscode` was
640
+ * denied whole, so a grant for it went through and changed nothing.
641
+ */
642
+ function heldOnLinux(config, target, re) {
643
+ const full = isAbsolute(target) ? target : join(config.root, target);
644
+ for (let at = full; ; at = dirname(at)) {
645
+ const rel = at.startsWith(config.root + sep) ? at.slice(config.root.length + 1) : at;
646
+ if (re.test(rel) && existsSync(at)) return true;
647
+ if (at === config.root || dirname(at) === at || !re.test(rel)) return false;
648
+ }
649
+ }
650
+
492
651
  /**
493
652
  * What `seisin check` shows: every protection that takes something away from a
494
653
  * role — a path inside its territory that it will not be able to write — with
@@ -500,19 +659,21 @@ export function protectedBy(config, target, { platform = process.platform } = {}
500
659
  * surface.js.
501
660
  */
502
661
  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
662
  const byPath = new Map();
505
663
  for (const role of roles) {
506
664
  let entries;
507
665
  try { entries = denyFor(config, role, opts); } catch { continue; }
508
666
  for (const e of entries) {
509
- if (always.has(e.why) || e.path.includes("*")) continue;
667
+ if (e.always || /[*?[\]]/.test(e.path)) continue;
510
668
  // Only what the role could otherwise write: a literal control-file path
511
669
  // is listed for every project in the territory whether it exists or not,
512
670
  // 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);
671
+ const hit = byPath.get(e.path) ??
672
+ byPath.set(e.path, { path: e.path, why: e.why, roles: [], ifPresent: e.ifPresent === true }).get(e.path);
514
673
  hit.roles.push(role.name);
515
674
  }
516
675
  }
517
- return [...byPath.values()].filter((e) => existsSync(e.path) || !e.why.includes(" run by "));
676
+ return [...byPath.values()]
677
+ .filter((e) => !e.ifPresent || existsSync(e.path))
678
+ .map(({ ifPresent, ...e }) => e);
518
679
  }
package/src/uilink.js CHANGED
@@ -26,9 +26,15 @@ function linkFile(port, base) {
26
26
  return join(runsRoot(base), `ui-${port}.url`);
27
27
  }
28
28
 
29
- /** Records this run's live URL for the port it bound. */
30
- export function writeUiLink(port, url, base) {
31
- writeFileSync(linkFile(port, base), JSON.stringify({ url, pid: process.pid }) + "\n", { mode: 0o600 });
29
+ /**
30
+ * Records this run's live URL for the port it bound, and the policy it serves.
31
+ *
32
+ * The record is per port, not per repo, so `ui --link` in one repo found the
33
+ * console another repo had started — and opened a page about the wrong policy
34
+ * with nothing on it to say so. The policy's path travels with the link now.
35
+ */
36
+ export function writeUiLink(port, url, base, policy = null) {
37
+ writeFileSync(linkFile(port, base), JSON.stringify({ url, pid: process.pid, policy }) + "\n", { mode: 0o600 });
32
38
  }
33
39
 
34
40
  /**
@@ -46,7 +52,7 @@ export function readUiLink(port, base) {
46
52
  // Signal 0 tests for the process without touching it: it throws if gone.
47
53
  try { process.kill(e.pid, 0); alive = true; } catch { alive = false; }
48
54
  }
49
- return { url: e.url, pid: e.pid ?? null, alive };
55
+ return { url: e.url, pid: e.pid ?? null, alive, policy: typeof e.policy === "string" ? e.policy : null };
50
56
  }
51
57
 
52
58
  /** Removes the record. Called on exit, and when a stale one is found. */