seisin 0.4.2 → 0.6.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (82) hide show
  1. package/README.md +506 -162
  2. package/package.json +14 -6
  3. package/src/browsers.js +52 -0
  4. package/src/canary.js +149 -0
  5. package/src/cli.js +175 -52
  6. package/src/commands/check.js +157 -5
  7. package/src/commands/explain.js +31 -13
  8. package/src/commands/hook.js +20 -6
  9. package/src/commands/init.js +183 -88
  10. package/src/commands/log.js +48 -9
  11. package/src/commands/requests.js +122 -70
  12. package/src/commands/review.js +73 -21
  13. package/src/commands/run.js +422 -78
  14. package/src/commands/scan.js +18 -3
  15. package/src/commands/ui.js +18 -5
  16. package/src/commands/walls.js +62 -11
  17. package/src/commands/whose.js +9 -7
  18. package/src/commands/wire.js +156 -17
  19. package/src/config.js +505 -37
  20. package/src/controls.js +244 -0
  21. package/src/diagnose.js +102 -49
  22. package/src/env.js +14 -8
  23. package/src/grants.js +122 -9
  24. package/src/handoff.js +285 -10
  25. package/src/help.js +179 -0
  26. package/src/hook.js +101 -42
  27. package/src/index.d.ts +217 -0
  28. package/src/index.js +24 -16
  29. package/src/inspect/codex.js +64 -0
  30. package/src/inspect/credentials.js +48 -0
  31. package/src/inspect/enforce.js +24 -0
  32. package/src/inspect/files.js +71 -0
  33. package/src/inspect/home.js +43 -0
  34. package/src/inspect/inject.js +68 -0
  35. package/src/inspect/keys.js +74 -0
  36. package/src/inspect/layout.js +258 -0
  37. package/src/inspect/limits.js +98 -0
  38. package/src/inspect/machine.js +107 -0
  39. package/src/inspect/network.js +37 -0
  40. package/src/inspect/providers.js +71 -0
  41. package/src/inspect/read-territory.js +105 -0
  42. package/src/inspect/role-keys.js +136 -0
  43. package/src/inspect/shared.js +37 -0
  44. package/src/inspect/warnings.js +58 -0
  45. package/src/inspect.js +20 -756
  46. package/src/intake.js +150 -35
  47. package/src/keys.js +231 -73
  48. package/src/kinds.js +249 -0
  49. package/src/lines.js +26 -0
  50. package/src/log.js +242 -52
  51. package/src/mcp.js +57 -58
  52. package/src/nested.js +12 -3
  53. package/src/notify.js +66 -20
  54. package/src/owners.js +358 -39
  55. package/src/paths.js +54 -2
  56. package/src/provider-run.js +81 -0
  57. package/src/redact.js +46 -13
  58. package/src/render.js +101 -17
  59. package/src/requests.js +506 -87
  60. package/src/review.js +50 -25
  61. package/src/rundir.js +33 -14
  62. package/src/sandboxes/command.js +93 -0
  63. package/src/sandboxes/index.js +113 -0
  64. package/src/sandboxes/mach-profile.js +31 -0
  65. package/src/sandboxes/srt-mach.mjs +43 -0
  66. package/src/sandboxes/srt.js +163 -0
  67. package/src/sandboxes/tartaros.js +80 -0
  68. package/src/scan.js +181 -19
  69. package/src/serve.js +396 -230
  70. package/src/spool.js +16 -45
  71. package/src/srt.js +296 -49
  72. package/src/suggest.js +58 -0
  73. package/src/surface.js +314 -54
  74. package/src/territory.js +261 -0
  75. package/src/trace.js +33 -0
  76. package/src/uilink.js +24 -9
  77. package/src/views.js +315 -0
  78. package/src/violations.js +247 -23
  79. package/src/walls.js +76 -46
  80. package/src/worktree.js +3 -11
  81. package/ui/index.html +1429 -538
  82. package/ui/favicon.svg +0 -1
package/README.md CHANGED
@@ -1,57 +1,65 @@
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
- **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.
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 cells 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) · [Permission 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"}}
48
55
  K -->|not this role's| D["denied"]
49
- D --> S["seisin reads the refusal<br/>and looks up the policy"]
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
@@ -60,35 +68,58 @@ flowchart LR
60
68
 
61
69
  **The kernel says no. seisin says whose.** That second half is the whole thing: other
62
70
  permission layers in this space answer *yes* or *no*, or at best *which rule* said no — and a
63
- refusal that also names an owner turns a dead end into a handoff.
64
-
65
- ![seisin denying a write outside a role's territory, then naming the owner](docs/img/demo.gif)
71
+ denial that also names an owner turns a dead end into a handoff.
66
72
 
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
 
80
- **Whose it was, at the moment it was denied.** Other permission layers answer *yes* or *no*, and the good ones say which rule it was and how to widen it. Answering **"no, and it belongs to `backend`"** turns a block into a handoff — and one a person can approve in a command, rather than a line somebody has to remember to go and read.
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
+
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 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).
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
- The agent can ask directly too, from inside the box:
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.
@@ -106,7 +137,7 @@ On Linux, [three system packages first](#install). Everything below is why it wo
106
137
 
107
138
  ## Why this exists
108
139
 
109
- Two things are true about agent permissions today, and both are in the official docs.
140
+ Two things are true about how agents are kept in bounds today, and both are in the official docs.
110
141
 
111
142
  **1. Permission rules match text, not behaviour.** From the Claude Code documentation:
112
143
 
@@ -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 |
160
- | the hook is text-based and evadable | on purpose — what escapes it goes **unblocked by nothing**: the kernel still refuses. On macOS it is no longer unexplained either, because [the kernel's own refusals are recorded](#seeing-what-happened); on Linux it is. Either way, attribution is not a control |
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 |
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 |
193
+
194
+ Others are the sandbox's, and differ between the two built in:
161
195
 
162
- And three things that outweigh all of the above:
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 |
163
202
 
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.**
203
+ `seisin check` says which sandbox each role gets before anything runs.
169
204
 
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.
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,18 +238,75 @@ 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
+
291
+ `seisin <command> --help` lists a command's flags, examples and exit codes. The exit codes, so
292
+ the commands compose in a script, a pre-commit hook or CI:
293
+
294
+ | command | 0 | 1 | other |
295
+ |---|---|---|---|
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 | |
298
+ | `explain` | allowed | denied | |
299
+ | `scan` | no certain credential | a certain credential outside the key dirs | |
300
+ | `review` | no role denied repeatedly | a role denied repeatedly | |
301
+ | `walls` | no walls | walls listed | |
302
+ | `requests` | nothing pending | requests pending | |
303
+ | `log verify` | the hash chain holds | the hash chain is broken | |
304
+ | `whose`, `grant`, `decline`, `log`, `init`, `wire`, `ui`, `watch`, `mcp` | done | — | |
305
+ | `hook` | always | — | |
306
+
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.
309
+
218
310
  ## Configure
219
311
 
220
312
  One file at the root of your repo. Anything not listed is denied — there is no permissive default.
@@ -236,19 +328,59 @@ keys = ["database-url.txt", "sentry-dsn.txt"]
236
328
  network = ["api.stripe.com"] # this role only — it replaces [network] rather than adding
237
329
  ```
238
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
+
239
369
  `[network] allow` is the fallback for roles that do not name their own. A role that needs one
240
370
  extra host should say so on its own line: a domain in the global list is reachable by every
241
371
  role, including the ones whose whole job is to have nothing to reach.
242
372
 
243
- > ⚠️ **HTTP(S) works; SSH does not.** The allow list is enforced by proxies the runtime
244
- > starts, and although one of them is a SOCKS5 proxy that could carry SSH, the chain breaks
245
- > at its last link: it requires authentication and the `ProxyCommand` the runtime wires up
246
- > cannot send any. Measured — `curl https://github.com` returns `200`, `ssh -T git@github.com`
247
- > cannot resolve the hostname, and `git ls-remote git@github.com:…` reaches the proxy and
248
- > 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.
249
381
  >
250
- > **Use an HTTPS remote for git**, and run SSH deploys outside the confined turn.
251
- > [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)
252
384
 
253
385
  A role can also give something back. `never_writes` subtracts from its own `writes`, and it
254
386
  wins however wide the grant is:
@@ -260,25 +392,49 @@ never_writes = ["services/api/.git/index.lock"] # works in a worktree; the can
260
392
  ```
261
393
 
262
394
  It is per role on purpose — there is no global version, because a global deny on a lock file
263
- breaks every role that legitimately commits there. A refusal it causes says so by name and
264
- leaves **no request** in the queue: approving one would undo a subtraction somebody wrote. An
395
+ breaks every role that legitimately commits there. A denial it causes says so by name and
396
+ leaves **no request** in the queue: granting one would undo a subtraction somebody wrote. An
265
397
  entry that no `writes` of the same role covers, or a misspelling like `never_write`, is named
266
398
  by `seisin check` instead of being ignored.
267
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
+
268
422
  A role that starts a server — a dev server, the backend an end-to-end test drives — needs
269
423
  `local_binding = true`. Listening is off by default and per role: most roles never start one.
270
424
  It is wider than the name: the role can also listen on every interface and **connect to every
271
425
  port on localhost**, so anything listening there without authentication is within its reach.
272
426
  `seisin check` says this next to the role. That is macOS: on Linux each role already has a
273
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.
274
430
 
275
431
  If a role only needs to *reach* a local service, name the ports instead:
276
432
  `local_ports = [8001, 8081]`. The role reaches those and nothing else on localhost, on macOS
277
- and Linux. The sandbox's proxy enforces the list, so the client has to use the proxy. curl,
278
- Node's `fetch` and Python's `urllib` do. A database driver or a raw socket don't, and get
279
- refused. For Chromium, pass Playwright's `proxy` option from `HTTP_PROXY`; on macOS it also
280
- needs `--single-process` to start. A listed port is open to anything that speaks `CONNECT`, not
281
- 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.
282
438
 
283
439
  `mcp = ["playwright"]` lists the MCP servers a role may load; `mcp = []` means none, and no
284
440
  key means the policy doesn't say. seisin declares this and `explain` answers it
@@ -286,35 +442,96 @@ key means the policy doesn't say. seisin declares this and `explain` answers it
286
442
  launcher has to apply it. With Claude Code: `--mcp-config` holding only those servers, plus
287
443
  `--strict-mcp-config`.
288
444
 
289
- Refused connections are logged too: `connect tcp:<port>` (the kernel gives the port, not the
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
+
469
+ Some files are not code but still make something happen outside the sandbox, and no territory
470
+ reaches them: git hooks and config, a project's `.claude/`, `.mcp.json`, `.envrc`. Two softer
471
+ families sit beside those, and a policy can hand them to one role:
472
+
473
+ ```toml
474
+ [roles.frontend]
475
+ writes = ["apps/web/**"]
476
+ control_files = ["ide"] # keeps apps/web/.vscode/launch.json current
477
+
478
+ [protect]
479
+ instructions = true # CLAUDE.md, AGENTS.md… only for roles handed "instructions"
480
+ ```
481
+
482
+ - **`ide`** — `.vscode/`, `.cursor/`, `.windsurf/`. Your editor applies them as soon as they
483
+ change, which is how an injected agent turns on tool auto-approval. **Protected by default.**
484
+ - **`instructions`** — `CLAUDE.md`, `AGENTS.md`, `GEMINI.md`, Cursor and Windsurf rules, Copilot
485
+ instructions. Nothing runs them; the next agent session reads them as its instructions, so
486
+ one role writing them reaches another role's context. **Off unless `[protect]` turns it on**:
487
+ keeping them current is ordinary work for most teams.
488
+
489
+ A role handed a family writes it only inside its own `writes`, and `seisin check` names it on
490
+ every run. A denial says which setting would change it, and leaves no request: it is a decision
491
+ about the policy, not a grant. Inside `node_modules` neither family is touched, so npm still
492
+ unpacks a package's own `.vscode` or `AGENTS.md`. On Linux only the files that already exist are
493
+ protected: bubblewrap would have to create a missing one to deny it.
494
+
495
+ Denied connections are logged too: `connect tcp:<port>` (the kernel gives the port, not the
290
496
  host) or the socket path, once per target per run. `seisin walls` says whether the port is
291
497
  missing from `local_ports`, listed but dialled directly, or a socket. No request is filed: a
292
498
  port isn't anyone's territory. macOS only.
293
499
 
294
- `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.
505
+
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`.
295
507
 
296
508
  ## What will look like a bug on the first day
297
509
 
298
- Four shapes a correct policy still produces that read as breakage: a granted file whose
299
- neighbours are not granted, a lock file in somebody else's repository, a tool that reports a
300
- denied write as a *readonly database*, and an agent that diagnoses file permissions when the
301
- answer is ownership.
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.
302
515
 
303
- All four came out of one production window where none of them was the boundary misbehaving.
516
+ One more comes with protected files: **a role's `git checkout`, `pull` or `rebase` cannot update
517
+ a file the role may not write.** Git exits 0, the branch moves, and the old `.vscode/settings.json`
518
+ stays behind as a modification — which a later `commit -a` would turn into a revert. Switch
519
+ branches outside the sandbox, or hand that role the family if keeping those files is its job.
304
520
 
305
- **[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)**
306
522
 
307
523
  ## Which agents it has been run with
308
524
 
309
- Claude Code, opencode, codex, gemini and a LangGraph script, each against a real policy with
310
- 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.
311
528
 
312
529
  **One thing is worth knowing before you start: an agent that sandboxes itself has to be told
313
530
  to stop.** `codex` confines every command it runs, and no OS applies a second profile to a
314
531
  process that already has one. seisin says so before it starts one, because the error you get
315
532
  otherwise names neither seisin, nor the agent, nor the fix.
316
533
 
317
- **[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)**
318
535
 
319
536
  ## What the log says about the policy
320
537
 
@@ -324,27 +541,48 @@ actually happened and tells you where the config was wrong about it.
324
541
  ```
325
542
  $ seisin review
326
543
 
327
- 60 decisions, 2026-08-23 to 2026-09-11
544
+ 60 entries, 2026-08-23 to 2026-09-11
545
+
546
+ Denied repeatedly
547
+ a role denied on the same place over and over is a policy that is wrong,
548
+ not an agent that is misbehaving
328
549
 
329
- Stopped, repeatedly
330
550
  9× frontend write src/api/checkout — belongs to backend
331
551
  4× backend write src/web — belongs to frontend
332
552
 
553
+ seisin grant, or move the territory. Either way it is a decision, not noise.
554
+
333
555
  Granted, never used
556
+ nothing was written here in the window above. This is the only evidence
557
+ anyone will ever have for making a policy smaller
558
+
334
559
  frontend public/**
335
- backend migrations/**
560
+ backend migrations/**
336
561
 
337
- Owned by nobody
338
- 5× legacy
562
+ A short window proves nothing. Check the dates before you delete a line.
563
+
564
+ Unowned
565
+ touched by an agent, claimed by no role. Only an ownable path (kind `territory`)
566
+ is a hole in the map; the rest is a kind of thing with its own answer, and none
567
+ of them is an owner
568
+
569
+ 4× territory legacy
570
+
571
+ territory: an ownable path no role claims: a real decision — give it an owner, or tell the agent not to write there
339
572
  ```
340
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
+
341
579
  Three questions, and the middle one is the reason this exists. **Every
342
580
  permission file only ever grows**, in every system, and always for the same
343
581
  reason: nobody can prove a line is dead, so the safe move is to leave it. Here
344
582
  the log can prove it — `public/**` was granted and nothing was written there in
345
583
  three weeks. That is the only direction that makes a policy *smaller*.
346
584
 
347
- The first is the one that changes how a denial reads. Forty blocks on one
585
+ The first is the one that changes how a denial reads. Forty denials on one
348
586
  directory is not an agent misbehaving; it is a policy that is wrong, and until
349
587
  you add them up it looks like forty tidy amber lines.
350
588
 
@@ -361,10 +599,17 @@ the first unjustified denial is when the tool gets uninstalled. So don't write i
361
599
  write:
362
600
 
363
601
  ```bash
364
- seisin run frontend --observe -- claude -p "…" # records, denies nothing
602
+ seisin wire # the hook is what records
603
+ seisin run frontend --observe -- claude -p "…" # the repo opens, the network does not
365
604
  seisin init --from-observations # writes seisin.toml.observed
366
605
  ```
367
606
 
607
+ What `--observe` changes: the whole repo is writable for that run, so a write outside the
608
+ territory is recorded instead of refused. What it does not: the key directories, the policy and
609
+ `.seisin/` stay denied, and so does every domain not on the allowlist. What gets recorded is what
610
+ the hook sees — the tool calls of an agent (Claude Code, Codex…) in a repo where `seisin wire` ran.
611
+ A plain shell command (`--observe -- npm test`) goes through no hook and records nothing.
612
+
368
613
  It lands as `.observed`, not as your config. A policy generated behind your back is not a
369
614
  policy: diff it, then move it.
370
615
 
@@ -372,35 +617,49 @@ policy: diff it, then move it.
372
617
 
373
618
  A permission tool that can only say no is a tool people uninstall — the loop of *denied,
374
619
  stop, edit a config, run again* is three context switches for one line of policy, and the
375
- cheapest way to stop it is to widen the policy and never look again. That is how a permission
376
- file becomes seven hundred entries nobody reads.
620
+ cheapest way to stop it is to widen the policy and never look again. That is how an allowlist
621
+ becomes seven hundred entries nobody reads.
377
622
 
378
623
  So a denial leaves something to act on, and many denials in one directory are **one** request:
379
624
 
380
625
  ```
626
+ $ seisin requests
627
+
381
628
  1 pending request(s)
382
629
 
383
630
  #1 frontend wants write on src/api/** (owned by backend) · asked 3×
384
631
  first asked over src/api/orders.ts
632
+ id frontend:write:src/api
385
633
 
386
- 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.
387
636
  ```
388
637
 
389
638
  The grant writes its own provenance next to the line it adds:
390
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
+
391
647
  ```toml
392
- 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
+ ]
393
652
  ```
394
653
 
395
- **Approving is never a tool call.** An agent can read the queue and draft the change; only a
654
+ **Granting is never a tool call.** An agent can read the queue and draft the change; only a
396
655
  person applies it, in a terminal or in the console. That asymmetry is deliberate and it is
397
656
  the reason the MCP server exists at all.
398
657
 
399
658
  **A person hears about it.** With `[notify]`, the parent process sends one message per new
400
- 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
401
660
  webhook. The URL lives in a key directory no role declares (`url_file`) or in the parent's
402
661
  `SEISIN_NOTIFY_URL`; written plainly into `seisin.toml`, which every role can read, it refuses to
403
- load, because whoever reads it can send you a fake "approve".
662
+ load, because whoever reads it can send you a fake "grant this".
404
663
 
405
664
  ```toml
406
665
  [notify]
@@ -409,12 +668,12 @@ format = "text" # or "slack", "json"
409
668
  ```
410
669
 
411
670
  **And so does the agent.** `seisin wire` installs the hook for four events. Before a tool call
412
- it explains a refusal it can see coming; after a command fails it reads the kernel's own refusal
413
- from the log and tells the agent whose the path was and how many times it has been refused —
671
+ it explains a denial it can see coming; after a command fails it reads the kernel's own denial
672
+ from the log and tells the agent whose the path was and how many times it has been denied —
414
673
  the half PreToolUse never sees; and when a session starts, resumes or is compacted it hands the
415
674
  agent its territory and its walls, before the first one.
416
675
 
417
- **[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)**
418
677
 
419
678
  ## Ask your own assistant
420
679
 
@@ -447,33 +706,75 @@ seisin ui # the same thing, for a person deciding
447
706
  ```
448
707
 
449
708
  ```
450
- 04:13:26 allowed frontend write src/web/app.ts
451
- 04:13:26 denied frontend write src/api/server.ts → backend
452
- 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
453
717
  ```
454
718
 
719
+ `[kernel]` marks a line the kernel reported; the others came from the hook.
720
+
455
721
  One append-only JSONL under `.seisin/`, and that is the whole storage design — no daemon, no
456
- 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.
457
742
 
458
743
  **The agent cannot reach that file.** `.seisin/` is in no role's territory, because a record
459
744
  the recorded process can edit is not a record. The hook sends each line to `seisin run` over
460
745
  a unix socket granted by path and the parent does the writing, so the only verb available
461
- from inside the box is *append one line*. A hostile agent can still add noise to its own
746
+ from inside the sandbox is *append one line*. A hostile agent can still add noise to its own
462
747
  history; what it cannot do is change what is already there.
463
748
 
464
749
  **Two writers, and they see different things.** The **hook** reports the attempt before it
465
750
  happens — which is how an *allowed* action gets recorded at all — and it is text-based and
466
751
  evadable, on purpose. The **kernel** reports what it actually denied, and is not fooled by
467
- anything. What escapes the hook goes unexplained, never unblocked.
752
+ anything. What escapes the hook goes unexplained, never unenforced.
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)
468
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.
469
760
 
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.
470
767
 
471
- ![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)
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.
472
771
 
473
- It opens on what needs a decision, not on the policy. Five numbers ride on every screen and
474
- each is a link to the page that explains it — including two a log cannot answer, because
475
- they are recomputed against the policy: how much of the day is **one cause**, and how many
476
- of those causes are on paths **nobody owns**.
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.
477
778
 
478
779
  ## How it holds
479
780
 
@@ -481,7 +782,7 @@ of those causes are on paths **nobody owns**.
481
782
  |---|---|---|
482
783
  | your agent's `allow`/`deny` rules | match the command string before it runs | yes — `/bin/rm`, a heredoc, a child process |
483
784
  | **seisin** | decides what to ask for, and names the owner | it doesn't enforce, it explains |
484
- | **sandbox-runtime → Seatbelt / bubblewrap** | the OS denies the syscall | no |
785
+ | **the sandbox → Seatbelt / bubblewrap / Landlock** | the OS denies the syscall | no |
485
786
 
486
787
  ```
487
788
  Write tool · Bash `rm -rf` · /bin/rm · python -c "open(…)" · a grandchild
@@ -505,6 +806,10 @@ of those causes are on paths **nobody owns**.
505
806
  explanation — never the boundary
506
807
  ```
507
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
+
508
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.
509
814
 
510
815
  Verified by the test suite, which runs real commands in a real sandbox:
@@ -530,7 +835,7 @@ than hidden.
530
835
  | reading another role's key file | the kernel — the key directory is denied, each declared key re-allowed |
531
836
  | a key riding in the environment | the launcher — the child's environment is **built, not inherited** |
532
837
  | spilling a key the role does hold | the launcher — masked on stdout and stderr, even split across buffers |
533
- | 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) |
534
839
  | **a key stored outside the declared directories** | **nothing.** `seisin scan` finds them so you know |
535
840
 
536
841
  ```toml
@@ -548,19 +853,31 @@ A **provider** is a command with a `{ref}` placeholder, so adding 1Password, Bit
548
853
  `sops` is TOML and not code. `file://` ships built in. The parent resolves, never the
549
854
  confined process. Nothing degrades: a provider that fails stops the run.
550
855
 
551
- **[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)**
552
863
 
553
- **Measured, over four agent cells and three days: every refused read was a read of something
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)**
869
+
870
+ **Measured, over four teams of agents and three days: every denied read was a read of something
554
871
  the policy had declared a key.** 148 of them, no exceptions, from five roles — all running
555
872
  searches that swept a repository root. That is how a key gets read without anyone deciding
556
- it should.
873
+ it should. ([The measurement, ~370 confined runs over one production window →](https://github.com/carlostapiacl/seisin/blob/main/docs/keys.md))
557
874
 
558
875
  ## When the boundary is right and the agent cannot hear it
559
876
 
560
877
  seisin answers *whose is this* at the moment of the denial — once, mid-turn — and then the
561
878
  sentence is gone. Nothing carries it forward, so the agent tries again.
562
879
 
563
- Measured on a real team: of **345 denials, 88 (25%) were a repeat of something that same role
880
+ Measured in the author's own multi-agent setup: of **345 denials, 88 (25%) were a repeat of something that same role
564
881
  had already been denied.** One role spent 37 calls on two walls, hitting one of them nineteen
565
882
  times. Every denial was correct. Not one was a false positive. It still cost thirty-seven
566
883
  calls, because *correct* and *heard* are different properties and only the first was being
@@ -569,29 +886,43 @@ measured.
569
886
  So a denial now carries its own history:
570
887
 
571
888
  ```
572
- deploy/x.yml belongs to infra. It is not dev's to change — hand it over rather than
573
- working around it. You have been denied this 3 times now; it is not going to work on
574
- 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…
575
893
  ```
576
894
 
577
895
  And the standing list is a command:
578
896
 
579
897
  ```
580
898
  $ seisin walls dev
581
- WALLS — you have already been refused these, and the policy still refuses them.
582
- Do not retry; the reason is where the other way in is.
583
- 19× write ../equipo/.git/index.lock
584
- ../equipo/.git/index.lock belongs to plataforma-dev
585
- 6× read ~/.npmrc
586
- no role declares ~/.npmrc — add it under a [roles.<name>] keys list
587
- (23 of your calls went into retrying these.)
899
+
900
+ dev keeps hitting:
901
+
902
+ 19× write ../shared/.git/index.lock belongs to platform
903
+ 6× read ~/.npmrc no role declares ~/.npmrc — add it under a [roles.<name>] keys list
904
+
905
+ 23 of its calls went into retrying these.
906
+ ```
907
+
908
+ That is the list said to the person at the terminal. The agent gets it in its own voice, at the
909
+ start of every session the hook is wired for:
910
+
911
+ ```
912
+ Already denied more than once, and still denied — do not try again, hand it over:
913
+ - write ../shared/.git/index.lock (19×) — belongs to platform
914
+ - read ~/.npmrc (6×) — no role declares ~/.npmrc — add it under a [roles.<name>] keys list
588
915
  ```
589
916
 
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)).
920
+
590
921
  **A wall is recomputed against the policy, not read out of the log.** If the role would be
591
922
  allowed today, it is not a wall — whatever happened yesterday. The obvious implementation is a
592
923
  time window, on the argument that an old wall may have been granted since; the window is a
593
924
  proxy for the question, and seisin has the policy right there, so it asks the question. A grant
594
- makes its wall disappear on the next turn instead of when a window expires. Same move as
925
+ makes its wall disappear on the next run instead of when a window expires. Same move as
595
926
  `owners` and the request queue: recompute rather than believe.
596
927
 
597
928
  Silent on the first denial, by design. A counter that reads `1×` every time is noise on the
@@ -602,7 +933,7 @@ obvious way to check is to compare repeats before and after — but the repeats
602
933
  lock file, and the release before this one deleted that wall outright with
603
934
  `GIT_OPTIONAL_LOCKS=0`. Repeats did fall, by a lot, and **the fall is the wall going away,
604
935
  not the sentence landing**: both shipped within days of each other and the bigger cause is the
605
- other one. Isolating this needs a forward test — walls per role, rounds with the hook wired
936
+ other one. Isolating this needs a forward test — walls per role, runs with the hook wired
606
937
  and no git locks in the way — not the history. Measured in the field and written up rather
607
938
  than smoothed over, because the alternative is a claim the numbers do not carry.
608
939
 
@@ -610,15 +941,20 @@ than smoothed over, because the alternative is a claim the numbers do not carry.
610
941
 
611
942
  Territory alone is correct and unusable: an agent writes session state under its own config
612
943
  directory and its tools write to the temp dir, so *your folders and nothing else* stops the
613
- agent from starting. Every role gets `~/.claude`, `~/.cache`, `$TMPDIR` and `/tmp` — and that
614
- is a real hole, because two roles share a temp directory and territory does not hold in it.
615
- 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.
616
952
 
617
- **[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)**
618
954
 
619
955
  ## What it is not
620
956
 
621
- - **Not a sandbox.** [`sandbox-runtime`](https://github.com/anthropics/sandbox-runtime) is the sandbox, and it is Anthropic's. seisin writes its settings and explains its refusals.
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.
622
958
  - **Not an orchestrator.** It does not run your agents, schedule them, or merge their work. It runs one command as one role.
623
959
  - **Not a secret manager.** It scopes *reads* of files you already have. Where those files come from is your problem.
624
960
  - **Not what a single agent needs first.** If only one agent ever touches the repo,
@@ -653,8 +989,8 @@ files, the same keys, no answer to *whose is it*. That question is not about
653
989
  isolation strength at all — it is about ownership, and it is the one thing an
654
990
  operating system cannot know for you.
655
991
 
656
- Which is also why this is thin. Enforcement is
657
- [`@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) —
658
994
  asking the kernel; seisin is the layer that decides what to ask for and says
659
995
  whose file it was when the answer is no. The two compose — put seisin inside a
660
996
  container if you want both, and the roles still hold.
@@ -671,28 +1007,28 @@ This space already has good work, and seisin is not the first thing here:
671
1007
 
672
1008
  - [`anthropics/sandbox-runtime`](https://github.com/anthropics/sandbox-runtime) — the enforcement. seisin is a thin thing on top of a serious one.
673
1009
  - [`kornysietsma/claude-code-permissions-hook`](https://github.com/kornysietsma/claude-code-permissions-hook) — granular `PreToolUse` rules, one global policy.
674
- - [`XuebinMa/agent-guard`](https://github.com/XuebinMa/agent-guard) — a permission-enforcement SDK, also one global policy.
675
- - [`NVIDIA/OpenShell`](https://github.com/NVIDIA/openshell) — a runtime that sandboxes an agent with Landlock and seccomp, under a declarative policy. Serious, and the closest thing here by weight. 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.
1010
+ - [`XuebinMa/agent-guard`](https://github.com/XuebinMa/agent-guard) — an SDK that enforces what an agent may do, also one global policy.
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.
676
1012
  - [`dredozubov/hazmat`](https://github.com/dredozubov/hazmat) — runs the agent as a different system user, with `pf` rules and snapshots.
677
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.
678
- - *Directory ownership* is recommended in half the multi-agent write-ups. As far as I could find, nobody enforces it, and nobody names the owner in the refusal. That gap is the reason for this repo.
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.
679
1015
 
680
1016
  ### One sentence to tell these apart, because the names all sound the same
681
1017
 
682
1018
  **OpenShell, hazmat and nono isolate the agent from your machine. seisin separates roles from
683
- each other inside one repository, and when it blocks something it names the owner.**
1019
+ each other inside one repository, and when the kernel denies something it names the owner.**
684
1020
 
685
1021
  Those are different problems and the first one is not the one this solves. If what you want
686
- is "this agent cannot touch anything outside its box", they do that and OpenShell does it
1022
+ is "this agent cannot touch anything outside its sandbox", they do that and OpenShell does it
687
1023
  with a large organisation behind it. If what you want is "four agents work in the same
688
- repository and none of them edits another's files — and when one is stopped it is told whose
1024
+ repository and none of them edits another's files — and when one is denied it is told whose
689
1025
  those files are", nothing above answers that, which is why this exists.
690
1026
 
691
1027
  They compose rather than compete: the agent seisin confines could be running inside one of
692
1028
  them.
693
1029
 
694
1030
  *One detail worth knowing before you compare, and it is about placement rather than quality.
695
- 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
696
1032
  an Intel Mac. That confines an agent inside Docker Desktop's Linux VM; seisin confines a
697
1033
  process on the host through Seatbelt. OpenShell also offers other drivers, including a local
698
1034
  microVM path on supported Apple Silicon Macs. These are kernel-enforced boundaries, but not
@@ -700,16 +1036,24 @@ the same kernel or workspace, and which one you want depends on what you are pro
700
1036
 
701
1037
  ## Status
702
1038
 
703
- `0.4.2`, 500 tests, of which **26 need `@anthropic-ai/sandbox-runtime` installed**
704
- and run real commands through the real kernel — and CI fails if the sandbox half *skips*, because
705
- a green run that quietly tested nothing looks exactly like a real one. That is not hypothetical:
706
- those eighteen skipped on Linux for a day, behind a runtime check that looked for the global
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:
1046
+ when there were eighteen of them, they skipped on Linux for a day, behind a runtime check that looked for the global
707
1047
  install and missed the bundled one, and hid a defect that broke `seisin run` on that platform
708
- entirely. **500 tests on macOS 15, 499 passing and 1 skipped** (it is Linux-only) — 2026-09-29;
709
- `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
710
- with bubblewrap 0.8.0, the last time the suite was run in Docker.
711
- [Which claim was measured where](docs/what-it-has-been-put-through.md#where-each-claim-was-actually-run),
712
- 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.
713
1057
  The config format may still move before `1.0` — if it does, `seisin check` will
714
1058
  tell you what changed.
715
1059
 
@@ -719,19 +1063,19 @@ already done:
719
1063
  | | |
720
1064
  |---|---|
721
1065
  | **Windows** | the runtime has a backend. seisin has never been pointed at it, and no CI runner covers it |
722
- | **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/) |
723
- | **`init` heuristics** | it reads `.claude/agents/` then `CODEOWNERS`. Every other convention is a guess nobody has made yet |
724
- | **SSH inside a turn** | the `ProxyCommand` the runtime sets can't authenticate to its own proxy, so `git` over SSH fails at the handshake. Fix upstream: [PR #516](https://github.com/anthropics/sandbox-runtime/pull/516). Use an HTTPS remote |
725
- | **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) |
726
1070
 
727
- Every word above has one meaning, listed in [the glossary](docs/glossary.md) — the boundary
728
- **denies**, a person **declines**, seisin **refuses** a config it cannot enforce. A tool whose
1071
+ Every word above has one meaning, listed in [the glossary](https://github.com/carlostapiacl/seisin/blob/main/docs/glossary.md) — the boundary
1072
+ **denies**, a person **grants** or **declines**, seisin **refuses** a config it cannot enforce. A tool whose
729
1073
  product is a sentence cannot afford two words for one thing.
730
1074
 
731
- 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
732
1076
  different about contributing here, which is mostly that a claim has to be downstream of
733
1077
  something measured. If you are reporting something that got past the boundary, do it
734
- [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
735
1079
  things that make it reproducible.
736
1080
 
737
1081
  ## The name