seisin 0.6.4 → 0.6.5

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -5,9 +5,9 @@
5
5
  [![npm](https://img.shields.io/npm/v/seisin?color=222)](https://www.npmjs.com/package/seisin)
6
6
  [![license](https://img.shields.io/badge/license-MIT-222)](https://github.com/carlostapiacl/seisin/blob/main/LICENSE)
7
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)
8
+ [![one dependency, the sandbox runtime; tartaros is optional](https://img.shields.io/badge/dependencies-1-222)](https://github.com/carlostapiacl/seisin/blob/main/package.json)
9
9
 
10
- > **seisin** *(n.)* — the legal possession of a piece of land. Not who owns it on paper: who holds it now.
10
+ > **seisin** *(n.)*: the legal possession of a piece of land, held by whoever is standing on it.
11
11
 
12
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.
13
13
 
@@ -19,35 +19,29 @@ seisin init && seisin check # propose a policy from the repo, then re
19
19
  seisin run <role> -- claude -p "…" # the roles are the ones seisin check lists
20
20
  ```
21
21
 
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)).
22
+ **Before you install:** by default a role can still read your home directory, `~/.ssh`, `~/.aws`
23
+ and `~/.config` included. seisin closes the keys you declare, not every credential on the machine.
24
+ To close the rest, set `[runtime] isolate = "credentials"` ([how it protects secrets](#how-it-protects-secrets)).
25
+
26
+ **Is it for me?** If two or more agents work in one repository, yes. With one agent, its own
27
+ sandbox covers most of this ([what is left over](#what-it-is-for)).
24
28
 
25
29
  **[The short version, with the demos playing →](https://carlostapiacl.github.io/seisin/)** · this page is the long one.
26
30
 
27
31
  <details>
28
32
  <summary><b>Contents</b></summary>
29
33
 
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)
31
-
32
- **Living with it** · [Day one](#what-will-look-like-a-bug-on-the-first-day) · [Which agents it has been run with](#which-agents-it-has-been-run-with) · [Where the first policy comes from](#where-the-first-policy-comes-from) · [When it says no, it leaves a request behind](#when-it-says-no-it-leaves-a-request-behind) · [When the boundary is right and the agent cannot hear it](#when-the-boundary-is-right-and-the-agent-cannot-hear-it) · [Scratch space, and what it costs](#scratch-space-and-what-it-costs)
33
-
34
- **Secrets** · [How it protects secrets](#how-it-protects-secrets) · [Keys in full](https://github.com/carlostapiacl/seisin/blob/main/docs/keys.md)
35
-
36
- **Looking at it** · [What the log says about the policy](#what-the-log-says-about-the-policy) · [Seeing what happened](#seeing-what-happened) · [Ask your own assistant](#ask-your-own-assistant)
34
+ [What it does](#what-it-does) · [Sixty seconds](#sixty-seconds) · [Why this exists](#why-this-exists) · [What it is for](#what-it-is-for) · [Install](#install) · [Use](#use) · [Configure](#configure) · [Day one](#what-will-look-like-a-bug-on-the-first-day) · [Agents](#which-agents-it-has-been-run-with) · [The first policy](#where-the-first-policy-comes-from) · [Requests](#when-it-says-no-it-leaves-a-request-behind) · [Secrets](#how-it-protects-secrets) · [The log and the console](#the-log-the-console-and-your-assistant) · [How it holds](#how-it-holds) · [Scratch space](#scratch-space-and-what-it-costs) · [Prior art](#prior-art) · [Status](#status)
37
35
 
38
- **The honest parts** · [How it holds](#how-it-holds) · [What it is not](#what-it-is-not) · [Why not a container](#why-not-a-container) · [Prior art](#prior-art) · [Status](#status)
39
-
40
- **Deeper** · [Keys](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)
36
+ **Deeper:** [Configure in full](https://github.com/carlostapiacl/seisin/blob/main/docs/configure.md) · [Keys](https://github.com/carlostapiacl/seisin/blob/main/docs/keys.md) · [Requests](https://github.com/carlostapiacl/seisin/blob/main/docs/requests.md) · [Log, console, MCP and walls](https://github.com/carlostapiacl/seisin/blob/main/docs/log.md) · [Sandboxes](https://github.com/carlostapiacl/seisin/blob/main/docs/sandboxes.md) · [Runs for a launcher](https://github.com/carlostapiacl/seisin/blob/main/docs/runs.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) · [JavaScript API](https://github.com/carlostapiacl/seisin/blob/main/docs/api.md) · [Prior art](https://github.com/carlostapiacl/seisin/blob/main/docs/prior-art.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) · [Decisions](https://github.com/carlostapiacl/seisin/blob/main/docs/decisions.md) · [Contributing](https://github.com/carlostapiacl/seisin/blob/main/CONTRIBUTING.md)
41
37
 
42
38
  </details>
43
39
 
44
40
  ## What it does
45
41
 
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)*
42
+ seisin is a permission layer on top of a sandbox. The OS does the isolation. seisin adds what the OS cannot know: which role a path belongs to, and so who to ask next.
49
43
 
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)*
44
+ Five reviews looked for ways past the boundary: [what they found and what is still open](https://github.com/carlostapiacl/seisin/blob/main/docs/what-it-has-been-put-through.md). What running four teams of agents behind it cost is in the [field notes](https://github.com/carlostapiacl/seisin/blob/main/docs/field-notes.md).
51
45
 
52
46
  ```mermaid
53
47
  flowchart LR
@@ -66,9 +60,8 @@ flowchart LR
66
60
  style H fill:#eef,stroke:#66c
67
61
  ```
68
62
 
69
- **The kernel says no. seisin says whose.** That second half is the whole thing: other
70
- permission layers in this space answer *yes* or *no*, or at best *which rule* said no — and a
71
- denial that also names an owner turns a dead end into a handoff.
63
+ **The kernel says no. seisin says whose.** Other permission layers answer *yes* or *no*, or
64
+ which rule said no. A denial that names an owner turns a dead end into a handoff.
72
65
 
73
66
  ```
74
67
  $ seisin run frontend -- sh -c 'echo // fix >> src/api/orders.ts'
@@ -88,23 +81,13 @@ The first line is the run's own header: what this role may write, which keys it
88
81
  much of your environment it did not inherit. The last lists only what this run filed; the
89
82
  rest of the queue is one count, and `seisin requests`.
90
83
 
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)).
84
+ The line under the request says where it goes: *handed to backend*, or *a person decides* when
85
+ nobody or several roles own the path. seisin does not start backend; whatever launches your agents
86
+ reads that decision and runs `seisin run backend --handoff frontend:write:src/api`
87
+ ([where a request goes](https://github.com/carlostapiacl/seisin/blob/main/docs/requests.md#where-a-request-goes),
88
+ and [denials a caller expects](https://github.com/carlostapiacl/seisin/blob/main/docs/requests.md#a-denial-the-caller-expects)).
104
89
 
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.
106
-
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).
90
+ The kernel denies; the name comes from the policy. `Operation not permitted` is all the boundary itself says, so seisin reads the denial from the kernel's log and looks up the owner. 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).
108
91
 
109
92
  The agent can ask directly too, from inside the sandbox:
110
93
 
@@ -116,9 +99,8 @@ seisin: frontend · writes src/web/** public/** (+6 scratch) · keys netlify-tok
116
99
  you are frontend. It is handed to backend: hand it over rather than working around it.
117
100
  ```
118
101
 
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.
102
+ The second line is the same handoff decision the queue shows. The hook's denial and the note after
103
+ a failed command say it the same way.
122
104
 
123
105
  ## Sixty seconds
124
106
 
@@ -145,7 +127,7 @@ Two things are true about how agents are kept in bounds today, and both are in t
145
127
 
146
128
  A rule is a string comparison against a command someone might not spell that way. A child process does not spell it at all.
147
129
 
148
- **2. The tools that *do* ask the OS don't know who anyone is.** They take one global policy. That is fine for one agent. The moment you run two — separate worktrees, a frontend agent and a backend agent, a hotfix agent and a sprint agent — "may this be written?" is the wrong question. The right one is **"whose is it?"**
130
+ **2. The tools that *do* ask the OS don't know who anyone is.** They take one global policy. That is fine for one agent. Once you run two (separate worktrees, a frontend agent and a backend agent, a hotfix agent and a sprint agent), "may this be written?" is the wrong question. The right one is **"whose is it?"**
149
131
 
150
132
  `seisin` answers the second question and hands the first one to the operating system.
151
133
 
@@ -160,23 +142,16 @@ A rule is a string comparison against a command someone might not spell that way
160
142
  the whole repo, all the time → hand it over, do not work around it
161
143
  ```
162
144
 
163
- ## What it is for, and what it is not for
164
-
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.**
167
- >
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
170
- > fixed, what was measured, and what is still open.
145
+ ## What it is for
171
146
 
172
- That distinction is the whole threat model, and it is worth reading before you install
173
- rather than after.
147
+ **It contains an agent's mistakes. Against a hostile agent, the guarantee is the one of the
148
+ sandbox you run it under.** Five reviews went looking for ways past the boundary;
149
+ [what they found and what is still open](https://github.com/carlostapiacl/seisin/blob/main/docs/what-it-has-been-put-through.md).
174
150
 
175
- **It holds against an agent that is wrong.** One that edits the file it was not asked
176
- about, reads the key meant for another role, or calls an API nobody declared. The kernel
177
- denies, and it does not care how the command was spelled — verified against a grandchild
178
- process, an absolute path, a symlink, a `python -c`, and a shell redirect the agent fell
179
- back to when its own file API was denied.
151
+ **It holds against an agent that is wrong:** one that edits a file it was not asked about, reads
152
+ another role's key, or calls an API nobody declared. The kernel denies however the command was
153
+ spelled. Verified against a grandchild process, an absolute path, a symlink, `python -c`, and a
154
+ shell redirect.
180
155
 
181
156
  **Against an agent that is trying, the line is drawn by the sandbox.** seisin writes the
182
157
  policy and checks that the sandbox can hold it; [the sandbox you choose](https://github.com/carlostapiacl/seisin/blob/main/docs/sandboxes.md)
@@ -185,11 +160,11 @@ own fixtures:
185
160
 
186
161
  | | |
187
162
  |---|---|
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 |
163
+ | 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
164
  | it can write anything inside its own territory | including staging an exfiltration |
190
165
  | `base64` defeats the redaction | only the literal value is masked, and only on stdout and stderr |
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 |
166
+ | 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`](https://github.com/carlostapiacl/seisin/blob/main/docs/configure.md) keeps an allowed name from resolving into your LAN |
167
+ | the hook is text-based and evadable | on purpose: what escapes the hook still hits the kernel, which denies. On macOS [the kernel's own denials are recorded](https://github.com/carlostapiacl/seisin/blob/main/docs/log.md#seeing-what-happened); on Linux they are not. Attribution is not a control |
193
168
 
194
169
  Others are the sandbox's, and differ between the two built in:
195
170
 
@@ -202,9 +177,18 @@ Others are the sandbox's, and differ between the two built in:
202
177
 
203
178
  `seisin check` says which sandbox each role gets before anything runs.
204
179
 
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.
180
+ It is for several of your own agents, on your own machine, getting in each other's way. For an
181
+ agent you do not control, choose the sandbox for that threat: seisin does not make its guarantee
182
+ stronger.
183
+
184
+ It does not schedule agents or merge their work (it runs one command as one role), and it does not
185
+ manage secrets (it scopes reads of files you already have). With **one** agent, *whose file is
186
+ this* has one answer: turn on your agent's own sandboxing first; Claude Code has it built in, over
187
+ the same runtime. What seisin still adds for one agent is narrow. The environment is built rather
188
+ than inherited (measured on one machine: **15 variables kept, 60 dropped**, among them
189
+ `GITHUB_TOKEN` and the AWS pair), the declared key directory is denied to every process, and the
190
+ network allowlist covers the whole process rather than one tool. It was designed for two agents
191
+ or more.
208
192
 
209
193
  ## Install
210
194
 
@@ -212,10 +196,10 @@ will not make its guarantee stronger than it is.
212
196
  npm install -g seisin
213
197
  ```
214
198
 
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. Its one other dependency is optional: [tartaros](https://github.com/carlostapiacl/tartaros), prebuilt for macOS and Linux, which can enforce instead — as can a sandbox of your own ([docs/sandboxes.md](https://github.com/carlostapiacl/seisin/blob/main/docs/sandboxes.md)).
199
+ 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. Its one other dependency is optional: [tartaros](https://github.com/carlostapiacl/tartaros), prebuilt for macOS and Linux, which can enforce instead, as can a sandbox of your own ([docs/sandboxes.md](https://github.com/carlostapiacl/seisin/blob/main/docs/sandboxes.md)).
216
200
 
217
- **On Linux you also need three system packages** — the runtime refuses to start without
218
- them rather than running unconfined, which is the right failure and an unhelpful message:
201
+ **On Linux you also need three system packages.** Without them the runtime refuses to start
202
+ rather than run unconfined:
219
203
 
220
204
  ```bash
221
205
  apt install bubblewrap ripgrep socat # or your distro's equivalent
@@ -229,45 +213,14 @@ namespace`:
229
213
  sudo sysctl -w kernel.apparmor_restrict_unprivileged_userns=0
230
214
  ```
231
215
 
232
- Worth knowing what this looks like when you hit it: seisin appears to deny
233
- *everything*, because the sandbox never starts. It is not a policy problem and
234
- no amount of editing `seisin.toml` will move it.
216
+ When this happens seisin seems to deny everything, because the sandbox never starts. Editing
217
+ `seisin.toml` will not fix it.
235
218
 
236
219
  **Inside a container** bubblewrap needs to create namespaces and mount `/proc`, which a
237
220
  default Docker container forbids. `--cap-add SYS_ADMIN --security-opt seccomp=unconfined`
238
221
  gets namespaces; mounting `/proc` needs `--privileged`. If you cannot grant that, run
239
222
  seisin on the host and let the container be what it sandboxes.
240
223
 
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
-
271
224
  ## Use
272
225
 
273
226
  ```bash
@@ -286,7 +239,7 @@ seisin ui --runs <dir> # read only, over many runs a launcher
286
239
  **Three ways in, and they say the same thing.** The **CLI** is what a person types and what
287
240
  every agent runs under. The **console** (`seisin ui`) is for a person deciding something. The
288
241
  **MCP server** is for an agent asking about its own situation. A change to what any of them
289
- 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)
242
+ 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)
290
243
  says why, and a test holds one half of it.
291
244
 
292
245
  `seisin <command> --help` lists a command's flags, examples and exit codes. The exit codes, so
@@ -305,12 +258,14 @@ the commands compose in a script, a pre-commit hook or CI:
305
258
  | `whose`, `grant`, `decline`, `log`, `init`, `wire`, `ui`, `watch`, `mcp` | done | — | |
306
259
  | `hook` | always | — | |
307
260
 
308
- Every command exits **2** on a usage error — an unknown command, flag, role or action — and every
261
+ Every command exits **2** on a usage error (an unknown command, flag, role or action), and every
309
262
  command that reads the policy exits 2 when there is no `seisin.toml` here or above.
310
263
 
264
+ To call it from code, the same functions are exported: [docs/api.md](https://github.com/carlostapiacl/seisin/blob/main/docs/api.md).
265
+
311
266
  ## Configure
312
267
 
313
- One file at the root of your repo. Anything not listed is denied — there is no permissive default.
268
+ One file at the root of your repo. Anything not listed is denied; there is no permissive default.
314
269
 
315
270
  ```toml
316
271
  [keys]
@@ -326,185 +281,14 @@ keys = ["netlify-token.txt"]
326
281
  [roles.backend]
327
282
  writes = ["src/api/**", "migrations/**"]
328
283
  keys = ["database-url.txt", "sentry-dsn.txt"]
329
- network = ["api.stripe.com"] # this role only — it replaces [network] rather than adding
330
- ```
331
-
332
- An allowed name is only as safe as its DNS: one whose records somebody else controls can be
333
- pointed at a host on your network. The sandbox already denies loopback, link-local and
334
- cloud-metadata addresses. Private ranges are left to you, because allowing an intranet name is
335
- legitimate; to close them, list them:
336
-
337
- ```toml
338
- [network]
339
- allow = ["github.com", "*.github.com"]
340
- deny_addresses = ["10.0.0.0/8", "172.16.0.0/12", "192.168.0.0/16", "100.64.0.0/10", "fc00::/7"]
341
- ```
342
-
343
- `[runtime] read = "all"` is the default. The opt-in `"territory"` mode keeps homes,
344
- temporary directories and mounted data roots shut, while retaining the repository and the
345
- paths each role names:
346
-
347
- ```toml
348
- [runtime]
349
- read = "territory"
350
-
351
- [roles.backend]
352
- writes = ["src/api/**"]
353
- reads = ["../shared-schemas/**"] # data needed read-only
354
- toolchain = ["~/.venvs/api"] # program stack under a data root; bin/ goes first on PATH
355
- verify = ["python", "-m", "pytest"] # run by seisin check --verify inside this role
356
- ```
357
-
358
- `seisin check --verify --parallel` is an experimental speed option: role checks run at the same
359
- time. Use it only when those commands do not share mutable build outputs, caches, ports, or other
360
- resources; concurrent checks can race and produce misleading failures. Plain `--verify` remains
361
- serial and is the recommended default. `--parallel` requires `--verify`.
362
-
363
- `reads` and `toolchain` take literal paths or a subtree ending in `/**`; interior globs,
364
- `*` and `?` are refused. `verify` is an argv array and may not be empty. Each data root is
365
- denied whole and what the role keeps is allowed back, so what appears there during the run is
366
- shut as well. On Linux a kept directory holding a write path is allowed back entry by entry;
367
- one with more than 1,000 entries is refused, and the error names whether the repo or a `reads`
368
- 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).
369
-
370
- `[network] allow` is the fallback for roles that do not name their own. A role that needs one
371
- extra host should say so on its own line: a domain in the global list is reachable by every
372
- role, including the ones whose whole job is to have nothing to reach.
373
-
374
- > ⚠️ **HTTP(S) works on both platforms. SSH does not work on macOS.** The allow list is
375
- > enforced by proxies the runtime starts, and although one of them is a SOCKS5 proxy that could
376
- > carry SSH, on macOS the chain breaks at its last link: it requires authentication and the
377
- > `ProxyCommand` the runtime wires up cannot send any. Measured on macOS — `curl
378
- > https://github.com` returns `200`, `ssh -T git@github.com` cannot resolve the hostname, and
379
- > `git ls-remote git@github.com:…` reaches the proxy and dies at the handshake. **On Linux** the
380
- > runtime routes git's SSH through its HTTP proxy with credentials instead, so it is expected to
381
- > pass — read from the runtime's source, not measured here.
382
- >
383
- > **Use an HTTPS remote for git**, and on macOS run SSH deploys outside the confined run.
384
- > [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)
385
-
386
- A role can also give something back. `never_writes` subtracts from its own `writes`, and it
387
- wins however wide the grant is:
388
-
389
- ```toml
390
- [roles.backend]
391
- writes = ["services/api/**"]
392
- never_writes = ["services/api/.git/index.lock"] # works in a worktree; the canonical index is not its business
393
- ```
394
-
395
- It is per role on purpose — there is no global version, because a global deny on a lock file
396
- breaks every role that legitimately commits there. A denial it causes says so by name and
397
- leaves **no request** in the queue: granting one would undo a subtraction somebody wrote. An
398
- entry that no `writes` of the same role covers, or a misspelling like `never_write`, is named
399
- by `seisin check` instead of being ignored.
400
-
401
- A folder only one role may see is `private`. The role reads and writes it; every other role is
402
- denied both, whatever its `writes` say, in either read mode and under `isolate`:
403
-
404
- ```toml
405
- [roles.legal]
406
- writes = ["contracts/drafts/**"]
407
- private = ["contracts/signed/**"] # part of legal's territory, and no other role reads it
408
-
409
- [roles.dev]
410
- writes = ["**"] # still cannot read or write contracts/signed
411
- ```
412
-
413
- An entry is one folder inside the repo, `dir` or `dir/**`: no patterns, no absolute path, no
414
- `..`, not the repo itself. Two roles naming the same folder, or one inside the other, is refused
415
- at load, because each would be denied its own. `seisin check` shows it under the role and says
416
- when it sits inside another role's territory, since that role loses it. `seisin explain dev read
417
- contracts/signed/x` answers denied and names the owner, and a denial there leaves **no request**:
418
- no grant opens it. On Linux a missing private folder is created empty by `seisin run` (never by `check`), because
419
- bubblewrap cannot deny a path that does not exist without placing something there; and another
420
- role sees an empty folder in its place, where a write succeeds into memory that is discarded with
421
- the run and never reaches the real one. On macOS the write is denied.
422
-
423
- A role that starts a server — a dev server, the backend an end-to-end test drives — needs
424
- `local_binding = true`. Listening is off by default and per role: most roles never start one.
425
- It is wider than the name: the role can also listen on every interface and **connect to every
426
- port on localhost**, so anything listening there without authentication is within its reach.
427
- `seisin check` says this next to the role. That is macOS: on Linux each role already has a
428
- private loopback, serves without the key, and reaches nothing on the host either way.
429
- The canary therefore omits its loopback probe for a role with `local_binding = true`: reaching
430
- the decoy is allowed by that policy, not evidence that confinement failed.
431
-
432
- If a role only needs to *reach* a local service, name the ports instead:
433
- `local_ports = [8001, 8081]`. The role reaches those and nothing else on localhost, on macOS
434
- and Linux. Under srt the sandbox's proxy enforces the list, so the client has to use the proxy.
435
- curl, Node's `fetch` and Python's `urllib` do. A database driver or a raw socket don't, and are
436
- denied. Under tartaros the kernel profile holds the list itself, so any client reaches a listed
437
- port directly, and loopback stays off its proxy. For a headless browser, see `browsers` below. A listed port is open to anything that
438
- speaks `CONNECT`, not just HTTP, so list the service, not the protocol.
439
-
440
- `mcp = ["playwright"]` lists the MCP servers a role may load; `mcp = []` means none, and no
441
- key means the policy doesn't say. seisin declares this and `explain` answers it
442
- (`seisin explain qa mcp tradingview`), but the servers are started by the agent's CLI, so the
443
- launcher has to apply it. With Claude Code: `--mcp-config` holding only those servers, plus
444
- `--strict-mcp-config`.
445
-
446
- A role that drives a headless Chromium — Playwright, `@playwright/mcp` — needs
447
- `browsers = ["chromium"]` on macOS. Without it the browser exits as it starts: its first act
448
- is to register a Mach service (`org.chromium.Chromium.MachPortRendezvousServer.<pid>`) that its
449
- own child processes look up, and the sandbox denies both. The preset allows exactly those two,
450
- by that prefix, and nothing else; every other role keeps the profile it had. The network is
451
- still `[network] allow`, through the sandbox's proxy, which takes credentials Playwright has to
452
- be handed apart from the URL:
453
-
454
- ```js
455
- const u = new URL(process.env.HTTP_PROXY); // http://<user>:<token>@localhost:<port>
456
- const browser = await chromium.launch({ proxy: {
457
- server: `${u.protocol}//${u.host}`,
458
- username: decodeURIComponent(u.username),
459
- password: decodeURIComponent(u.password),
460
- } });
461
- ```
462
-
463
- Passing `HTTP_PROXY` as `server` alone does not work: Playwright gives Chromium the address
464
- without the credentials and the proxy answers `407`. The token is the run's own, so the traffic
465
- stays tied to the run. `@playwright/mcp` runs under `[sandbox] provider = "tartaros"`, which
466
- gives the role a temp directory of its own for the unix socket the server always opens; under
467
- srt it does not yet. [docs/browsers.md](https://github.com/carlostapiacl/seisin/blob/main/docs/browsers.md) has the measurements, the MCP config,
468
- and what Chrome and Chrome for Testing still lack.
469
-
470
- Some files are not code but still make something happen outside the sandbox, and no territory
471
- reaches them: git hooks and config, a project's `.claude/`, `.mcp.json`, `.envrc`. Two softer
472
- families sit beside those, and a policy can hand them to one role:
473
-
474
- ```toml
475
- [roles.frontend]
476
- writes = ["apps/web/**"]
477
- control_files = ["ide"] # keeps apps/web/.vscode/launch.json current
478
-
479
- [protect]
480
- instructions = true # CLAUDE.md, AGENTS.md… only for roles handed "instructions"
284
+ network = ["api.stripe.com"] # this role only: it replaces [network] rather than adding
481
285
  ```
482
286
 
483
- - **`ide`** — `.vscode/`, `.cursor/`, `.windsurf/`. Your editor applies them as soon as they
484
- change, which is how an injected agent turns on tool auto-approval. **Protected by default.**
485
- - **`instructions`** — `CLAUDE.md`, `AGENTS.md`, `GEMINI.md`, Cursor and Windsurf rules, Copilot
486
- instructions. Nothing runs them; the next agent session reads them as its instructions, so
487
- one role writing them reaches another role's context. **Off unless `[protect]` turns it on**:
488
- keeping them current is ordinary work for most teams.
489
-
490
- A role handed a family writes it only inside its own `writes`, and `seisin check` names it on
491
- every run. A denial says which setting would change it, and leaves no request: it is a decision
492
- about the policy, not a grant. Inside `node_modules` neither family is touched, so npm still
493
- unpacks a package's own `.vscode` or `AGENTS.md`. On Linux only the files that already exist are
494
- protected: bubblewrap would have to create a missing one to deny it.
495
-
496
- Denied connections are logged too: `connect tcp:<port>` (the kernel gives the port, not the
497
- host) or the socket path, once per target per run. `seisin walls` says whether the port is
498
- missing from `local_ports`, listed but dialled directly, or a socket. No request is filed: a
499
- port isn't anyone's territory. macOS only.
500
-
501
- So are the denials that are neither a file nor a connection: a Mach service, a `sysctl`, as
502
- `mach-register org.chromium.Chromium.MachPortRendezvousServer.7354`, once per operation and
503
- name per run, and never as a request. Most are the sandbox doing its job and the program
504
- carrying on; one of them is how a browser dies at start, and `seisin run` names the preset
505
- that answers it. macOS only.
506
-
507
- `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`.
287
+ Private address ranges, `read = "territory"`, toolchains, `verify`, protected control files,
288
+ browsers, local ports, providers and the sandbox per role:
289
+ **[docs/configure.md](https://github.com/carlostapiacl/seisin/blob/main/docs/configure.md)**.
290
+ `seisin init` proposes a first policy from what the repo already says
291
+ ([where the first policy comes from](#where-the-first-policy-comes-from)).
508
292
 
509
293
  ## What will look like a bug on the first day
510
294
 
@@ -516,7 +300,7 @@ production window where none of them was the boundary misbehaving.
516
300
 
517
301
  One more comes with protected files: **a role's `git checkout`, `pull` or `rebase` cannot update
518
302
  a file the role may not write.** Git exits 0, the branch moves, and the old `.vscode/settings.json`
519
- stays behind as a modification — which a later `commit -a` would turn into a revert. Switch
303
+ stays behind as a modification, which a later `commit -a` would turn into a revert. Switch
520
304
  branches outside the sandbox, or hand that role the family if keeping those files is its job.
521
305
 
522
306
  **[What each one looks like, and what to do →](https://github.com/carlostapiacl/seisin/blob/main/docs/first-day.md)**
@@ -524,79 +308,19 @@ branches outside the sandbox, or hand that role the family if keeping those file
524
308
  ## Which agents it has been run with
525
309
 
526
310
  Four: Claude Code, opencode, codex and a LangGraph script, each against a real policy with
527
- the boundary actually applied — not a smoke test. gemini 0.57.0 has only been started under it,
311
+ the boundary applied. gemini 0.57.0 has only been started under it,
528
312
  which is not the same claim.
529
313
 
530
- **One thing is worth knowing before you start: an agent that sandboxes itself has to be told
531
- to stop.** `codex` confines every command it runs, and no OS applies a second profile to a
314
+ **An agent that sandboxes itself has to be told to stop.** `codex` confines every command it runs, and no OS applies a second profile to a
532
315
  process that already has one. seisin says so before it starts one, because the error you get
533
316
  otherwise names neither seisin, nor the agent, nor the fix.
534
317
 
535
318
  **[Every agent, its version, what it needed, and how each reacted to being stopped →](https://github.com/carlostapiacl/seisin/blob/main/docs/agents.md)**
536
319
 
537
- ## What the log says about the policy
538
-
539
- `check` reads the config and tells you what it would do. `review` reads what
540
- actually happened and tells you where the config was wrong about it.
541
-
542
- ```
543
- $ seisin review
544
-
545
- 60 entries, 2026-08-23 to 2026-09-11
546
-
547
- Denied repeatedly
548
- a role denied on the same place over and over is a policy that is wrong,
549
- not an agent that is misbehaving
550
-
551
- 9× frontend write src/api/checkout — belongs to backend
552
- 4× backend write src/web — belongs to frontend
553
-
554
- seisin grant, or move the territory. Either way it is a decision, not noise.
555
-
556
- Granted, never used
557
- nothing was written here in the window above. This is the only evidence
558
- anyone will ever have for making a policy smaller
559
-
560
- frontend public/**
561
- backend migrations/**
562
-
563
- A short window proves nothing. Check the dates before you delete a line.
564
-
565
- Unowned
566
- touched by an agent, claimed by no role. Only an ownable path (kind `territory`)
567
- is a hole in the map; the rest is a kind of thing with its own answer, and none
568
- of them is an owner
569
-
570
- 4× territory legacy
571
-
572
- territory: an ownable path no role claims: a real decision — give it an owner, or tell the agent not to write there
573
- ```
574
-
575
- `--since 2026-10-01` or `--since 7d` reads only that stretch of the log: on a log months long,
576
- "Denied repeatedly" is otherwise led by paths fixed long ago. A denial that is correct and needs
577
- nothing — Claude Code updating its plugins from inside the sandbox — is listed apart as "Denied as
578
- expected" and does not make `review` exit 1.
579
-
580
- Three questions, and the middle one is the reason this exists. **Every
581
- permission file only ever grows**, in every system, and always for the same
582
- reason: nobody can prove a line is dead, so the safe move is to leave it. Here
583
- the log can prove it — `public/**` was granted and nothing was written there in
584
- three weeks. That is the only direction that makes a policy *smaller*.
585
-
586
- The first is the one that changes how a denial reads. Forty denials on one
587
- directory is not an agent misbehaving; it is a policy that is wrong, and until
588
- you add them up it looks like forty tidy amber lines.
589
-
590
- It is arithmetic over the log. No model, no network, no heuristics — the
591
- argument this whole tool makes is that interpreting text is the wrong way to
592
- decide things, and a component that reads the log and forms an opinion would
593
- contradict it on the way in. Every finding carries the window it was computed
594
- over, because *never used* means nothing without *in how long*.
595
-
596
320
  ## Where the first policy comes from
597
321
 
598
322
  Every permission tool dodges this question. Written by hand, the first policy is a guess, and
599
- the first unjustified denial is when the tool gets uninstalled. So don't write it — watch, then
323
+ the first unjustified denial is when the tool gets uninstalled. So don't write it: watch, then
600
324
  write:
601
325
 
602
326
  ```bash
@@ -608,7 +332,7 @@ seisin init --from-observations # writes seisin.toml.observed
608
332
  What `--observe` changes: the whole repo is writable for that run, so a write outside the
609
333
  territory is recorded instead of refused. What it does not: the key directories, the policy and
610
334
  `.seisin/` stay denied, and so does every domain not on the allowlist. What gets recorded is what
611
- the hook sees — the tool calls of an agent (Claude Code, Codex…) in a repo where `seisin wire` ran.
335
+ the hook sees: the tool calls of an agent (Claude Code, Codex…) in a repo where `seisin wire` ran.
612
336
  A plain shell command (`--observe -- npm test`) goes through no hook and records nothing.
613
337
 
614
338
  It lands as `.observed`, not as your config. A policy generated behind your back is not a
@@ -616,12 +340,8 @@ policy: diff it, then move it.
616
340
 
617
341
  ## When it says no, it leaves a request behind
618
342
 
619
- A permission tool that can only say no is a tool people uninstall — the loop of *denied,
620
- stop, edit a config, run again* is three context switches for one line of policy, and the
621
- cheapest way to stop it is to widen the policy and never look again. That is how an allowlist
622
- becomes seven hundred entries nobody reads.
623
-
624
- So a denial leaves something to act on, and many denials in one directory are **one** request:
343
+ A tool that only says no gets its policy widened until nobody reads it. So a denial leaves
344
+ something to act on, and many denials in one directory are **one** request:
625
345
 
626
346
  ```
627
347
  $ seisin requests
@@ -653,135 +373,80 @@ writes = ["src/web/**", "public/**",
653
373
  ```
654
374
 
655
375
  **Granting is never a tool call.** An agent can read the queue and draft the change; only a
656
- person applies it, in a terminal or in the console. That asymmetry is deliberate and it is
657
- the reason the MCP server exists at all.
658
-
659
- **A person hears about it.** With `[notify]`, the parent process sends one message per new
660
- request — the role, the path, whose it is, and `seisin grant '<id>'` — to ntfy, Slack or any
661
- webhook. The URL lives in a key directory no role declares (`url_file`) or in the parent's
662
- `SEISIN_NOTIFY_URL`; written plainly into `seisin.toml`, which every role can read, it refuses to
663
- load, because whoever reads it can send you a fake "grant this".
664
-
665
- ```toml
666
- [notify]
667
- url_file = ".secrets/notify-url.txt" # e.g. https://ntfy.sh/<your-topic>
668
- format = "text" # or "slack", "json"
669
- ```
376
+ person applies it, in a terminal or in the console. The MCP server is read-only for that reason.
670
377
 
671
- **And so does the agent.** `seisin wire` installs the hook for four events. Before a tool call
672
- it explains a denial it can see coming; after a command fails it reads the kernel's own denial
673
- from the log and tells the agent whose the path was and how many times it has been denied —
674
- the half PreToolUse never sees; and when a session starts, resumes or is compacted it hands the
675
- agent its territory and its walls, before the first one.
378
+ With [`[notify]`](https://github.com/carlostapiacl/seisin/blob/main/docs/configure.md#notifications) a person gets one message per new request (ntfy, Slack or any webhook), and
379
+ `seisin wire` lets the hook tell the agent whose a path was before and after it is denied.
676
380
 
677
381
  **[How a denial becomes a request, why it deduplicates, and what a grant records →](https://github.com/carlostapiacl/seisin/blob/main/docs/requests.md)**
678
382
 
679
- ## Ask your own assistant
680
-
681
- ```jsonc
682
- { "mcpServers": { "seisin": { "command": "seisin", "args": ["mcp"] } } }
683
- ```
684
-
685
- > *who owns src/api, and what is frontend waiting on?*
686
-
687
- Seven tools. Four answer about the policy — `state`, `explain`, `requests`, `draft_grant` —
688
- and three about what has happened: `activity` is the raw log, and **`causes` and `walls` are
689
- the two an agent actually wants.** `activity` hands over events and leaves the assistant to
690
- re-derive the shape without the policy; `causes` returns the day grouped by path and by name
691
- with how many are on paths nobody owns, and `walls` returns what a role keeps being denied
692
- *and would still be denied today*. Both are recomputed against the policy, so something
693
- granted since stops counting — which is the part a log cannot know.
383
+ ## How it protects secrets
694
384
 
695
- Read-only **by construction** — the server opens nothing for writing, and a test
696
- asserts that no tool in it mutates anything. It can draft the exact change a
697
- request would make; applying it is a command a person runs. Zero dependencies:
698
- the official SDK wanted 91 packages for a server that speaks JSON on two file
699
- descriptors.
385
+ Six ways a secret leaves a process. seisin closes four by default, one when you turn it on, and
386
+ one not at all.
700
387
 
701
- ## Seeing what happened
388
+ | way out | closed by |
389
+ |---|---|
390
+ | reading another role's key file | the kernel: the key directory is denied, each declared key re-allowed |
391
+ | a key riding in the environment | the launcher: the child's environment is **built, not inherited** |
392
+ | spilling a key the role does hold | the launcher: masked on stdout and stderr, even split across buffers |
393
+ | 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) |
394
+ | **credentials in your home** (`~/.ssh`, `~/.aws`, `~/.npmrc`, `~/.config`) | **not by default** (`read = "all"`). `[runtime] isolate = "credentials"` closes them and the agent stays logged in; `read = "territory"` closes more |
395
+ | **a key stored outside the declared directories** | **nothing.** `seisin scan` finds them so you know |
702
396
 
703
- ```bash
704
- seisin log --verdict denied # what the agents tried and could not do
705
- seisin watch # follow it live
706
- seisin ui # the same thing, for a person deciding
707
- ```
397
+ ```toml
398
+ [keys]
399
+ dir = [".secrets"]
708
400
 
401
+ [roles.frontend]
402
+ key_mode = "env"
403
+ keys = ["netlify-token.txt", # a file the role may read
404
+ "TOKEN=file://.secrets/all.env#NETLIFY", # one value, and no read at all
405
+ "OP=op://vault/item/field"] # anything that prints a secret
709
406
  ```
710
- $ seisin log
711
407
 
712
- 04:13:26 allowed frontend write src/web/app.ts
713
- 04:13:26 denied frontend write src/api/orders.ts → backend
714
- 04:13:27 denied frontend read .secrets/database-url.txt → backend
715
- 04:13:31 denied frontend write src/api/orders.ts → backend [kernel]
408
+ A **provider** is a command with a `{ref}` placeholder, so adding 1Password, Bitwarden or
409
+ `sops` is TOML and not code. `file://` ships built in. The parent resolves, never the
410
+ confined process. Nothing degrades: a provider that fails stops the run.
716
411
 
717
- 4 entries · 3 denied
718
- ```
412
+ A key reaches the role as a variable (`key_mode = "env"`), as a file in the run's private
413
+ directory (`"run-file"`), or not at all (on Linux, and on macOS under tartaros): with `"inject"` the agent holds a
414
+ placeholder and the sandbox swaps in the real value only on the way to the hosts you list for
415
+ it. That costs terminating TLS to those hosts, which `seisin check` names, and a host that
416
+ stores what it is sent can still hand the value back, so use a least-scope token.
719
417
 
720
- `[kernel]` marks a line the kernel reported; the others came from the hook.
418
+ **[Key directories, references, providers, delivery modes and nine recipes →](https://github.com/carlostapiacl/seisin/blob/main/docs/keys.md)**
721
419
 
722
- One append-only JSONL under `.seisin/`, and that is the whole storage design — no daemon, no
723
- database, and `watch` is a tail. A run started with a W3C `TRACEPARENT` (a launcher's trace)
724
- stamps its `trace_id` and `span_id` on every line, so the log joins that trace in any
725
- OpenTelemetry tool.
420
+ For agents you would not trust with your machine, `[runtime] read = "territory"` denies the places
421
+ data lives (homes, temp directories, volumes) and leaves each role its repo, its territory and
422
+ a toolchain it declares, which `seisin check --verify` proves it can run. Every run, in every
423
+ mode, starts with a canary that tries the policy from inside and refuses to start the agent if
424
+ 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)**
726
425
 
727
- Each line carries the hash of the one before it, and the first time a chained line is written
728
- the start of the chain is recorded beside the log (`log.jsonl.genesis`). `seisin log verify`
729
- checks both, so a line edited, removed or reordered breaks it, and so does stripping the chain
730
- from every line to make the log read as one written before chaining:
426
+ **Measured, over four teams of agents and three days: every denied read was a read of something
427
+ the policy had declared a key.** 148 of them, no exceptions, from five roles, all running
428
+ searches that swept a repository root. That is how a key gets read without anyone deciding
429
+ it should. ([The measurement, ~370 confined runs over one production window →](https://github.com/carlostapiacl/seisin/blob/main/docs/keys.md))
731
430
 
732
- ```
733
- $ seisin log verify
431
+ ## The log, the console and your assistant
734
432
 
735
- broken 4 chained line(s), 2 break(s):
736
- line 1: expected prev 106c24967e0bdde077d73073b648f218, found 43918a17f2089effc44394a72e4289e9
737
- line 2: expected prev 43918a17f2089effc44394a72e4289e9, found 106c24967e0bdde077d73073b648f218
738
- a line was edited, removed or reordered just before each of these
433
+ ```bash
434
+ seisin log --verdict denied # what the agents tried and could not do
435
+ seisin review --since 7d # where the policy was wrong, from what happened
436
+ seisin walls dev # what a role keeps being denied, and what it cost
437
+ seisin ui # a console for the person deciding
739
438
  ```
740
439
 
741
- Tamper-evident, not tamper-proof: whoever can write `.seisin/` could rewrite both files
742
- together, which is why no role can write it.
743
-
744
- **The agent cannot reach that file.** `.seisin/` is in no role's territory, because a record
745
- the recorded process can edit is not a record. The hook sends each line to `seisin run` over
746
- a unix socket granted by path and the parent does the writing, so the only verb available
747
- from inside the sandbox is *append one line*. A hostile agent can still add noise to its own
748
- history; what it cannot do is change what is already there.
749
-
750
- **Two writers, and they see different things.** The **hook** reports the attempt before it
751
- happens — which is how an *allowed* action gets recorded at all — and it is text-based and
752
- evadable, on purpose. The **kernel** reports what it actually denied, and is not fooled by
753
- anything. What escapes the hook goes unexplained, never unenforced.
754
-
755
- ![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)
756
-
757
- It opens on what needs a decision, not on the policy. Four numbers sit on top — waiting on
758
- you, denied, the **top cause**, and calls **spent retrying** walls — each a link to the page
759
- that explains it, and the last two are recomputed against the policy, which a log alone cannot
760
- do: something granted since stops counting.
761
-
762
- **A decision needs a reason.** Grant without one asks for it; Decline without one fills in
763
- a default and asks again, so what is recorded is on screen first. The request says what a
764
- grant covers before the click — *asked for `src/api/routes.ts` → granting
765
- covers all of `src/api/**`*, and whose it is today. Sharing another role's territory takes a
766
- second click that names it. After it, the console shows the line it wrote to `seisin.toml`
767
- and where.
768
-
769
- **Denied and Walls say the same thing about one path.** A path no role owns says what kind
770
- of thing it is — *ownable*, or git's, temporary, build output, a credential — and the move
771
- that fits each, which for all but the first is not an owner.
772
-
773
- **Live, the roles are read-only:** territories and keys change in `seisin.toml`. The two
774
- writes are decisions and the control files: beside each role, a toggle for each family it can
775
- be handed (editor settings, instruction files), and one for `[protect] instructions`. A
776
- toggle saves nothing. It shows the sandbox settings each role gains or loses and the exact
777
- line it would write, and a second button, with a reason, saves it. If the server stops, the
778
- page says *offline* and turns every write off until it answers again.
440
+ One append-only, hash-chained JSONL under `.seisin/`, which no role can write. `review` finds
441
+ denials that repeat (a policy that is wrong) and grants never used (the only evidence for making a
442
+ policy smaller). The MCP server (`seisin mcp`) is read-only and answers an agent's questions about
443
+ its own situation. **[The log, `review`, the console, the MCP server and walls →](https://github.com/carlostapiacl/seisin/blob/main/docs/log.md)**
779
444
 
780
445
  ## How it holds
781
446
 
782
447
  | layer | what it does | can it be talked around? |
783
448
  |---|---|---|
784
- | your agent's `allow`/`deny` rules | match the command string before it runs | yes — `/bin/rm`, a heredoc, a child process |
449
+ | your agent's `allow`/`deny` rules | match the command string before it runs | yes: `/bin/rm`, a heredoc, a child process |
785
450
  | **seisin** | decides what to ask for, and names the owner | it doesn't enforce, it explains |
786
451
  | **the sandbox → Seatbelt / bubblewrap / Landlock** | the OS denies the syscall | no |
787
452
 
@@ -807,11 +472,11 @@ page says *offline* and turns every write off until it answers again.
807
472
  explanation — never the boundary
808
473
  ```
809
474
 
810
- `sandbox-runtime` is the default enforcer, not the only one. `[sandbox] provider` picks another —
811
- `tartaros`, or any sandbox declared as a command — per role, and only where it enforces the whole
475
+ `sandbox-runtime` is the default enforcer, not the only one. `[sandbox] provider` picks another
476
+ (`tartaros`, or any sandbox declared as a command) per role, and only where it enforces the whole
812
477
  policy: see [docs/sandboxes.md](https://github.com/carlostapiacl/seisin/blob/main/docs/sandboxes.md).
813
478
 
814
- 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.
479
+ That split keeps seisin small. Because the kernel is the boundary, seisin does not have to be airtight, only *legible*. A leaky explainer costs you a confusing log line. A leaky enforcer costs you the repo.
815
480
 
816
481
  Verified by the test suite, which runs real commands in a real sandbox:
817
482
 
@@ -826,126 +491,14 @@ Verified by the test suite, which runs real commands in a real sandbox:
826
491
  ✔ an absolute path does not walk around the rule
827
492
  ```
828
493
 
829
- ## How it protects secrets
830
-
831
- Four ways a secret leaves a process, and seisin closes three. The fourth is named rather
832
- than hidden.
833
-
834
- | way out | closed by |
835
- |---|---|
836
- | reading another role's key file | the kernel — the key directory is denied, each declared key re-allowed |
837
- | a key riding in the environment | the launcher — the child's environment is **built, not inherited** |
838
- | spilling a key the role does hold | the launcher — masked on stdout and stderr, even split across buffers |
839
- | 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) |
840
- | **a key stored outside the declared directories** | **nothing.** `seisin scan` finds them so you know |
841
-
842
- ```toml
843
- [keys]
844
- dir = [".secrets"]
845
-
846
- [roles.frontend]
847
- key_mode = "env"
848
- keys = ["netlify-token.txt", # a file the role may read
849
- "TOKEN=file://.secrets/all.env#NETLIFY", # one value, and no read at all
850
- "OP=op://vault/item/field"] # anything that prints a secret
851
- ```
852
-
853
- A **provider** is a command with a `{ref}` placeholder, so adding 1Password, Bitwarden or
854
- `sops` is TOML and not code. `file://` ships built in. The parent resolves, never the
855
- confined process. Nothing degrades: a provider that fails stops the run.
856
-
857
- A key reaches the role as a variable (`key_mode = "env"`), as a file in the run's private
858
- directory (`"run-file"`), or not at all (on Linux, and on macOS under tartaros): with `"inject"` the agent holds a
859
- placeholder and the sandbox swaps in the real value only on the way to the hosts you list for
860
- it. That costs terminating TLS to those hosts, which `seisin check` names, and a host that
861
- stores what it is sent can still hand the value back — use a least-scope token.
862
-
863
- **[Key directories, references, providers, delivery modes and nine recipes →](https://github.com/carlostapiacl/seisin/blob/main/docs/keys.md)**
864
-
865
- For agents you would not trust with your machine, `[runtime] read = "territory"` denies the places
866
- data lives — homes, temp directories, volumes — and leaves each role its repo, its territory and
867
- a toolchain it declares, which `seisin check --verify` proves it can run. Every run, in every
868
- mode, starts with a canary that tries the policy from inside and refuses to start the agent if
869
- 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)**
870
-
871
- **Measured, over four teams of agents and three days: every denied read was a read of something
872
- the policy had declared a key.** 148 of them, no exceptions, from five roles — all running
873
- searches that swept a repository root. That is how a key gets read without anyone deciding
874
- it should. ([The measurement, ~370 confined runs over one production window →](https://github.com/carlostapiacl/seisin/blob/main/docs/keys.md))
875
-
876
- ## When the boundary is right and the agent cannot hear it
877
-
878
- seisin answers *whose is this* at the moment of the denial — once, mid-turn — and then the
879
- sentence is gone. Nothing carries it forward, so the agent tries again.
880
-
881
- Measured in the author's own multi-agent setup: of **345 denials, 88 (25%) were a repeat of something that same role
882
- had already been denied.** One role spent 37 calls on two walls, hitting one of them nineteen
883
- times. Every denial was correct. Not one was a false positive. It still cost thirty-seven
884
- calls, because *correct* and *heard* are different properties and only the first was being
885
- measured.
886
-
887
- So a denial now carries its own history:
888
-
889
- ```
890
- deploy/x.yml belongs to infra. It is not dev's to change. It is handed to infra: hand it
891
- over rather than working around it. Do not put the change somewhere else instead (another
892
- path, a copy, /tmp). You have been denied this 3 times now; it is not going to work on the
893
- fourth try. Already queued as a handoff to infra…
894
- ```
895
-
896
- And the standing list is a command:
897
-
898
- ```
899
- $ seisin walls dev
900
-
901
- dev keeps hitting:
902
-
903
- 19× write ../shared/.git/index.lock belongs to platform
904
- 6× read ~/.npmrc no role declares ~/.npmrc — add it under a [roles.<name>] keys list
905
-
906
- 23 of its calls went into retrying these.
907
- ```
908
-
909
- That is the list said to the person at the terminal. The agent gets it in its own voice, at the
910
- start of every session the hook is wired for:
911
-
912
- ```
913
- Already denied more than once, and still denied — do not try again, hand it over:
914
- - write ../shared/.git/index.lock (19×) — belongs to platform
915
- - read ~/.npmrc (6×) — no role declares ~/.npmrc — add it under a [roles.<name>] keys list
916
- ```
917
-
918
- and, on request, as data from the MCP server's `seisin_walls`. In a run handed along a chain, a
919
- wall where the chain stops is listed without its owner: a person decides it, and the agent is
920
- 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)).
921
-
922
- **A wall is recomputed against the policy, not read out of the log.** If the role would be
923
- allowed today, it is not a wall — whatever happened yesterday. The obvious implementation is a
924
- time window, on the argument that an old wall may have been granted since; the window is a
925
- proxy for the question, and seisin has the policy right there, so it asks the question. A grant
926
- makes its wall disappear on the next run instead of when a window expires. Same move as
927
- `owners` and the request queue: recompute rather than believe.
928
-
929
- Silent on the first denial, by design. A counter that reads `1×` every time is noise on the
930
- turn where the sentence is already doing its job.
931
-
932
- **Whether it makes an agent stop is not demonstrated, and will not be for a while.** The
933
- obvious way to check is to compare repeats before and after — but the repeats were 86% one
934
- lock file, and the release before this one deleted that wall outright with
935
- `GIT_OPTIONAL_LOCKS=0`. Repeats did fall, by a lot, and **the fall is the wall going away,
936
- not the sentence landing**: both shipped within days of each other and the bigger cause is the
937
- other one. Isolating this needs a forward test — walls per role, runs with the hook wired
938
- and no git locks in the way — not the history. Measured in the field and written up rather
939
- than smoothed over, because the alternative is a claim the numbers do not carry.
940
-
941
494
  ## Scratch space, and what it costs
942
495
 
943
496
  Territory alone is correct and unusable: an agent writes session state under its own config
944
497
  directory and its tools write to the temp dir, so *your folders and nothing else* stops the
945
498
  agent from starting. Every role gets `~/.cache`, `$TMPDIR` and `/tmp`, and a run of Claude Code
946
- or Codex gets that agent's own directory (`~/.claude`, `~/.codex`) — never the other's, nor the
499
+ or Codex gets that agent's own directory (`~/.claude`, `~/.codex`), never the other's, nor the
947
500
  other's sign-in. Shared scratch is a real hole, because two roles share a temp directory and
948
- territory does not hold in it. A repo that lives inside scratch — a toy repo under `/tmp` — is
501
+ territory does not hold in it. A repo that lives inside scratch (a toy repo under `/tmp`) is
949
502
  writable by every role whatever its `writes` say; `seisin check` says so (*every role can write
950
503
  the whole repo*), and the same for a `writes` entry whose real path is in scratch.
951
504
  It is one line to turn off. The runtime's own shared temp directory, `/tmp/claude`, is never
@@ -953,113 +506,26 @@ writable: each run gets a temp directory of its own instead.
953
506
 
954
507
  **[What that costs, and how to close it →](https://github.com/carlostapiacl/seisin/blob/main/docs/scratch.md)**
955
508
 
956
- ## What it is not
957
-
958
- - **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.
959
- - **Not an orchestrator.** It does not run your agents, schedule them, or merge their work. It runs one command as one role.
960
- - **Not a secret manager.** It scopes *reads* of files you already have. Where those files come from is your problem.
961
- - **Not what a single agent needs first.** If only one agent ever touches the repo,
962
- *whose file is this* has one answer, and the ownership half of this tool is dead
963
- weight. Turn on your agent's own sandboxing instead — Claude Code has it built in,
964
- over this same runtime, with nothing to install.
965
-
966
- What is left over for one agent is real but narrow: the environment gets built
967
- rather than inherited (measured on one machine: **15 variables kept, 60 dropped**,
968
- among them `GITHUB_TOKEN` and the AWS pair), reads of the declared key directory
969
- are denied to everything, and the network allowlist covers the whole process
970
- rather than one tool. Worth it if that is a gap you actually have. seisin starts
971
- earning its setup at **two** agents, and that is where it was designed to be.
972
-
973
- ## Why not a container
974
-
975
- A fair question, and the honest answer is that a container is *more* isolation
976
- than this. If what you need is to run something you do not trust at all, run it
977
- in a VM — [Kaiden](https://openkaiden.ai), [Containarium](https://containarium.dev)
978
- and a growing shelf of others do that well, and this does not try to.
979
-
980
- They answer **"can this process reach the host?"** seisin answers a different
981
- question:
982
-
983
- ```
984
- container / microVM one box, one boundary, everything inside it is equal
985
- seisin one repo, several roles, and a boundary between them
986
- ```
987
-
988
- Put two agents in one container and they are back where they started: the same
989
- files, the same keys, no answer to *whose is it*. That question is not about
990
- isolation strength at all — it is about ownership, and it is the one thing an
991
- operating system cannot know for you.
992
-
993
- Which is also why this is thin. Enforcement is the sandbox — by default
994
- [`@anthropic-ai/sandbox-runtime`](https://github.com/anthropics/sandbox-runtime) —
995
- asking the kernel; seisin is the layer that decides what to ask for and says
996
- whose file it was when the answer is no. The two compose — put seisin inside a
997
- container if you want both, and the roles still hold.
998
-
999
- | | what it answers |
1000
- |---|---|
1001
- | VM / microVM | can this reach the host |
1002
- | container | can this reach the host, cheaply |
1003
- | **seisin** | **whose file is this, and may this role change it** |
1004
-
1005
509
  ## Prior art
1006
510
 
1007
- This space already has good work, and seisin is not the first thing here:
1008
-
1009
- - [`anthropics/sandbox-runtime`](https://github.com/anthropics/sandbox-runtime) — the enforcement. seisin is a thin thing on top of a serious one.
1010
- - [`kornysietsma/claude-code-permissions-hook`](https://github.com/kornysietsma/claude-code-permissions-hook) — granular `PreToolUse` rules, one global policy.
1011
- - [`XuebinMa/agent-guard`](https://github.com/XuebinMa/agent-guard) — an SDK that enforces what an agent may do, also one global policy.
1012
- - [`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.
1013
- - [`dredozubov/hazmat`](https://github.com/dredozubov/hazmat) — runs the agent as a different system user, with `pf` rules and snapshots.
1014
- - [`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.
1015
- - *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.
1016
-
1017
- ### One sentence to tell these apart, because the names all sound the same
1018
-
1019
- **OpenShell, hazmat and nono isolate the agent from your machine. seisin separates roles from
1020
- each other inside one repository, and when the kernel denies something it names the owner.**
511
+ [`sandbox-runtime`](https://github.com/anthropics/sandbox-runtime) is the enforcement seisin sits on.
512
+ OpenShell, hazmat and nono isolate the agent from your machine; containers and microVMs isolate a
513
+ process from the host. seisin separates roles from each other inside one repository, and names the
514
+ owner when the kernel denies something. They compose: the agent seisin confines can run inside any
515
+ of them. **[Each one, and why not a container →](https://github.com/carlostapiacl/seisin/blob/main/docs/prior-art.md)**
1021
516
 
1022
- Those are different problems and the first one is not the one this solves. If what you want
1023
- is "this agent cannot touch anything outside its sandbox", they do that and OpenShell does it
1024
- with a large organisation behind it. If what you want is "four agents work in the same
1025
- repository and none of them edits another's files — and when one is denied it is told whose
1026
- those files are", nothing above answers that, which is why this exists.
1027
-
1028
- They compose rather than compete: the agent seisin confines could be running inside one of
1029
- them.
517
+ ## Status
1030
518
 
1031
- *One detail worth knowing before you compare, and it is about placement rather than quality.
1032
- OpenShell was [evaluated here on 2026-09-27](https://github.com/carlostapiacl/seisin/blob/main/docs/openshell.md), through its Docker driver on
1033
- an Intel Mac. That confines an agent inside Docker Desktop's Linux VM; seisin confines a
1034
- process on the host through Seatbelt. OpenShell also offers other drivers, including a local
1035
- microVM path on supported Apple Silicon Macs. These are kernel-enforced boundaries, but not
1036
- the same kernel or workspace, and which one you want depends on what you are protecting.*
519
+ CI runs the full suite on macOS and `ubuntu-latest`, Node 18/20/22/24, on every pull request and
520
+ push to `main`. On 2026-11-02, on Node 24: macOS **1133 tests, 1124 passing, 9 skipped**;
521
+ Linux **1132, 1103 passing, 29 skipped**. The skips are platform tests; the job fails if the 26
522
+ core boundary tests in `test/sandbox.test.js` skip. A release is published only from CI, after
523
+ that passes, with npm provenance. [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),
524
+ and [what running agents behind it cost](https://github.com/carlostapiacl/seisin/blob/main/docs/field-notes.md).
1037
525
 
1038
- ## Status
526
+ The config format may still change before `1.0`; `seisin check` will say what changed.
1039
527
 
1040
- **1120 tests on macOS, 1113 passing and 7 platform skips** (with the 0.6.4 changes, 2026-10-26, local); for 0.6.3, in CI
1041
- the same day, on `ubuntu-latest` **1100, 1071 passing and 29 platform skips**, and on macOS 1101,
1042
- 1092 passing and 9 skips, Node 18/20/22/24, with the 26 core boundary tests skipping none. On 2026-10-12, 91 of the suite's tests
1043
- needed `@anthropic-ai/sandbox-runtime` installed — counted by running it with the runtime
1044
- hidden — and run real commands through the real kernel; 26 of them are the core boundary file,
1045
- `test/sandbox.test.js`, which the CI gate fails if it *skips*, because a green run that quietly
1046
- tested nothing looks exactly like a real one. That is not hypothetical:
1047
- when there were eighteen of them, they skipped on Linux for a day, behind a runtime check that looked for the global
1048
- install and missed the bundled one, and hid a defect that broke `seisin run` on that platform
1049
- entirely.
1050
-
1051
- A release is published only from CI, after the full suite passes on macOS and `ubuntu-latest`,
1052
- Node 18/20/22/24 (`publish.yml`), with npm provenance.
1053
- Historically: 811 tests on macOS 15, 809 passing and 2 skipped, on 2026-09-30.
1054
- [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),
1055
- and [what running agents behind it cost the people using it](https://github.com/carlostapiacl/seisin/blob/main/docs/field-notes.md).
1056
- 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)
1057
- added 32 regressions and record their portable checks apart from the kernel measurements above.
1058
- The config format may still move before `1.0` — if it does, `seisin check` will
1059
- tell you what changed.
1060
-
1061
- **What is genuinely open**, so nobody spends an afternoon on something that is
1062
- already done:
528
+ **Still open:**
1063
529
 
1064
530
  | | |
1065
531
  |---|---|
@@ -1069,23 +535,14 @@ already done:
1069
535
  | **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 |
1070
536
  | **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) |
1071
537
 
1072
- Every word above has one meaning, listed in [the glossary](https://github.com/carlostapiacl/seisin/blob/main/docs/glossary.md) — the boundary
1073
- **denies**, a person **grants** or **declines**, seisin **refuses** a config it cannot enforce. A tool whose
1074
- product is a sentence cannot afford two words for one thing.
538
+ Each term has one meaning, listed in [the glossary](https://github.com/carlostapiacl/seisin/blob/main/docs/glossary.md): the boundary
539
+ **denies**, a person **grants** or **declines**, seisin **refuses** a config it cannot enforce.
1075
540
 
1076
- Issues and pull requests welcome — [CONTRIBUTING.md](https://github.com/carlostapiacl/seisin/blob/main/CONTRIBUTING.md) says what is actually
1077
- different about contributing here, which is mostly that a claim has to be downstream of
1078
- something measured. If you are reporting something that got past the boundary, do it
541
+ Issues and pull requests are welcome. [CONTRIBUTING.md](https://github.com/carlostapiacl/seisin/blob/main/CONTRIBUTING.md) asks mainly that a claim come
542
+ from something measured. If you are reporting something that got past the boundary, do it
1079
543
  [privately](https://github.com/carlostapiacl/seisin/blob/main/SECURITY.md); `seisin log --verdict denied` and the `seisin.toml` are the two
1080
544
  things that make it reproducible.
1081
545
 
1082
- ## The name
1083
-
1084
- A deed says who owns land. **Seisin** is the older idea underneath: who is actually standing on it.
1085
-
1086
- That is the question this tool answers. Not *may this be written* — the kernel settles that — but
1087
- *whose is it*, which is the one that tells an agent what to do next.
1088
-
1089
546
  I write up what I measure running agents behind it at [carlostapia.cl](https://carlostapia.cl/notes/).
1090
547
 
1091
548
  ## License