seisin 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md ADDED
@@ -0,0 +1,732 @@
1
+ # seisin
2
+
3
+ [![test](https://github.com/carlostapiaolguin3-stack/seisin/actions/workflows/test.yml/badge.svg)](https://github.com/carlostapiaolguin3-stack/seisin/actions/workflows/test.yml)
4
+ [![npm](https://img.shields.io/npm/v/seisin?color=222)](https://www.npmjs.com/package/seisin)
5
+ [![license](https://img.shields.io/badge/license-MIT-222)](LICENSE)
6
+ [![node](https://img.shields.io/badge/node-%E2%89%A518-222)](package.json)
7
+ [![no dependencies](https://img.shields.io/badge/dependencies-1-222)](package.json)
8
+
9
+ > **seisin** *(n.)* — the legal possession of a piece of land. Not who owns it on paper: who holds it now.
10
+
11
+ **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
+
13
+ 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
+
15
+ *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
+
17
+ ![seisin denying a write outside a role's territory, then naming the owner](docs/img/demo.gif)
18
+
19
+ ```
20
+ $ seisin run frontend -- sh -c 'echo // fix >> src/api/orders.ts'
21
+ sh: src/api/orders.ts: Operation not permitted
22
+ seisin: 1 kernel denial(s) recorded
23
+
24
+ 1 pending request(s)
25
+
26
+ #1 frontend wants write on src/api/** (owned by backend)
27
+ first asked over src/api/orders.ts
28
+
29
+ seisin grant <n> [--reason "…"] · seisin deny <n> [--reason "…"]
30
+ ```
31
+
32
+ **Whose it was, at the moment it was refused.** Every other permission layer in this space answers *yes* or *no*. Answering **"no, and it belongs to `backend`"** turns a block into a handoff — and one a person can approve in a command, rather than a line somebody has to remember to go and read.
33
+
34
+ The kernel is what refuses; the name comes from the policy. That first line is all the boundary itself can say — no path, no reason, nothing to read afterwards — so seisin reads the refusal out of the kernel's own log and answers the question it leaves open. On Linux only the hook can do that; [the ask is upstream](docs/upstream/cli-violations.md).
35
+
36
+ The agent can ask directly too, from inside the box:
37
+
38
+ ```
39
+ $ seisin run frontend -- seisin whose src/api/orders.ts
40
+ src/api/orders.ts belongs to backend
41
+ you are frontend. Hand it over rather than working around it.
42
+ ```
43
+
44
+ ## Sixty seconds
45
+
46
+ ```bash
47
+ npm install -g seisin
48
+ cd your-repo
49
+ seisin init # proposes a policy from what the repo already says
50
+ seisin check # read it before you trust it
51
+ seisin wire # let the agent record what it does
52
+ seisin run frontend -- claude -p "…" # run an agent inside its own territory
53
+ ```
54
+
55
+ On Linux, [three system packages first](#install). Everything below is why it works and where it does not.
56
+
57
+ **Contents** · [Why this exists](#why-this-exists) · [What it is *not* for](#what-it-is-for-and-what-it-is-not-for) · [Install](#install) · [Configure](#configure) · [Agents it runs](#which-agents-it-has-been-run-with) · [Requests](#when-it-says-no-it-leaves-a-request-behind) · [MCP](#ask-your-own-assistant) · [Why not a container](#why-not-a-container) · [How it holds](#how-it-holds) · [Secrets](#how-it-protects-secrets) · [Decisions](docs/decisions.md) · [What it survived](docs/what-it-has-been-put-through.md) · [Status](#status)
58
+
59
+ ---
60
+
61
+ ## Why this exists
62
+
63
+ Two things are true about agent permissions today, and both are in the official docs.
64
+
65
+ **1. Permission rules match text, not behaviour.** From the Claude Code documentation:
66
+
67
+ > Calls matching `rm *` **as written** are denied […] Other `Bash` calls, **including `/bin/rm`**, fall through to the permission mode.
68
+
69
+ A rule is a string comparison against a command someone might not spell that way. A child process does not spell it at all.
70
+
71
+ **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?"**
72
+
73
+ `seisin` answers the second question and hands the first one to the operating system.
74
+
75
+ ```
76
+ ONE GLOBAL POLICY WHOSE IS IT?
77
+
78
+ frontend ──┐ frontend ──► src/web/** ok
79
+ ├──► may this be written? backend ──► src/api/** ok
80
+ backend ──┘ yes
81
+ frontend ──► src/api/** no
82
+ two agents, one answer, belongs to backend
83
+ the whole repo, all the time → hand it over, do not work around it
84
+ ```
85
+
86
+ ## What it is for, and what it is not for
87
+
88
+ > **It turns an agent's mistake into a contained mistake. It does not turn a hostile
89
+ > agent into a harmless one.**
90
+ >
91
+ > Two outside reviews have gone through it looking for ways past the boundary and
92
+ > found eleven things between them. [What it has been put
93
+ > through](docs/what-it-has-been-put-through.md) lists all of them — what was
94
+ > fixed, what was measured, and what is still open.
95
+
96
+ That distinction is the whole threat model, and it is worth reading before you install
97
+ rather than after.
98
+
99
+ **It holds against an agent that is wrong.** One that edits the file it was not asked
100
+ about, reads the key meant for another role, or calls an API nobody declared. The kernel
101
+ refuses, and it does not care how the command was spelled — verified against a grandchild
102
+ process, an absolute path, a symlink, a `python -c`, and a shell redirect the agent fell
103
+ back to when its own file API was refused.
104
+
105
+ **It is not built to hold against an agent that is trying.** Measured, in this repo's own
106
+ fixtures:
107
+
108
+ | | |
109
+ |---|---|
110
+ | 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 |
111
+ | it can write anything inside its own territory | including staging an exfiltration, or destroying its own uncommitted work with `rm` |
112
+ | `base64` defeats the redaction | only the literal value is masked, and only on stdout and stderr |
113
+ | an allowed domain is an allowed channel | if the agent's own API is reachable, so is everything that goes through it |
114
+ | 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 |
115
+
116
+ And three things that outweigh all of the above:
117
+
118
+ 1. **It is days old and has one author.** Around three thousand lines that nobody has
119
+ audited except its writer and one field report. "Secure" is not a word earned that fast.
120
+ 2. **The enforcement is someone else's beta.** `sandbox-runtime` describes itself as a
121
+ research preview with an evolving API. A hole there is a hole here.
122
+ 3. **Two platforms tested, by one person, on one machine each.**
123
+
124
+ For several of your own agents, on your own machine, getting in each other's way — that is
125
+ what this is for, and it is a great deal better than nothing. For putting an agent you do
126
+ not control in front of data you care about, it is not.
127
+
128
+ ## Install
129
+
130
+ ```bash
131
+ npm install -g seisin
132
+ ```
133
+
134
+ 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**.
135
+
136
+ **On Linux you also need three system packages** — the runtime refuses to start without
137
+ them rather than running unconfined, which is the right failure and an unhelpful message:
138
+
139
+ ```bash
140
+ apt install bubblewrap ripgrep socat # or your distro's equivalent
141
+ ```
142
+
143
+ **On Ubuntu 24.04 and newer**, unprivileged user namespaces are off by default and
144
+ bubblewrap needs one. Every run dies with `bwrap: No permissions to create new
145
+ namespace`:
146
+
147
+ ```bash
148
+ sudo sysctl -w kernel.apparmor_restrict_unprivileged_userns=0
149
+ ```
150
+
151
+ Worth knowing what this looks like when you hit it: seisin appears to deny
152
+ *everything*, because the sandbox never starts. It is not a policy problem and
153
+ no amount of editing `seisin.toml` will move it.
154
+
155
+ **Inside a container** bubblewrap needs to create namespaces and mount `/proc`, which a
156
+ default Docker container forbids. `--cap-add SYS_ADMIN --security-opt seccomp=unconfined`
157
+ gets namespaces; mounting `/proc` needs `--privileged`. If you cannot grant that, run
158
+ seisin on the host and let the container be what it sandboxes.
159
+
160
+ ## Use
161
+
162
+ ```bash
163
+ seisin init # proposes a seisin.toml for this repo
164
+ seisin check # print the map, run nothing
165
+ seisin run frontend -- claude -p "…" # run an agent as that role
166
+ seisin explain frontend write src/api/x # ask one question, exit 0 or 1
167
+ seisin review # what the log says about the policy
168
+ seisin wire # install the PreToolUse hook in this repo
169
+ seisin ui # a console for editing the map
170
+ ```
171
+
172
+ ## Configure
173
+
174
+ One file at the root of your repo. Anything not listed is denied — there is no permissive default.
175
+
176
+ ```toml
177
+ [keys]
178
+ dir = ".secrets" # every key lives here; roles name the files they may read
179
+
180
+ [network]
181
+ allow = ["github.com", "*.github.com"]
182
+
183
+ [roles.frontend]
184
+ writes = ["src/web/**", "public/**"]
185
+ keys = ["netlify-token.txt"]
186
+
187
+ [roles.backend]
188
+ writes = ["src/api/**", "migrations/**"]
189
+ keys = ["database-url.txt", "sentry-dsn.txt"]
190
+ network = ["api.stripe.com"] # this role only — it replaces [network] rather than adding
191
+ ```
192
+
193
+ `[network] allow` is the fallback for roles that do not name their own. A role that needs one
194
+ extra host should say so on its own line: a domain in the global list is reachable by every
195
+ role, including the ones whose whole job is to have nothing to reach.
196
+
197
+ `seisin init` will propose this from whatever your repo already says: `.claude/agents/`, then `CODEOWNERS`, then a blank start. It **proposes** — a generated policy you did not read is not a policy.
198
+
199
+ ## Which agents it has been run with
200
+
201
+ seisin wraps a process, so in principle it works with any agent that runs as one — CLI or not. In
202
+ practice "in principle" is not a claim worth making about a permission tool, so here is what has
203
+ actually been exercised:
204
+
205
+ | agent | version | result |
206
+ |---|---|---|
207
+ | **Claude Code** (`claude -p`) | 2.1.x | Territory and keys enforced; network egress refused an undeclared domain by name |
208
+ | **opencode** (`opencode run`) | **1.18.30** | Same, **on a free model with no API key at all** |
209
+ | **LangGraph** (`python graph.py`) | **1.2.11** | Same, **enforced against the interpreter's own `open()`** — in-process tools, no child command to match |
210
+ | **codex** (`codex exec`) | **0.150.1** | Same, with its own sandbox off — see below. Refused twice: its patch tool, then the shell redirect it fell back to |
211
+
212
+ The codex run is the one that needed a flag. **An agent that sandboxes itself has to stop.** `codex` confines each command it runs with its
213
+ own `sandbox-exec` profile, and macOS refuses to apply a second Seatbelt profile to a process
214
+ that already has one — `sandbox_apply: Operation not permitted`, with the most permissive
215
+ profile that can be written, on both layers. So an agent like that runs under seisin with its
216
+ own sandbox turned off — `codex exec --dangerously-bypass-approvals-and-sandbox`, whose own
217
+ help says it is "intended solely for running in environments that are externally sandboxed".
218
+ It starts fine either way (`codex 0.150.1`, `gemini 0.57.0`, both verified), which is why this
219
+ is worth saying out loud: the failure arrives later, on the first command the agent tries to
220
+ confine.
221
+
222
+ Asked to write into another role's territory, codex failed through its patch tool, went looking
223
+ for a file ACL with `ls -le@` and `stat`, found nothing, and fell back to a shell redirect that
224
+ failed too:
225
+
226
+ ```
227
+ /bin/zsh -lc "printf 'pisado\n' > qa/informe.md"
228
+ exited 1: zsh:1: operation not permitted
229
+ ```
230
+
231
+ Same shape as opencode, including the wrong diagnosis — it reasoned about file permissions,
232
+ not about ownership. That is the case for the hook: the boundary holds either way, but only the
233
+ hook can say *whose* it was.
234
+
235
+ All four on macOS 15 (Seatbelt). Linux is no longer a one-off measurement on one machine:
236
+ [CI](.github/workflows/test.yml) runs the whole suite on Ubuntu and macOS, Node 18/20/22,
237
+ on every push — and fails if the sandbox half *skips*.
238
+
239
+ The opencode run is the interesting one, because **opencode has no per-path sandbox flag** —
240
+ its only permission control is `--auto`, "auto-approve permissions that are not explicitly
241
+ denied". Asked to write into another role's territory it failed twice: once through its own
242
+ `FileSystem.writeFile`, and again through the shell redirect it fell back to.
243
+
244
+ ```
245
+ Error: Unknown: FileSystem.writeFile (.../qa/informe.md)
246
+ $ echo "revisado" > .../qa/informe.md
247
+ zsh:1: operation not permitted
248
+ ```
249
+
250
+ That is the point of putting the boundary in the kernel instead of in the agent: **an agent
251
+ that cannot confine itself is confined anyway**, and adding a new one costs no adapter.
252
+
253
+ It also found a real bug. The scratch list named `~/.claude` and `~/.codex` and stopped there,
254
+ so the first agent that was neither did not fail a task — it failed to start, on its own log
255
+ file. A boundary that only fits the agents its author happened to use is a coincidence, not a
256
+ boundary. The XDG directories are in the list now.
257
+
258
+ The LangGraph run answers a different question: **an agent framework is not a CLI.** Its tools
259
+ are Python calls inside the same process, so there is no child command for a rule to match and no
260
+ argv to inspect — the write is the interpreter's own `open()`. A six-node graph asked to write into
261
+ another role's territory was refused there, refused again through the shell redirect it fell back
262
+ to, refused the other role's key, and refused an undeclared domain. Run as the other role, the same
263
+ graph gave the mirror image — every territory and key answer flipped, the undeclared domain refused
264
+ for both — and its SQLite checkpointer persisted normally inside whichever territory was its own.
265
+
266
+ One caveat belongs beside that row rather than after it: **a role is scoped to a process, and a
267
+ graph is one process.** Every node of one graph shares one territory and one set of keys. Per-node
268
+ territory means per-node subprocess, which is the thing an in-process framework exists to avoid.
269
+ seisin fits a multi-agent framework at the boundary of the whole graph, not between its agents.
270
+
271
+ And one failure mode worth meeting here rather than at 2am: SQLite reports a refused write as
272
+ `attempt to write a readonly database`, not as a permission error. That reads like a misconfigured
273
+ database, and you will debug the database. The hook names the owner instead — it is the case
274
+ `seisin wire` is for.
275
+
276
+ **Re-verified without an agent in the loop**, which is better evidence: an agent in the middle
277
+ makes a permission test non-deterministic — in that first run the agent reported the opposite
278
+ of what it had actually done. Wrapping a plain shell and a plain interpreter instead:
279
+
280
+ | as `dev`, with `qa/**` owned by `qa` | result |
281
+ |---|---|
282
+ | `sh -c 'echo x > proyecto/FILE.txt'` (own territory) | rc=0, created |
283
+ | `sh -c 'echo x > qa/report.md'` (shell redirect) | `Operation not permitted`, rc=1 |
284
+ | `python3 -c "open('qa/report.md','w')"` (the program's own syscall) | `PermissionError: [Errno 1]` |
285
+ | `rm -f proyecto/FILE.txt` (**destructive, inside** own territory) | **rc=0, file gone** |
286
+
287
+ The last row is not a bug, it is the shape of the tool: seisin answers *where*, not *what*.
288
+ A team running this in front of an agent fleet measured how often it matters: across ~330
289
+ rounds, sixty-six times a role reached for a command that would have destroyed its own
290
+ uncommitted work, and the kernel boundary permitted every one. The ask that would close it
291
+ is written up in [docs/upstream/denyUnlink.md](docs/upstream/denyUnlink.md), filed against
292
+ the sandbox runtime rather than worked around here.
293
+
294
+ If you would rather see it than read it, [thirty seconds in a temp
295
+ directory](docs/demo/) — an agent confined to its own project deletes the git history of the
296
+ repo it is working in, and the `.git/` left behind looks intact. Nothing installed, seisin not
297
+ used, because the gap is the runtime's and every tool on top of it inherits the same one.
298
+
299
+ ## What the log says about the policy
300
+
301
+ `check` reads the config and tells you what it would do. `review` reads what
302
+ actually happened and tells you where the config was wrong about it.
303
+
304
+ ```
305
+ $ seisin review
306
+
307
+ 60 decisions, 2026-08-23 to 2026-09-11
308
+
309
+ Stopped, repeatedly
310
+ 9× frontend write src/api/checkout — belongs to backend
311
+ 4× backend write src/web — belongs to frontend
312
+
313
+ Granted, never used
314
+ frontend public/**
315
+ backend migrations/**
316
+
317
+ Owned by nobody
318
+ 5× legacy
319
+ ```
320
+
321
+ Three questions, and the middle one is the reason this exists. **Every
322
+ permission file only ever grows**, in every system, and always for the same
323
+ reason: nobody can prove a line is dead, so the safe move is to leave it. Here
324
+ the log can prove it — `public/**` was granted and nothing was written there in
325
+ three weeks. That is the only direction that makes a policy *smaller*.
326
+
327
+ The first is the one that changes how a denial reads. Forty blocks on one
328
+ directory is not an agent misbehaving; it is a policy that is wrong, and until
329
+ you add them up it looks like forty tidy amber lines.
330
+
331
+ It is arithmetic over the log. No model, no network, no heuristics — the
332
+ argument this whole tool makes is that interpreting text is the wrong way to
333
+ decide things, and a component that reads the log and forms an opinion would
334
+ contradict it on the way in. Every finding carries the window it was computed
335
+ over, because *never used* means nothing without *in how long*.
336
+
337
+ ## Where the first policy comes from
338
+
339
+ Every permission tool dodges this question. Written by hand, the first policy is a guess, and
340
+ the first unjustified denial is when the tool gets uninstalled. So don't write it — watch, then
341
+ write:
342
+
343
+ ```bash
344
+ seisin run frontend --observe -- claude -p "…" # records, denies nothing
345
+ seisin init --from-observations # writes seisin.toml.observed
346
+ ```
347
+
348
+ It lands as `.observed`, not as your config. A policy generated behind your back is not a
349
+ policy: diff it, then move it.
350
+
351
+ ## When it says no, it leaves a request behind
352
+
353
+ A permission tool that can only say no is a tool people uninstall. The loop —
354
+ denied, stop, go edit a config, run again — is three context switches for one
355
+ line of policy, and the cheapest way to make it stop is to widen the policy
356
+ generously and never look again. That is how a permission file becomes seven
357
+ hundred entries nobody can read.
358
+
359
+ So a denial leaves something you can act on:
360
+
361
+ ```
362
+ frontend write src/api/orders.ts
363
+ denied — src/api/orders.ts belongs to backend
364
+
365
+ 1 pending request(s)
366
+
367
+ #1 frontend wants write on src/api/** (owned by backend) · asked 3×
368
+ first asked over src/api/orders.ts
369
+
370
+ seisin grant 1 --reason "…" · seisin deny 1 --reason "…"
371
+ ```
372
+
373
+ Many refusals in one directory are **one** request, not many — an agent denied
374
+ on `a.ts` and then on `b.ts` is not asking two questions. And the grant records
375
+ where it came from, next to the line it adds:
376
+
377
+ ```toml
378
+ [roles.frontend]
379
+ writes = [
380
+ "src/web/**",
381
+ "src/api/**" # granted 2026-09-12 · asked 3× · "frontend owns checkout now"
382
+ ]
383
+ ```
384
+
385
+ ```
386
+ frontend denied on src/api/orders.ts
387
+ │
388
+ ▼
389
+ request #1 ◄──── denied again on b.ts ──── same request, asked 2×
390
+ │
391
+ │ nothing inside the box moves it from here
392
+ ▼
393
+ ┌───────┴────────┐
394
+ ▼ ▼
395
+ seisin grant 1 seisin deny 1
396
+ │ ▼
397
+ │ policy unchanged,
398
+ ▼ the reason recorded
399
+ one line added to seisin.toml,
400
+ carrying where it came from
401
+ ```
402
+
403
+ Without that, a policy is a list of permissions with no history, and the only
404
+ safe thing to do with a line nobody remembers is leave it there.
405
+
406
+ **Approving is deliberately not a tool call.** An agent — or an MCP server on
407
+ your behalf — can read the queue and draft the change. Turning it into policy
408
+ takes a person in a channel the agent does not have. The public API reflects
409
+ that: `pendingRequests` is exported, the functions that approve are not.
410
+ [The reasoning is written down](docs/permission-requests.md).
411
+
412
+ ![the console: a pending request, a reason, and the territory changing when it is approved](docs/img/console.gif)
413
+
414
+ Two places, both human: `seisin grant <n>` in a terminal, or the console, where
415
+ the queue is a panel with a reason field and two buttons. Approving there edits
416
+ your `seisin.toml` in place and the territory on screen changes with it.
417
+
418
+ The console is allowed to hold that half and the MCP server is not, and the
419
+ reason is measured rather than asserted: a confined role curling the console's
420
+ own port gets the same nothing it gets from a domain outside its allowlist. The
421
+ MCP server, by contrast, speaks on the agent's own stdio.
422
+
423
+ The notice rides on what you are already looking at: `seisin run` prints the
424
+ queue when the run ends, in the same terminal that just showed you the denial.
425
+ There is no daemon and nothing to leave running.
426
+
427
+ ## Ask your own assistant
428
+
429
+ ```jsonc
430
+ { "mcpServers": { "seisin": { "command": "seisin", "args": ["mcp"] } } }
431
+ ```
432
+
433
+ > *who owns src/api, and what is frontend waiting on?*
434
+
435
+ Read-only **by construction** — the server opens nothing for writing, and a test
436
+ asserts that no tool in it mutates anything. It can draft the exact change a
437
+ request would make; applying it is a command a person runs. Zero dependencies:
438
+ the official SDK wanted 91 packages for a server that speaks JSON on two file
439
+ descriptors.
440
+
441
+ ## Seeing what happened
442
+
443
+ ```bash
444
+ seisin log --verdict denied # what the agents tried and could not do
445
+ seisin watch # follow it live
446
+ ```
447
+
448
+ ```
449
+ 04:13:26 allowed frontend write src/web/app.ts
450
+ 04:13:26 denied frontend write src/api/server.ts → backend
451
+ 04:13:27 denied frontend read .secrets/database.txt → backend
452
+ ```
453
+
454
+ One append-only JSONL under `.seisin/`, and that is the whole storage design — no daemon, no
455
+ database. `watch` is a tail. The file is the shared state, so anything that can read it can
456
+ watch it.
457
+
458
+ **The agent cannot reach that file.** `.seisin/` is not in any role's territory, because a
459
+ record the recorded process can edit is not a record. The hook sends each line to `seisin run`
460
+ over a unix socket granted by path, and the parent does the writing — so the only verb
461
+ available from inside the box is *append one line*: no seek, no truncate, no unlink. What a
462
+ hostile agent can still do is send lines, so it can add noise to its own history. What it can
463
+ no longer do is change what is already there.
464
+
465
+ **Two writers, and they see different things.**
466
+
467
+ The **hook** reports the attempt before it happens, which is how an allowed action
468
+ gets recorded at all. It has to be installed: `seisin wire` writes the
469
+ `PreToolUse` entry into this repo's `.claude/settings.json` — the project's,
470
+ never your machine's. It is also text-based and evadable, on purpose.
471
+
472
+ The **kernel** reports what it actually refused. On macOS every Seatbelt denial
473
+ lands in the system log with its operation, its absolute path and the runtime's
474
+ own attribution tag, and `seisin run` reads that stream for the duration of the
475
+ run. Nothing to install, nothing to evade — and it catches exactly what the hook
476
+ missed:
477
+
478
+ ```
479
+ $ seisin run frontend -- sh -c 'echo // fix >> src/api/orders.ts'
480
+ sh: src/api/orders.ts: Operation not permitted
481
+ seisin: 1 kernel denial(s) recorded
482
+
483
+ 1 pending request(s)
484
+ #1 frontend wants write on src/api/** (owned by backend)
485
+ first asked over src/api/orders.ts
486
+ ```
487
+
488
+ That is with no hook installed. Before this, the same run left `Operation not
489
+ permitted` on the terminal and an empty log — which is how four real defects in
490
+ production each cost a night to find: every one of them looked like a file that
491
+ had quietly stopped growing.
492
+
493
+ Skip `wire` and the boundary holds exactly as well, denials are still recorded
494
+ and still queue a request; what you lose is the record of what was *allowed*,
495
+ which is what `init --from-observations` is built out of. `seisin check` says so
496
+ until you run it.
497
+
498
+ **On Linux only the hook writes.** bubblewrap does not log refusals and the
499
+ runtime's substitute is not readable from outside it, so `seisin run` says so
500
+ once and records nothing from the kernel. [The ask is
501
+ upstream](docs/upstream/cli-violations.md).
502
+
503
+ ## How it holds
504
+
505
+ | layer | what it does | can it be talked around? |
506
+ |---|---|---|
507
+ | your agent's `allow`/`deny` rules | match the command string before it runs | yes — `/bin/rm`, a heredoc, a child process |
508
+ | **seisin** | decides what to ask for, and names the owner | it doesn't enforce, it explains |
509
+ | **sandbox-runtime → Seatbelt / bubblewrap** | the OS refuses the syscall | no |
510
+
511
+ ```
512
+ Write tool · Bash `rm -rf` · /bin/rm · python -c "open(…)" · a grandchild
513
+ │
514
+ │ however it was spelled, it arrives as one syscall
515
+ ▼
516
+ KERNEL
517
+ │
518
+ │ against the policy `seisin run` installed
519
+ ▼
520
+ inside this role's territory?
521
+ │ │
522
+ yes no
523
+ │ │
524
+ ▼ ▼
525
+ done Operation not permitted
526
+ │
527
+ │ and, on a separate path, the hook
528
+ ▼
529
+ "belongs to backend"
530
+ explanation — never the boundary
531
+ ```
532
+
533
+ 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.
534
+
535
+ Verified by the test suite, which runs real commands in a real sandbox:
536
+
537
+ ```
538
+ ✔ a role reads the key it declares
539
+ ✔ a role cannot read another role's key
540
+ ✔ each role reads its own, so the deny is not just blanket
541
+ ✔ a role writes inside its territory
542
+ ✔ a role cannot write outside it
543
+ ✔ reading another role's code still works
544
+ ✔ the boundary survives a grandchild process
545
+ ✔ an absolute path does not walk around the rule
546
+ ```
547
+
548
+ ## How it protects secrets
549
+
550
+ Four ways out of a process, and seisin closes three of them. The fourth is named below,
551
+ because a security tool that lists only its wins is not one.
552
+
553
+ | way out | closed by | how |
554
+ |---|---|---|
555
+ | reading another role's key file | the kernel | the key directory is denied, each declared key re-allowed. Survives a grandchild process and an absolute path |
556
+ | a key riding in the environment | the launcher | the child's environment is **built, not inherited**. Measured before this existed: 93 variables crossed into every turn, a planted token among them |
557
+ | spilling a key the role does hold | the launcher | the value is masked on stdout and stderr, including when it lands split across two buffers |
558
+ | sending it somewhere | the kernel | egress is allow-only. `curl` to a domain you did not list gets nothing |
559
+ | **a key stored outside the declared directories** | **nothing** | `seisin scan` finds them so you know what is not covered |
560
+
561
+ ```toml
562
+ [keys]
563
+ dir = [".secrets", "config/credentials"] # one directory or several
564
+
565
+ [roles.frontend]
566
+ keys = ["netlify-token.txt"] # everything else in those directories is denied
567
+ env = ["BUILD_ID"] # everything else in the environment is dropped
568
+ ```
569
+
570
+ **If your agent runs hooks of its own, they need a line here too.** Whatever a hook reads to learn which role it is gets dropped with everything else, and the symptom is two layers disagreeing about one file — your hook refusing a write that seisin just allowed, and the lower one is the one that is right — until the variable is named in `env`.
571
+
572
+ **Two things this deliberately does not claim.** Redaction masks a literal value on the way
573
+ through the launcher — a key written straight to a file never passes through it, and neither
574
+ does one the agent base64'd first. It narrows a careless print; it is not a containment
575
+ boundary. And to mask a value seisin must read it, so a role's own keys pass through this
576
+ process in memory. `[runtime] redact = false` turns that off.
577
+
578
+ ## Scratch space, and what it costs
579
+
580
+ Territory alone is correct and unusable. An agent writes session state under its own config
581
+ directory and its tools write to the temp dir, so a policy of *your folders and nothing else*
582
+ stops the agent before it starts. Every role therefore also gets:
583
+
584
+ ```toml
585
+ [runtime]
586
+ # the default, in full — `~/.local/{share,state}` is where an agent that is neither
587
+ # claude nor codex keeps its log, and one that cannot write it does not fail a task,
588
+ # it fails to start
589
+ writes = ["~/.claude", "~/.codex", "~/.local/share", "~/.local/state",
590
+ "~/.cache", "$TMPDIR", "/tmp"]
591
+ ```
592
+
593
+ **Writing this key replaces that list; it does not add to it.** So a `[runtime] writes` copied
594
+ from somewhere to grant one extra path silently drops the six it did not mention. Start from the
595
+ list above and append.
596
+
597
+ Two things follow, and both are the kind of thing you want to hear from the tool rather than
598
+ discover:
599
+
600
+ - **Scratch is shared.** Every role can write it, so two agents can reach each other's temp
601
+ files. If your repo lives inside the temp dir, territory does not hold at all — `seisin check`
602
+ says so out loud when it detects that.
603
+ - **The home directory itself is never granted.** Only those named subdirectories. A grant that
604
+ reached `~` would hand over the shell profile, the ssh config, and every dotfile with a token
605
+ in it. There is a test that fails if that ever changes.
606
+ - **Reading your home is a different question, and by default it is open.** None of the above
607
+ stops a role from *reading* `~/.ssh`, `~/.aws/credentials`, `~/.npmrc` or
608
+ `~/.config/gh/hosts.yml`. That is the deliberate trade in the table above — an agent that
609
+ cannot read the machine cannot work — and `seisin check` now says it on every run rather than
610
+ leaving you to find out.
611
+
612
+ Set `writes = []` to opt out and find out why it is there.
613
+
614
+ ### Closing your credentials, without signing the agent out
615
+
616
+ ```toml
617
+ [runtime]
618
+ isolate = "credentials" # ~/.ssh, ~/.aws, ~/.npmrc, ~/.config and the rest go dark
619
+ ```
620
+
621
+ Measured, on the same machine, same policy, same command:
622
+
623
+ | | `~/.ssh` `~/.aws` `~/.npmrc` `~/.config/gh` | `~/.claude` | `claude -p` |
624
+ |---|---|---|---|
625
+ | default | open | open | **OK** |
626
+ | `isolate = "credentials"` | **closed** | open | **OK** |
627
+ | `isolate = "home"` | **closed** | closed | `Not logged in` |
628
+
629
+ `"home"` also gives each role its own `HOME`, `TMPDIR` and XDG directories, so one role cannot
630
+ read the session another's CLI just wrote. It is the stronger claim and it has a real price:
631
+ **every CLI in the box sees an empty home and asks you to log in again.** That is not this
632
+ sandbox being strict — on macOS the agent's credential is in the login keychain, the keychain is
633
+ found through `$HOME`, and `HOME=/empty claude -p` reproduces the same message with no sandbox
634
+ involved at all.
635
+
636
+ Which of the two you want is your threat model, so neither is a default. `"credentials"` is the
637
+ one you can turn on today on a machine you are already working on.
638
+
639
+ ## What it is not
640
+
641
+ - **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.
642
+ - **Not an orchestrator.** It does not run your agents, schedule them, or merge their work. It runs one command as one role.
643
+ - **Not a secret manager.** It scopes *reads* of files you already have. Where those files come from is your problem.
644
+ - **Not what a single agent needs first.** If only one agent ever touches the repo,
645
+ *whose file is this* has one answer, and the ownership half of this tool is dead
646
+ weight. Turn on your agent's own sandboxing instead — Claude Code has it built in,
647
+ over this same runtime, with nothing to install.
648
+
649
+ What is left over for one agent is real but narrow: the environment gets built
650
+ rather than inherited (measured on one machine: **15 variables kept, 60 dropped**,
651
+ among them `GITHUB_TOKEN` and the AWS pair), reads of the declared key directory
652
+ are denied to everything, and the network allowlist covers the whole process
653
+ rather than one tool. Worth it if that is a gap you actually have. seisin starts
654
+ earning its setup at **two** agents, and that is where it was designed to be.
655
+
656
+ ## Why not a container
657
+
658
+ A fair question, and the honest answer is that a container is *more* isolation
659
+ than this. If what you need is to run something you do not trust at all, run it
660
+ in a VM — [Kaiden](https://openkaiden.ai), [Containarium](https://containarium.dev)
661
+ and a growing shelf of others do that well, and this does not try to.
662
+
663
+ They answer **"can this process reach the host?"** seisin answers a different
664
+ question:
665
+
666
+ ```
667
+ container / microVM one box, one boundary, everything inside it is equal
668
+ seisin one repo, several roles, and a boundary between them
669
+ ```
670
+
671
+ Put two agents in one container and they are back where they started: the same
672
+ files, the same keys, no answer to *whose is it*. That question is not about
673
+ isolation strength at all — it is about ownership, and it is the one thing an
674
+ operating system cannot know for you.
675
+
676
+ Which is also why this is thin. Enforcement is
677
+ [`@anthropic-ai/sandbox-runtime`](https://github.com/anthropics/sandbox-runtime)
678
+ asking the kernel; seisin is the layer that decides what to ask for and says
679
+ whose file it was when the answer is no. The two compose — put seisin inside a
680
+ container if you want both, and the roles still hold.
681
+
682
+ | | what it answers |
683
+ |---|---|
684
+ | VM / microVM | can this reach the host |
685
+ | container | can this reach the host, cheaply |
686
+ | **seisin** | **whose file is this, and may this role change it** |
687
+
688
+ ## Prior art
689
+
690
+ This space already has good work, and seisin is not the first thing here:
691
+
692
+ - [`anthropics/sandbox-runtime`](https://github.com/anthropics/sandbox-runtime) — the enforcement. seisin is a thin thing on top of a serious one.
693
+ - [`kornysietsma/claude-code-permissions-hook`](https://github.com/kornysietsma/claude-code-permissions-hook) — granular `PreToolUse` rules, one global policy.
694
+ - [`XuebinMa/agent-guard`](https://github.com/XuebinMa/agent-guard) — a permission-enforcement SDK, also one global policy.
695
+ - *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.
696
+
697
+ ## Status
698
+
699
+ `0.1.0`, 225 tests, of which **18 need `@anthropic-ai/sandbox-runtime` installed**
700
+ and run real commands through the real kernel — and CI fails if the sandbox half *skips*, because
701
+ a green run that quietly tested nothing looks exactly like a real one. That is not hypothetical:
702
+ those eighteen skipped on Linux for a day, behind a runtime check that looked for the global
703
+ install and missed the bundled one, and hid a defect that broke `seisin run` on that platform
704
+ entirely. **225/225 on macOS 15 and on Debian 12.15 with bubblewrap 0.8.0**, nothing skipped on
705
+ either, plus Node 18/20/22 in CI.
706
+ [Which claim was measured where](docs/what-it-has-been-put-through.md#where-each-claim-was-actually-run).
707
+ The config format may still move before `1.0` — if it does, `seisin check` will
708
+ tell you what changed.
709
+
710
+ **What is genuinely open**, so nobody spends an afternoon on something that is
711
+ already done:
712
+
713
+ | | |
714
+ |---|---|
715
+ | **Windows** | the runtime has a backend. seisin has never been pointed at it, and no CI runner covers it |
716
+ | **Deleting inside your own territory** | not covered, and not coverable here — [the ask is upstream](docs/upstream/denyUnlink.md), with the measurement behind it and [a demo](docs/demo/) |
717
+ | **`init` heuristics** | it reads `.claude/agents/` then `CODEOWNERS`. Every other convention is a guess nobody has made yet |
718
+
719
+ Issues and pull requests welcome. If you are reporting something that got past
720
+ the boundary, `seisin log --verdict denied` and the `seisin.toml` are the two
721
+ things that make it reproducible.
722
+
723
+ ## The name
724
+
725
+ A deed says who owns land. **Seisin** is the older idea underneath: who is actually standing on it.
726
+
727
+ That is the question this tool answers. Not *may this be written* — the kernel settles that — but
728
+ *whose is it*, which is the one that tells an agent what to do next.
729
+
730
+ ## License
731
+
732
+ MIT