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/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,17 +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));
238
266
  const policy = abs(config.path ?? CONFIG_NAME);
239
267
  const out = [
240
- { path: policy, why: "the policy" },
268
+ { path: policy, why: "the policy", always: true },
241
269
  // `seisin.toml.observed` is the proposal `init --from-observations` writes
242
270
  // for a person to diff and move onto the policy; a role that could write it
243
271
  // could plant a policy the operator then promotes in good faith.
244
- { path: policy + ".observed", why: "the policy" },
245
- { path: abs(STATE_DIR), why: "seisin's log and request queue" },
246
- ...(config.keyDirs ?? []).map((d) => ({ path: abs(d), why: "a key directory" })),
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 })),
247
275
  ];
248
276
  for (const [scheme, p] of Object.entries(config.keyProviders ?? {})) {
249
277
  const cmd = p.command?.[0];
@@ -252,7 +280,7 @@ export function parentInputs(config, { env = process.env } = {}) {
252
280
  // A provider that does not resolve here is not denied here: the run that
253
281
  // needs it fails at resolution, with the message resolveExecutable gives.
254
282
  try { path = resolveExecutable(cmd, config, env); } catch { continue; }
255
- 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` });
256
284
  }
257
285
  for (const { path, raw } of fileRefTargets(config))
258
286
  out.push({ path, why: `read by the parent for key "${raw}"` });
@@ -267,13 +295,14 @@ export function parentInputs(config, { env = process.env } = {}) {
267
295
  }
268
296
 
269
297
  /**
270
- * 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.
271
299
  *
272
300
  * Git runs `.git/hooks/*` and whatever `.git/config` names (core.hooksPath,
273
301
  * core.fsmonitor). Claude Code runs the hooks, plugins and skills its settings
274
302
  * declare, and the MCP servers in `.mcp.json`. direnv runs `.envrc` on `cd`.
275
- * Codex starts what `config.toml` lists. A role that writes one of these does
276
- * not escape the box; it leaves a command for you to run the next time you
303
+ * Codex starts what its `config.toml` and `hooks.json` list and lets its `rules`
304
+ * run without asking. A role that writes one of these does
305
+ * not escape the sandbox; it leaves a command for you to run the next time you
277
306
  * open that project — measured on 2026-09-23: every role could open
278
307
  * `~/.claude/settings.json` for writing through the shared runtime scratch.
279
308
  *
@@ -287,36 +316,114 @@ export const CONTROL_FILES = [
287
316
  ".git/hooks", ".git/config",
288
317
  ".claude/settings.json", ".claude/settings.local.json",
289
318
  ".claude/hooks", ".claude/plugins", ".claude/skills", ".claude/commands", ".claude/agents",
290
- ".mcp.json", ".envrc", ".codex/config.toml",
319
+ ".mcp.json", ".envrc", ".codex",
291
320
  ];
292
321
 
293
322
  const RUNNER = (f) =>
294
- f.startsWith(".git/") ? "git" : f === ".envrc" ? "direnv" : f.startsWith(".codex/") ? "Codex" : "Claude Code";
323
+ f.startsWith(".git/") ? "git" : f === ".envrc" ? "direnv" : f.startsWith(".codex") ? "Codex" : "Claude Code";
324
+
325
+ /**
326
+ * A project's `.codex/` is protected whole, as `.claude/` is, and for the same
327
+ * reason plus one: it holds `config.toml` (MCP servers, profiles, and the trust
328
+ * a hook needs before Codex runs it), `hooks.json` and `rules/`, which Codex
329
+ * reads from any project the user has trusted. Codex itself keeps `.codex`
330
+ * read-only inside the directories its own sandbox lets it write, so a role
331
+ * loses nothing there that Codex would have given it.
332
+ */
333
+ const CODEX_WHY = ".codex, whose config, hooks and rules Codex runs outside the sandbox";
334
+ const controlWhy = (f) => f === ".codex" ? CODEX_WHY : `${f}, run by ${RUNNER(f)} outside the sandbox`;
335
+
336
+ /** `s` as a literal inside a RegExp — every metacharacter, not only `.`. */
337
+ const literal = (s) => s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
338
+
339
+ /** `name` as a whole path segment run, anywhere in a path. */
340
+ const segment = (name, flags = "") => new RegExp(`(^|/)${literal(name)}(/|$)`, flags);
295
341
 
296
342
  // Case-insensitive, because the disk is: on APFS `.CLAUDE/Settings.json` is
297
- // the same file, and the kernel was measured refusing it.
298
- const CONTROL_RE = new RegExp(
299
- `(^|/)(${CONTROL_FILES.map((f) => f.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")).join("|")})(/|$)`, "i");
343
+ // the same file, and the kernel was measured refusing it. Compiled once: this
344
+ // used to build one RegExp per control file on every call to protectedBy.
345
+ const CLAUDE_DIR = segment(".claude", "i");
346
+ const CONTROL_RES = CONTROL_FILES.map((c) => [c, segment(c, "i")]);
300
347
 
301
- /** Does this path name a control file, at any depth? What `explain` asks. */
302
- export const isControlPath = (p) => CONTROL_RE.test(p);
348
+ /**
349
+ * Two more families of control files, which a role may be let to edit.
350
+ *
351
+ * The ones above make a program outside the sandbox *execute* something, and no
352
+ * role writes them. These two are softer, and that is why a policy can hand
353
+ * them to a role (`control_files = ["ide"]`, `["instructions"]`) where the
354
+ * others cannot be handed to anybody:
355
+ *
356
+ * - **ide** — `.vscode/`, `.cursor/`, `.windsurf/`. The editor applies them as
357
+ * soon as they change: tasks, terminal profiles, settings that turn on tool
358
+ * auto-approval or add an MCP server. GitHub Security Lab showed an injected
359
+ * agent doing exactly that to `.vscode/settings.json`; the editor reloads
360
+ * before a person can choose Undo. Protected by default — but a front-end
361
+ * role that maintains its project's launch configs has a real reason to edit
362
+ * them, and a boundary with no way to say so gets switched off whole.
363
+ * - **instructions** — `CLAUDE.md`, `AGENTS.md` and their kin. Nothing runs
364
+ * them; the next agent session *reads* them, as instructions. One role
365
+ * writing them is how it reaches another role's context. Off by default
366
+ * (`[protect] instructions = true` turns it on), because keeping them current
367
+ * is ordinary work for most teams, and git then fights the protection: a
368
+ * role's `git checkout` cannot update a file it may not write, git exits 0,
369
+ * and the old version stays behind as a modification.
370
+ */
371
+ export const FAMILIES = {
372
+ ide: [".vscode", ".cursor", ".windsurf"],
373
+ instructions: ["CLAUDE.md", "AGENTS.md", "GEMINI.md", ".cursorrules", ".windsurfrules",
374
+ ".github/copilot-instructions.md", ".github/instructions"],
375
+ };
376
+
377
+ const EDITOR = { ".vscode": "VS Code", ".cursor": "Cursor", ".windsurf": "Windsurf" };
378
+ // The families, compiled once per case rule (see protectedBy).
379
+ const FAMILY_RES = {
380
+ fold: Object.fromEntries(Object.entries(FAMILIES).map(([f, names]) => [f, names.map((n) => [n, segment(n, "i")])])),
381
+ exact: Object.fromEntries(Object.entries(FAMILIES).map(([f, names]) => [f, names.map((n) => [n, segment(n)])])),
382
+ };
383
+
384
+ const familyWhy = (family, name) => family === "ide"
385
+ ? `${name}, whose settings and tasks ${EDITOR[name]} applies outside the sandbox`
386
+ : `${name}, which the next agent session reads as its instructions`;
387
+
388
+ /** The families this role's profile protects: the defaults, minus what the policy hands it. */
389
+ export function familiesFor(config, role) {
390
+ const mine = new Set(role?.controlFiles ?? []);
391
+ const out = [];
392
+ if (!mine.has("ide")) out.push("ide");
393
+ if (config.protect?.instructions && !mine.has("instructions")) out.push("instructions");
394
+ return out;
395
+ }
396
+
397
+ /**
398
+ * A pattern that matches exactly `name` under `root`, and only there.
399
+ *
400
+ * `**\/.vscode` would also match inside `node_modules`, and npm would then
401
+ * drop those files from the packages it unpacks, with `EPERM` in a warning and
402
+ * exit 0. Anchored at the project root it cannot. The last character goes in a
403
+ * one-letter class so the runtime treats the entry as a pattern — which costs
404
+ * nothing at start-up — and not as a literal, which costs a rule for each of
405
+ * its ancestors and would be paid for every project whether the file is there
406
+ * or not.
407
+ */
408
+ const exactly = (root, name) => `${root}/${name.slice(0, -1)}[${name.slice(-1)}]`;
303
409
 
304
410
  /** Where no control file is looked for: dependencies and build output. */
305
411
  const PRUNE = new Set(["node_modules", ".git", "vendor", "dist", "build", ".venv", "venv",
306
412
  "__pycache__", ".next", "target", ".turbo", ".cache", "Pods", ".gradle", ".dart_tool"]);
307
413
 
308
414
  /** A directory holding one of these is where somebody opens a project. */
309
- const MARKERS = new Set([".git", ".claude", ".mcp.json", ".envrc", ".codex"]);
415
+ const MARKERS = new Set([".git", ".claude", ".mcp.json", ".envrc", ".codex",
416
+ ...FAMILIES.ide, ...FAMILIES.instructions.filter((f) => !f.includes("/"))]);
310
417
 
311
418
  /**
312
419
  * How deep under a territory to look for projects.
313
420
  *
314
- * Measured on the portfolio over every territory at once: depth 3 finds 128
421
+ * Measured on a multi-repo workspace over every territory at once: depth 3 finds 128
315
422
  * project roots reading 2.6k directories (~0.4 s under load); depth 4 finds
316
423
  * 130 reading 5.5k (0.9 s); depth 6, 132 and 2.1 s. The four below depth 3
317
424
  * are `.claude` folders agents left inside source trees (`features/…`,
318
425
  * `tests/Feature/Api`), not projects anybody opens — and on macOS the pattern
319
- * layer still refuses writing into them. The whole portfolio at depth 8 is
426
+ * layer still denies writing into them. The whole workspace at depth 8 is
320
427
  * 23k directories, which is why the walk is per role and never over the repo.
321
428
  */
322
429
  const SCAN_DEPTH = 3;
@@ -344,14 +451,14 @@ function projectRoots(dir, depth = SCAN_DEPTH) {
344
451
  * (never_writes has the measurement). A `.git` that is a file is a worktree
345
452
  * pointing at its repository, and the pointer is what gets protected.
346
453
  */
347
- function controlsOf(dir, platform) {
454
+ function controlsOf(dir, platform, families = []) {
348
455
  const out = [];
349
456
  let git = null;
350
457
  try { git = statSync(join(dir, ".git")); } catch {}
351
458
  if (git?.isFile()) out.push({ path: join(dir, ".git"), why: "a worktree's pointer to its repository, read by git" });
352
459
  if (git?.isDirectory())
353
460
  for (const f of [".git/hooks", ".git/config"])
354
- out.push({ path: join(dir, f), why: `${f}, run by git outside the box` });
461
+ out.push({ path: join(dir, f), why: `${f}, run by git outside the sandbox`, ifPresent: true });
355
462
  /**
356
463
  * `.claude` whole, as one entry, rather than its seven control files.
357
464
  *
@@ -364,13 +471,21 @@ function controlsOf(dir, platform) {
364
471
  * directory Claude Code reads its instructions to itself from.
365
472
  */
366
473
  if (platform !== "linux" || existsSync(join(dir, ".claude")))
367
- out.push({ path: join(dir, ".claude"), why: ".claude, whose settings, hooks and skills Claude Code runs outside the box" });
474
+ out.push({ path: join(dir, ".claude"), why: ".claude, whose settings, hooks and skills Claude Code runs outside the sandbox" });
368
475
  // The single files only where they exist: where they do not, the patterns
369
476
  // refuse creating them, and a literal each would be start-up for nothing.
370
- for (const f of [".mcp.json", ".envrc", ".codex/config.toml"]) {
477
+ for (const f of [".mcp.json", ".envrc", ".codex"]) {
371
478
  const p = join(dir, f);
372
- if (existsSync(p)) out.push({ path: p, why: `${f}, run by ${RUNNER(f)} outside the box` });
479
+ if (existsSync(p)) out.push({ path: p, why: controlWhy(f), ifPresent: true });
373
480
  }
481
+ // The families literally only where they exist, on every platform. Where
482
+ // they do not, macOS gets a pattern for creating them (computeDenies) and
483
+ // Linux gets nothing: bubblewrap would create the path to mount over it.
484
+ for (const family of families)
485
+ for (const f of FAMILIES[family]) {
486
+ const p = join(dir, f);
487
+ if (existsSync(p)) out.push({ path: p, why: familyWhy(family, f), family });
488
+ }
374
489
  return out;
375
490
  }
376
491
 
@@ -378,21 +493,80 @@ function controlsOf(dir, platform) {
378
493
  * The home's control files, one by one — never `~/.claude` whole.
379
494
  *
380
495
  * That directory is also where Claude Code keeps its sessions, todos and
381
- * history, and it writes them from inside the box on every turn. Denying the
496
+ * history, and it writes them from inside the sandbox on every turn. Denying the
382
497
  * directory would stop the agent; denying what it executes does not (checked
383
498
  * with `claude -p` under the new profile: it answered, and what it was refused
384
499
  * were lock and cache files under plugins/, which it tolerates).
385
500
  */
386
- function homeControls(platform) {
387
- const home = homedir();
501
+ function homeControls(platform, home = homedir()) {
388
502
  return [
389
503
  ".claude/settings.json", ".claude/settings.local.json", ".claude/hooks", ".claude/plugins",
390
- ".claude/skills", ".claude/commands", ".claude/agents", ".codex/config.toml",
504
+ ".claude/skills", ".claude/commands", ".claude/agents",
505
+ ...CODEX_HOME_CONTROLS, ...codexProfiles(home),
391
506
  ]
392
- .map((f) => ({ path: join(home, f), why: `${f}, run by ${RUNNER(f)} outside the box` }))
507
+ .map((f) => ({ path: join(home, f), why: `${f}, run by ${RUNNER(f)} outside the sandbox`, ifPresent: true }))
508
+ .filter((e) => platform !== "linux" || existsSync(e.path));
509
+ }
510
+
511
+ /**
512
+ * What Codex runs or obeys from its home, file by file — never `~/.codex`
513
+ * whole, because Codex writes its sessions, logs and state databases there on
514
+ * every turn (measured 2026-09-30 with codex 0.150.1 under seisin: sessions/,
515
+ * logs_2.sqlite, state_5.sqlite, models_cache.json, cache/, plugins/cache/).
516
+ *
517
+ * - `config.toml`: MCP servers, profiles, and `hooks.state.*.trusted_hash`, the
518
+ * trust Codex asks before running a hook. Codex tries to rewrite it at every
519
+ * start and carries on when refused.
520
+ * - `hooks.json`: commands run on every tool call, outside any sandbox.
521
+ * - `rules/`: the commands Codex runs without asking.
522
+ * - `skills/`, `plugins/`: what the next session loads. Codex installs OpenAI's
523
+ * curated plugins into `plugins/cache/` by itself and carries on when refused.
524
+ * - `managed_config.toml`: a layer that overrides `config.toml`.
525
+ * - `.tmp/plugins`: Codex's copy of OpenAI's curated plugin marketplace, a git
526
+ * checkout it syncs at start. `codex plugin add` installs from it as it is on
527
+ * disk — measured on 2026-09-30 with codex 0.150.1 in a scratch CODEX_HOME: a
528
+ * file and an `.mcp.json` planted in the copy landed in `plugins/cache/` and
529
+ * the plugin was enabled. Codex re-fetches the copy only when the upstream
530
+ * commit differs from `.tmp/plugins.sha`, so an edit made while they match
531
+ * stays until upstream moves. The `.sha` file is left writable: changing it
532
+ * makes Codex fetch a clean copy, which repairs rather than plants.
533
+ */
534
+ export const CODEX_HOME_CONTROLS = [
535
+ ".codex/config.toml", ".codex/hooks.json", ".codex/rules", ".codex/skills", ".codex/plugins",
536
+ ".codex/managed_config.toml", ".codex/.tmp/plugins",
537
+ ];
538
+
539
+ /**
540
+ * `CODEX_HOME`, when it names somewhere other than `~/.codex`, absolute and
541
+ * resolved — or null. Codex takes it from the environment of whoever runs it,
542
+ * so the parent's environment is the one that says where your Codex looks.
543
+ */
544
+ export function codexHomeOf(env = process.env) {
545
+ const v = env?.CODEX_HOME;
546
+ if (!v || !isAbsolute(v)) return null;
547
+ const real = realOrSelf(v);
548
+ return real === realOrSelf(join(homedir(), ".codex")) ? null : real;
549
+ }
550
+
551
+ /** The control entries of a Codex home that is not `~/.codex`, by the same list. */
552
+ function codexControlsAt(dir, platform) {
553
+ let profiles = [];
554
+ try { profiles = readdirSync(dir).filter((f) => f.endsWith(".config.toml") && f !== "config.toml"); } catch {}
555
+ return [...CODEX_HOME_CONTROLS.map((c) => c.replace(/^\.codex\//, "")), ...profiles]
556
+ .map((f) => ({ path: join(dir, f), why: `CODEX_HOME/${f}, run by Codex outside the sandbox`, ifPresent: true }))
393
557
  .filter((e) => platform !== "linux" || existsSync(e.path));
394
558
  }
395
559
 
560
+ /** `codex -p <name>` layers `~/.codex/<name>.config.toml`: the ones there now. */
561
+ function codexProfiles(home) {
562
+ try {
563
+ return readdirSync(join(home, ".codex")).filter((f) => f.endsWith(".config.toml") && f !== "config.toml")
564
+ .map((f) => `.codex/${f}`);
565
+ } catch {
566
+ return [];
567
+ }
568
+ }
569
+
396
570
  /**
397
571
  * Everything this role's profile must deny beyond its own `never_writes`.
398
572
  *
@@ -407,9 +581,9 @@ const ROOTS = new WeakMap(); // config -> dir -> project roots under
407
581
 
408
582
  export function denyFor(config, role, { env = process.env, platform = process.platform, observe = false } = {}) {
409
583
  // Memoised per config: `check` builds every role's settings and then asks
410
- // for the same list again to show it; on the portfolio that was 2.6 s twice.
584
+ // for the same list again to show it; on a multi-repo workspace that was 2.6 s twice.
411
585
  const memo = DENIES.get(config) ?? DENIES.set(config, new Map()).get(config);
412
- const key = `${role.name}\0${observe}\0${platform}\0${env.PATH ?? ""}`;
586
+ const key = `${role.name}\0${observe}\0${platform}\0${env.PATH ?? ""}\0${env.CODEX_HOME ?? ""}`;
413
587
  if (memo.has(key)) return memo.get(key);
414
588
  const result = computeDenies(config, role, { env, platform, observe });
415
589
  memo.set(key, result);
@@ -432,9 +606,18 @@ function computeDenies(config, role, { env, platform, observe }) {
432
606
 
433
607
  const dirs = mine.filter((p) => { try { return statSync(p).isDirectory(); } catch { return false; } });
434
608
  const repoDirs = dirs.filter((p) => under(p, config.root));
435
- const controls = repoDirs.flatMap((d) => rootsUnder(config, d)).flatMap((d) => controlsOf(d, platform));
609
+ const families = familiesFor(config, role);
610
+ // Discover projects from the policy root, not from the territory. A territory
611
+ // may itself be .claude; starting the walk there called <repo>/.claude/.claude
612
+ // protected and left the real settings and hooks writable. A project's
613
+ // control files win over every grant that covers or sits inside the project.
614
+ const roots = [...new Set(rootsUnder(config, config.root)
615
+ .filter((d) => minePaths.some((p) => under(p, d) || under(d, p))))];
616
+ const controls = roots.flatMap((d) => controlsOf(d, platform, families))
617
+ .filter((e) => inMine(e.path) || minePaths.some((p) => under(p, e.path)));
436
618
  // The home is a project too, for Claude Code and Codex: ~/.claude/settings.json
437
619
  // is read by every session. Not walked — only its own control files.
620
+ const codexHome = join(homedir(), ".codex");
438
621
  if (["~/.claude", "~/.codex"].some((h) => inMine(join(homedir(), h.slice(2)))))
439
622
  controls.push(...homeControls(platform));
440
623
 
@@ -449,28 +632,47 @@ function computeDenies(config, role, { env, platform, observe }) {
449
632
  * files stay literal (homeControls).
450
633
  */
451
634
  const globs = platform === "darwin"
452
- ? repoDirs.flatMap((d) => [
635
+ ? roots.flatMap((d) => [
453
636
  `${d}/**/.claude`, `${d}/**/.claude/**`,
454
637
  `${d}/**/.git/hooks/**`, `${d}/**/.git/config`,
455
638
  // What git runs that lives beside, not in, `.git/config`: a submodule's
456
639
  // own config and hooks, and the files that redirect a worktree to
457
640
  // another gitdir or config. Ordinary commits never write these, so
458
641
  // denying them costs a working role nothing; a role that could write
459
- // one would leave a command for the next `git` outside the box. The
642
+ // one would leave a command for the next `git` outside the sandbox. The
460
643
  // subtree of `.git` stays writable on purpose, so the agent can commit.
461
644
  `${d}/**/.git/config.worktree`,
462
645
  `${d}/**/.git/modules/**/config`, `${d}/**/.git/modules/**/hooks/**`,
463
646
  `${d}/**/.git/worktrees/**/config.worktree`, `${d}/**/.git/worktrees/**/commondir`,
464
- `${d}/**/.mcp.json`, `${d}/**/.envrc`, `${d}/**/.codex/config.toml`,
647
+ `${d}/**/.mcp.json`, `${d}/**/.envrc`, `${d}/**/.codex`, `${d}/**/.codex/**`,
465
648
  ]).map((path) => ({ path, why: "any project's control files in this territory, by pattern" }))
466
649
  : [];
650
+ // A profile created after the walk: `codex -p x` reads `~/.codex/x.config.toml`.
651
+ if (platform === "darwin" && inMine(codexHome))
652
+ globs.push({ path: `${codexHome}/*.config.toml`, why: "a Codex profile, if it is created" });
653
+ // `CODEX_HOME` moves all of it. Codex run with it set — by you, outside the
654
+ // sandbox — reads its config, hooks, rules and plugins from there, so when a
655
+ // role can write that directory the same entries are taken back there.
656
+ const elsewhere = codexHomeOf(env);
657
+ if (elsewhere && inMine(elsewhere)) {
658
+ controls.push(...codexControlsAt(elsewhere, platform));
659
+ if (platform === "darwin")
660
+ globs.push({ path: `${elsewhere}/*.config.toml`, why: "a Codex profile under CODEX_HOME, if it is created" });
661
+ }
662
+ // Creating one of the families where it is not there yet: one exact pattern
663
+ // per project root, never `**` (see `exactly`).
664
+ if (platform === "darwin")
665
+ for (const root of roots)
666
+ for (const family of families)
667
+ for (const f of FAMILIES[family])
668
+ if (inMine(join(root, f)) && !existsSync(join(root, f)))
669
+ globs.push({ path: exactly(root, f), why: `${familyWhy(family, f)}, if it is created`, family });
467
670
 
468
671
  // The policy, `.seisin/` and the key directories are denied whatever the
469
672
  // territory, as they always were. What the providers and `file://` keys
470
673
  // point at is denied where this role can write it: `/usr/bin/security` in
471
674
  // every profile would be a rule about the machine, and no role reaches it.
472
- const always = new Set(["the policy", "seisin's log and request queue", "a key directory"]);
473
- const inputs = parentInputs(config, { env }).filter((e) => always.has(e.why) || inMine(e.path));
675
+ const inputs = parentInputs(config, { env }).filter((e) => e.always || inMine(e.path));
474
676
 
475
677
  return dedupe([...inputs, ...surface, ...controls, ...globs]);
476
678
  }
@@ -482,27 +684,83 @@ function computeDenies(config, role, { env, platform, observe }) {
482
684
  * to be filed as "belongs to dev" for a role with `writes = ["**"]`, and
483
685
  * granting it would have granted nothing.
484
686
  */
485
- export function protectedBy(config, target, { platform = process.platform } = {}) {
687
+ export function protectedBy(config, target, { platform = process.platform, role = null, env = process.env } = {}) {
486
688
  /**
487
689
  * A project's `.claude/`, any file in it, and the other control files — the
488
690
  * same rule the kernel is given. On Linux the kernel only holds the ones
489
691
  * that exist, so only those are called protected there: a sentence stricter
490
692
  * than the boundary is the direction this project refuses.
491
693
  */
492
- const f = /(^|\/)\.claude(\/|$)/i.test(target)
694
+ /**
695
+ * Codex's home is not a project's `.codex`: Codex writes its sessions there
696
+ * on every turn and the kernel lets it. Only the entries it runs or obeys are
697
+ * protected, so only those are called protected.
698
+ */
699
+ const codexHome = join(homedir(), ".codex");
700
+ const elsewhere = codexHomeOf(env);
701
+ if (elsewhere && isAbsolute(target) && under(target, elsewhere)) {
702
+ const rel = target.slice(elsewhere.length + 1);
703
+ const hit = CODEX_HOME_CONTROLS.map((c) => c.replace(/^\.codex\//, "")).find((c) => under(rel, c)) ??
704
+ (dirname(target) === elsewhere && target.endsWith(".config.toml") ? rel : null);
705
+ return hit && (platform !== "linux" || existsSync(join(elsewhere, hit)))
706
+ ? { path: target, why: `CODEX_HOME/${hit}, run by Codex outside the sandbox` }
707
+ : null;
708
+ }
709
+ if (isAbsolute(target) && under(target, codexHome)) {
710
+ const rel = target.slice(homedir().length + 1);
711
+ const hit = [...CODEX_HOME_CONTROLS].find((c) => under(rel, c)) ??
712
+ (dirname(target) === codexHome && target.endsWith(".config.toml") ? rel : null);
713
+ return hit && (platform !== "linux" || existsSync(join(homedir(), hit)))
714
+ ? { path: target, why: `${hit}, run by Codex outside the sandbox` }
715
+ : null;
716
+ }
717
+ const f = CLAUDE_DIR.test(target)
493
718
  ? ".claude"
494
- : CONTROL_FILES.find((c) => new RegExp(`(^|/)${c.replace(/\./g, "\\.")}(/|$)`, "i").test(target));
495
- if (f && (platform !== "linux" || existsSync(isAbsolute(target) ? target : join(config.root, target)))) {
719
+ : CONTROL_RES.find(([, re]) => re.test(target))?.[0];
720
+ const re = f === ".claude" ? CLAUDE_DIR : CONTROL_RES.find(([name]) => name === f)?.[1];
721
+ if (f && (platform !== "linux" || heldOnLinux(config, target, re))) {
496
722
  const why = f === ".claude"
497
- ? ".claude, whose settings, hooks and skills Claude Code runs outside the box"
498
- : `${f}, run by ${RUNNER(f)} outside the box`;
723
+ ? ".claude, whose settings, hooks and skills Claude Code runs outside the sandbox"
724
+ : controlWhy(f);
499
725
  return { path: target, why };
500
726
  }
727
+ /**
728
+ * The two families, as the kernel is given them. Case-insensitive on macOS
729
+ * only: APFS folds case and the kernel refuses `.VSCODE/`, ext4 does not, and
730
+ * saying "protected" about a spelling the kernel lets through is the one
731
+ * direction this project refuses. With a role, what the policy hands that
732
+ * role is not protected for it; without one, the answer carries the family
733
+ * so the caller can ask whose it is.
734
+ */
735
+ const active = role ? familiesFor(config, config.roles?.[role]) : familiesFor(config, null);
736
+ const compiled = platform === "darwin" ? FAMILY_RES.fold : FAMILY_RES.exact;
737
+ for (const family of active)
738
+ for (const [name, re] of compiled[family]) {
739
+ if (!re.test(target)) continue;
740
+ if (platform === "linux" && !heldOnLinux(config, target, re)) continue;
741
+ return { path: target, why: familyWhy(family, name), family };
742
+ }
501
743
  const full = realOrSelf(isAbsolute(target) ? target : join(config.root, target));
502
744
  return parentInputs(config)
503
745
  .find((e) => under(full, realOrSelf(e.path)) || under(join(config.root, target), e.path)) ?? null;
504
746
  }
505
747
 
748
+ /**
749
+ * Whether the Linux kernel holds `target`: the path itself, or a directory
750
+ * above it that the same rule names, exists — bubblewrap denies what exists,
751
+ * and a denied directory takes everything created inside it. Checking only the
752
+ * target called `web/.vscode/settings.json` unprotected while `web/.vscode` was
753
+ * denied whole, so a grant for it went through and changed nothing.
754
+ */
755
+ function heldOnLinux(config, target, re) {
756
+ const full = isAbsolute(target) ? target : join(config.root, target);
757
+ for (let at = full; ; at = dirname(at)) {
758
+ const rel = at.startsWith(config.root + sep) ? at.slice(config.root.length + 1) : at;
759
+ if (re.test(rel) && existsSync(at)) return true;
760
+ if (at === config.root || dirname(at) === at || !re.test(rel)) return false;
761
+ }
762
+ }
763
+
506
764
  /**
507
765
  * What `seisin check` shows: every protection that takes something away from a
508
766
  * role — a path inside its territory that it will not be able to write — with
@@ -514,19 +772,21 @@ export function protectedBy(config, target, { platform = process.platform } = {}
514
772
  * surface.js.
515
773
  */
516
774
  export function protections(config, roles = Object.values(config.roles), opts = {}) {
517
- const always = new Set(["the policy", "seisin's log and request queue", "a key directory"]);
518
775
  const byPath = new Map();
519
776
  for (const role of roles) {
520
777
  let entries;
521
778
  try { entries = denyFor(config, role, opts); } catch { continue; }
522
779
  for (const e of entries) {
523
- if (always.has(e.why) || e.path.includes("*")) continue;
780
+ if (e.always || /[*?[\]]/.test(e.path)) continue;
524
781
  // Only what the role could otherwise write: a literal control-file path
525
782
  // is listed for every project in the territory whether it exists or not,
526
783
  // and the ones that do not exist are shown only if their project does.
527
- const hit = byPath.get(e.path) ?? byPath.set(e.path, { path: e.path, why: e.why, roles: [] }).get(e.path);
784
+ const hit = byPath.get(e.path) ??
785
+ byPath.set(e.path, { path: e.path, why: e.why, roles: [], ifPresent: e.ifPresent === true }).get(e.path);
528
786
  hit.roles.push(role.name);
529
787
  }
530
788
  }
531
- return [...byPath.values()].filter((e) => existsSync(e.path) || !e.why.includes(" run by "));
789
+ return [...byPath.values()]
790
+ .filter((e) => !e.ifPresent || existsSync(e.path))
791
+ .map(({ ifPresent, ...e }) => e);
532
792
  }