seisin 0.1.1 → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -6,6 +6,8 @@
6
6
  [![node](https://img.shields.io/badge/node-%E2%89%A518-222)](package.json)
7
7
  [![no dependencies](https://img.shields.io/badge/dependencies-1-222)](package.json)
8
8
 
9
+ **[The short version, with the demos playing →](https://carlostapiaolguin3-stack.github.io/seisin/)** · this page is the long one.
10
+
9
11
  > **seisin** *(n.)* — the legal possession of a piece of land. Not who owns it on paper: who holds it now.
10
12
 
11
13
  **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,6 +16,51 @@ A **permission layer**, not a sandbox — it sits on top of one. The isolation c
14
16
 
15
17
  *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)*
16
18
 
19
+ *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
+
21
+ **Three ways in, and they say the same thing.** The **CLI** is what a person types and what
22
+ every agent runs under. The **console** (`seisin ui`) is for a person deciding something. The
23
+ **MCP server** is for an agent asking about its own situation. A change to what any of them
24
+ says lands in all three — [CONTRIBUTING](CONTRIBUTING.md#three-surfaces-and-a-change-lands-in-all-of-them)
25
+ says why, and a test holds one half of it.
26
+
27
+ <details>
28
+ <summary><b>Contents</b></summary>
29
+
30
+ **Start here** · [Sixty seconds](#sixty-seconds) · [Why this exists](#why-this-exists) · [What it is for, and what it is not for](#what-it-is-for-and-what-it-is-not-for) · [Install](#install) · [Use](#use) · [Configure](#configure)
31
+
32
+ **Living with it** · [Day one](#what-will-look-like-a-bug-on-the-first-day) · [Which agents it has been run with](#which-agents-it-has-been-run-with) · [Where the first policy comes from](#where-the-first-policy-comes-from) · [When it says no, it leaves a request behind](#when-it-says-no-it-leaves-a-request-behind) · [When the boundary is right and the agent cannot hear it](#when-the-boundary-is-right-and-the-agent-cannot-hear-it) · [Scratch space, and what it costs](#scratch-space-and-what-it-costs)
33
+
34
+ **Secrets** · [How it protects secrets](#how-it-protects-secrets) · [Keys in full](docs/keys.md)
35
+
36
+ **Looking at it** · [What the log says about the policy](#what-the-log-says-about-the-policy) · [Seeing what happened](#seeing-what-happened) · [Ask your own assistant](#ask-your-own-assistant)
37
+
38
+ **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)
39
+
40
+ **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
+
42
+ </details>
43
+
44
+ ```mermaid
45
+ flowchart LR
46
+ A["agent<br/>running as a role"] -->|writes src/api/orders.ts| K{{"the kernel<br/>Seatbelt · bubblewrap"}}
47
+ K -->|not this role's| D["denied"]
48
+ D --> S["seisin reads the refusal<br/>and looks up the policy"]
49
+ S --> W["<b>whose it was</b><br/>belongs to backend"]
50
+ S --> Q["a request, queued"]
51
+ Q --> H(["a person<br/>grants or declines"])
52
+ H -->|edits seisin.toml| P[("seisin.toml")]
53
+ P -.->|settings| K
54
+
55
+ style D fill:#fde,stroke:#c66
56
+ style W fill:#dfd,stroke:#6a6
57
+ style H fill:#eef,stroke:#66c
58
+ ```
59
+
60
+ **The kernel says no. seisin says whose.** That second half is the whole thing: every other
61
+ permission layer in this space answers *yes* or *no*, and a refusal that also names an owner
62
+ turns a dead end into a handoff.
63
+
17
64
  ![seisin denying a write outside a role's territory, then naming the owner](docs/img/demo.gif)
18
65
 
19
66
  ```
@@ -26,12 +73,12 @@ $ seisin run frontend -- sh -c 'echo // fix >> src/api/orders.ts'
26
73
  #1 frontend wants write on src/api/** (owned by backend)
27
74
  first asked over src/api/orders.ts
28
75
 
29
- seisin grant <n> [--reason "…"] · seisin deny <n> [--reason "…"]
76
+ seisin grant <n> [--reason "…"] · seisin decline <n> [--reason "…"]
30
77
  ```
31
78
 
32
- **Whose it was, at the moment it was refused.** Every other permission layer in this space answers *yes* or *no*. 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.
79
+ **Whose it was, at the moment it was denied.** Every other permission layer in this space answers *yes* or *no*. 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.
33
80
 
34
- The kernel is what refuses; 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).
81
+ 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).
35
82
 
36
83
  The agent can ask directly too, from inside the box:
37
84
 
@@ -54,8 +101,6 @@ seisin run frontend -- claude -p "…" # run an agent inside its own territory
54
101
 
55
102
  On Linux, [three system packages first](#install). Everything below is why it works and where it does not.
56
103
 
57
- **Contents** · [Why this exists](#why-this-exists) · [What it is *not* for](#what-it-is-for-and-what-it-is-not-for) · [Install](#install) · [Configure](#configure) · [Day one](#what-will-look-like-a-bug-on-the-first-day) · [Agents it runs](#which-agents-it-has-been-run-with) · [Requests](#when-it-says-no-it-leaves-a-request-behind) · [MCP](#ask-your-own-assistant) · [Why not a container](#why-not-a-container) · [How it holds](#how-it-holds) · [Secrets](#how-it-protects-secrets) · [Decisions](docs/decisions.md) · [What it survived](docs/what-it-has-been-put-through.md) · [Status](#status)
58
-
59
104
  ---
60
105
 
61
106
  ## Why this exists
@@ -98,9 +143,9 @@ rather than after.
98
143
 
99
144
  **It holds against an agent that is wrong.** One that edits the file it was not asked
100
145
  about, reads the key meant for another role, or calls an API nobody declared. The kernel
101
- refuses, and it does not care how the command was spelled — verified against a grandchild
146
+ denies, and it does not care how the command was spelled — verified against a grandchild
102
147
  process, an absolute path, a symlink, a `python -c`, and a shell redirect the agent fell
103
- back to when its own file API was refused.
148
+ back to when its own file API was denied.
104
149
 
105
150
  **It is not built to hold against an agent that is trying.** Measured, in this repo's own
106
151
  fixtures:
@@ -194,162 +239,40 @@ network = ["api.stripe.com"] # this role only — it replaces [network] rathe
194
239
  extra host should say so on its own line: a domain in the global list is reachable by every
195
240
  role, including the ones whose whole job is to have nothing to reach.
196
241
 
242
+ > ⚠️ **HTTP(S) works; SSH does not.** The allow list is enforced by proxies the runtime
243
+ > starts, and although one of them is a SOCKS5 proxy that could carry SSH, the chain breaks
244
+ > at its last link: it requires authentication and the `ProxyCommand` the runtime wires up
245
+ > cannot send any. Measured — `curl https://github.com` returns `200`, `ssh -T git@github.com`
246
+ > cannot resolve the hostname, and `git ls-remote git@github.com:…` reaches the proxy and
247
+ > dies at the handshake.
248
+ >
249
+ > **Use an HTTPS remote for git**, and run SSH deploys outside the confined turn.
250
+ > [Why, exactly, and whose gap it is →](docs/decisions.md#ssh-does-not-work-and-it-is-not-seisin-that-decided-that)
251
+
197
252
  `seisin init` will propose this from whatever your repo already says: `.claude/agents/`, then `CODEOWNERS`, then a blank start. It **proposes** — a generated policy you did not read is not a policy.
198
253
 
199
254
  ## What will look like a bug on the first day
200
255
 
201
- Four shapes that a correct policy still produces, and that a reader reads as breakage. All
202
- four come out of one production window — four agent cells, ~370 confined turns over three
203
- days, 1,409 kernel refusals — where none of them was the boundary misbehaving. They are here
204
- because every one of them cost someone an afternoon before it cost this paragraph.
205
-
206
- **1 · Granting a file does not grant its neighbours.** The kernel grants exactly the path you
207
- wrote. Anything a tool creates *beside* it — a database journal, a lock file, the temporary
208
- file of an atomic write — is a sibling, and a sibling is outside the grant. The failure then
209
- arrives in the tool's own words rather than as a permission error, which is why it survives a
210
- policy review: the policy looks right because it *is* right about the file you named.
211
-
212
- `seisin check` says so before you hit it:
256
+ Four shapes a correct policy still produces that read as breakage: a granted file whose
257
+ neighbours are not granted, a lock file in somebody else's repository, a tool that reports a
258
+ denied write as a *readonly database*, and an agent that diagnoses file permissions when the
259
+ answer is ownership.
213
260
 
214
- ```
215
- N role(s) grant individual files rather than folders: reviewer (3 of 11)
216
- ```
261
+ All four came out of one production window where none of them was the boundary misbehaving.
217
262
 
218
- Grant the folder where the tool needs neighbours. `seisin check <role>` names the paths.
219
-
220
- **The Claude Code case is worth stating outright**, because it is the agent most people will
221
- point this at first: `Write` and `Edit` do not write the file you named. They write
222
- `<name>.tmp.<pid>.<random>` next to it and rename over the target. So a grant on a literal
223
- file path is both correct and useless — `seisin explain` answers *allowed* about the
224
- destination, the kernel answers `Operation not permitted` about the temporary, and neither is
225
- lying. Grant the directory.
226
-
227
- **2 · `.git/index.lock`, in a repo the role does not own.** In that window this single
228
- filename was **1,071 of the 1,409 refusals** — three quarters of everything the kernel said no
229
- to. A role runs `git status`, git tries to refresh the index of a checkout that belongs to
230
- another role, and the lock write is refused.
231
-
232
- It is noise, not a wall, and the distinction is measurable: inside the box
233
- `git status --short --branch` and `git log` still exit 0. Git cannot refresh its index cache
234
- and carries on without it. If a role genuinely needs to commit, give it **its own worktree**
235
- rather than a share of the main index — two agents staging into one index corrupt each other
236
- regardless of who is allowed to write it.
237
-
238
- **3 · Worktrees are two paths for one repo.** A policy names the canonical checkout; the
239
- process is running in a linked worktree somewhere else entirely, and to the kernel that is a
240
- different place. seisin resolves the pair rather than making you write both — `seisin whose`
241
- and `seisin explain` answer the same thing from either side, and say which checkout they are
242
- talking about. Worth knowing it is handled, because the symptom when a tool does *not* handle
243
- it is a role denied inside its own territory.
244
-
245
- **4 · SQLite reports a refused write as `attempt to write a readonly database`** — see
246
- [below](#which-agents-it-has-been-run-with); it is the one that sends you to debug the
247
- database instead of the policy, and the reason `seisin wire` exists.
248
-
249
- Where these stand: the sibling case is a `check` warning instead of a surprise, the worktree
250
- case is resolved in the tool, the SQLite case is named by the hook, and the git one is
251
- friction that gets logged rather than silenced — a boundary that hides what it refused is the
252
- thing this project exists to argue against.
263
+ **[What each one looks like, and what to do →](docs/first-day.md)**
253
264
 
254
265
  ## Which agents it has been run with
255
266
 
256
- seisin wraps a process, so in principle it works with any agent that runs as one — CLI or not. In
257
- practice "in principle" is not a claim worth making about a permission tool, so here is what has
258
- actually been exercised:
259
-
260
- | agent | version | result |
261
- |---|---|---|
262
- | **Claude Code** (`claude -p`) | 2.1.x | Territory and keys enforced; network egress refused an undeclared domain by name |
263
- | **opencode** (`opencode run`) | **1.18.30** | Same, **on a free model with no API key at all** |
264
- | **LangGraph** (`python graph.py`) | **1.2.11** | Same, **enforced against the interpreter's own `open()`** — in-process tools, no child command to match |
265
- | **codex** (`codex exec`) | **0.150.1** | Same, with its own sandbox off — see below. Refused twice: its patch tool, then the shell redirect it fell back to |
266
-
267
- The codex run is the one that needed a flag. **An agent that sandboxes itself has to stop.** `codex` confines each command it runs with its
268
- own `sandbox-exec` profile, and macOS refuses to apply a second Seatbelt profile to a process
269
- that already has one — `sandbox_apply: Operation not permitted`, with the most permissive
270
- profile that can be written, on both layers. So an agent like that runs under seisin with its
271
- own sandbox turned off — `codex exec --dangerously-bypass-approvals-and-sandbox`, whose own
272
- help says it is "intended solely for running in environments that are externally sandboxed".
273
- It starts fine either way (`codex 0.150.1`, `gemini 0.57.0`, both verified), which is why this
274
- is worth saying out loud: the failure arrives later, on the first command the agent tries to
275
- confine.
276
-
277
- Asked to write into another role's territory, codex failed through its patch tool, went looking
278
- for a file ACL with `ls -le@` and `stat`, found nothing, and fell back to a shell redirect that
279
- failed too:
280
-
281
- ```
282
- /bin/zsh -lc "printf 'pisado\n' > qa/informe.md"
283
- exited 1: zsh:1: operation not permitted
284
- ```
285
-
286
- Same shape as opencode, including the wrong diagnosis — it reasoned about file permissions,
287
- not about ownership. That is the case for the hook: the boundary holds either way, but only the
288
- hook can say *whose* it was.
289
-
290
- All four on macOS 15 (Seatbelt). Linux is no longer a one-off measurement on one machine:
291
- [CI](.github/workflows/test.yml) runs the whole suite on Ubuntu and macOS, Node 18/20/22,
292
- on every push — and fails if the sandbox half *skips*.
293
-
294
- The opencode run is the interesting one, because **opencode has no per-path sandbox flag** —
295
- its only permission control is `--auto`, "auto-approve permissions that are not explicitly
296
- denied". Asked to write into another role's territory it failed twice: once through its own
297
- `FileSystem.writeFile`, and again through the shell redirect it fell back to.
298
-
299
- ```
300
- Error: Unknown: FileSystem.writeFile (.../qa/informe.md)
301
- $ echo "revisado" > .../qa/informe.md
302
- zsh:1: operation not permitted
303
- ```
304
-
305
- That is the point of putting the boundary in the kernel instead of in the agent: **an agent
306
- that cannot confine itself is confined anyway**, and adding a new one costs no adapter.
307
-
308
- It also found a real bug. The scratch list named `~/.claude` and `~/.codex` and stopped there,
309
- so the first agent that was neither did not fail a task — it failed to start, on its own log
310
- file. A boundary that only fits the agents its author happened to use is a coincidence, not a
311
- boundary. The XDG directories are in the list now.
267
+ Claude Code, opencode, codex, gemini and a LangGraph script, each against a real policy with
268
+ the boundary actually applied — not a smoke test.
312
269
 
313
- The LangGraph run answers a different question: **an agent framework is not a CLI.** Its tools
314
- are Python calls inside the same process, so there is no child command for a rule to match and no
315
- argv to inspect — the write is the interpreter's own `open()`. A six-node graph asked to write into
316
- another role's territory was refused there, refused again through the shell redirect it fell back
317
- to, refused the other role's key, and refused an undeclared domain. Run as the other role, the same
318
- graph gave the mirror image — every territory and key answer flipped, the undeclared domain refused
319
- for both — and its SQLite checkpointer persisted normally inside whichever territory was its own.
270
+ **One thing is worth knowing before you start: an agent that sandboxes itself has to be told
271
+ to stop.** `codex` confines every command it runs, and no OS applies a second profile to a
272
+ process that already has one. seisin says so before it starts one, because the error you get
273
+ otherwise names neither seisin, nor the agent, nor the fix.
320
274
 
321
- One caveat belongs beside that row rather than after it: **a role is scoped to a process, and a
322
- graph is one process.** Every node of one graph shares one territory and one set of keys. Per-node
323
- territory means per-node subprocess, which is the thing an in-process framework exists to avoid.
324
- seisin fits a multi-agent framework at the boundary of the whole graph, not between its agents.
325
-
326
- And one failure mode worth meeting here rather than at 2am: SQLite reports a refused write as
327
- `attempt to write a readonly database`, not as a permission error. That reads like a misconfigured
328
- database, and you will debug the database. The hook names the owner instead — it is the case
329
- `seisin wire` is for.
330
-
331
- **Re-verified without an agent in the loop**, which is better evidence: an agent in the middle
332
- makes a permission test non-deterministic — in that first run the agent reported the opposite
333
- of what it had actually done. Wrapping a plain shell and a plain interpreter instead:
334
-
335
- | as `dev`, with `qa/**` owned by `qa` | result |
336
- |---|---|
337
- | `sh -c 'echo x > proyecto/FILE.txt'` (own territory) | rc=0, created |
338
- | `sh -c 'echo x > qa/report.md'` (shell redirect) | `Operation not permitted`, rc=1 |
339
- | `python3 -c "open('qa/report.md','w')"` (the program's own syscall) | `PermissionError: [Errno 1]` |
340
- | `rm -f proyecto/FILE.txt` (**destructive, inside** own territory) | **rc=0, file gone** |
341
-
342
- The last row is not a bug, it is the shape of the tool: seisin answers *where*, not *what*.
343
- A team running this in front of an agent fleet measured how often it matters: across ~330
344
- rounds, sixty-six times a role reached for a command that would have destroyed its own
345
- uncommitted work, and the kernel boundary permitted every one. The ask that would close it
346
- is written up in [docs/upstream/denyUnlink.md](docs/upstream/denyUnlink.md), filed against
347
- the sandbox runtime rather than worked around here.
348
-
349
- If you would rather see it than read it, [thirty seconds in a temp
350
- directory](docs/demo/) — an agent confined to its own project deletes the git history of the
351
- repo it is working in, and the `.git/` left behind looks intact. Nothing installed, seisin not
352
- used, because the gap is the runtime's and every tool on top of it inherits the same one.
275
+ **[Every agent, its version, what it needed, and how each reacted to being stopped →](docs/agents.md)**
353
276
 
354
277
  ## What the log says about the policy
355
278
 
@@ -405,79 +328,33 @@ policy: diff it, then move it.
405
328
 
406
329
  ## When it says no, it leaves a request behind
407
330
 
408
- A permission tool that can only say no is a tool people uninstall. The loop —
409
- denied, stop, go edit a config, run again — is three context switches for one
410
- line of policy, and the cheapest way to make it stop is to widen the policy
411
- generously and never look again. That is how a permission file becomes seven
412
- hundred entries nobody can read.
331
+ A permission tool that can only say no is a tool people uninstall — the loop of *denied,
332
+ stop, edit a config, run again* is three context switches for one line of policy, and the
333
+ cheapest way to stop it is to widen the policy and never look again. That is how a permission
334
+ file becomes seven hundred entries nobody reads.
413
335
 
414
- So a denial leaves something you can act on:
336
+ So a denial leaves something to act on, and many denials in one directory are **one** request:
415
337
 
416
338
  ```
417
- frontend write src/api/orders.ts
418
- denied — src/api/orders.ts belongs to backend
419
-
420
339
  1 pending request(s)
421
340
 
422
341
  #1 frontend wants write on src/api/** (owned by backend) · asked 3×
423
342
  first asked over src/api/orders.ts
424
343
 
425
- seisin grant 1 --reason "…" · seisin deny 1 --reason "…"
344
+ seisin grant 1 --reason "…" · seisin decline 1 --reason "…"
426
345
  ```
427
346
 
428
- Many refusals in one directory are **one** request, not many — an agent denied
429
- on `a.ts` and then on `b.ts` is not asking two questions. And the grant records
430
- where it came from, next to the line it adds:
347
+ The grant writes its own provenance next to the line it adds:
431
348
 
432
349
  ```toml
433
- [roles.frontend]
434
- writes = [
435
- "src/web/**",
436
- "src/api/**" # granted 2026-09-12 · asked 3× · "frontend owns checkout now"
437
- ]
438
- ```
439
-
440
- ```
441
- frontend denied on src/api/orders.ts
442
- │
443
- ▼
444
- request #1 ◄──── denied again on b.ts ──── same request, asked 2×
445
- │
446
- │ nothing inside the box moves it from here
447
- ▼
448
- ┌───────┴────────┐
449
- ▼ ▼
450
- seisin grant 1 seisin deny 1
451
- │ ▼
452
- │ policy unchanged,
453
- ▼ the reason recorded
454
- one line added to seisin.toml,
455
- carrying where it came from
456
- ```
457
-
458
- Without that, a policy is a list of permissions with no history, and the only
459
- safe thing to do with a line nobody remembers is leave it there.
460
-
461
- **Approving is deliberately not a tool call.** An agent — or an MCP server on
462
- your behalf — can read the queue and draft the change. Turning it into policy
463
- takes a person in a channel the agent does not have. The public API reflects
464
- that: `pendingRequests` is exported, the functions that approve are not.
465
- [The reasoning is written down](docs/permission-requests.md).
466
-
467
- ![the console: a pending request, a reason, and the territory changing when it is approved](docs/img/console.gif)
468
-
469
- Two places, both human: `seisin grant <n>` in a terminal, or the console, where
470
- the queue is a panel with a reason field and two buttons. Approving there edits
471
- your `seisin.toml` in place and the territory on screen changes with it.
472
-
473
- The console is allowed to hold that half and the MCP server is not, and the
474
- reason is measured rather than asserted: a confined role curling the console's
475
- own port gets the same nothing it gets from a domain outside its allowlist. The
476
- MCP server, by contrast, speaks on the agent's own stdio.
477
-
478
- The notice rides on what you are already looking at: `seisin run` prints the
479
- queue when the run ends, in the same terminal that just showed you the denial.
480
- There is no daemon and nothing to leave running.
350
+ writes = ["src/api/**"] # granted 2026-09-12 · asked 3× · "frontend owns checkout now"
351
+ ```
352
+
353
+ **Approving is never a tool call.** An agent can read the queue and draft the change; only a
354
+ person applies it, in a terminal or in the console. That asymmetry is deliberate and it is
355
+ the reason the MCP server exists at all.
356
+
357
+ **[How a denial becomes a request, why it deduplicates, and what a grant records →](docs/permission-requests.md)**
481
358
 
482
359
  ## Ask your own assistant
483
360
 
@@ -487,6 +364,14 @@ There is no daemon and nothing to leave running.
487
364
 
488
365
  > *who owns src/api, and what is frontend waiting on?*
489
366
 
367
+ Seven tools. Four answer about the policy — `state`, `explain`, `requests`, `draft_grant` —
368
+ and three about what has happened: `activity` is the raw log, and **`causes` and `walls` are
369
+ the two an agent actually wants.** `activity` hands over events and leaves the assistant to
370
+ re-derive the shape without the policy; `causes` returns the day grouped by path and by name
371
+ with how many are on paths nobody owns, and `walls` returns what a role keeps being denied
372
+ *and would still be denied today*. Both are recomputed against the policy, so something
373
+ granted since stops counting — which is the part a log cannot know.
374
+
490
375
  Read-only **by construction** — the server opens nothing for writing, and a test
491
376
  asserts that no tool in it mutates anything. It can draft the exact change a
492
377
  request would make; applying it is a command a person runs. Zero dependencies:
@@ -498,6 +383,7 @@ descriptors.
498
383
  ```bash
499
384
  seisin log --verdict denied # what the agents tried and could not do
500
385
  seisin watch # follow it live
386
+ seisin ui # the same thing, for a person deciding
501
387
  ```
502
388
 
503
389
  ```
@@ -507,56 +393,27 @@ seisin watch # follow it live
507
393
  ```
508
394
 
509
395
  One append-only JSONL under `.seisin/`, and that is the whole storage design — no daemon, no
510
- database. `watch` is a tail. The file is the shared state, so anything that can read it can
511
- watch it.
512
-
513
- **The agent cannot reach that file.** `.seisin/` is not in any role's territory, because a
514
- record the recorded process can edit is not a record. The hook sends each line to `seisin run`
515
- over a unix socket granted by path, and the parent does the writing — so the only verb
516
- available from inside the box is *append one line*: no seek, no truncate, no unlink. What a
517
- hostile agent can still do is send lines, so it can add noise to its own history. What it can
518
- no longer do is change what is already there.
396
+ database, and `watch` is a tail.
519
397
 
520
- **Two writers, and they see different things.**
398
+ **The agent cannot reach that file.** `.seisin/` is in no role's territory, because a record
399
+ the recorded process can edit is not a record. The hook sends each line to `seisin run` over
400
+ a unix socket granted by path and the parent does the writing, so the only verb available
401
+ from inside the box is *append one line*. A hostile agent can still add noise to its own
402
+ history; what it cannot do is change what is already there.
521
403
 
522
- The **hook** reports the attempt before it happens, which is how an allowed action
523
- gets recorded at all. It has to be installed: `seisin wire` writes the
524
- `PreToolUse` entry into this repo's `.claude/settings.json` — the project's,
525
- never your machine's. It is also text-based and evadable, on purpose.
526
-
527
- The **kernel** reports what it actually refused. On macOS every Seatbelt denial
528
- lands in the system log with its operation, its absolute path and the runtime's
529
- own attribution tag, and `seisin run` reads that stream for the duration of the
530
- run. Nothing to install, nothing to evade — and it catches exactly what the hook
531
- missed:
532
-
533
- ```
534
- $ seisin run frontend -- sh -c 'echo // fix >> src/api/orders.ts'
535
- sh: src/api/orders.ts: Operation not permitted
536
- seisin: 1 kernel denial(s) recorded
537
-
538
- 1 pending request(s)
539
- #1 frontend wants write on src/api/** (owned by backend)
540
- first asked over src/api/orders.ts
541
- ```
404
+ **Two writers, and they see different things.** The **hook** reports the attempt before it
405
+ happens — which is how an *allowed* action gets recorded at all — and it is text-based and
406
+ evadable, on purpose. The **kernel** reports what it actually denied, and is not fooled by
407
+ anything. What escapes the hook goes unexplained, never unblocked.
542
408
 
543
- That is with no hook installed. Before this, the same run left `Operation not
544
- permitted` on the terminal and an empty log — which is how four real defects in
545
- production each cost a night to find: every one of them looked like a file that
546
- had quietly stopped growing.
547
409
 
548
- Skip `wire` and the boundary holds exactly as well, denials are still recorded
549
- and still queue a request; what you lose is the record of what was *allowed*,
550
- which is what `init --from-observations` is built out of. `seisin check` says so
551
- until you run it.
552
410
 
553
- **On Linux only the hook writes.** bubblewrap does not log refusals and the
554
- runtime's substitute is not readable from outside it, so `seisin run` says so
555
- once and records nothing from the kernel. [The ask is
556
- upstream](docs/upstream/cli-violations.md) as [issue #582][582], filed 2026-09-20
557
- against 0.0.77, open and unanswered.
411
+ ![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)
558
412
 
559
- [582]: https://github.com/anthropics/sandbox-runtime/issues/582
413
+ It opens on what needs a decision, not on the policy. Five numbers ride on every screen and
414
+ each is a link to the page that explains it — including two a log cannot answer, because
415
+ they are recomputed against the policy: how much of the day is **one cause**, and how many
416
+ of those causes are on paths **nobody owns**.
560
417
 
561
418
  ## How it holds
562
419
 
@@ -564,7 +421,7 @@ against 0.0.77, open and unanswered.
564
421
  |---|---|---|
565
422
  | your agent's `allow`/`deny` rules | match the command string before it runs | yes — `/bin/rm`, a heredoc, a child process |
566
423
  | **seisin** | decides what to ask for, and names the owner | it doesn't enforce, it explains |
567
- | **sandbox-runtime → Seatbelt / bubblewrap** | the OS refuses the syscall | no |
424
+ | **sandbox-runtime → Seatbelt / bubblewrap** | the OS denies the syscall | no |
568
425
 
569
426
  ```
570
427
  Write tool · Bash `rm -rf` · /bin/rm · python -c "open(…)" · a grandchild
@@ -605,105 +462,99 @@ Verified by the test suite, which runs real commands in a real sandbox:
605
462
 
606
463
  ## How it protects secrets
607
464
 
608
- Four ways out of a process, and seisin closes three of them. The fourth is named below,
609
- because a security tool that lists only its wins is not one.
465
+ Four ways a secret leaves a process, and seisin closes three. The fourth is named rather
466
+ than hidden.
610
467
 
611
- | way out | closed by | how |
612
- |---|---|---|
613
- | reading another role's key file | the kernel | the key directory is denied, each declared key re-allowed. Survives a grandchild process and an absolute path |
614
- | a key riding in the environment | the launcher | the child's environment is **built, not inherited**. Measured before this existed: 93 variables crossed into every turn, a planted token among them |
615
- | spilling a key the role does hold | the launcher | the value is masked on stdout and stderr, including when it lands split across two buffers |
616
- | sending it somewhere | the kernel | egress is allow-only. `curl` to a domain you did not list gets nothing |
617
- | **a key stored outside the declared directories** | **nothing** | `seisin scan` finds them so you know what is not covered |
468
+ | way out | closed by |
469
+ |---|---|
470
+ | reading another role's key file | the kernel — the key directory is denied, each declared key re-allowed |
471
+ | a key riding in the environment | the launcher — the child's environment is **built, not inherited** |
472
+ | spilling a key the role does hold | the launcher — masked on stdout and stderr, even split across buffers |
473
+ | sending it somewhere | the kernel — egress is allow-only, by hostname. HTTP(S) works; [SSH does not](docs/decisions.md#ssh-does-not-work-and-it-is-not-seisin-that-decided-that) |
474
+ | **a key stored outside the declared directories** | **nothing.** `seisin scan` finds them so you know |
618
475
 
619
476
  ```toml
620
477
  [keys]
621
- dir = [".secrets", "config/credentials"] # one directory or several
478
+ dir = [".secrets"]
622
479
 
623
480
  [roles.frontend]
624
- keys = ["netlify-token.txt"] # everything else in those directories is denied
625
- env = ["BUILD_ID"] # everything else in the environment is dropped
481
+ key_mode = "env"
482
+ keys = ["netlify-token.txt", # a file the role may read
483
+ "TOKEN=file://.secrets/all.env#NETLIFY", # one value, and no read at all
484
+ "OP=op://vault/item/field"] # anything that prints a secret
626
485
  ```
627
486
 
628
- **If your agent runs hooks of its own, they need a line here too.** Whatever a hook reads to learn which role it is gets dropped with everything else, and the symptom is two layers disagreeing about one file — your hook refusing a write that seisin just allowed, and the lower one is the one that is right — until the variable is named in `env`.
487
+ A **provider** is a command with a `{ref}` placeholder, so adding 1Password, Bitwarden or
488
+ `sops` is TOML and not code. `file://` ships built in. The parent resolves, never the
489
+ confined process. Nothing degrades: a provider that fails stops the run.
629
490
 
630
- **What this bought, measured rather than argued.** Over the same production window — four
631
- agent cells, ~370 confined turns, three days — **every refused read was a read of something
632
- the policy had declared a key.** 148 of them, no exceptions, from five different roles, and
633
- not one was doing anything unusual: they were running searches that swept a repository root.
634
- That is how a key gets read without anyone deciding it should, and it is the whole case for
635
- declaring the key directory rather than trusting the instruction not to look.
491
+ **[Key directories, references, providers, delivery modes and nine recipes →](docs/keys.md)**
636
492
 
637
- Refused *writes* were a different story, and both halves are worth reading: those were
638
- ordinary collisions between roles, the boundary keeping two agents out of each other's work.
639
- It is the reads where the policy was the only thing there.
493
+ **Measured, over four agent cells and three days: every refused read was a read of something
494
+ the policy had declared a key.** 148 of them, no exceptions, from five roles — all running
495
+ searches that swept a repository root. That is how a key gets read without anyone deciding
496
+ it should.
640
497
 
641
- **Two things this deliberately does not claim.** Redaction masks a literal value on the way
642
- through the launcher — a key written straight to a file never passes through it, and neither
643
- does one the agent base64'd first. It narrows a careless print; it is not a containment
644
- boundary. And to mask a value seisin must read it, so a role's own keys pass through this
645
- process in memory. `[runtime] redact = false` turns that off.
498
+ ## When the boundary is right and the agent cannot hear it
646
499
 
647
- ## Scratch space, and what it costs
500
+ seisin answers *whose is this* at the moment of the denial — once, mid-turn — and then the
501
+ sentence is gone. Nothing carries it forward, so the agent tries again.
648
502
 
649
- Territory alone is correct and unusable. An agent writes session state under its own config
650
- directory and its tools write to the temp dir, so a policy of *your folders and nothing else*
651
- stops the agent before it starts. Every role therefore also gets:
503
+ Measured on a real team: of **345 denials, 88 (25%) were a repeat of something that same role
504
+ had already been denied.** One role spent 37 calls on two walls, hitting one of them nineteen
505
+ times. Every denial was correct. Not one was a false positive. It still cost thirty-seven
506
+ calls, because *correct* and *heard* are different properties and only the first was being
507
+ measured.
508
+
509
+ So a denial now carries its own history:
652
510
 
653
- ```toml
654
- [runtime]
655
- # the default, in full — `~/.local/{share,state}` is where an agent that is neither
656
- # claude nor codex keeps its log, and one that cannot write it does not fail a task,
657
- # it fails to start
658
- writes = ["~/.claude", "~/.codex", "~/.local/share", "~/.local/state",
659
- "~/.cache", "$TMPDIR", "/tmp"]
511
+ ```
512
+ deploy/x.yml belongs to infra. It is not dev's to change — hand it over rather than
513
+ working around it. You have been denied this 3 times now; it is not going to work on
514
+ the fourth try. Already queued for a person to answer…
660
515
  ```
661
516
 
662
- **Writing this key replaces that list; it does not add to it.** So a `[runtime] writes` copied
663
- from somewhere to grant one extra path silently drops the six it did not mention. Start from the
664
- list above and append.
517
+ And the standing list is a command:
665
518
 
666
- Two things follow, and both are the kind of thing you want to hear from the tool rather than
667
- discover:
519
+ ```
520
+ $ seisin walls dev
521
+ WALLS — you have already been refused these, and the policy still refuses them.
522
+ Do not retry; the reason is where the other way in is.
523
+ 19× write ../equipo/.git/index.lock
524
+ ../equipo/.git/index.lock belongs to plataforma-dev
525
+ 6× read ~/.npmrc
526
+ no role declares ~/.npmrc — add it under a [roles.<name>] keys list
527
+ (23 of your calls went into retrying these.)
528
+ ```
668
529
 
669
- - **Scratch is shared.** Every role can write it, so two agents can reach each other's temp
670
- files. If your repo lives inside the temp dir, territory does not hold at all — `seisin check`
671
- says so out loud when it detects that.
672
- - **The home directory itself is never granted.** Only those named subdirectories. A grant that
673
- reached `~` would hand over the shell profile, the ssh config, and every dotfile with a token
674
- in it. There is a test that fails if that ever changes.
675
- - **Reading your home is a different question, and by default it is open.** None of the above
676
- stops a role from *reading* `~/.ssh`, `~/.aws/credentials`, `~/.npmrc` or
677
- `~/.config/gh/hosts.yml`. That is the deliberate trade in the table above — an agent that
678
- cannot read the machine cannot work — and `seisin check` now says it on every run rather than
679
- leaving you to find out.
530
+ **A wall is recomputed against the policy, not read out of the log.** If the role would be
531
+ allowed today, it is not a wall — whatever happened yesterday. The obvious implementation is a
532
+ time window, on the argument that an old wall may have been granted since; the window is a
533
+ proxy for the question, and seisin has the policy right there, so it asks the question. A grant
534
+ makes its wall disappear on the next turn instead of when a window expires. Same move as
535
+ `owners` and the request queue: recompute rather than believe.
680
536
 
681
- Set `writes = []` to opt out and find out why it is there.
537
+ Silent on the first denial, by design. A counter that reads `1×` every time is noise on the
538
+ turn where the sentence is already doing its job.
682
539
 
683
- ### Closing your credentials, without signing the agent out
540
+ **Whether it makes an agent stop is not demonstrated, and will not be for a while.** The
541
+ obvious way to check is to compare repeats before and after — but the repeats were 86% one
542
+ lock file, and the release before this one deleted that wall outright with
543
+ `GIT_OPTIONAL_LOCKS=0`. Repeats did fall, by a lot, and **the fall is the wall going away,
544
+ not the sentence landing**: both shipped within days of each other and the bigger cause is the
545
+ other one. Isolating this needs a forward test — walls per role, rounds with the hook wired
546
+ and no git locks in the way — not the history. Measured in the field and written up rather
547
+ than smoothed over, because the alternative is a claim the numbers do not carry.
684
548
 
685
- ```toml
686
- [runtime]
687
- isolate = "credentials" # ~/.ssh, ~/.aws, ~/.npmrc, ~/.config and the rest go dark
688
- ```
689
-
690
- Measured, on the same machine, same policy, same command:
691
-
692
- | | `~/.ssh` `~/.aws` `~/.npmrc` `~/.config/gh` | `~/.claude` | `claude -p` |
693
- |---|---|---|---|
694
- | default | open | open | **OK** |
695
- | `isolate = "credentials"` | **closed** | open | **OK** |
696
- | `isolate = "home"` | **closed** | closed | `Not logged in` |
549
+ ## Scratch space, and what it costs
697
550
 
698
- `"home"` also gives each role its own `HOME`, `TMPDIR` and XDG directories, so one role cannot
699
- read the session another's CLI just wrote. It is the stronger claim and it has a real price:
700
- **every CLI in the box sees an empty home and asks you to log in again.** That is not this
701
- sandbox being strict — on macOS the agent's credential is in the login keychain, the keychain is
702
- found through `$HOME`, and `HOME=/empty claude -p` reproduces the same message with no sandbox
703
- involved at all.
551
+ Territory alone is correct and unusable: an agent writes session state under its own config
552
+ directory and its tools write to the temp dir, so *your folders and nothing else* stops the
553
+ agent from starting. Every role gets `~/.claude`, `~/.cache`, `$TMPDIR` and `/tmp` — and that
554
+ is a real hole, because two roles share a temp directory and territory does not hold in it.
555
+ It is one line to turn off.
704
556
 
705
- Which of the two you want is your threat model, so neither is a default. `"credentials"` is the
706
- one you can turn on today on a machine you are already working on.
557
+ **[What that costs, and how to close it →](docs/scratch.md)**
707
558
 
708
559
  ## What it is not
709
560
 
@@ -761,19 +612,47 @@ This space already has good work, and seisin is not the first thing here:
761
612
  - [`anthropics/sandbox-runtime`](https://github.com/anthropics/sandbox-runtime) — the enforcement. seisin is a thin thing on top of a serious one.
762
613
  - [`kornysietsma/claude-code-permissions-hook`](https://github.com/kornysietsma/claude-code-permissions-hook) — granular `PreToolUse` rules, one global policy.
763
614
  - [`XuebinMa/agent-guard`](https://github.com/XuebinMa/agent-guard) — a permission-enforcement SDK, also one global policy.
615
+ - [`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.
616
+ - [`dredozubov/hazmat`](https://github.com/dredozubov/hazmat) — runs the agent as a different system user, with `pf` rules and snapshots.
764
617
  - *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.
765
618
 
619
+ ### One sentence to tell these apart, because the names all sound the same
620
+
621
+ **OpenShell and hazmat isolate the agent from your machine. seisin separates roles from each
622
+ other inside one repository, and when it blocks something it names the owner.**
623
+
624
+ Those are different problems and the first one is not the one this solves. If what you want
625
+ is "this agent cannot touch anything outside its box", they do that and OpenShell does it
626
+ with a large organisation behind it. If what you want is "four agents work in the same
627
+ repository and none of them edits another's files — and when one is stopped it is told whose
628
+ those files are", nothing above answers that, which is why this exists.
629
+
630
+ They compose rather than compete: the agent seisin confines could be running inside one of
631
+ them.
632
+
633
+ *One detail worth knowing before you compare, and it is about placement rather than quality.
634
+ Read from [OpenShell's support matrix][osm] on 2026-09-21, not measured here: macOS is a
635
+ supported platform, and on it "these kernel modules run inside the Docker Desktop Linux VM,
636
+ not on the host kernel". So on a Mac, OpenShell confines an agent inside a Linux VM, and
637
+ seisin confines a process on the host through Seatbelt. Both are kernel enforcement; they are
638
+ not the same kernel, and which one you want depends on whether the thing you are protecting is
639
+ on the host. seisin [says where each of its own claims was
640
+ measured](docs/what-it-has-been-put-through.md#where-each-claim-was-actually-run).*
641
+
642
+ [osm]: https://docs.nvidia.com/openshell/reference/support-matrix
643
+
766
644
  ## Status
767
645
 
768
- `0.1.1`, 228 tests, of which **18 need `@anthropic-ai/sandbox-runtime` installed**
646
+ `0.2.0`, 326 tests, of which **26 need `@anthropic-ai/sandbox-runtime` installed**
769
647
  and run real commands through the real kernel — and CI fails if the sandbox half *skips*, because
770
648
  a green run that quietly tested nothing looks exactly like a real one. That is not hypothetical:
771
649
  those eighteen skipped on Linux for a day, behind a runtime check that looked for the global
772
650
  install and missed the bundled one, and hid a defect that broke `seisin run` on that platform
773
- entirely. **228/228 on macOS 15 and on `ubuntu-latest` under bubblewrap**, nothing skipped on
651
+ entirely. **326/326 on macOS 15 and on `ubuntu-latest` under bubblewrap**, nothing skipped on
774
652
  either, Node 18/20/22 in CI at every push — and 225/225 the same way on Debian 12.15
775
653
  with bubblewrap 0.8.0, the last time the suite was run in Docker.
776
- [Which claim was measured where](docs/what-it-has-been-put-through.md#where-each-claim-was-actually-run).
654
+ [Which claim was measured where](docs/what-it-has-been-put-through.md#where-each-claim-was-actually-run),
655
+ and [what running agents behind it cost the people using it](docs/field-notes.md).
777
656
  The config format may still move before `1.0` — if it does, `seisin check` will
778
657
  tell you what changed.
779
658
 
@@ -785,9 +664,16 @@ already done:
785
664
  | **Windows** | the runtime has a backend. seisin has never been pointed at it, and no CI runner covers it |
786
665
  | **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/) |
787
666
  | **`init` heuristics** | it reads `.claude/agents/` then `CODEOWNERS`. Every other convention is a guess nobody has made yet |
667
+ | **SSH inside a turn** | the transport exists — the runtime's SOCKS proxy filters by `(port, host)` — and the `ProxyCommand` it wires up cannot authenticate to it, so `git` over SSH dies at the handshake. [The ask](docs/upstream/ssh-proxycommand.md) is written and verified, not filed. Use an HTTPS remote |
668
+
669
+ Every word above has one meaning, listed in [the glossary](docs/glossary.md) — the boundary
670
+ **denies**, a person **declines**, seisin **refuses** a config it cannot enforce. A tool whose
671
+ product is a sentence cannot afford two words for one thing.
788
672
 
789
- Issues and pull requests welcome. If you are reporting something that got past
790
- the boundary, `seisin log --verdict denied` and the `seisin.toml` are the two
673
+ Issues and pull requests welcome — [CONTRIBUTING.md](CONTRIBUTING.md) says what is actually
674
+ different about contributing here, which is mostly that a claim has to be downstream of
675
+ something measured. If you are reporting something that got past the boundary, do it
676
+ [privately](SECURITY.md); `seisin log --verdict denied` and the `seisin.toml` are the two
791
677
  things that make it reproducible.
792
678
 
793
679
  ## The name