seisin 0.5.0 → 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 (76) hide show
  1. package/README.md +385 -129
  2. package/package.json +13 -5
  3. package/src/browsers.js +52 -0
  4. package/src/canary.js +149 -0
  5. package/src/cli.js +47 -23
  6. package/src/commands/check.js +154 -3
  7. package/src/commands/explain.js +2 -5
  8. package/src/commands/hook.js +7 -3
  9. package/src/commands/init.js +57 -50
  10. package/src/commands/requests.js +31 -56
  11. package/src/commands/review.js +33 -6
  12. package/src/commands/run.js +332 -110
  13. package/src/commands/walls.js +39 -7
  14. package/src/commands/whose.js +2 -1
  15. package/src/config.js +321 -15
  16. package/src/controls.js +5 -5
  17. package/src/diagnose.js +67 -16
  18. package/src/env.js +3 -0
  19. package/src/grants.js +107 -2
  20. package/src/handoff.js +285 -10
  21. package/src/help.js +39 -19
  22. package/src/hook.js +59 -30
  23. package/src/index.d.ts +217 -0
  24. package/src/index.js +20 -12
  25. package/src/inspect/codex.js +64 -0
  26. package/src/inspect/credentials.js +48 -0
  27. package/src/inspect/enforce.js +24 -0
  28. package/src/inspect/files.js +71 -0
  29. package/src/inspect/home.js +43 -0
  30. package/src/inspect/inject.js +68 -0
  31. package/src/inspect/keys.js +74 -0
  32. package/src/inspect/layout.js +258 -0
  33. package/src/inspect/limits.js +98 -0
  34. package/src/inspect/machine.js +107 -0
  35. package/src/inspect/network.js +37 -0
  36. package/src/inspect/providers.js +71 -0
  37. package/src/inspect/read-territory.js +105 -0
  38. package/src/inspect/role-keys.js +136 -0
  39. package/src/inspect/shared.js +37 -0
  40. package/src/inspect/warnings.js +58 -0
  41. package/src/inspect.js +13 -815
  42. package/src/intake.js +131 -21
  43. package/src/keys.js +223 -67
  44. package/src/kinds.js +42 -0
  45. package/src/lines.js +26 -0
  46. package/src/log.js +7 -2
  47. package/src/mcp.js +28 -43
  48. package/src/nested.js +12 -3
  49. package/src/notify.js +53 -12
  50. package/src/owners.js +165 -3
  51. package/src/paths.js +17 -1
  52. package/src/provider-run.js +81 -0
  53. package/src/redact.js +34 -15
  54. package/src/render.js +35 -9
  55. package/src/requests.js +363 -45
  56. package/src/review.js +21 -9
  57. package/src/rundir.js +29 -8
  58. package/src/sandboxes/command.js +93 -0
  59. package/src/sandboxes/index.js +113 -0
  60. package/src/sandboxes/mach-profile.js +31 -0
  61. package/src/sandboxes/srt-mach.mjs +43 -0
  62. package/src/sandboxes/srt.js +163 -0
  63. package/src/sandboxes/tartaros.js +80 -0
  64. package/src/scan.js +7 -3
  65. package/src/serve.js +35 -17
  66. package/src/spool.js +8 -17
  67. package/src/srt.js +263 -33
  68. package/src/surface.js +129 -16
  69. package/src/territory.js +261 -0
  70. package/src/trace.js +33 -0
  71. package/src/uilink.js +15 -6
  72. package/src/views.js +40 -14
  73. package/src/violations.js +143 -7
  74. package/src/walls.js +62 -9
  75. package/ui/index.html +85 -13
  76. package/ui/favicon.svg +0 -1
package/README.md CHANGED
@@ -1,47 +1,54 @@
1
1
  # seisin
2
2
 
3
- [![test](https://github.com/carlostapiacl/seisin/actions/workflows/test.yml/badge.svg)](https://github.com/carlostapiacl/seisin/actions/workflows/test.yml)
3
+ [![test](https://github.com/carlostapiacl/seisin/actions/workflows/test.yml/badge.svg?branch=main)](https://github.com/carlostapiacl/seisin/actions/workflows/test.yml)
4
4
  [![OpenSSF Scorecard](https://api.scorecard.dev/projects/github.com/carlostapiacl/seisin/badge)](https://scorecard.dev/viewer/?uri=github.com/carlostapiacl/seisin)
5
5
  [![npm](https://img.shields.io/npm/v/seisin?color=222)](https://www.npmjs.com/package/seisin)
6
- [![license](https://img.shields.io/badge/license-MIT-222)](LICENSE)
7
- [![node](https://img.shields.io/badge/node-%E2%89%A518-222)](package.json)
8
- [![no dependencies](https://img.shields.io/badge/dependencies-1-222)](package.json)
9
-
10
- **[The short version, with the demos playing →](https://carlostapiacl.github.io/seisin/)** · this page is the long one.
6
+ [![license](https://img.shields.io/badge/license-MIT-222)](https://github.com/carlostapiacl/seisin/blob/main/LICENSE)
7
+ [![node](https://img.shields.io/badge/node-%E2%89%A518-222)](https://github.com/carlostapiacl/seisin/blob/main/package.json)
8
+ [![one dependency: the sandbox runtime](https://img.shields.io/badge/dependencies-1-222)](https://github.com/carlostapiacl/seisin/blob/main/package.json)
11
9
 
12
10
  > **seisin** *(n.)* — the legal possession of a piece of land. Not who owns it on paper: who holds it now.
13
11
 
14
12
  **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
13
 
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.
14
+ ![seisin denying a write outside a role's territory, then naming the owner](https://raw.githubusercontent.com/carlostapiacl/seisin/main/docs/img/demo.gif)
17
15
 
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)*
16
+ ```bash
17
+ npm install -g seisin # on Linux, three system packages first: see Install
18
+ seisin init && seisin check # propose a policy from the repo, then read it
19
+ seisin run <role> -- claude -p "…" # the roles are the ones seisin check lists
20
+ ```
19
21
 
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)*
22
+ **Is it for me?** If two or more agents work in one repository, yes — that is what it is for.
23
+ With one agent, its own sandbox covers most of this ([what is left over](#what-it-is-not)).
21
24
 
22
- **Three ways in, and they say the same thing.** The **CLI** is what a person types and what
23
- every agent runs under. The **console** (`seisin ui`) is for a person deciding something. The
24
- **MCP server** is for an agent asking about its own situation. A change to what any of them
25
- says lands in all three — [CONTRIBUTING](CONTRIBUTING.md#three-surfaces-and-a-change-lands-in-all-of-them)
26
- says why, and a test holds one half of it.
25
+ **[The short version, with the demos playing →](https://carlostapiacl.github.io/seisin/)** · this page is the long one.
27
26
 
28
27
  <details>
29
28
  <summary><b>Contents</b></summary>
30
29
 
31
- **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)
30
+ **Start here** · [What it does](#what-it-does) · [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)
32
31
 
33
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)
34
33
 
35
- **Secrets** · [How it protects secrets](#how-it-protects-secrets) · [Keys in full](docs/keys.md)
34
+ **Secrets** · [How it protects secrets](#how-it-protects-secrets) · [Keys in full](https://github.com/carlostapiacl/seisin/blob/main/docs/keys.md)
36
35
 
37
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)
38
37
 
39
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)
40
39
 
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)
40
+ **Deeper** · [Keys](https://github.com/carlostapiacl/seisin/blob/main/docs/keys.md) · [Agents](https://github.com/carlostapiacl/seisin/blob/main/docs/agents.md) · [Day one](https://github.com/carlostapiacl/seisin/blob/main/docs/first-day.md) · [Scratch](https://github.com/carlostapiacl/seisin/blob/main/docs/scratch.md) · [Glossary](https://github.com/carlostapiacl/seisin/blob/main/docs/glossary.md) · [What it has been put through](https://github.com/carlostapiacl/seisin/blob/main/docs/what-it-has-been-put-through.md) · [Field notes](https://github.com/carlostapiacl/seisin/blob/main/docs/field-notes.md) · [OpenShell compatibility](https://github.com/carlostapiacl/seisin/blob/main/docs/openshell.md) · [Decisions](https://github.com/carlostapiacl/seisin/blob/main/docs/decisions.md) · [nono backend contract](https://github.com/carlostapiacl/seisin/blob/main/docs/nono-backend.md) · [Requests](https://github.com/carlostapiacl/seisin/blob/main/docs/requests.md) · [Contributing](https://github.com/carlostapiacl/seisin/blob/main/CONTRIBUTING.md)
42
41
 
43
42
  </details>
44
43
 
44
+ ## What it does
45
+
46
+ 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.
47
+
48
+ *Outside reviews went looking for ways past the boundary. [What they found, what broke, and what is still open →](https://github.com/carlostapiacl/seisin/blob/main/docs/what-it-has-been-put-through.md)*
49
+
50
+ *And what running four teams of agents behind it actually cost — including the numbers that did not survive a re-check. [Field notes →](https://github.com/carlostapiacl/seisin/blob/main/docs/field-notes.md)*
51
+
45
52
  ```mermaid
46
53
  flowchart LR
47
54
  A["agent<br/>running as a role"] -->|writes src/api/orders.ts| K{{"the kernel<br/>Seatbelt · bubblewrap"}}
@@ -49,9 +56,10 @@ flowchart LR
49
56
  D --> S["seisin reads the denial<br/>and looks up the policy"]
50
57
  S --> W["<b>whose it was</b><br/>belongs to backend"]
51
58
  S --> Q["a request, queued"]
52
- Q --> H(["a person<br/>grants or declines"])
53
- H -->|edits seisin.toml| P[("seisin.toml")]
54
- P -.->|settings| K
59
+ S --> L[(".seisin/log.jsonl<br/>one hash-chained entry")]
60
+ Q --> H(["a person grants or declines<br/>in a terminal or the console"])
61
+ H -->|edits, with provenance| P[("seisin.toml")]
62
+ P -.->|"seisin run turns it into<br/>the sandbox settings"| K
55
63
 
56
64
  style D fill:#fde,stroke:#c66
57
65
  style W fill:#dfd,stroke:#6a6
@@ -62,33 +70,56 @@ flowchart LR
62
70
  permission layers in this space answer *yes* or *no*, or at best *which rule* said no — and a
63
71
  denial that also names an owner turns a dead end into a handoff.
64
72
 
65
- ![seisin denying a write outside a role's territory, then naming the owner](docs/img/demo.gif)
66
-
67
73
  ```
68
74
  $ seisin run frontend -- sh -c 'echo // fix >> src/api/orders.ts'
69
- sh: src/api/orders.ts: Operation not permitted
70
- seisin: 1 kernel denial(s) recorded
71
-
72
- 1 pending request(s)
75
+ seisin: frontend · writes src/web/** public/** (+6 scratch) · keys netlify-token.txt · runs no agent seisin knows · env 20 kept, 58 dropped · read all
76
+ sh: src/api/orders.ts: Operation not permitted
77
+ seisin: 1 kernel denial(s) recorded
73
78
 
74
79
  #1 frontend wants write on src/api/** (owned by backend)
75
80
  first asked over src/api/orders.ts
81
+ id frontend:write:src/api
82
+ handed to backend
76
83
 
77
- seisin grant <n> [--reason "…"] · seisin decline <n> [--reason "…"]
84
+ this run: 1 new request(s) — seisin requests
78
85
  ```
79
86
 
87
+ The first line is the run's own header: what this role may write, which keys it holds, and how
88
+ much of your environment it did not inherit. The last lists only what this run filed; the
89
+ rest of the queue is one count, and `seisin requests`.
90
+
91
+ The line under the request is where it goes, decided from the policy as it stands: *handed to
92
+ backend*, or *a person decides* when nobody or several roles own it. seisin does not start
93
+ backend. Whatever does reads that decision (`seisin requests --json`, `seisin_requests`) and runs
94
+ `seisin run backend --handoff frontend:write:src/api`, which starts only if the policy hands that
95
+ request to backend — and anything backend is then denied joins the same handoff chain, so work
96
+ cannot bounce back to frontend ([where a request goes](https://github.com/carlostapiacl/seisin/blob/main/docs/requests.md#where-a-request-goes)).
97
+ A role that is a pipeline phase rather than a worker says `handoff = false`: it keeps its
98
+ territory and is never handed work — what it owns goes to another owner, or to a person.
99
+
100
+ A caller that probes the sandbox with a write it expects to be denied — its own canary — names
101
+ that path with `--expect-denied <path>` (repeatable). The denial is logged as expected and asked
102
+ of nobody: no request, no notification, no handoff. Only that exact path; any other is a request
103
+ as usual ([expected denials](https://github.com/carlostapiacl/seisin/blob/main/docs/requests.md#a-denial-the-caller-expects)).
104
+
80
105
  **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
106
 
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).
107
+ 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](https://github.com/carlostapiacl/seisin/blob/main/docs/upstream/cli-violations.md) as [issue #582](https://github.com/anthropics/sandbox-runtime/issues/582).
83
108
 
84
109
  The agent can ask directly too, from inside the sandbox:
85
110
 
86
111
  ```
87
112
  $ seisin run frontend -- seisin whose src/api/orders.ts
113
+ seisin: frontend · writes src/web/** public/** (+6 scratch) · keys netlify-token.txt · runs no agent seisin knows · env 20 kept, 58 dropped · read all
114
+
88
115
  src/api/orders.ts belongs to backend
89
- you are frontend. Hand it over rather than working around it.
116
+ you are frontend. It is handed to backend: hand it over rather than working around it.
90
117
  ```
91
118
 
119
+ The second line is the same handoff decision the queue shows: with two owners it says a person
120
+ decides and not to pick one, and in a run started with `--handoff` it says when handing the work
121
+ back would be a cycle. The hook's denial and the note after a failed command say it the same way.
122
+
92
123
  ## Sixty seconds
93
124
 
94
125
  ```bash
@@ -97,7 +128,7 @@ cd your-repo
97
128
  seisin init # proposes a policy from what the repo already says
98
129
  seisin check # read it before you trust it
99
130
  seisin wire # let the agent record what it does
100
- seisin run frontend -- claude -p "…" # run an agent inside its own territory
131
+ seisin run <role> -- claude -p "…" # a role from seisin check, inside its own territory
101
132
  ```
102
133
 
103
134
  On Linux, [three system packages first](#install). Everything below is why it works and where it does not.
@@ -131,12 +162,11 @@ A rule is a string comparison against a command someone might not spell that way
131
162
 
132
163
  ## What it is for, and what it is not for
133
164
 
134
- > **It turns an agent's mistake into a contained mistake. It does not turn a hostile
135
- > agent into a harmless one.**
165
+ > **It turns an agent's mistake into a contained mistake. Against a hostile agent, the
166
+ > guarantee is the one of the sandbox you run it under.**
136
167
  >
137
- > Two outside reviews have gone through it looking for ways past the boundary and
138
- > found eleven things between them. [What it has been put
139
- > through](docs/what-it-has-been-put-through.md) lists all of them — what was
168
+ > Two outside reviews, plus three follow-ups, have gone through it looking for ways past the boundary.
169
+ > [What they found and what remains open](https://github.com/carlostapiacl/seisin/blob/main/docs/what-it-has-been-put-through.md) lists all of them — what was
140
170
  > fixed, what was measured, and what is still open.
141
171
 
142
172
  That distinction is the whole threat model, and it is worth reading before you install
@@ -148,28 +178,33 @@ denies, and it does not care how the command was spelled — verified against a
148
178
  process, an absolute path, a symlink, a `python -c`, and a shell redirect the agent fell
149
179
  back to when its own file API was denied.
150
180
 
151
- **It is not built to hold against an agent that is trying.** Measured, in this repo's own
152
- fixtures:
181
+ **Against an agent that is trying, the line is drawn by the sandbox.** seisin writes the
182
+ policy and checks that the sandbox can hold it; [the sandbox you choose](https://github.com/carlostapiacl/seisin/blob/main/docs/sandboxes.md)
183
+ enforces it. Some limits are the policy's and hold under any sandbox, measured in this repo's
184
+ own fixtures:
153
185
 
154
186
  | | |
155
187
  |---|---|
156
- | it can read everything except the declared keys | by design — an agent that cannot read the repo cannot work. `seisin scan` exists because secrets live elsewhere too |
157
- | it can write anything inside its own territory | including staging an exfiltration, or destroying its own uncommitted work with `rm` |
188
+ | with the default read mode (`read = "all"`) it can read everything except the declared key directories — **including `~/.ssh`, `~/.aws`, `~/.npmrc` and `~/.config`** | by design — an agent that cannot read the repo cannot work. `read = "territory"` closes those homes (as does `[runtime] isolate = "credentials"`); `seisin scan` finds secrets stored elsewhere |
189
+ | it can write anything inside its own territory | including staging an exfiltration |
158
190
  | `base64` defeats the redaction | only the literal value is masked, and only on stdout and stderr |
159
- | an allowed domain is an allowed channel | if the agent's own API is reachable, so is everything that goes through it |
191
+ | an allowed domain is an allowed channel | if the agent's own API is reachable, so is everything that goes through it. [`[network] deny_addresses`](#configure) keeps an allowed name from resolving into your LAN |
160
192
  | 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
193
 
162
- And three things that outweigh all of the above:
194
+ Others are the sandbox's, and differ between the two built in:
163
195
 
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
- 2. **The enforcement is someone else's beta.** `sandbox-runtime` describes itself as a
167
- research preview with an evolving API. A hole there is a hole here.
168
- 3. **Two platforms tested, by one person, on one machine each.**
196
+ | | `srt` (the default) | `tartaros` |
197
+ |---|---|---|
198
+ | a process the agent leaves running | **macOS:** it outlives the run, whether it double-forks, ignores signals under `nohup` or calls `setsid` (measured 2026-10-16). **Linux:** the PID namespace ends with the run (read from its source) | **macOS:** the run's process group is killed; one that calls `setsid` outlives it (measured 2026-10-16). **Linux:** the run's cgroup is killed whole (read from its source) |
199
+ | deleting inside the territory | write includes delete; the ask is upstream as [issue #545](https://github.com/anthropics/sandbox-runtime/issues/545) | not measured here |
200
+ | `[network] deny_addresses` | enforced | not yet: a role that has it runs under `srt`, and `check` says why |
201
+ | what it is | Anthropic's, described by its authors as a research preview | by the author of seisin, alpha |
169
202
 
170
- For several of your own agents, on your own machine, getting in each other's way — that is
171
- what this is for, and it is a great deal better than nothing. For putting an agent you do
172
- not control in front of data you care about, it is not.
203
+ `seisin check` says which sandbox each role gets before anything runs.
204
+
205
+ For several of your own agents, on your own machine, getting in each other's way, this is
206
+ what it is for. For an agent you do not control, choose the sandbox for that threat: seisin
207
+ will not make its guarantee stronger than it is.
173
208
 
174
209
  ## Install
175
210
 
@@ -177,7 +212,7 @@ not control in front of data you care about, it is not.
177
212
  npm install -g seisin
178
213
  ```
179
214
 
180
- That pulls in [`@anthropic-ai/sandbox-runtime`](https://github.com/anthropics/sandbox-runtime), which does the enforcing: Seatbelt on macOS, bubblewrap on Linux. No containers, no VMs, no daemon. seisin has **no other dependencies**.
215
+ That pulls in [`@anthropic-ai/sandbox-runtime`](https://github.com/anthropics/sandbox-runtime), the default sandbox: Seatbelt on macOS, bubblewrap on Linux. No containers, no VMs, no daemon. seisin has **no other dependencies**. [tartaros](https://github.com/carlostapiacl/tartaros), or a sandbox of your own, can enforce instead ([docs/sandboxes.md](https://github.com/carlostapiacl/seisin/blob/main/docs/sandboxes.md)).
181
216
 
182
217
  **On Linux you also need three system packages** — the runtime refuses to start without
183
218
  them rather than running unconfined, which is the right failure and an unhelpful message:
@@ -203,25 +238,63 @@ default Docker container forbids. `--cap-add SYS_ADMIN --security-opt seccomp=un
203
238
  gets namespaces; mounting `/proc` needs `--privileged`. If you cannot grant that, run
204
239
  seisin on the host and let the container be what it sandboxes.
205
240
 
241
+ ## JavaScript API
242
+
243
+ The package is ESM. Import policy decisions from `seisin`; the same functions
244
+ back the CLI. TypeScript declarations ship with the package. Install it in the project that
245
+ imports it — a global install puts the CLI on your PATH, not the package where `import` looks:
246
+
247
+ ```bash
248
+ npm install seisin
249
+ ```
250
+
251
+ ```js
252
+ import { loadConfig, explain, ownersOf } from "seisin";
253
+
254
+ const config = loadConfig("seisin.toml");
255
+ console.log(ownersOf(config, "src/api/server.js"));
256
+ console.log(explain(config, "frontend", "write", "src/api/server.js"));
257
+ ```
258
+
259
+ The stable surface covers policy loading (`findConfig`, `loadConfig`,
260
+ `parseToml`) and where it lives (`CONFIG_NAME`, `STATE_DIR`, `LOG_NAME`), ownership and
261
+ explanations (`covers`, `ownersOf`, `keyHolders`, `explain`, `explainFileRead`, `readTarget`),
262
+ resolved sandbox settings (`settingsFor`), key references (`parseKey`, `defaultName`,
263
+ `entriesOf`, `MODES`), inspection and scanning (`inspect`, `sharedPaths`, `scan`), activity
264
+ and requests (`readLog`, `logPath`, `observed`, `pendingRequests`, `requestQueue`,
265
+ `requestsPath`, `grantFor`, `generalise`), handoffs (`decideHandoff`, `MAX_DEPTH`,
266
+ `recordHandoff`), walls (`walls`, `wasted`), and hook decisions (`targetsOf`, `decide`).
267
+ Granting is not in it: a grant is a person's, through the CLI or the console.
268
+ Sandbox tuning constants, environment construction and output masking remain
269
+ internal; they may change without an API compatibility promise.
270
+
206
271
  ## Use
207
272
 
208
273
  ```bash
209
274
  seisin init # proposes a seisin.toml for this repo
210
275
  seisin check # print the map, run nothing
276
+ seisin check --system-log # macOS: is the system log dropping denials right now?
277
+ seisin help limits # what seisin cannot do, in any repo
211
278
  seisin run frontend -- claude -p "…" # run an agent as that role
212
279
  seisin explain frontend write src/api/x # ask one question, exit 0 or 1
213
- seisin review # what the log says about the policy
280
+ seisin review --since 7d # what the log says about the policy, this week
214
281
  seisin wire # install the PreToolUse hook in this repo
215
- seisin ui # a console for editing the map
282
+ seisin ui # a console for deciding what is waiting
216
283
  ```
217
284
 
285
+ **Three ways in, and they say the same thing.** The **CLI** is what a person types and what
286
+ every agent runs under. The **console** (`seisin ui`) is for a person deciding something. The
287
+ **MCP server** is for an agent asking about its own situation. A change to what any of them
288
+ says lands in all three — [CONTRIBUTING](https://github.com/carlostapiacl/seisin/blob/main/CONTRIBUTING.md#three-surfaces-and-a-change-lands-in-all-of-them)
289
+ says why, and a test holds one half of it.
290
+
218
291
  `seisin <command> --help` lists a command's flags, examples and exit codes. The exit codes, so
219
292
  the commands compose in a script, a pre-commit hook or CI:
220
293
 
221
294
  | command | 0 | 1 | other |
222
295
  |---|---|---|---|
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 | |
296
+ | `run` | — | — | the command's own status; 86 if the canary proves the boundary does not hold; 127 if it is not on the role's PATH; 128+n when killed by signal n (130 for ctrl-c) |
297
+ | `check` | the policy can be enforced (warnings included) | a role cannot be enforced as written, `--verify` failed, or `--system-log` measured messages dropped | |
225
298
  | `explain` | allowed | denied | |
226
299
  | `scan` | no certain credential | a certain credential outside the key dirs | |
227
300
  | `review` | no role denied repeatedly | a role denied repeatedly | |
@@ -231,8 +304,8 @@ the commands compose in a script, a pre-commit hook or CI:
231
304
  | `whose`, `grant`, `decline`, `log`, `init`, `wire`, `ui`, `watch`, `mcp` | done | — | |
232
305
  | `hook` | always | — | |
233
306
 
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.
307
+ Every command exits **2** on a usage error — an unknown command, flag, role or action — and every
308
+ command that reads the policy exits 2 when there is no `seisin.toml` here or above.
236
309
 
237
310
  ## Configure
238
311
 
@@ -255,19 +328,59 @@ keys = ["database-url.txt", "sentry-dsn.txt"]
255
328
  network = ["api.stripe.com"] # this role only — it replaces [network] rather than adding
256
329
  ```
257
330
 
331
+ An allowed name is only as safe as its DNS: one whose records somebody else controls can be
332
+ pointed at a host on your network. The sandbox already denies loopback, link-local and
333
+ cloud-metadata addresses. Private ranges are left to you, because allowing an intranet name is
334
+ legitimate; to close them, list them:
335
+
336
+ ```toml
337
+ [network]
338
+ allow = ["github.com", "*.github.com"]
339
+ deny_addresses = ["10.0.0.0/8", "172.16.0.0/12", "192.168.0.0/16", "100.64.0.0/10", "fc00::/7"]
340
+ ```
341
+
342
+ `[runtime] read = "all"` is the default. The opt-in `"territory"` mode keeps homes,
343
+ temporary directories and mounted data roots shut, while retaining the repository and the
344
+ paths each role names:
345
+
346
+ ```toml
347
+ [runtime]
348
+ read = "territory"
349
+
350
+ [roles.backend]
351
+ writes = ["src/api/**"]
352
+ reads = ["../shared-schemas/**"] # data needed read-only
353
+ toolchain = ["~/.venvs/api"] # program stack under a data root; bin/ goes first on PATH
354
+ verify = ["python", "-m", "pytest"] # run by seisin check --verify inside this role
355
+ ```
356
+
357
+ `seisin check --verify --parallel` is an experimental speed option: role checks run at the same
358
+ time. Use it only when those commands do not share mutable build outputs, caches, ports, or other
359
+ resources; concurrent checks can race and produce misleading failures. Plain `--verify` remains
360
+ serial and is the recommended default. `--parallel` requires `--verify`.
361
+
362
+ `reads` and `toolchain` take literal paths or a subtree ending in `/**`; interior globs,
363
+ `*` and `?` are refused. `verify` is an argv array and may not be empty. Each data root is
364
+ denied whole and what the role keeps is allowed back, so what appears there during the run is
365
+ shut as well. On Linux a kept directory holding a write path is allowed back entry by entry;
366
+ one with more than 1,000 entries is refused, and the error names whether the repo or a `reads`
367
+ path must move or narrow. Details and platform costs are in [Scratch and read territory](https://github.com/carlostapiacl/seisin/blob/main/docs/scratch.md).
368
+
258
369
  `[network] allow` is the fallback for roles that do not name their own. A role that needs one
259
370
  extra host should say so on its own line: a domain in the global list is reachable by every
260
371
  role, including the ones whose whole job is to have nothing to reach.
261
372
 
262
- > ⚠️ **HTTP(S) works; SSH does not.** The allow list is enforced by proxies the runtime
263
- > starts, and although one of them is a SOCKS5 proxy that could carry SSH, the chain breaks
264
- > at its last link: it requires authentication and the `ProxyCommand` the runtime wires up
265
- > cannot send any. Measured — `curl https://github.com` returns `200`, `ssh -T git@github.com`
266
- > cannot resolve the hostname, and `git ls-remote git@github.com:…` reaches the proxy and
267
- > dies at the handshake.
373
+ > ⚠️ **HTTP(S) works on both platforms. SSH does not work on macOS.** The allow list is
374
+ > enforced by proxies the runtime starts, and although one of them is a SOCKS5 proxy that could
375
+ > carry SSH, on macOS the chain breaks at its last link: it requires authentication and the
376
+ > `ProxyCommand` the runtime wires up cannot send any. Measured on macOS — `curl
377
+ > https://github.com` returns `200`, `ssh -T git@github.com` cannot resolve the hostname, and
378
+ > `git ls-remote git@github.com:…` reaches the proxy and dies at the handshake. **On Linux** the
379
+ > runtime routes git's SSH through its HTTP proxy with credentials instead, so it is expected to
380
+ > pass — read from the runtime's source, not measured here.
268
381
  >
269
- > **Use an HTTPS remote for git**, and run SSH deploys outside the confined run.
270
- > [Why, exactly, and whose gap it is →](docs/decisions.md#ssh-does-not-work-and-it-is-not-seisin-that-decided-that)
382
+ > **Use an HTTPS remote for git**, and on macOS run SSH deploys outside the confined run.
383
+ > [Why, exactly, and whose gap it is →](https://github.com/carlostapiacl/seisin/blob/main/docs/decisions.md#ssh-does-not-work-and-it-is-not-seisin-that-decided-that)
271
384
 
272
385
  A role can also give something back. `never_writes` subtracts from its own `writes`, and it
273
386
  wins however wide the grant is:
@@ -284,20 +397,44 @@ leaves **no request** in the queue: granting one would undo a subtraction somebo
284
397
  entry that no `writes` of the same role covers, or a misspelling like `never_write`, is named
285
398
  by `seisin check` instead of being ignored.
286
399
 
400
+ A folder only one role may see is `private`. The role reads and writes it; every other role is
401
+ denied both, whatever its `writes` say, in either read mode and under `isolate`:
402
+
403
+ ```toml
404
+ [roles.legal]
405
+ writes = ["contracts/drafts/**"]
406
+ private = ["contracts/signed/**"] # part of legal's territory, and no other role reads it
407
+
408
+ [roles.dev]
409
+ writes = ["**"] # still cannot read or write contracts/signed
410
+ ```
411
+
412
+ An entry is one folder inside the repo, `dir` or `dir/**`: no patterns, no absolute path, no
413
+ `..`, not the repo itself. Two roles naming the same folder, or one inside the other, is refused
414
+ at load, because each would be denied its own. `seisin check` shows it under the role and says
415
+ when it sits inside another role's territory, since that role loses it. `seisin explain dev read
416
+ contracts/signed/x` answers denied and names the owner, and a denial there leaves **no request**:
417
+ no grant opens it. On Linux a missing private folder is created empty by `seisin run` (never by `check`), because
418
+ bubblewrap cannot deny a path that does not exist without placing something there; and another
419
+ role sees an empty folder in its place, where a write succeeds into memory that is discarded with
420
+ the run and never reaches the real one. On macOS the write is denied.
421
+
287
422
  A role that starts a server — a dev server, the backend an end-to-end test drives — needs
288
423
  `local_binding = true`. Listening is off by default and per role: most roles never start one.
289
424
  It is wider than the name: the role can also listen on every interface and **connect to every
290
425
  port on localhost**, so anything listening there without authentication is within its reach.
291
426
  `seisin check` says this next to the role. That is macOS: on Linux each role already has a
292
427
  private loopback, serves without the key, and reaches nothing on the host either way.
428
+ The canary therefore omits its loopback probe for a role with `local_binding = true`: reaching
429
+ the decoy is allowed by that policy, not evidence that confinement failed.
293
430
 
294
431
  If a role only needs to *reach* a local service, name the ports instead:
295
432
  `local_ports = [8001, 8081]`. The role reaches those and nothing else on localhost, on macOS
296
- and Linux. The sandbox's proxy enforces the list, so the client has to use the proxy. curl,
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
299
- needs `--single-process` to start. A listed port is open to anything that speaks `CONNECT`, not
300
- just HTTP, so list the service, not the protocol.
433
+ and Linux. Under srt the sandbox's proxy enforces the list, so the client has to use the proxy.
434
+ curl, Node's `fetch` and Python's `urllib` do. A database driver or a raw socket don't, and are
435
+ denied. Under tartaros the kernel profile holds the list itself, so any client reaches a listed
436
+ port directly, and loopback stays off its proxy. For a headless browser, see `browsers` below. A listed port is open to anything that
437
+ speaks `CONNECT`, not just HTTP, so list the service, not the protocol.
301
438
 
302
439
  `mcp = ["playwright"]` lists the MCP servers a role may load; `mcp = []` means none, and no
303
440
  key means the policy doesn't say. seisin declares this and `explain` answers it
@@ -305,6 +442,30 @@ key means the policy doesn't say. seisin declares this and `explain` answers it
305
442
  launcher has to apply it. With Claude Code: `--mcp-config` holding only those servers, plus
306
443
  `--strict-mcp-config`.
307
444
 
445
+ A role that drives a headless Chromium — Playwright, `@playwright/mcp` — needs
446
+ `browsers = ["chromium"]` on macOS. Without it the browser exits as it starts: its first act
447
+ is to register a Mach service (`org.chromium.Chromium.MachPortRendezvousServer.<pid>`) that its
448
+ own child processes look up, and the sandbox denies both. The preset allows exactly those two,
449
+ by that prefix, and nothing else; every other role keeps the profile it had. The network is
450
+ still `[network] allow`, through the sandbox's proxy, which takes credentials Playwright has to
451
+ be handed apart from the URL:
452
+
453
+ ```js
454
+ const u = new URL(process.env.HTTP_PROXY); // http://<user>:<token>@localhost:<port>
455
+ const browser = await chromium.launch({ proxy: {
456
+ server: `${u.protocol}//${u.host}`,
457
+ username: decodeURIComponent(u.username),
458
+ password: decodeURIComponent(u.password),
459
+ } });
460
+ ```
461
+
462
+ Passing `HTTP_PROXY` as `server` alone does not work: Playwright gives Chromium the address
463
+ without the credentials and the proxy answers `407`. The token is the run's own, so the traffic
464
+ stays tied to the run. `@playwright/mcp` runs under `[sandbox] provider = "tartaros"`, which
465
+ gives the role a temp directory of its own for the unix socket the server always opens; under
466
+ srt it does not yet. [docs/browsers.md](https://github.com/carlostapiacl/seisin/blob/main/docs/browsers.md) has the measurements, the MCP config,
467
+ and what Chrome and Chrome for Testing still lack.
468
+
308
469
  Some files are not code but still make something happen outside the sandbox, and no territory
309
470
  reaches them: git hooks and config, a project's `.claude/`, `.mcp.json`, `.envrc`. Two softer
310
471
  families sit beside those, and a policy can hand them to one role:
@@ -336,35 +497,41 @@ host) or the socket path, once per target per run. `seisin walls` says whether t
336
497
  missing from `local_ports`, listed but dialled directly, or a socket. No request is filed: a
337
498
  port isn't anyone's territory. macOS only.
338
499
 
339
- `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.
500
+ So are the denials that are neither a file nor a connection: a Mach service, a `sysctl`, as
501
+ `mach-register org.chromium.Chromium.MachPortRendezvousServer.7354`, once per operation and
502
+ name per run, and never as a request. Most are the sandbox doing its job and the program
503
+ carrying on; one of them is how a browser dies at start, and `seisin run` names the preset
504
+ that answers it. macOS only.
340
505
 
341
- ## What will look like a bug on the first day
506
+ `seisin init` will propose this from whatever your repo already says: `.claude/agents/`, then `CODEOWNERS` (one role per owner, with all of that owner's paths), then one role per top-level folder that holds code. It declares `[keys] dir` when `.secrets/`, `secrets/` or `.keys/` is there, and adds `.seisin/` to an existing `.gitignore`. It **proposes** — a generated policy you did not read is not a policy — and a second `init` changes nothing without `--force`, which keeps the old one as `seisin.toml.bak`.
342
507
 
343
- Four shapes a correct policy still produces that read as breakage: a granted file whose
344
- neighbours are not granted, a lock file in somebody else's repository, a tool that reports a
345
- denied write as a *readonly database*, and an agent that diagnoses file permissions when the
346
- answer is ownership.
508
+ ## What will look like a bug on the first day
347
509
 
348
- All four came out of one production window where none of them was the boundary misbehaving.
510
+ Shapes a correct policy still produces that read as breakage: a granted file whose
511
+ neighbours are not granted, a lock file in somebody else's repository, a worktree that is two
512
+ paths for one repository, a tool that reports a denied write as a *readonly database*, and Go
513
+ or Dart failing TLS on macOS with the domain allowed. The first four came out of one
514
+ production window where none of them was the boundary misbehaving.
349
515
 
350
- A fifth comes with protected files: **a role's `git checkout`, `pull` or `rebase` cannot update
516
+ One more comes with protected files: **a role's `git checkout`, `pull` or `rebase` cannot update
351
517
  a file the role may not write.** Git exits 0, the branch moves, and the old `.vscode/settings.json`
352
518
  stays behind as a modification — which a later `commit -a` would turn into a revert. Switch
353
519
  branches outside the sandbox, or hand that role the family if keeping those files is its job.
354
520
 
355
- **[What each one looks like, and what to do →](docs/first-day.md)**
521
+ **[What each one looks like, and what to do →](https://github.com/carlostapiacl/seisin/blob/main/docs/first-day.md)**
356
522
 
357
523
  ## Which agents it has been run with
358
524
 
359
- Claude Code, opencode, codex, gemini and a LangGraph script, each against a real policy with
360
- the boundary actually applied — not a smoke test.
525
+ Four: Claude Code, opencode, codex and a LangGraph script, each against a real policy with
526
+ the boundary actually applied — not a smoke test. gemini 0.57.0 has only been started under it,
527
+ which is not the same claim.
361
528
 
362
529
  **One thing is worth knowing before you start: an agent that sandboxes itself has to be told
363
530
  to stop.** `codex` confines every command it runs, and no OS applies a second profile to a
364
531
  process that already has one. seisin says so before it starts one, because the error you get
365
532
  otherwise names neither seisin, nor the agent, nor the fix.
366
533
 
367
- **[Every agent, its version, what it needed, and how each reacted to being stopped →](docs/agents.md)**
534
+ **[Every agent, its version, what it needed, and how each reacted to being stopped →](https://github.com/carlostapiacl/seisin/blob/main/docs/agents.md)**
368
535
 
369
536
  ## What the log says about the policy
370
537
 
@@ -404,6 +571,11 @@ $ seisin review
404
571
  territory: an ownable path no role claims: a real decision — give it an owner, or tell the agent not to write there
405
572
  ```
406
573
 
574
+ `--since 2026-10-01` or `--since 7d` reads only that stretch of the log: on a log months long,
575
+ "Denied repeatedly" is otherwise led by paths fixed long ago. A denial that is correct and needs
576
+ nothing — Claude Code updating its plugins from inside the sandbox — is listed apart as "Denied as
577
+ expected" and does not make `review` exit 1.
578
+
407
579
  Three questions, and the middle one is the reason this exists. **Every
408
580
  permission file only ever grows**, in every system, and always for the same
409
581
  reason: nobody can prove a line is dead, so the safe move is to leave it. Here
@@ -451,18 +623,32 @@ becomes seven hundred entries nobody reads.
451
623
  So a denial leaves something to act on, and many denials in one directory are **one** request:
452
624
 
453
625
  ```
626
+ $ seisin requests
627
+
454
628
  1 pending request(s)
455
629
 
456
630
  #1 frontend wants write on src/api/** (owned by backend) · asked 3×
457
631
  first asked over src/api/orders.ts
632
+ id frontend:write:src/api
458
633
 
459
- seisin grant 1 --reason "…" · seisin decline 1 --reason "…"
634
+ seisin grant <n|id> [--reason "…"] · seisin decline <n|id> [--reason "…"]
635
+ #n is a position and shifts as requests are settled; the id does not.
460
636
  ```
461
637
 
462
638
  The grant writes its own provenance next to the line it adds:
463
639
 
640
+ ```
641
+ $ seisin grant 1 --reason "frontend owns checkout now"
642
+
643
+ granted frontend → src/api/**
644
+ written into /path/to/repo/seisin.toml with its provenance. It applies on the next run.
645
+ ```
646
+
464
647
  ```toml
465
- writes = ["src/api/**"] # granted 2026-09-12 · asked 3× · "frontend owns checkout now"
648
+ [roles.frontend]
649
+ writes = ["src/web/**", "public/**",
650
+ "src/api/**" # granted 2026-09-12 · asked 3× · «frontend owns checkout now»
651
+ ]
466
652
  ```
467
653
 
468
654
  **Granting is never a tool call.** An agent can read the queue and draft the change; only a
@@ -470,7 +656,7 @@ person applies it, in a terminal or in the console. That asymmetry is deliberate
470
656
  the reason the MCP server exists at all.
471
657
 
472
658
  **A person hears about it.** With `[notify]`, the parent process sends one message per new
473
- request — the role, the path, whose it is, and `seisin grant <n>` — to ntfy, Slack or any
659
+ request — the role, the path, whose it is, and `seisin grant '<id>'` — to ntfy, Slack or any
474
660
  webhook. The URL lives in a key directory no role declares (`url_file`) or in the parent's
475
661
  `SEISIN_NOTIFY_URL`; written plainly into `seisin.toml`, which every role can read, it refuses to
476
662
  load, because whoever reads it can send you a fake "grant this".
@@ -487,7 +673,7 @@ from the log and tells the agent whose the path was and how many times it has be
487
673
  the half PreToolUse never sees; and when a session starts, resumes or is compacted it hands the
488
674
  agent its territory and its walls, before the first one.
489
675
 
490
- **[How a denial becomes a request, why it deduplicates, and what a grant records →](docs/permission-requests.md)**
676
+ **[How a denial becomes a request, why it deduplicates, and what a grant records →](https://github.com/carlostapiacl/seisin/blob/main/docs/requests.md)**
491
677
 
492
678
  ## Ask your own assistant
493
679
 
@@ -520,13 +706,39 @@ seisin ui # the same thing, for a person deciding
520
706
  ```
521
707
 
522
708
  ```
523
- 04:13:26 allowed frontend write src/web/app.ts
524
- 04:13:26 denied frontend write src/api/server.ts → backend
525
- 04:13:27 denied frontend read .secrets/database.txt → backend
709
+ $ seisin log
710
+
711
+ 04:13:26 allowed frontend write src/web/app.ts
712
+ 04:13:26 denied frontend write src/api/orders.ts → backend
713
+ 04:13:27 denied frontend read .secrets/database-url.txt → backend
714
+ 04:13:31 denied frontend write src/api/orders.ts → backend [kernel]
715
+
716
+ 4 entries · 3 denied
526
717
  ```
527
718
 
719
+ `[kernel]` marks a line the kernel reported; the others came from the hook.
720
+
528
721
  One append-only JSONL under `.seisin/`, and that is the whole storage design — no daemon, no
529
- database, and `watch` is a tail.
722
+ database, and `watch` is a tail. A run started with a W3C `TRACEPARENT` (a launcher's trace)
723
+ stamps its `trace_id` and `span_id` on every line, so the log joins that trace in any
724
+ OpenTelemetry tool.
725
+
726
+ Each line carries the hash of the one before it, and the first time a chained line is written
727
+ the start of the chain is recorded beside the log (`log.jsonl.genesis`). `seisin log verify`
728
+ checks both, so a line edited, removed or reordered breaks it, and so does stripping the chain
729
+ from every line to make the log read as one written before chaining:
730
+
731
+ ```
732
+ $ seisin log verify
733
+
734
+ broken 4 chained line(s), 2 break(s):
735
+ line 1: expected prev 106c24967e0bdde077d73073b648f218, found 43918a17f2089effc44394a72e4289e9
736
+ line 2: expected prev 43918a17f2089effc44394a72e4289e9, found 106c24967e0bdde077d73073b648f218
737
+ a line was edited, removed or reordered just before each of these
738
+ ```
739
+
740
+ Tamper-evident, not tamper-proof: whoever can write `.seisin/` could rewrite both files
741
+ together, which is why no role can write it.
530
742
 
531
743
  **The agent cannot reach that file.** `.seisin/` is in no role's territory, because a record
532
744
  the recorded process can edit is not a record. The hook sends each line to `seisin run` over
@@ -539,18 +751,30 @@ happens — which is how an *allowed* action gets recorded at all — and it is
539
751
  evadable, on purpose. The **kernel** reports what it actually denied, and is not fooled by
540
752
  anything. What escapes the hook goes unexplained, never unenforced.
541
753
 
754
+ ![the console: requests waiting on a person, a grant that needs a reason and a second click for another role's path, the line it wrote, the denials grouped by path with the kind of each unowned one, the walls each role keeps hitting, the roles, and a control-files change shown before it is saved](https://raw.githubusercontent.com/carlostapiacl/seisin/main/docs/img/console.gif)
542
755
 
756
+ It opens on what needs a decision, not on the policy. Four numbers sit on top — waiting on
757
+ you, denied, the **top cause**, and calls **spent retrying** walls — each a link to the page
758
+ that explains it, and the last two are recomputed against the policy, which a log alone cannot
759
+ do: something granted since stops counting.
543
760
 
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)
761
+ **A decision needs a reason.** Grant without one asks for it; Decline without one fills in
762
+ a default and asks again, so what is recorded is on screen first. The request says what a
763
+ grant covers before the click — *asked for `src/api/routes.ts` → granting
764
+ covers all of `src/api/**`*, and whose it is today. Sharing another role's territory takes a
765
+ second click that names it. After it, the console shows the line it wrote to `seisin.toml`
766
+ and where.
545
767
 
546
- It opens on what needs a decision, not on the policy. Four numbers ride on every screen and
547
- each is a link to the page that explains it — including two a log cannot answer, because
548
- they are recomputed against the policy: how much of the day is **one cause**, and how many
549
- of those causes are on paths **nobody owns**.
768
+ **Denied and Walls say the same thing about one path.** A path no role owns says what kind
769
+ of thing it is — *ownable*, or git's, temporary, build output, a credential — and the move
770
+ that fits each, which for all but the first is not an owner.
550
771
 
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.
772
+ **Live, the roles are read-only:** territories and keys change in `seisin.toml`. The two
773
+ writes are decisions and the control files: beside each role, a toggle for each family it can
774
+ be handed (editor settings, instruction files), and one for `[protect] instructions`. A
775
+ toggle saves nothing. It shows the sandbox settings each role gains or loses and the exact
776
+ line it would write, and a second button, with a reason, saves it. If the server stops, the
777
+ page says *offline* and turns every write off until it answers again.
554
778
 
555
779
  ## How it holds
556
780
 
@@ -558,7 +782,7 @@ what it does to each role's sandbox settings, and written to `seisin.toml` only
558
782
  |---|---|---|
559
783
  | your agent's `allow`/`deny` rules | match the command string before it runs | yes — `/bin/rm`, a heredoc, a child process |
560
784
  | **seisin** | decides what to ask for, and names the owner | it doesn't enforce, it explains |
561
- | **sandbox-runtime → Seatbelt / bubblewrap** | the OS denies the syscall | no |
785
+ | **the sandbox → Seatbelt / bubblewrap / Landlock** | the OS denies the syscall | no |
562
786
 
563
787
  ```
564
788
  Write tool · Bash `rm -rf` · /bin/rm · python -c "open(…)" · a grandchild
@@ -582,6 +806,10 @@ what it does to each role's sandbox settings, and written to `seisin.toml` only
582
806
  explanation — never the boundary
583
807
  ```
584
808
 
809
+ `sandbox-runtime` is the default enforcer, not the only one. `[sandbox] provider` picks another —
810
+ `tartaros`, or any sandbox declared as a command — per role, and only where it enforces the whole
811
+ policy: see [docs/sandboxes.md](https://github.com/carlostapiacl/seisin/blob/main/docs/sandboxes.md).
812
+
585
813
  That split is deliberate, and it is why seisin is small. Because the kernel is the boundary, seisin never has to be airtight — it only has to be *legible*. A leaky explainer costs you a confusing log line. A leaky enforcer costs you the repo.
586
814
 
587
815
  Verified by the test suite, which runs real commands in a real sandbox:
@@ -607,7 +835,7 @@ than hidden.
607
835
  | reading another role's key file | the kernel — the key directory is denied, each declared key re-allowed |
608
836
  | a key riding in the environment | the launcher — the child's environment is **built, not inherited** |
609
837
  | spilling a key the role does hold | the launcher — masked on stdout and stderr, even split across buffers |
610
- | 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) |
838
+ | sending it somewhere | the kernel — egress is allow-only, by hostname. HTTP(S) works; [SSH does not on macOS](https://github.com/carlostapiacl/seisin/blob/main/docs/decisions.md#ssh-does-not-work-and-it-is-not-seisin-that-decided-that) |
611
839
  | **a key stored outside the declared directories** | **nothing.** `seisin scan` finds them so you know |
612
840
 
613
841
  ```toml
@@ -625,12 +853,24 @@ A **provider** is a command with a `{ref}` placeholder, so adding 1Password, Bit
625
853
  `sops` is TOML and not code. `file://` ships built in. The parent resolves, never the
626
854
  confined process. Nothing degrades: a provider that fails stops the run.
627
855
 
628
- **[Key directories, references, providers, delivery modes and nine recipes →](docs/keys.md)**
856
+ A key reaches the role as a variable (`key_mode = "env"`), as a file in the run's private
857
+ directory (`"run-file"`), or, on Linux, not at all: with `"inject"` the agent holds a
858
+ placeholder and the sandbox swaps in the real value only on the way to the hosts you list for
859
+ it. That costs terminating TLS to those hosts, which `seisin check` names, and a host that
860
+ stores what it is sent can still hand the value back — use a least-scope token.
861
+
862
+ **[Key directories, references, providers, delivery modes and nine recipes →](https://github.com/carlostapiacl/seisin/blob/main/docs/keys.md)**
863
+
864
+ For agents you would not trust with your machine, `[runtime] read = "territory"` denies the places
865
+ data lives — homes, temp directories, volumes — and leaves each role its repo, its territory and
866
+ a toolchain it declares, which `seisin check --verify` proves it can run. Every run, in every
867
+ mode, starts with a canary that tries the policy from inside and refuses to start the agent if
868
+ it does not hold. **[What it closes, what it costs →](https://github.com/carlostapiacl/seisin/blob/main/docs/scratch.md#reading-only-what-the-role-needs)**
629
869
 
630
870
  **Measured, over four teams of agents and three days: every denied read was a read of something
631
871
  the policy had declared a key.** 148 of them, no exceptions, from five roles — all running
632
872
  searches that swept a repository root. That is how a key gets read without anyone deciding
633
- it should.
873
+ it should. ([The measurement, ~370 confined runs over one production window →](https://github.com/carlostapiacl/seisin/blob/main/docs/keys.md))
634
874
 
635
875
  ## When the boundary is right and the agent cannot hear it
636
876
 
@@ -646,9 +886,10 @@ measured.
646
886
  So a denial now carries its own history:
647
887
 
648
888
  ```
649
- deploy/x.yml belongs to infra. It is not dev's to change — hand it over rather than
650
- working around it. You have been denied this 3 times now; it is not going to work on
651
- the fourth try. Already queued for a person to answer…
889
+ deploy/x.yml belongs to infra. It is not dev's to change. It is handed to infra: hand it
890
+ over rather than working around it. Do not put the change somewhere else instead (another
891
+ path, a copy, /tmp). You have been denied this 3 times now; it is not going to work on the
892
+ fourth try. Already queued as a handoff to infra…
652
893
  ```
653
894
 
654
895
  And the standing list is a command:
@@ -673,7 +914,9 @@ Already denied more than once, and still denied — do not try again, hand it ov
673
914
  - read ~/.npmrc (6×) — no role declares ~/.npmrc — add it under a [roles.<name>] keys list
674
915
  ```
675
916
 
676
- and, on request, as data from the MCP server's `seisin_walls`.
917
+ and, on request, as data from the MCP server's `seisin_walls`. In a run handed along a chain, a
918
+ wall where the chain stops is listed without its owner: a person decides it, and the agent is
919
+ to pass on the one sentence its denial gives ([where a request goes](https://github.com/carlostapiacl/seisin/blob/main/docs/requests.md#where-a-request-goes)).
677
920
 
678
921
  **A wall is recomputed against the policy, not read out of the log.** If the role would be
679
922
  allowed today, it is not a wall — whatever happened yesterday. The obvious implementation is a
@@ -698,15 +941,20 @@ than smoothed over, because the alternative is a claim the numbers do not carry.
698
941
 
699
942
  Territory alone is correct and unusable: an agent writes session state under its own config
700
943
  directory and its tools write to the temp dir, so *your folders and nothing else* stops the
701
- agent from starting. Every role gets `~/.claude`, `~/.cache`, `$TMPDIR` and `/tmp` — and that
702
- is a real hole, because two roles share a temp directory and territory does not hold in it.
703
- It is one line to turn off.
944
+ agent from starting. Every role gets `~/.cache`, `$TMPDIR` and `/tmp`, and a run of Claude Code
945
+ or Codex gets that agent's own directory (`~/.claude`, `~/.codex`) — never the other's, nor the
946
+ other's sign-in. Shared scratch is a real hole, because two roles share a temp directory and
947
+ territory does not hold in it. A repo that lives inside scratch — a toy repo under `/tmp` — is
948
+ writable by every role whatever its `writes` say; `seisin check` says so (*every role can write
949
+ the whole repo*), and the same for a `writes` entry whose real path is in scratch.
950
+ It is one line to turn off. The runtime's own shared temp directory, `/tmp/claude`, is never
951
+ writable: each run gets a temp directory of its own instead.
704
952
 
705
- **[What that costs, and how to close it →](docs/scratch.md)**
953
+ **[What that costs, and how to close it →](https://github.com/carlostapiacl/seisin/blob/main/docs/scratch.md)**
706
954
 
707
955
  ## What it is not
708
956
 
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.
957
+ - **Not a sandbox.** The sandbox is the one you choose: [`sandbox-runtime`](https://github.com/anthropics/sandbox-runtime) by default, or [tartaros](https://github.com/carlostapiacl/tartaros). seisin writes its settings, checks that it can hold them and explains its denials.
710
958
  - **Not an orchestrator.** It does not run your agents, schedule them, or merge their work. It runs one command as one role.
711
959
  - **Not a secret manager.** It scopes *reads* of files you already have. Where those files come from is your problem.
712
960
  - **Not what a single agent needs first.** If only one agent ever touches the repo,
@@ -741,8 +989,8 @@ files, the same keys, no answer to *whose is it*. That question is not about
741
989
  isolation strength at all — it is about ownership, and it is the one thing an
742
990
  operating system cannot know for you.
743
991
 
744
- Which is also why this is thin. Enforcement is
745
- [`@anthropic-ai/sandbox-runtime`](https://github.com/anthropics/sandbox-runtime)
992
+ Which is also why this is thin. Enforcement is the sandbox — by default
993
+ [`@anthropic-ai/sandbox-runtime`](https://github.com/anthropics/sandbox-runtime) —
746
994
  asking the kernel; seisin is the layer that decides what to ask for and says
747
995
  whose file it was when the answer is no. The two compose — put seisin inside a
748
996
  container if you want both, and the roles still hold.
@@ -760,7 +1008,7 @@ This space already has good work, and seisin is not the first thing here:
760
1008
  - [`anthropics/sandbox-runtime`](https://github.com/anthropics/sandbox-runtime) — the enforcement. seisin is a thin thing on top of a serious one.
761
1009
  - [`kornysietsma/claude-code-permissions-hook`](https://github.com/kornysietsma/claude-code-permissions-hook) — granular `PreToolUse` rules, one global policy.
762
1010
  - [`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.
1011
+ - [`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](https://github.com/carlostapiacl/seisin/blob/main/docs/openshell.md) explains why it is currently an execution target candidate rather than a drop-in replacement.
764
1012
  - [`dredozubov/hazmat`](https://github.com/dredozubov/hazmat) — runs the agent as a different system user, with `pf` rules and snapshots.
765
1013
  - [`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.
766
1014
  - *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.
@@ -780,7 +1028,7 @@ They compose rather than compete: the agent seisin confines could be running ins
780
1028
  them.
781
1029
 
782
1030
  *One detail worth knowing before you compare, and it is about placement rather than quality.
783
- OpenShell was [evaluated here on 2026-09-27](docs/openshell.md), through its Docker driver on
1031
+ OpenShell was [evaluated here on 2026-09-27](https://github.com/carlostapiacl/seisin/blob/main/docs/openshell.md), through its Docker driver on
784
1032
  an Intel Mac. That confines an agent inside Docker Desktop's Linux VM; seisin confines a
785
1033
  process on the host through Seatbelt. OpenShell also offers other drivers, including a local
786
1034
  microVM path on supported Apple Silicon Macs. These are kernel-enforced boundaries, but not
@@ -788,16 +1036,24 @@ the same kernel or workspace, and which one you want depends on what you are pro
788
1036
 
789
1037
  ## Status
790
1038
 
791
- `0.5.0`, 780 tests, of which **26 need `@anthropic-ai/sandbox-runtime` installed**
792
- and run real commands through the real kernel — and CI fails if the sandbox half *skips*, because
793
- a green run that quietly tested nothing looks exactly like a real one. That is not hypothetical:
1039
+ **1089 tests on macOS, 1082 passing and 7 platform skips** (0.6.0, 2026-10-23, local); in CI the
1040
+ same day, on `ubuntu-latest` **1088, 1059 passing and 29 platform skips**, and on macOS 1089,
1041
+ 1079 passing and 10 skips, Node 18/20/22/24, with the 26 core boundary tests skipping none. On 2026-10-12, 91 of the suite's tests
1042
+ needed `@anthropic-ai/sandbox-runtime` installed — counted by running it with the runtime
1043
+ hidden — and run real commands through the real kernel; 26 of them are the core boundary file,
1044
+ `test/sandbox.test.js`, which the CI gate fails if it *skips*, because a green run that quietly
1045
+ tested nothing looks exactly like a real one. That is not hypothetical:
794
1046
  when there were eighteen of them, they skipped on Linux for a day, behind a runtime check that looked for the global
795
1047
  install and missed the bundled one, and hid a defect that broke `seisin run` on that platform
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
798
- with bubblewrap 0.8.0, the last time the suite was run in Docker.
799
- [Which claim was measured where](docs/what-it-has-been-put-through.md#where-each-claim-was-actually-run),
800
- and [what running agents behind it cost the people using it](docs/field-notes.md).
1048
+ entirely.
1049
+
1050
+ A release is published only from CI, after the full suite passes on macOS and `ubuntu-latest`,
1051
+ Node 18/20/22/24 (`publish.yml`), with npm provenance.
1052
+ Historically: 811 tests on macOS 15, 809 passing and 2 skipped, on 2026-09-30.
1053
+ [Which claim was measured where](https://github.com/carlostapiacl/seisin/blob/main/docs/what-it-has-been-put-through.md#where-each-claim-was-actually-run),
1054
+ and [what running agents behind it cost the people using it](https://github.com/carlostapiacl/seisin/blob/main/docs/field-notes.md).
1055
+ The [2026-10-02 review](https://github.com/carlostapiacl/seisin/blob/main/docs/reviews/2026-10-02.md) and its [follow-up](https://github.com/carlostapiacl/seisin/blob/main/docs/reviews/2026-10-02-followup.md)
1056
+ added 32 regressions and record their portable checks apart from the kernel measurements above.
801
1057
  The config format may still move before `1.0` — if it does, `seisin check` will
802
1058
  tell you what changed.
803
1059
 
@@ -807,19 +1063,19 @@ already done:
807
1063
  | | |
808
1064
  |---|---|
809
1065
  | **Windows** | the runtime has a backend. seisin has never been pointed at it, and no CI runner covers it |
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/) |
811
- | **`init` heuristics** | it reads `.claude/agents/` then `CODEOWNERS`. Every other convention is a guess nobody has made yet |
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 |
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) |
1066
+ | **Deleting inside your own territory** | the sandbox's to enforce, and `srt` cannot: write includes delete. 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](https://github.com/carlostapiacl/seisin/blob/main/docs/upstream/denyUnlink.md) and [a demo](https://github.com/carlostapiacl/seisin/tree/main/docs/demo/) |
1067
+ | **`init` heuristics** | it reads `.claude/agents/`, then `CODEOWNERS`, then the top-level folders that hold code. Every other convention is a guess nobody has made yet |
1068
+ | **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 — read from its source, not measured here). Fix upstream: [PR #516](https://github.com/anthropics/sandbox-runtime/pull/516). Use an HTTPS remote |
1069
+ | **A browser inside a role (macOS)** | headless Chromium runs with `browsers = ["chromium"]`. Not covered yet: Chrome and Chrome for Testing, and `@playwright/mcp` under srt (it runs under tartaros). [docs/browsers.md](https://github.com/carlostapiacl/seisin/blob/main/docs/browsers.md#what-is-not-covered-yet) |
814
1070
 
815
- Every word above has one meaning, listed in [the glossary](docs/glossary.md) — the boundary
1071
+ Every word above has one meaning, listed in [the glossary](https://github.com/carlostapiacl/seisin/blob/main/docs/glossary.md) — the boundary
816
1072
  **denies**, a person **grants** or **declines**, seisin **refuses** a config it cannot enforce. A tool whose
817
1073
  product is a sentence cannot afford two words for one thing.
818
1074
 
819
- Issues and pull requests welcome — [CONTRIBUTING.md](CONTRIBUTING.md) says what is actually
1075
+ Issues and pull requests welcome — [CONTRIBUTING.md](https://github.com/carlostapiacl/seisin/blob/main/CONTRIBUTING.md) says what is actually
820
1076
  different about contributing here, which is mostly that a claim has to be downstream of
821
1077
  something measured. If you are reporting something that got past the boundary, do it
822
- [privately](SECURITY.md); `seisin log --verdict denied` and the `seisin.toml` are the two
1078
+ [privately](https://github.com/carlostapiacl/seisin/blob/main/SECURITY.md); `seisin log --verdict denied` and the `seisin.toml` are the two
823
1079
  things that make it reproducible.
824
1080
 
825
1081
  ## The name