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/README.md CHANGED
@@ -11,13 +11,13 @@
11
11
 
12
12
  > **seisin** *(n.)* — the legal possession of a piece of land. Not who owns it on paper: who holds it now.
13
13
 
14
- **Give each AI agent its own folders and its own keys.** The kernel enforces it, and when it blocks something it tells you *whose* file it was.
14
+ **Give each AI agent its own folders and its own keys.** The kernel enforces it, and when it denies something, seisin tells you *whose* file it was.
15
15
 
16
16
  A **permission layer**, not a sandbox — it sits on top of one. The isolation comes from the OS; what seisin adds is the part an OS cannot know: which role a path belongs to, and therefore who to ask next.
17
17
 
18
18
  *Two outside reviews went looking for ways past the boundary. [What they found, what broke, and what is still open →](docs/what-it-has-been-put-through.md)*
19
19
 
20
- *And what running four cells of agents behind it actually cost — including the numbers that did not survive a re-check. [Field notes →](docs/field-notes.md)*
20
+ *And what running four teams of agents behind it actually cost — including the numbers that did not survive a re-check. [Field notes →](docs/field-notes.md)*
21
21
 
22
22
  **Three ways in, and they say the same thing.** The **CLI** is what a person types and what
23
23
  every agent runs under. The **console** (`seisin ui`) is for a person deciding something. The
@@ -38,7 +38,7 @@ says why, and a test holds one half of it.
38
38
 
39
39
  **The honest parts** · [How it holds](#how-it-holds) · [What it is not](#what-it-is-not) · [Why not a container](#why-not-a-container) · [Prior art](#prior-art) · [Status](#status)
40
40
 
41
- **Deeper** · [Keys](docs/keys.md) · [Agents](docs/agents.md) · [Day one](docs/first-day.md) · [Scratch](docs/scratch.md) · [Glossary](docs/glossary.md) · [What it has been put through](docs/what-it-has-been-put-through.md) · [Field notes](docs/field-notes.md) · [Decisions](docs/decisions.md) · [Permission requests](docs/permission-requests.md) · [Contributing](CONTRIBUTING.md)
41
+ **Deeper** · [Keys](docs/keys.md) · [Agents](docs/agents.md) · [Day one](docs/first-day.md) · [Scratch](docs/scratch.md) · [Glossary](docs/glossary.md) · [What it has been put through](docs/what-it-has-been-put-through.md) · [Field notes](docs/field-notes.md) · [OpenShell compatibility](docs/openshell.md) · [Decisions](docs/decisions.md) · [nono backend contract](docs/nono-backend.md) · [Requests](docs/permission-requests.md) · [Contributing](CONTRIBUTING.md)
42
42
 
43
43
  </details>
44
44
 
@@ -46,7 +46,7 @@ says why, and a test holds one half of it.
46
46
  flowchart LR
47
47
  A["agent<br/>running as a role"] -->|writes src/api/orders.ts| K{{"the kernel<br/>Seatbelt · bubblewrap"}}
48
48
  K -->|not this role's| D["denied"]
49
- D --> S["seisin reads the refusal<br/>and looks up the policy"]
49
+ D --> S["seisin reads the denial<br/>and looks up the policy"]
50
50
  S --> W["<b>whose it was</b><br/>belongs to backend"]
51
51
  S --> Q["a request, queued"]
52
52
  Q --> H(["a person<br/>grants or declines"])
@@ -60,7 +60,7 @@ flowchart LR
60
60
 
61
61
  **The kernel says no. seisin says whose.** That second half is the whole thing: other
62
62
  permission layers in this space answer *yes* or *no*, or at best *which rule* said no — and a
63
- refusal that also names an owner turns a dead end into a handoff.
63
+ denial that also names an owner turns a dead end into a handoff.
64
64
 
65
65
  ![seisin denying a write outside a role's territory, then naming the owner](docs/img/demo.gif)
66
66
 
@@ -77,11 +77,11 @@ $ seisin run frontend -- sh -c 'echo // fix >> src/api/orders.ts'
77
77
  seisin grant <n> [--reason "…"] · seisin decline <n> [--reason "…"]
78
78
  ```
79
79
 
80
- **Whose it was, at the moment it was denied.** Other permission layers answer *yes* or *no*, and the good ones say which rule it was and how to widen it. Answering **"no, and it belongs to `backend`"** turns a block into a handoff — and one a person can approve in a command, rather than a line somebody has to remember to go and read.
80
+ **Whose it was, at the moment it was denied.** Other permission layers answer *yes* or *no*, and the good ones say which rule it was and how to widen it. Answering **"no, and it belongs to `backend`"** turns a denial into a handoff — and one a person can grant in a command, rather than a line somebody has to remember to go and read.
81
81
 
82
- The kernel is what denies; the name comes from the policy. That first line is all the boundary itself can say — no path, no reason, nothing to read afterwards — so seisin reads the refusal out of the kernel's own log and answers the question it leaves open. On Linux only the hook can do that; [the ask is upstream](docs/upstream/cli-violations.md) as [issue #582](https://github.com/anthropics/sandbox-runtime/issues/582).
82
+ The kernel is what denies; the name comes from the policy. That first line is all the boundary itself can say — no path, no reason, nothing to read afterwards — so seisin reads the denial out of the kernel's own log and answers the question it leaves open. On Linux only the hook can do that; [the ask is upstream](docs/upstream/cli-violations.md) as [issue #582](https://github.com/anthropics/sandbox-runtime/issues/582).
83
83
 
84
- The agent can ask directly too, from inside the box:
84
+ The agent can ask directly too, from inside the sandbox:
85
85
 
86
86
  ```
87
87
  $ seisin run frontend -- seisin whose src/api/orders.ts
@@ -106,7 +106,7 @@ On Linux, [three system packages first](#install). Everything below is why it wo
106
106
 
107
107
  ## Why this exists
108
108
 
109
- Two things are true about agent permissions today, and both are in the official docs.
109
+ Two things are true about how agents are kept in bounds today, and both are in the official docs.
110
110
 
111
111
  **1. Permission rules match text, not behaviour.** From the Claude Code documentation:
112
112
 
@@ -157,12 +157,12 @@ fixtures:
157
157
  | it can write anything inside its own territory | including staging an exfiltration, or destroying its own uncommitted work with `rm` |
158
158
  | `base64` defeats the redaction | only the literal value is masked, and only on stdout and stderr |
159
159
  | an allowed domain is an allowed channel | if the agent's own API is reachable, so is everything that goes through it |
160
- | the hook is text-based and evadable | on purpose — what escapes it goes **unblocked by nothing**: the kernel still refuses. On macOS it is no longer unexplained either, because [the kernel's own refusals are recorded](#seeing-what-happened); on Linux it is. Either way, attribution is not a control |
160
+ | the hook is text-based and evadable | on purpose — what escapes it goes **past the hook, not past the kernel**: the kernel still denies. On macOS it is no longer unexplained either, because [the kernel's own denials are recorded](#seeing-what-happened); on Linux it is. Either way, attribution is not a control |
161
161
 
162
162
  And three things that outweigh all of the above:
163
163
 
164
- 1. **It is days old and has one author.** Around three thousand lines that nobody has
165
- audited except its writer and one field report. "Secure" is not a word earned that fast.
164
+ 1. **It is young and has one author.** The first public release was September 2026, and the
165
+ implementation is still moving quickly. "Secure" is not a word earned by size or test count.
166
166
  2. **The enforcement is someone else's beta.** `sandbox-runtime` describes itself as a
167
167
  research preview with an evolving API. A hole there is a hole here.
168
168
  3. **Two platforms tested, by one person, on one machine each.**
@@ -215,6 +215,25 @@ seisin wire # install the PreToolUse hook in this r
215
215
  seisin ui # a console for editing the map
216
216
  ```
217
217
 
218
+ `seisin <command> --help` lists a command's flags, examples and exit codes. The exit codes, so
219
+ the commands compose in a script, a pre-commit hook or CI:
220
+
221
+ | command | 0 | 1 | other |
222
+ |---|---|---|---|
223
+ | `run` | — | — | the command's own status; 127 if it is not on the role's PATH; 128+n when killed by signal n (130 for ctrl-c) |
224
+ | `check` | the policy can be enforced (warnings included) | a role cannot be enforced as written | |
225
+ | `explain` | allowed | denied | |
226
+ | `scan` | no certain credential | a certain credential outside the key dirs | |
227
+ | `review` | no role denied repeatedly | a role denied repeatedly | |
228
+ | `walls` | no walls | walls listed | |
229
+ | `requests` | nothing pending | requests pending | |
230
+ | `log verify` | the hash chain holds | the hash chain is broken | |
231
+ | `whose`, `grant`, `decline`, `log`, `init`, `wire`, `ui`, `watch`, `mcp` | done | — | |
232
+ | `hook` | always | — | |
233
+
234
+ Every command exits **2** on a usage error — an unknown command, flag, role or action — and when
235
+ there is no `seisin.toml` here or above.
236
+
218
237
  ## Configure
219
238
 
220
239
  One file at the root of your repo. Anything not listed is denied — there is no permissive default.
@@ -247,7 +266,7 @@ role, including the ones whose whole job is to have nothing to reach.
247
266
  > cannot resolve the hostname, and `git ls-remote git@github.com:…` reaches the proxy and
248
267
  > dies at the handshake.
249
268
  >
250
- > **Use an HTTPS remote for git**, and run SSH deploys outside the confined turn.
269
+ > **Use an HTTPS remote for git**, and run SSH deploys outside the confined run.
251
270
  > [Why, exactly, and whose gap it is →](docs/decisions.md#ssh-does-not-work-and-it-is-not-seisin-that-decided-that)
252
271
 
253
272
  A role can also give something back. `never_writes` subtracts from its own `writes`, and it
@@ -260,8 +279,8 @@ never_writes = ["services/api/.git/index.lock"] # works in a worktree; the can
260
279
  ```
261
280
 
262
281
  It is per role on purpose — there is no global version, because a global deny on a lock file
263
- breaks every role that legitimately commits there. A refusal it causes says so by name and
264
- leaves **no request** in the queue: approving one would undo a subtraction somebody wrote. An
282
+ breaks every role that legitimately commits there. A denial it causes says so by name and
283
+ leaves **no request** in the queue: granting one would undo a subtraction somebody wrote. An
265
284
  entry that no `writes` of the same role covers, or a misspelling like `never_write`, is named
266
285
  by `seisin check` instead of being ignored.
267
286
 
@@ -275,8 +294,8 @@ private loopback, serves without the key, and reaches nothing on the host either
275
294
  If a role only needs to *reach* a local service, name the ports instead:
276
295
  `local_ports = [8001, 8081]`. The role reaches those and nothing else on localhost, on macOS
277
296
  and Linux. The sandbox's proxy enforces the list, so the client has to use the proxy. curl,
278
- Node's `fetch` and Python's `urllib` do. A database driver or a raw socket don't, and get
279
- refused. For Chromium, pass Playwright's `proxy` option from `HTTP_PROXY`; on macOS it also
297
+ Node's `fetch` and Python's `urllib` do. A database driver or a raw socket don't, and are
298
+ denied. For Chromium, pass Playwright's `proxy` option from `HTTP_PROXY`; on macOS it also
280
299
  needs `--single-process` to start. A listed port is open to anything that speaks `CONNECT`, not
281
300
  just HTTP, so list the service, not the protocol.
282
301
 
@@ -286,7 +305,33 @@ key means the policy doesn't say. seisin declares this and `explain` answers it
286
305
  launcher has to apply it. With Claude Code: `--mcp-config` holding only those servers, plus
287
306
  `--strict-mcp-config`.
288
307
 
289
- Refused connections are logged too: `connect tcp:<port>` (the kernel gives the port, not the
308
+ Some files are not code but still make something happen outside the sandbox, and no territory
309
+ reaches them: git hooks and config, a project's `.claude/`, `.mcp.json`, `.envrc`. Two softer
310
+ families sit beside those, and a policy can hand them to one role:
311
+
312
+ ```toml
313
+ [roles.frontend]
314
+ writes = ["apps/web/**"]
315
+ control_files = ["ide"] # keeps apps/web/.vscode/launch.json current
316
+
317
+ [protect]
318
+ instructions = true # CLAUDE.md, AGENTS.md… only for roles handed "instructions"
319
+ ```
320
+
321
+ - **`ide`** — `.vscode/`, `.cursor/`, `.windsurf/`. Your editor applies them as soon as they
322
+ change, which is how an injected agent turns on tool auto-approval. **Protected by default.**
323
+ - **`instructions`** — `CLAUDE.md`, `AGENTS.md`, `GEMINI.md`, Cursor and Windsurf rules, Copilot
324
+ instructions. Nothing runs them; the next agent session reads them as its instructions, so
325
+ one role writing them reaches another role's context. **Off unless `[protect]` turns it on**:
326
+ keeping them current is ordinary work for most teams.
327
+
328
+ A role handed a family writes it only inside its own `writes`, and `seisin check` names it on
329
+ every run. A denial says which setting would change it, and leaves no request: it is a decision
330
+ about the policy, not a grant. Inside `node_modules` neither family is touched, so npm still
331
+ unpacks a package's own `.vscode` or `AGENTS.md`. On Linux only the files that already exist are
332
+ protected: bubblewrap would have to create a missing one to deny it.
333
+
334
+ Denied connections are logged too: `connect tcp:<port>` (the kernel gives the port, not the
290
335
  host) or the socket path, once per target per run. `seisin walls` says whether the port is
291
336
  missing from `local_ports`, listed but dialled directly, or a socket. No request is filed: a
292
337
  port isn't anyone's territory. macOS only.
@@ -302,6 +347,11 @@ answer is ownership.
302
347
 
303
348
  All four came out of one production window where none of them was the boundary misbehaving.
304
349
 
350
+ A fifth comes with protected files: **a role's `git checkout`, `pull` or `rebase` cannot update
351
+ a file the role may not write.** Git exits 0, the branch moves, and the old `.vscode/settings.json`
352
+ stays behind as a modification — which a later `commit -a` would turn into a revert. Switch
353
+ branches outside the sandbox, or hand that role the family if keeping those files is its job.
354
+
305
355
  **[What each one looks like, and what to do →](docs/first-day.md)**
306
356
 
307
357
  ## Which agents it has been run with
@@ -324,18 +374,34 @@ actually happened and tells you where the config was wrong about it.
324
374
  ```
325
375
  $ seisin review
326
376
 
327
- 60 decisions, 2026-08-23 to 2026-09-11
377
+ 60 entries, 2026-08-23 to 2026-09-11
378
+
379
+ Denied repeatedly
380
+ a role denied on the same place over and over is a policy that is wrong,
381
+ not an agent that is misbehaving
328
382
 
329
- Stopped, repeatedly
330
383
  9× frontend write src/api/checkout — belongs to backend
331
384
  4× backend write src/web — belongs to frontend
332
385
 
386
+ seisin grant, or move the territory. Either way it is a decision, not noise.
387
+
333
388
  Granted, never used
389
+ nothing was written here in the window above. This is the only evidence
390
+ anyone will ever have for making a policy smaller
391
+
334
392
  frontend public/**
335
- backend migrations/**
393
+ backend migrations/**
336
394
 
337
- Owned by nobody
338
- 5× legacy
395
+ A short window proves nothing. Check the dates before you delete a line.
396
+
397
+ Unowned
398
+ touched by an agent, claimed by no role. Only an ownable path (kind `territory`)
399
+ is a hole in the map; the rest is a kind of thing with its own answer, and none
400
+ of them is an owner
401
+
402
+ 4× territory legacy
403
+
404
+ territory: an ownable path no role claims: a real decision — give it an owner, or tell the agent not to write there
339
405
  ```
340
406
 
341
407
  Three questions, and the middle one is the reason this exists. **Every
@@ -344,7 +410,7 @@ reason: nobody can prove a line is dead, so the safe move is to leave it. Here
344
410
  the log can prove it — `public/**` was granted and nothing was written there in
345
411
  three weeks. That is the only direction that makes a policy *smaller*.
346
412
 
347
- The first is the one that changes how a denial reads. Forty blocks on one
413
+ The first is the one that changes how a denial reads. Forty denials on one
348
414
  directory is not an agent misbehaving; it is a policy that is wrong, and until
349
415
  you add them up it looks like forty tidy amber lines.
350
416
 
@@ -361,10 +427,17 @@ the first unjustified denial is when the tool gets uninstalled. So don't write i
361
427
  write:
362
428
 
363
429
  ```bash
364
- seisin run frontend --observe -- claude -p "…" # records, denies nothing
430
+ seisin wire # the hook is what records
431
+ seisin run frontend --observe -- claude -p "…" # the repo opens, the network does not
365
432
  seisin init --from-observations # writes seisin.toml.observed
366
433
  ```
367
434
 
435
+ What `--observe` changes: the whole repo is writable for that run, so a write outside the
436
+ territory is recorded instead of refused. What it does not: the key directories, the policy and
437
+ `.seisin/` stay denied, and so does every domain not on the allowlist. What gets recorded is what
438
+ the hook sees — the tool calls of an agent (Claude Code, Codex…) in a repo where `seisin wire` ran.
439
+ A plain shell command (`--observe -- npm test`) goes through no hook and records nothing.
440
+
368
441
  It lands as `.observed`, not as your config. A policy generated behind your back is not a
369
442
  policy: diff it, then move it.
370
443
 
@@ -372,8 +445,8 @@ policy: diff it, then move it.
372
445
 
373
446
  A permission tool that can only say no is a tool people uninstall — the loop of *denied,
374
447
  stop, edit a config, run again* is three context switches for one line of policy, and the
375
- cheapest way to stop it is to widen the policy and never look again. That is how a permission
376
- file becomes seven hundred entries nobody reads.
448
+ cheapest way to stop it is to widen the policy and never look again. That is how an allowlist
449
+ becomes seven hundred entries nobody reads.
377
450
 
378
451
  So a denial leaves something to act on, and many denials in one directory are **one** request:
379
452
 
@@ -392,7 +465,7 @@ The grant writes its own provenance next to the line it adds:
392
465
  writes = ["src/api/**"] # granted 2026-09-12 · asked 3× · "frontend owns checkout now"
393
466
  ```
394
467
 
395
- **Approving is never a tool call.** An agent can read the queue and draft the change; only a
468
+ **Granting is never a tool call.** An agent can read the queue and draft the change; only a
396
469
  person applies it, in a terminal or in the console. That asymmetry is deliberate and it is
397
470
  the reason the MCP server exists at all.
398
471
 
@@ -400,7 +473,7 @@ the reason the MCP server exists at all.
400
473
  request — the role, the path, whose it is, and `seisin grant <n>` — to ntfy, Slack or any
401
474
  webhook. The URL lives in a key directory no role declares (`url_file`) or in the parent's
402
475
  `SEISIN_NOTIFY_URL`; written plainly into `seisin.toml`, which every role can read, it refuses to
403
- load, because whoever reads it can send you a fake "approve".
476
+ load, because whoever reads it can send you a fake "grant this".
404
477
 
405
478
  ```toml
406
479
  [notify]
@@ -409,8 +482,8 @@ format = "text" # or "slack", "json"
409
482
  ```
410
483
 
411
484
  **And so does the agent.** `seisin wire` installs the hook for four events. Before a tool call
412
- it explains a refusal it can see coming; after a command fails it reads the kernel's own refusal
413
- from the log and tells the agent whose the path was and how many times it has been refused —
485
+ it explains a denial it can see coming; after a command fails it reads the kernel's own denial
486
+ from the log and tells the agent whose the path was and how many times it has been denied —
414
487
  the half PreToolUse never sees; and when a session starts, resumes or is compacted it hands the
415
488
  agent its territory and its walls, before the first one.
416
489
 
@@ -458,23 +531,27 @@ database, and `watch` is a tail.
458
531
  **The agent cannot reach that file.** `.seisin/` is in no role's territory, because a record
459
532
  the recorded process can edit is not a record. The hook sends each line to `seisin run` over
460
533
  a unix socket granted by path and the parent does the writing, so the only verb available
461
- from inside the box is *append one line*. A hostile agent can still add noise to its own
534
+ from inside the sandbox is *append one line*. A hostile agent can still add noise to its own
462
535
  history; what it cannot do is change what is already there.
463
536
 
464
537
  **Two writers, and they see different things.** The **hook** reports the attempt before it
465
538
  happens — which is how an *allowed* action gets recorded at all — and it is text-based and
466
539
  evadable, on purpose. The **kernel** reports what it actually denied, and is not fooled by
467
- anything. What escapes the hook goes unexplained, never unblocked.
540
+ anything. What escapes the hook goes unexplained, never unenforced.
468
541
 
469
542
 
470
543
 
471
544
  ![the console: what is waiting on a decision, the day's denials grouped by cause, the walls a role keeps hitting, and the policy behind them](docs/img/console.gif)
472
545
 
473
- It opens on what needs a decision, not on the policy. Five numbers ride on every screen and
546
+ It opens on what needs a decision, not on the policy. Four numbers ride on every screen and
474
547
  each is a link to the page that explains it — including two a log cannot answer, because
475
548
  they are recomputed against the policy: how much of the day is **one cause**, and how many
476
549
  of those causes are on paths **nobody owns**.
477
550
 
551
+ Beside each role it also holds the two families a policy can hand out, editor settings and
552
+ instruction files, and the `[protect] instructions` switch. A change there is shown first as
553
+ what it does to each role's sandbox settings, and written to `seisin.toml` only when you confirm.
554
+
478
555
  ## How it holds
479
556
 
480
557
  | layer | what it does | can it be talked around? |
@@ -550,7 +627,7 @@ confined process. Nothing degrades: a provider that fails stops the run.
550
627
 
551
628
  **[Key directories, references, providers, delivery modes and nine recipes →](docs/keys.md)**
552
629
 
553
- **Measured, over four agent cells and three days: every refused read was a read of something
630
+ **Measured, over four teams of agents and three days: every denied read was a read of something
554
631
  the policy had declared a key.** 148 of them, no exceptions, from five roles — all running
555
632
  searches that swept a repository root. That is how a key gets read without anyone deciding
556
633
  it should.
@@ -560,7 +637,7 @@ it should.
560
637
  seisin answers *whose is this* at the moment of the denial — once, mid-turn — and then the
561
638
  sentence is gone. Nothing carries it forward, so the agent tries again.
562
639
 
563
- Measured on a real team: of **345 denials, 88 (25%) were a repeat of something that same role
640
+ Measured in the author's own multi-agent setup: of **345 denials, 88 (25%) were a repeat of something that same role
564
641
  had already been denied.** One role spent 37 calls on two walls, hitting one of them nineteen
565
642
  times. Every denial was correct. Not one was a false positive. It still cost thirty-seven
566
643
  calls, because *correct* and *heard* are different properties and only the first was being
@@ -578,20 +655,31 @@ And the standing list is a command:
578
655
 
579
656
  ```
580
657
  $ seisin walls dev
581
- WALLS — you have already been refused these, and the policy still refuses them.
582
- Do not retry; the reason is where the other way in is.
583
- 19× write ../equipo/.git/index.lock
584
- ../equipo/.git/index.lock belongs to plataforma-dev
585
- 6× read ~/.npmrc
586
- no role declares ~/.npmrc — add it under a [roles.<name>] keys list
587
- (23 of your calls went into retrying these.)
658
+
659
+ dev keeps hitting:
660
+
661
+ 19× write ../shared/.git/index.lock belongs to platform
662
+ 6× read ~/.npmrc no role declares ~/.npmrc — add it under a [roles.<name>] keys list
663
+
664
+ 23 of its calls went into retrying these.
588
665
  ```
589
666
 
667
+ That is the list said to the person at the terminal. The agent gets it in its own voice, at the
668
+ start of every session the hook is wired for:
669
+
670
+ ```
671
+ Already denied more than once, and still denied — do not try again, hand it over:
672
+ - write ../shared/.git/index.lock (19×) — belongs to platform
673
+ - read ~/.npmrc (6×) — no role declares ~/.npmrc — add it under a [roles.<name>] keys list
674
+ ```
675
+
676
+ and, on request, as data from the MCP server's `seisin_walls`.
677
+
590
678
  **A wall is recomputed against the policy, not read out of the log.** If the role would be
591
679
  allowed today, it is not a wall — whatever happened yesterday. The obvious implementation is a
592
680
  time window, on the argument that an old wall may have been granted since; the window is a
593
681
  proxy for the question, and seisin has the policy right there, so it asks the question. A grant
594
- makes its wall disappear on the next turn instead of when a window expires. Same move as
682
+ makes its wall disappear on the next run instead of when a window expires. Same move as
595
683
  `owners` and the request queue: recompute rather than believe.
596
684
 
597
685
  Silent on the first denial, by design. A counter that reads `1×` every time is noise on the
@@ -602,7 +690,7 @@ obvious way to check is to compare repeats before and after — but the repeats
602
690
  lock file, and the release before this one deleted that wall outright with
603
691
  `GIT_OPTIONAL_LOCKS=0`. Repeats did fall, by a lot, and **the fall is the wall going away,
604
692
  not the sentence landing**: both shipped within days of each other and the bigger cause is the
605
- other one. Isolating this needs a forward test — walls per role, rounds with the hook wired
693
+ other one. Isolating this needs a forward test — walls per role, runs with the hook wired
606
694
  and no git locks in the way — not the history. Measured in the field and written up rather
607
695
  than smoothed over, because the alternative is a claim the numbers do not carry.
608
696
 
@@ -618,7 +706,7 @@ It is one line to turn off.
618
706
 
619
707
  ## What it is not
620
708
 
621
- - **Not a sandbox.** [`sandbox-runtime`](https://github.com/anthropics/sandbox-runtime) is the sandbox, and it is Anthropic's. seisin writes its settings and explains its refusals.
709
+ - **Not a sandbox.** [`sandbox-runtime`](https://github.com/anthropics/sandbox-runtime) is the sandbox, and it is Anthropic's. seisin writes its settings and explains its denials.
622
710
  - **Not an orchestrator.** It does not run your agents, schedule them, or merge their work. It runs one command as one role.
623
711
  - **Not a secret manager.** It scopes *reads* of files you already have. Where those files come from is your problem.
624
712
  - **Not what a single agent needs first.** If only one agent ever touches the repo,
@@ -671,46 +759,42 @@ This space already has good work, and seisin is not the first thing here:
671
759
 
672
760
  - [`anthropics/sandbox-runtime`](https://github.com/anthropics/sandbox-runtime) — the enforcement. seisin is a thin thing on top of a serious one.
673
761
  - [`kornysietsma/claude-code-permissions-hook`](https://github.com/kornysietsma/claude-code-permissions-hook) — granular `PreToolUse` rules, one global policy.
674
- - [`XuebinMa/agent-guard`](https://github.com/XuebinMa/agent-guard) — a permission-enforcement SDK, also one global policy.
675
- - [`NVIDIA/OpenShell`](https://github.com/NVIDIA/openshell) — a runtime that sandboxes an agent with Landlock and seccomp, under a declarative policy. Serious, and the closest thing here by weight.
762
+ - [`XuebinMa/agent-guard`](https://github.com/XuebinMa/agent-guard) — an SDK that enforces what an agent may do, also one global policy.
763
+ - [`NVIDIA/OpenShell`](https://github.com/NVIDIA/openshell) — a runtime that sandboxes an agent with Landlock and seccomp, under a declarative policy. Serious, and the closest thing here by weight. It was evaluated as a possible backend; [the measured compatibility decision](docs/openshell.md) explains why it is currently an execution target candidate rather than a drop-in replacement.
676
764
  - [`dredozubov/hazmat`](https://github.com/dredozubov/hazmat) — runs the agent as a different system user, with `pf` rules and snapshots.
677
765
  - [`nolabs-ai/nono`](https://github.com/nolabs-ai/nono) — a kernel sandbox for agents on the host (Seatbelt, Landlock), with per-tool child sandboxes, a credential proxy with endpoint filtering, approval webhooks and a tamper-evident audit log. The most complete sandbox here, and on a Mac the one that enforces through the same kernel seisin does. `nono why` explains *which rule* denied something and how to allow it; it has no notion of *whose* it was.
678
- - *Directory ownership* is recommended in half the multi-agent write-ups. As far as I could find, nobody enforces it, and nobody names the owner in the refusal. That gap is the reason for this repo.
766
+ - *Directory ownership* is recommended in half the multi-agent write-ups. As far as I could find, nobody enforces it, and nobody names the owner in the denial. That gap is the reason for this repo.
679
767
 
680
768
  ### One sentence to tell these apart, because the names all sound the same
681
769
 
682
770
  **OpenShell, hazmat and nono isolate the agent from your machine. seisin separates roles from
683
- each other inside one repository, and when it blocks something it names the owner.**
771
+ each other inside one repository, and when the kernel denies something it names the owner.**
684
772
 
685
773
  Those are different problems and the first one is not the one this solves. If what you want
686
- is "this agent cannot touch anything outside its box", they do that and OpenShell does it
774
+ is "this agent cannot touch anything outside its sandbox", they do that and OpenShell does it
687
775
  with a large organisation behind it. If what you want is "four agents work in the same
688
- repository and none of them edits another's files — and when one is stopped it is told whose
776
+ repository and none of them edits another's files — and when one is denied it is told whose
689
777
  those files are", nothing above answers that, which is why this exists.
690
778
 
691
779
  They compose rather than compete: the agent seisin confines could be running inside one of
692
780
  them.
693
781
 
694
782
  *One detail worth knowing before you compare, and it is about placement rather than quality.
695
- Read from [OpenShell's support matrix][osm] on 2026-09-21, not measured here: macOS is a
696
- supported platform, and on it "these kernel modules run inside the Docker Desktop Linux VM,
697
- not on the host kernel". So on a Mac, OpenShell confines an agent inside a Linux VM, and
698
- seisin confines a process on the host through Seatbelt. Both are kernel enforcement; they are
699
- not the same kernel, and which one you want depends on whether the thing you are protecting is
700
- on the host. seisin [says where each of its own claims was
701
- measured](docs/what-it-has-been-put-through.md#where-each-claim-was-actually-run).*
702
-
703
- [osm]: https://docs.nvidia.com/openshell/reference/support-matrix
783
+ OpenShell was [evaluated here on 2026-09-27](docs/openshell.md), through its Docker driver on
784
+ an Intel Mac. That confines an agent inside Docker Desktop's Linux VM; seisin confines a
785
+ process on the host through Seatbelt. OpenShell also offers other drivers, including a local
786
+ microVM path on supported Apple Silicon Macs. These are kernel-enforced boundaries, but not
787
+ the same kernel or workspace, and which one you want depends on what you are protecting.*
704
788
 
705
789
  ## Status
706
790
 
707
- `0.4.0`, 470 tests, of which **26 need `@anthropic-ai/sandbox-runtime` installed**
791
+ `0.5.0`, 780 tests, of which **26 need `@anthropic-ai/sandbox-runtime` installed**
708
792
  and run real commands through the real kernel — and CI fails if the sandbox half *skips*, because
709
793
  a green run that quietly tested nothing looks exactly like a real one. That is not hypothetical:
710
- those eighteen skipped on Linux for a day, behind a runtime check that looked for the global
794
+ when there were eighteen of them, they skipped on Linux for a day, behind a runtime check that looked for the global
711
795
  install and missed the bundled one, and hid a defect that broke `seisin run` on that platform
712
- entirely. **326/326 on macOS 15 and on `ubuntu-latest` under bubblewrap**, nothing skipped on
713
- either, Node 18/20/22 in CI at every push — and 225/225 the same way on Debian 12.15
796
+ entirely. **780 tests on macOS 15, 778 passing and 2 skipped** (they are Linux-only) — 2026-09-30;
797
+ `ubuntu-latest` under bubblewrap runs the same suite, Node 18/20/22/24 in CI at every push — and 225/225 the same way on Debian 12.15
714
798
  with bubblewrap 0.8.0, the last time the suite was run in Docker.
715
799
  [Which claim was measured where](docs/what-it-has-been-put-through.md#where-each-claim-was-actually-run),
716
800
  and [what running agents behind it cost the people using it](docs/field-notes.md).
@@ -725,11 +809,11 @@ already done:
725
809
  | **Windows** | the runtime has a backend. seisin has never been pointed at it, and no CI runner covers it |
726
810
  | **Deleting inside your own territory** | not covered, and not coverable here — the ask is upstream as [issue #545](https://github.com/anthropics/sandbox-runtime/issues/545), open and unanswered since 2026-09-13, [with the measurement behind it](docs/upstream/denyUnlink.md) and [a demo](docs/demo/) |
727
811
  | **`init` heuristics** | it reads `.claude/agents/` then `CODEOWNERS`. Every other convention is a guess nobody has made yet |
728
- | **SSH inside a turn** | the `ProxyCommand` the runtime sets can't authenticate to its own proxy, so `git` over SSH fails at the handshake. Fix upstream: [PR #516](https://github.com/anthropics/sandbox-runtime/pull/516). Use an HTTPS remote |
812
+ | **SSH inside a run (macOS)** | on macOS the `ProxyCommand` the runtime sets can't authenticate to its own proxy, so `git` over SSH fails at the handshake (on Linux the runtime routes it through an authenticated proxy). Fix upstream: [PR #516](https://github.com/anthropics/sandbox-runtime/pull/516). Use an HTTPS remote |
729
813
  | **A browser inside a role (macOS)** | Chromium only starts with `--single-process`, which is unstable with several browsers. Fix upstream: [PR #598](https://github.com/anthropics/sandbox-runtime/pull/598). [Measurement](docs/upstream/mach-register.md) |
730
814
 
731
815
  Every word above has one meaning, listed in [the glossary](docs/glossary.md) — the boundary
732
- **denies**, a person **declines**, seisin **refuses** a config it cannot enforce. A tool whose
816
+ **denies**, a person **grants** or **declines**, seisin **refuses** a config it cannot enforce. A tool whose
733
817
  product is a sentence cannot afford two words for one thing.
734
818
 
735
819
  Issues and pull requests welcome — [CONTRIBUTING.md](CONTRIBUTING.md) says what is actually
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "seisin",
3
- "version": "0.4.0",
4
- "description": "A permission layer for AI coding agents: role-scoped folders and keys, enforced by the OS, and when it blocks it tells you whose file it was.",
3
+ "version": "0.5.0",
4
+ "description": "A permission layer for AI coding agents: role-scoped folders and keys, enforced by the OS, and when it denies something it tells you whose file it was.",
5
5
  "keywords": [
6
6
  "ai-agents",
7
7
  "claude-code",
@@ -39,7 +39,7 @@
39
39
  "node": ">=18"
40
40
  },
41
41
  "dependencies": {
42
- "@anthropic-ai/sandbox-runtime": "0.0.77"
42
+ "@anthropic-ai/sandbox-runtime": "0.0.78"
43
43
  },
44
44
  "scripts": {
45
45
  "test": "node --test test/*.test.js",