seisin 0.1.1 → 0.3.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 +287 -339
- package/package.json +7 -6
- package/src/cli.js +17 -3
- package/src/commands/explain.js +9 -2
- package/src/commands/hook.js +11 -0
- package/src/commands/init.js +1 -1
- package/src/commands/log.js +27 -1
- package/src/commands/requests.js +8 -3
- package/src/commands/review.js +33 -5
- package/src/commands/run.js +180 -151
- package/src/commands/ui.js +16 -2
- package/src/commands/walls.js +30 -0
- package/src/commands/whose.js +2 -1
- package/src/commands/wire.js +30 -5
- package/src/config.js +310 -5
- package/src/diagnose.js +172 -0
- package/src/env.js +54 -1
- package/src/grants.js +239 -0
- package/src/hook.js +45 -5
- package/src/index.js +12 -0
- package/src/inspect.js +332 -9
- package/src/intake.js +215 -0
- package/src/keys.js +585 -0
- package/src/log.js +96 -2
- package/src/mcp.js +87 -11
- package/src/nested.js +86 -0
- package/src/notify.js +146 -0
- package/src/owners.js +208 -6
- package/src/paths.js +50 -0
- package/src/render.js +63 -1
- package/src/requests.js +88 -0
- package/src/review.js +9 -4
- package/src/rundir.js +113 -0
- package/src/serve.js +263 -19
- package/src/spool.js +98 -20
- package/src/srt.js +148 -203
- package/src/surface.js +518 -0
- package/src/violations.js +37 -5
- package/src/walls.js +184 -0
- package/ui/favicon.svg +1 -1
- package/ui/index.html +585 -56
package/README.md
CHANGED
|
@@ -1,11 +1,14 @@
|
|
|
1
1
|
# seisin
|
|
2
2
|
|
|
3
|
-
[](https://github.com/carlostapiacl/seisin/actions/workflows/test.yml)
|
|
4
|
+
[](https://scorecard.dev/viewer/?uri=github.com/carlostapiacl/seisin)
|
|
4
5
|
[](https://www.npmjs.com/package/seisin)
|
|
5
6
|
[](LICENSE)
|
|
6
7
|
[](package.json)
|
|
7
8
|
[](package.json)
|
|
8
9
|
|
|
10
|
+
**[The short version, with the demos playing →](https://carlostapiacl.github.io/seisin/)** · this page is the long one.
|
|
11
|
+
|
|
9
12
|
> **seisin** *(n.)* — the legal possession of a piece of land. Not who owns it on paper: who holds it now.
|
|
10
13
|
|
|
11
14
|
**Give each AI agent its own folders and its own keys.** The kernel enforces it, and when it blocks something it tells you *whose* file it was.
|
|
@@ -14,6 +17,51 @@ A **permission layer**, not a sandbox — it sits on top of one. The isolation c
|
|
|
14
17
|
|
|
15
18
|
*Two outside reviews went looking for ways past the boundary. [What they found, what broke, and what is still open →](docs/what-it-has-been-put-through.md)*
|
|
16
19
|
|
|
20
|
+
*And what running four cells of agents behind it actually cost — including the numbers that did not survive a re-check. [Field notes →](docs/field-notes.md)*
|
|
21
|
+
|
|
22
|
+
**Three ways in, and they say the same thing.** The **CLI** is what a person types and what
|
|
23
|
+
every agent runs under. The **console** (`seisin ui`) is for a person deciding something. The
|
|
24
|
+
**MCP server** is for an agent asking about its own situation. A change to what any of them
|
|
25
|
+
says lands in all three — [CONTRIBUTING](CONTRIBUTING.md#three-surfaces-and-a-change-lands-in-all-of-them)
|
|
26
|
+
says why, and a test holds one half of it.
|
|
27
|
+
|
|
28
|
+
<details>
|
|
29
|
+
<summary><b>Contents</b></summary>
|
|
30
|
+
|
|
31
|
+
**Start here** · [Sixty seconds](#sixty-seconds) · [Why this exists](#why-this-exists) · [What it is for, and what it is not for](#what-it-is-for-and-what-it-is-not-for) · [Install](#install) · [Use](#use) · [Configure](#configure)
|
|
32
|
+
|
|
33
|
+
**Living with it** · [Day one](#what-will-look-like-a-bug-on-the-first-day) · [Which agents it has been run with](#which-agents-it-has-been-run-with) · [Where the first policy comes from](#where-the-first-policy-comes-from) · [When it says no, it leaves a request behind](#when-it-says-no-it-leaves-a-request-behind) · [When the boundary is right and the agent cannot hear it](#when-the-boundary-is-right-and-the-agent-cannot-hear-it) · [Scratch space, and what it costs](#scratch-space-and-what-it-costs)
|
|
34
|
+
|
|
35
|
+
**Secrets** · [How it protects secrets](#how-it-protects-secrets) · [Keys in full](docs/keys.md)
|
|
36
|
+
|
|
37
|
+
**Looking at it** · [What the log says about the policy](#what-the-log-says-about-the-policy) · [Seeing what happened](#seeing-what-happened) · [Ask your own assistant](#ask-your-own-assistant)
|
|
38
|
+
|
|
39
|
+
**The honest parts** · [How it holds](#how-it-holds) · [What it is not](#what-it-is-not) · [Why not a container](#why-not-a-container) · [Prior art](#prior-art) · [Status](#status)
|
|
40
|
+
|
|
41
|
+
**Deeper** · [Keys](docs/keys.md) · [Agents](docs/agents.md) · [Day one](docs/first-day.md) · [Scratch](docs/scratch.md) · [Glossary](docs/glossary.md) · [What it has been put through](docs/what-it-has-been-put-through.md) · [Field notes](docs/field-notes.md) · [Decisions](docs/decisions.md) · [Permission requests](docs/permission-requests.md) · [Contributing](CONTRIBUTING.md)
|
|
42
|
+
|
|
43
|
+
</details>
|
|
44
|
+
|
|
45
|
+
```mermaid
|
|
46
|
+
flowchart LR
|
|
47
|
+
A["agent<br/>running as a role"] -->|writes src/api/orders.ts| K{{"the kernel<br/>Seatbelt · bubblewrap"}}
|
|
48
|
+
K -->|not this role's| D["denied"]
|
|
49
|
+
D --> S["seisin reads the refusal<br/>and looks up the policy"]
|
|
50
|
+
S --> W["<b>whose it was</b><br/>belongs to backend"]
|
|
51
|
+
S --> Q["a request, queued"]
|
|
52
|
+
Q --> H(["a person<br/>grants or declines"])
|
|
53
|
+
H -->|edits seisin.toml| P[("seisin.toml")]
|
|
54
|
+
P -.->|settings| K
|
|
55
|
+
|
|
56
|
+
style D fill:#fde,stroke:#c66
|
|
57
|
+
style W fill:#dfd,stroke:#6a6
|
|
58
|
+
style H fill:#eef,stroke:#66c
|
|
59
|
+
```
|
|
60
|
+
|
|
61
|
+
**The kernel says no. seisin says whose.** That second half is the whole thing: other
|
|
62
|
+
permission layers in this space answer *yes* or *no*, or at best *which rule* said no — and a
|
|
63
|
+
refusal that also names an owner turns a dead end into a handoff.
|
|
64
|
+
|
|
17
65
|

|
|
18
66
|
|
|
19
67
|
```
|
|
@@ -26,12 +74,12 @@ $ seisin run frontend -- sh -c 'echo // fix >> src/api/orders.ts'
|
|
|
26
74
|
#1 frontend wants write on src/api/** (owned by backend)
|
|
27
75
|
first asked over src/api/orders.ts
|
|
28
76
|
|
|
29
|
-
seisin grant <n> [--reason "…"] · seisin
|
|
77
|
+
seisin grant <n> [--reason "…"] · seisin decline <n> [--reason "…"]
|
|
30
78
|
```
|
|
31
79
|
|
|
32
|
-
**Whose it was, at the moment it was
|
|
80
|
+
**Whose it was, at the moment it was denied.** Other permission layers answer *yes* or *no*, and the good ones say which rule it was and how to widen it. Answering **"no, and it belongs to `backend`"** turns a block into a handoff — and one a person can approve in a command, rather than a line somebody has to remember to go and read.
|
|
33
81
|
|
|
34
|
-
The kernel is what
|
|
82
|
+
The kernel is what denies; the name comes from the policy. That first line is all the boundary itself can say — no path, no reason, nothing to read afterwards — so seisin reads the refusal out of the kernel's own log and answers the question it leaves open. On Linux only the hook can do that; [the ask is upstream](docs/upstream/cli-violations.md) as [issue #582](https://github.com/anthropics/sandbox-runtime/issues/582).
|
|
35
83
|
|
|
36
84
|
The agent can ask directly too, from inside the box:
|
|
37
85
|
|
|
@@ -54,8 +102,6 @@ seisin run frontend -- claude -p "…" # run an agent inside its own territory
|
|
|
54
102
|
|
|
55
103
|
On Linux, [three system packages first](#install). Everything below is why it works and where it does not.
|
|
56
104
|
|
|
57
|
-
**Contents** · [Why this exists](#why-this-exists) · [What it is *not* for](#what-it-is-for-and-what-it-is-not-for) · [Install](#install) · [Configure](#configure) · [Day one](#what-will-look-like-a-bug-on-the-first-day) · [Agents it runs](#which-agents-it-has-been-run-with) · [Requests](#when-it-says-no-it-leaves-a-request-behind) · [MCP](#ask-your-own-assistant) · [Why not a container](#why-not-a-container) · [How it holds](#how-it-holds) · [Secrets](#how-it-protects-secrets) · [Decisions](docs/decisions.md) · [What it survived](docs/what-it-has-been-put-through.md) · [Status](#status)
|
|
58
|
-
|
|
59
105
|
---
|
|
60
106
|
|
|
61
107
|
## Why this exists
|
|
@@ -98,9 +144,9 @@ rather than after.
|
|
|
98
144
|
|
|
99
145
|
**It holds against an agent that is wrong.** One that edits the file it was not asked
|
|
100
146
|
about, reads the key meant for another role, or calls an API nobody declared. The kernel
|
|
101
|
-
|
|
147
|
+
denies, and it does not care how the command was spelled — verified against a grandchild
|
|
102
148
|
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
|
|
149
|
+
back to when its own file API was denied.
|
|
104
150
|
|
|
105
151
|
**It is not built to hold against an agent that is trying.** Measured, in this repo's own
|
|
106
152
|
fixtures:
|
|
@@ -194,162 +240,81 @@ network = ["api.stripe.com"] # this role only — it replaces [network] rathe
|
|
|
194
240
|
extra host should say so on its own line: a domain in the global list is reachable by every
|
|
195
241
|
role, including the ones whose whole job is to have nothing to reach.
|
|
196
242
|
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
**1 · Granting a file does not grant its neighbours.** The kernel grants exactly the path you
|
|
207
|
-
wrote. Anything a tool creates *beside* it — a database journal, a lock file, the temporary
|
|
208
|
-
file of an atomic write — is a sibling, and a sibling is outside the grant. The failure then
|
|
209
|
-
arrives in the tool's own words rather than as a permission error, which is why it survives a
|
|
210
|
-
policy review: the policy looks right because it *is* right about the file you named.
|
|
211
|
-
|
|
212
|
-
`seisin check` says so before you hit it:
|
|
213
|
-
|
|
214
|
-
```
|
|
215
|
-
N role(s) grant individual files rather than folders: reviewer (3 of 11)
|
|
216
|
-
```
|
|
243
|
+
> ⚠️ **HTTP(S) works; SSH does not.** The allow list is enforced by proxies the runtime
|
|
244
|
+
> starts, and although one of them is a SOCKS5 proxy that could carry SSH, the chain breaks
|
|
245
|
+
> at its last link: it requires authentication and the `ProxyCommand` the runtime wires up
|
|
246
|
+
> cannot send any. Measured — `curl https://github.com` returns `200`, `ssh -T git@github.com`
|
|
247
|
+
> cannot resolve the hostname, and `git ls-remote git@github.com:…` reaches the proxy and
|
|
248
|
+
> dies at the handshake.
|
|
249
|
+
>
|
|
250
|
+
> **Use an HTTPS remote for git**, and run SSH deploys outside the confined turn.
|
|
251
|
+
> [Why, exactly, and whose gap it is →](docs/decisions.md#ssh-does-not-work-and-it-is-not-seisin-that-decided-that)
|
|
217
252
|
|
|
218
|
-
|
|
253
|
+
A role can also give something back. `never_writes` subtracts from its own `writes`, and it
|
|
254
|
+
wins however wide the grant is:
|
|
219
255
|
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
256
|
+
```toml
|
|
257
|
+
[roles.backend]
|
|
258
|
+
writes = ["services/api/**"]
|
|
259
|
+
never_writes = ["services/api/.git/index.lock"] # works in a worktree; the canonical index is not its business
|
|
260
|
+
```
|
|
261
|
+
|
|
262
|
+
It is per role on purpose — there is no global version, because a global deny on a lock file
|
|
263
|
+
breaks every role that legitimately commits there. A refusal it causes says so by name and
|
|
264
|
+
leaves **no request** in the queue: approving one would undo a subtraction somebody wrote. An
|
|
265
|
+
entry that no `writes` of the same role covers, or a misspelling like `never_write`, is named
|
|
266
|
+
by `seisin check` instead of being ignored.
|
|
267
|
+
|
|
268
|
+
A role that starts a server — a dev server, the backend an end-to-end test drives — needs
|
|
269
|
+
`local_binding = true`. Listening is off by default and per role: most roles never start one.
|
|
270
|
+
It is wider than the name: the role can also listen on every interface and **connect to every
|
|
271
|
+
port on localhost**, so anything listening there without authentication is within its reach.
|
|
272
|
+
`seisin check` says this next to the role. That is macOS: on Linux each role already has a
|
|
273
|
+
private loopback, serves without the key, and reaches nothing on the host either way.
|
|
274
|
+
|
|
275
|
+
If a role only needs to *reach* a local service, name the ports instead:
|
|
276
|
+
`local_ports = [8001, 8081]`. The role reaches those and nothing else on localhost, on macOS
|
|
277
|
+
and Linux. The sandbox's proxy enforces the list, so the client has to use the proxy. curl,
|
|
278
|
+
Node's `fetch` and Python's `urllib` do. A database driver or a raw socket don't, and get
|
|
279
|
+
refused. For Chromium, pass Playwright's `proxy` option from `HTTP_PROXY`; on macOS it also
|
|
280
|
+
needs `--single-process` to start. A listed port is open to anything that speaks `CONNECT`, not
|
|
281
|
+
just HTTP, so list the service, not the protocol.
|
|
282
|
+
|
|
283
|
+
`mcp = ["playwright"]` lists the MCP servers a role may load; `mcp = []` means none, and no
|
|
284
|
+
key means the policy doesn't say. seisin declares this and `explain` answers it
|
|
285
|
+
(`seisin explain qa mcp tradingview`), but the servers are started by the agent's CLI, so the
|
|
286
|
+
launcher has to apply it. With Claude Code: `--mcp-config` holding only those servers, plus
|
|
287
|
+
`--strict-mcp-config`.
|
|
288
|
+
|
|
289
|
+
Refused connections are logged too: `connect tcp:<port>` (the kernel gives the port, not the
|
|
290
|
+
host) or the socket path, once per target per run. `seisin walls` says whether the port is
|
|
291
|
+
missing from `local_ports`, listed but dialled directly, or a socket. No request is filed: a
|
|
292
|
+
port isn't anyone's territory. macOS only.
|
|
226
293
|
|
|
227
|
-
|
|
228
|
-
filename was **1,071 of the 1,409 refusals** — three quarters of everything the kernel said no
|
|
229
|
-
to. A role runs `git status`, git tries to refresh the index of a checkout that belongs to
|
|
230
|
-
another role, and the lock write is refused.
|
|
294
|
+
`seisin init` will propose this from whatever your repo already says: `.claude/agents/`, then `CODEOWNERS`, then a blank start. It **proposes** — a generated policy you did not read is not a policy.
|
|
231
295
|
|
|
232
|
-
|
|
233
|
-
`git status --short --branch` and `git log` still exit 0. Git cannot refresh its index cache
|
|
234
|
-
and carries on without it. If a role genuinely needs to commit, give it **its own worktree**
|
|
235
|
-
rather than a share of the main index — two agents staging into one index corrupt each other
|
|
236
|
-
regardless of who is allowed to write it.
|
|
296
|
+
## What will look like a bug on the first day
|
|
237
297
|
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
talking about. Worth knowing it is handled, because the symptom when a tool does *not* handle
|
|
243
|
-
it is a role denied inside its own territory.
|
|
298
|
+
Four shapes a correct policy still produces that read as breakage: a granted file whose
|
|
299
|
+
neighbours are not granted, a lock file in somebody else's repository, a tool that reports a
|
|
300
|
+
denied write as a *readonly database*, and an agent that diagnoses file permissions when the
|
|
301
|
+
answer is ownership.
|
|
244
302
|
|
|
245
|
-
|
|
246
|
-
[below](#which-agents-it-has-been-run-with); it is the one that sends you to debug the
|
|
247
|
-
database instead of the policy, and the reason `seisin wire` exists.
|
|
303
|
+
All four came out of one production window where none of them was the boundary misbehaving.
|
|
248
304
|
|
|
249
|
-
|
|
250
|
-
case is resolved in the tool, the SQLite case is named by the hook, and the git one is
|
|
251
|
-
friction that gets logged rather than silenced — a boundary that hides what it refused is the
|
|
252
|
-
thing this project exists to argue against.
|
|
305
|
+
**[What each one looks like, and what to do →](docs/first-day.md)**
|
|
253
306
|
|
|
254
307
|
## Which agents it has been run with
|
|
255
308
|
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
actually been exercised:
|
|
259
|
-
|
|
260
|
-
| agent | version | result |
|
|
261
|
-
|---|---|---|
|
|
262
|
-
| **Claude Code** (`claude -p`) | 2.1.x | Territory and keys enforced; network egress refused an undeclared domain by name |
|
|
263
|
-
| **opencode** (`opencode run`) | **1.18.30** | Same, **on a free model with no API key at all** |
|
|
264
|
-
| **LangGraph** (`python graph.py`) | **1.2.11** | Same, **enforced against the interpreter's own `open()`** — in-process tools, no child command to match |
|
|
265
|
-
| **codex** (`codex exec`) | **0.150.1** | Same, with its own sandbox off — see below. Refused twice: its patch tool, then the shell redirect it fell back to |
|
|
266
|
-
|
|
267
|
-
The codex run is the one that needed a flag. **An agent that sandboxes itself has to stop.** `codex` confines each command it runs with its
|
|
268
|
-
own `sandbox-exec` profile, and macOS refuses to apply a second Seatbelt profile to a process
|
|
269
|
-
that already has one — `sandbox_apply: Operation not permitted`, with the most permissive
|
|
270
|
-
profile that can be written, on both layers. So an agent like that runs under seisin with its
|
|
271
|
-
own sandbox turned off — `codex exec --dangerously-bypass-approvals-and-sandbox`, whose own
|
|
272
|
-
help says it is "intended solely for running in environments that are externally sandboxed".
|
|
273
|
-
It starts fine either way (`codex 0.150.1`, `gemini 0.57.0`, both verified), which is why this
|
|
274
|
-
is worth saying out loud: the failure arrives later, on the first command the agent tries to
|
|
275
|
-
confine.
|
|
276
|
-
|
|
277
|
-
Asked to write into another role's territory, codex failed through its patch tool, went looking
|
|
278
|
-
for a file ACL with `ls -le@` and `stat`, found nothing, and fell back to a shell redirect that
|
|
279
|
-
failed too:
|
|
280
|
-
|
|
281
|
-
```
|
|
282
|
-
/bin/zsh -lc "printf 'pisado\n' > qa/informe.md"
|
|
283
|
-
exited 1: zsh:1: operation not permitted
|
|
284
|
-
```
|
|
285
|
-
|
|
286
|
-
Same shape as opencode, including the wrong diagnosis — it reasoned about file permissions,
|
|
287
|
-
not about ownership. That is the case for the hook: the boundary holds either way, but only the
|
|
288
|
-
hook can say *whose* it was.
|
|
289
|
-
|
|
290
|
-
All four on macOS 15 (Seatbelt). Linux is no longer a one-off measurement on one machine:
|
|
291
|
-
[CI](.github/workflows/test.yml) runs the whole suite on Ubuntu and macOS, Node 18/20/22,
|
|
292
|
-
on every push — and fails if the sandbox half *skips*.
|
|
293
|
-
|
|
294
|
-
The opencode run is the interesting one, because **opencode has no per-path sandbox flag** —
|
|
295
|
-
its only permission control is `--auto`, "auto-approve permissions that are not explicitly
|
|
296
|
-
denied". Asked to write into another role's territory it failed twice: once through its own
|
|
297
|
-
`FileSystem.writeFile`, and again through the shell redirect it fell back to.
|
|
298
|
-
|
|
299
|
-
```
|
|
300
|
-
Error: Unknown: FileSystem.writeFile (.../qa/informe.md)
|
|
301
|
-
$ echo "revisado" > .../qa/informe.md
|
|
302
|
-
zsh:1: operation not permitted
|
|
303
|
-
```
|
|
304
|
-
|
|
305
|
-
That is the point of putting the boundary in the kernel instead of in the agent: **an agent
|
|
306
|
-
that cannot confine itself is confined anyway**, and adding a new one costs no adapter.
|
|
307
|
-
|
|
308
|
-
It also found a real bug. The scratch list named `~/.claude` and `~/.codex` and stopped there,
|
|
309
|
-
so the first agent that was neither did not fail a task — it failed to start, on its own log
|
|
310
|
-
file. A boundary that only fits the agents its author happened to use is a coincidence, not a
|
|
311
|
-
boundary. The XDG directories are in the list now.
|
|
312
|
-
|
|
313
|
-
The LangGraph run answers a different question: **an agent framework is not a CLI.** Its tools
|
|
314
|
-
are Python calls inside the same process, so there is no child command for a rule to match and no
|
|
315
|
-
argv to inspect — the write is the interpreter's own `open()`. A six-node graph asked to write into
|
|
316
|
-
another role's territory was refused there, refused again through the shell redirect it fell back
|
|
317
|
-
to, refused the other role's key, and refused an undeclared domain. Run as the other role, the same
|
|
318
|
-
graph gave the mirror image — every territory and key answer flipped, the undeclared domain refused
|
|
319
|
-
for both — and its SQLite checkpointer persisted normally inside whichever territory was its own.
|
|
309
|
+
Claude Code, opencode, codex, gemini and a LangGraph script, each against a real policy with
|
|
310
|
+
the boundary actually applied — not a smoke test.
|
|
320
311
|
|
|
321
|
-
One
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
312
|
+
**One thing is worth knowing before you start: an agent that sandboxes itself has to be told
|
|
313
|
+
to stop.** `codex` confines every command it runs, and no OS applies a second profile to a
|
|
314
|
+
process that already has one. seisin says so before it starts one, because the error you get
|
|
315
|
+
otherwise names neither seisin, nor the agent, nor the fix.
|
|
325
316
|
|
|
326
|
-
|
|
327
|
-
`attempt to write a readonly database`, not as a permission error. That reads like a misconfigured
|
|
328
|
-
database, and you will debug the database. The hook names the owner instead — it is the case
|
|
329
|
-
`seisin wire` is for.
|
|
330
|
-
|
|
331
|
-
**Re-verified without an agent in the loop**, which is better evidence: an agent in the middle
|
|
332
|
-
makes a permission test non-deterministic — in that first run the agent reported the opposite
|
|
333
|
-
of what it had actually done. Wrapping a plain shell and a plain interpreter instead:
|
|
334
|
-
|
|
335
|
-
| as `dev`, with `qa/**` owned by `qa` | result |
|
|
336
|
-
|---|---|
|
|
337
|
-
| `sh -c 'echo x > proyecto/FILE.txt'` (own territory) | rc=0, created |
|
|
338
|
-
| `sh -c 'echo x > qa/report.md'` (shell redirect) | `Operation not permitted`, rc=1 |
|
|
339
|
-
| `python3 -c "open('qa/report.md','w')"` (the program's own syscall) | `PermissionError: [Errno 1]` |
|
|
340
|
-
| `rm -f proyecto/FILE.txt` (**destructive, inside** own territory) | **rc=0, file gone** |
|
|
341
|
-
|
|
342
|
-
The last row is not a bug, it is the shape of the tool: seisin answers *where*, not *what*.
|
|
343
|
-
A team running this in front of an agent fleet measured how often it matters: across ~330
|
|
344
|
-
rounds, sixty-six times a role reached for a command that would have destroyed its own
|
|
345
|
-
uncommitted work, and the kernel boundary permitted every one. The ask that would close it
|
|
346
|
-
is written up in [docs/upstream/denyUnlink.md](docs/upstream/denyUnlink.md), filed against
|
|
347
|
-
the sandbox runtime rather than worked around here.
|
|
348
|
-
|
|
349
|
-
If you would rather see it than read it, [thirty seconds in a temp
|
|
350
|
-
directory](docs/demo/) — an agent confined to its own project deletes the git history of the
|
|
351
|
-
repo it is working in, and the `.git/` left behind looks intact. Nothing installed, seisin not
|
|
352
|
-
used, because the gap is the runtime's and every tool on top of it inherits the same one.
|
|
317
|
+
**[Every agent, its version, what it needed, and how each reacted to being stopped →](docs/agents.md)**
|
|
353
318
|
|
|
354
319
|
## What the log says about the policy
|
|
355
320
|
|
|
@@ -405,79 +370,51 @@ policy: diff it, then move it.
|
|
|
405
370
|
|
|
406
371
|
## When it says no, it leaves a request behind
|
|
407
372
|
|
|
408
|
-
A permission tool that can only say no is a tool people uninstall
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
hundred entries nobody can read.
|
|
373
|
+
A permission tool that can only say no is a tool people uninstall — the loop of *denied,
|
|
374
|
+
stop, edit a config, run again* is three context switches for one line of policy, and the
|
|
375
|
+
cheapest way to stop it is to widen the policy and never look again. That is how a permission
|
|
376
|
+
file becomes seven hundred entries nobody reads.
|
|
413
377
|
|
|
414
|
-
So a denial leaves something
|
|
378
|
+
So a denial leaves something to act on, and many denials in one directory are **one** request:
|
|
415
379
|
|
|
416
380
|
```
|
|
417
|
-
frontend write src/api/orders.ts
|
|
418
|
-
denied — src/api/orders.ts belongs to backend
|
|
419
|
-
|
|
420
381
|
1 pending request(s)
|
|
421
382
|
|
|
422
383
|
#1 frontend wants write on src/api/** (owned by backend) · asked 3×
|
|
423
384
|
first asked over src/api/orders.ts
|
|
424
385
|
|
|
425
|
-
seisin grant 1 --reason "…" · seisin
|
|
386
|
+
seisin grant 1 --reason "…" · seisin decline 1 --reason "…"
|
|
426
387
|
```
|
|
427
388
|
|
|
428
|
-
|
|
429
|
-
on `a.ts` and then on `b.ts` is not asking two questions. And the grant records
|
|
430
|
-
where it came from, next to the line it adds:
|
|
389
|
+
The grant writes its own provenance next to the line it adds:
|
|
431
390
|
|
|
432
391
|
```toml
|
|
433
|
-
[
|
|
434
|
-
|
|
435
|
-
|
|
436
|
-
|
|
437
|
-
|
|
438
|
-
|
|
439
|
-
|
|
440
|
-
|
|
441
|
-
|
|
442
|
-
|
|
443
|
-
|
|
444
|
-
|
|
445
|
-
|
|
446
|
-
|
|
447
|
-
|
|
448
|
-
|
|
449
|
-
|
|
450
|
-
|
|
451
|
-
|
|
452
|
-
|
|
453
|
-
|
|
454
|
-
|
|
455
|
-
|
|
456
|
-
|
|
457
|
-
|
|
458
|
-
|
|
459
|
-
safe thing to do with a line nobody remembers is leave it there.
|
|
460
|
-
|
|
461
|
-
**Approving is deliberately not a tool call.** An agent — or an MCP server on
|
|
462
|
-
your behalf — can read the queue and draft the change. Turning it into policy
|
|
463
|
-
takes a person in a channel the agent does not have. The public API reflects
|
|
464
|
-
that: `pendingRequests` is exported, the functions that approve are not.
|
|
465
|
-
[The reasoning is written down](docs/permission-requests.md).
|
|
466
|
-
|
|
467
|
-

|
|
468
|
-
|
|
469
|
-
Two places, both human: `seisin grant <n>` in a terminal, or the console, where
|
|
470
|
-
the queue is a panel with a reason field and two buttons. Approving there edits
|
|
471
|
-
your `seisin.toml` in place and the territory on screen changes with it.
|
|
472
|
-
|
|
473
|
-
The console is allowed to hold that half and the MCP server is not, and the
|
|
474
|
-
reason is measured rather than asserted: a confined role curling the console's
|
|
475
|
-
own port gets the same nothing it gets from a domain outside its allowlist. The
|
|
476
|
-
MCP server, by contrast, speaks on the agent's own stdio.
|
|
477
|
-
|
|
478
|
-
The notice rides on what you are already looking at: `seisin run` prints the
|
|
479
|
-
queue when the run ends, in the same terminal that just showed you the denial.
|
|
480
|
-
There is no daemon and nothing to leave running.
|
|
392
|
+
writes = ["src/api/**"] # granted 2026-09-12 · asked 3× · "frontend owns checkout now"
|
|
393
|
+
```
|
|
394
|
+
|
|
395
|
+
**Approving is never a tool call.** An agent can read the queue and draft the change; only a
|
|
396
|
+
person applies it, in a terminal or in the console. That asymmetry is deliberate and it is
|
|
397
|
+
the reason the MCP server exists at all.
|
|
398
|
+
|
|
399
|
+
**A person hears about it.** With `[notify]`, the parent process sends one message per new
|
|
400
|
+
request — the role, the path, whose it is, and `seisin grant <n>` — to ntfy, Slack or any
|
|
401
|
+
webhook. The URL lives in a key directory no role declares (`url_file`) or in the parent's
|
|
402
|
+
`SEISIN_NOTIFY_URL`; written plainly into `seisin.toml`, which every role can read, it refuses to
|
|
403
|
+
load, because whoever reads it can send you a fake "approve".
|
|
404
|
+
|
|
405
|
+
```toml
|
|
406
|
+
[notify]
|
|
407
|
+
url_file = ".secrets/notify-url.txt" # e.g. https://ntfy.sh/<your-topic>
|
|
408
|
+
format = "text" # or "slack", "json"
|
|
409
|
+
```
|
|
410
|
+
|
|
411
|
+
**And so does the agent.** `seisin wire` installs the hook for four events. Before a tool call
|
|
412
|
+
it explains a refusal it can see coming; after a command fails it reads the kernel's own refusal
|
|
413
|
+
from the log and tells the agent whose the path was and how many times it has been refused —
|
|
414
|
+
the half PreToolUse never sees; and when a session starts, resumes or is compacted it hands the
|
|
415
|
+
agent its territory and its walls, before the first one.
|
|
416
|
+
|
|
417
|
+
**[How a denial becomes a request, why it deduplicates, and what a grant records →](docs/permission-requests.md)**
|
|
481
418
|
|
|
482
419
|
## Ask your own assistant
|
|
483
420
|
|
|
@@ -487,6 +424,14 @@ There is no daemon and nothing to leave running.
|
|
|
487
424
|
|
|
488
425
|
> *who owns src/api, and what is frontend waiting on?*
|
|
489
426
|
|
|
427
|
+
Seven tools. Four answer about the policy — `state`, `explain`, `requests`, `draft_grant` —
|
|
428
|
+
and three about what has happened: `activity` is the raw log, and **`causes` and `walls` are
|
|
429
|
+
the two an agent actually wants.** `activity` hands over events and leaves the assistant to
|
|
430
|
+
re-derive the shape without the policy; `causes` returns the day grouped by path and by name
|
|
431
|
+
with how many are on paths nobody owns, and `walls` returns what a role keeps being denied
|
|
432
|
+
*and would still be denied today*. Both are recomputed against the policy, so something
|
|
433
|
+
granted since stops counting — which is the part a log cannot know.
|
|
434
|
+
|
|
490
435
|
Read-only **by construction** — the server opens nothing for writing, and a test
|
|
491
436
|
asserts that no tool in it mutates anything. It can draft the exact change a
|
|
492
437
|
request would make; applying it is a command a person runs. Zero dependencies:
|
|
@@ -498,6 +443,7 @@ descriptors.
|
|
|
498
443
|
```bash
|
|
499
444
|
seisin log --verdict denied # what the agents tried and could not do
|
|
500
445
|
seisin watch # follow it live
|
|
446
|
+
seisin ui # the same thing, for a person deciding
|
|
501
447
|
```
|
|
502
448
|
|
|
503
449
|
```
|
|
@@ -507,56 +453,27 @@ seisin watch # follow it live
|
|
|
507
453
|
```
|
|
508
454
|
|
|
509
455
|
One append-only JSONL under `.seisin/`, and that is the whole storage design — no daemon, no
|
|
510
|
-
database
|
|
511
|
-
watch it.
|
|
512
|
-
|
|
513
|
-
**The agent cannot reach that file.** `.seisin/` is not in any role's territory, because a
|
|
514
|
-
record the recorded process can edit is not a record. The hook sends each line to `seisin run`
|
|
515
|
-
over a unix socket granted by path, and the parent does the writing — so the only verb
|
|
516
|
-
available from inside the box is *append one line*: no seek, no truncate, no unlink. What a
|
|
517
|
-
hostile agent can still do is send lines, so it can add noise to its own history. What it can
|
|
518
|
-
no longer do is change what is already there.
|
|
456
|
+
database, and `watch` is a tail.
|
|
519
457
|
|
|
520
|
-
**
|
|
458
|
+
**The agent cannot reach that file.** `.seisin/` is in no role's territory, because a record
|
|
459
|
+
the recorded process can edit is not a record. The hook sends each line to `seisin run` over
|
|
460
|
+
a unix socket granted by path and the parent does the writing, so the only verb available
|
|
461
|
+
from inside the box is *append one line*. A hostile agent can still add noise to its own
|
|
462
|
+
history; what it cannot do is change what is already there.
|
|
521
463
|
|
|
522
|
-
The **hook** reports the attempt before it
|
|
523
|
-
gets recorded at all
|
|
524
|
-
|
|
525
|
-
|
|
526
|
-
|
|
527
|
-
The **kernel** reports what it actually refused. On macOS every Seatbelt denial
|
|
528
|
-
lands in the system log with its operation, its absolute path and the runtime's
|
|
529
|
-
own attribution tag, and `seisin run` reads that stream for the duration of the
|
|
530
|
-
run. Nothing to install, nothing to evade — and it catches exactly what the hook
|
|
531
|
-
missed:
|
|
532
|
-
|
|
533
|
-
```
|
|
534
|
-
$ seisin run frontend -- sh -c 'echo // fix >> src/api/orders.ts'
|
|
535
|
-
sh: src/api/orders.ts: Operation not permitted
|
|
536
|
-
seisin: 1 kernel denial(s) recorded
|
|
537
|
-
|
|
538
|
-
1 pending request(s)
|
|
539
|
-
#1 frontend wants write on src/api/** (owned by backend)
|
|
540
|
-
first asked over src/api/orders.ts
|
|
541
|
-
```
|
|
464
|
+
**Two writers, and they see different things.** The **hook** reports the attempt before it
|
|
465
|
+
happens — which is how an *allowed* action gets recorded at all — and it is text-based and
|
|
466
|
+
evadable, on purpose. The **kernel** reports what it actually denied, and is not fooled by
|
|
467
|
+
anything. What escapes the hook goes unexplained, never unblocked.
|
|
542
468
|
|
|
543
|
-
That is with no hook installed. Before this, the same run left `Operation not
|
|
544
|
-
permitted` on the terminal and an empty log — which is how four real defects in
|
|
545
|
-
production each cost a night to find: every one of them looked like a file that
|
|
546
|
-
had quietly stopped growing.
|
|
547
469
|
|
|
548
|
-
Skip `wire` and the boundary holds exactly as well, denials are still recorded
|
|
549
|
-
and still queue a request; what you lose is the record of what was *allowed*,
|
|
550
|
-
which is what `init --from-observations` is built out of. `seisin check` says so
|
|
551
|
-
until you run it.
|
|
552
470
|
|
|
553
|
-
|
|
554
|
-
runtime's substitute is not readable from outside it, so `seisin run` says so
|
|
555
|
-
once and records nothing from the kernel. [The ask is
|
|
556
|
-
upstream](docs/upstream/cli-violations.md) as [issue #582][582], filed 2026-09-20
|
|
557
|
-
against 0.0.77, open and unanswered.
|
|
471
|
+

|
|
558
472
|
|
|
559
|
-
|
|
473
|
+
It opens on what needs a decision, not on the policy. Five numbers ride on every screen and
|
|
474
|
+
each is a link to the page that explains it — including two a log cannot answer, because
|
|
475
|
+
they are recomputed against the policy: how much of the day is **one cause**, and how many
|
|
476
|
+
of those causes are on paths **nobody owns**.
|
|
560
477
|
|
|
561
478
|
## How it holds
|
|
562
479
|
|
|
@@ -564,7 +481,7 @@ against 0.0.77, open and unanswered.
|
|
|
564
481
|
|---|---|---|
|
|
565
482
|
| your agent's `allow`/`deny` rules | match the command string before it runs | yes — `/bin/rm`, a heredoc, a child process |
|
|
566
483
|
| **seisin** | decides what to ask for, and names the owner | it doesn't enforce, it explains |
|
|
567
|
-
| **sandbox-runtime → Seatbelt / bubblewrap** | the OS
|
|
484
|
+
| **sandbox-runtime → Seatbelt / bubblewrap** | the OS denies the syscall | no |
|
|
568
485
|
|
|
569
486
|
```
|
|
570
487
|
Write tool · Bash `rm -rf` · /bin/rm · python -c "open(…)" · a grandchild
|
|
@@ -605,105 +522,99 @@ Verified by the test suite, which runs real commands in a real sandbox:
|
|
|
605
522
|
|
|
606
523
|
## How it protects secrets
|
|
607
524
|
|
|
608
|
-
Four ways
|
|
609
|
-
|
|
525
|
+
Four ways a secret leaves a process, and seisin closes three. The fourth is named rather
|
|
526
|
+
than hidden.
|
|
610
527
|
|
|
611
|
-
| way out | closed by |
|
|
612
|
-
|
|
613
|
-
| reading another role's key file | the kernel
|
|
614
|
-
| a key riding in the environment | the launcher
|
|
615
|
-
| spilling a key the role does hold | the launcher
|
|
616
|
-
| sending it somewhere | the kernel
|
|
617
|
-
| **a key stored outside the declared directories** | **nothing
|
|
528
|
+
| way out | closed by |
|
|
529
|
+
|---|---|
|
|
530
|
+
| reading another role's key file | the kernel — the key directory is denied, each declared key re-allowed |
|
|
531
|
+
| a key riding in the environment | the launcher — the child's environment is **built, not inherited** |
|
|
532
|
+
| spilling a key the role does hold | the launcher — masked on stdout and stderr, even split across buffers |
|
|
533
|
+
| sending it somewhere | the kernel — egress is allow-only, by hostname. HTTP(S) works; [SSH does not](docs/decisions.md#ssh-does-not-work-and-it-is-not-seisin-that-decided-that) |
|
|
534
|
+
| **a key stored outside the declared directories** | **nothing.** `seisin scan` finds them so you know |
|
|
618
535
|
|
|
619
536
|
```toml
|
|
620
537
|
[keys]
|
|
621
|
-
dir = [".secrets"
|
|
538
|
+
dir = [".secrets"]
|
|
622
539
|
|
|
623
540
|
[roles.frontend]
|
|
624
|
-
|
|
625
|
-
|
|
541
|
+
key_mode = "env"
|
|
542
|
+
keys = ["netlify-token.txt", # a file the role may read
|
|
543
|
+
"TOKEN=file://.secrets/all.env#NETLIFY", # one value, and no read at all
|
|
544
|
+
"OP=op://vault/item/field"] # anything that prints a secret
|
|
626
545
|
```
|
|
627
546
|
|
|
628
|
-
**
|
|
629
|
-
|
|
630
|
-
|
|
631
|
-
agent cells, ~370 confined turns, three days — **every refused read was a read of something
|
|
632
|
-
the policy had declared a key.** 148 of them, no exceptions, from five different roles, and
|
|
633
|
-
not one was doing anything unusual: they were running searches that swept a repository root.
|
|
634
|
-
That is how a key gets read without anyone deciding it should, and it is the whole case for
|
|
635
|
-
declaring the key directory rather than trusting the instruction not to look.
|
|
547
|
+
A **provider** is a command with a `{ref}` placeholder, so adding 1Password, Bitwarden or
|
|
548
|
+
`sops` is TOML and not code. `file://` ships built in. The parent resolves, never the
|
|
549
|
+
confined process. Nothing degrades: a provider that fails stops the run.
|
|
636
550
|
|
|
637
|
-
|
|
638
|
-
ordinary collisions between roles, the boundary keeping two agents out of each other's work.
|
|
639
|
-
It is the reads where the policy was the only thing there.
|
|
551
|
+
**[Key directories, references, providers, delivery modes and nine recipes →](docs/keys.md)**
|
|
640
552
|
|
|
641
|
-
**
|
|
642
|
-
|
|
643
|
-
|
|
644
|
-
|
|
645
|
-
process in memory. `[runtime] redact = false` turns that off.
|
|
553
|
+
**Measured, over four agent cells and three days: every refused read was a read of something
|
|
554
|
+
the policy had declared a key.** 148 of them, no exceptions, from five roles — all running
|
|
555
|
+
searches that swept a repository root. That is how a key gets read without anyone deciding
|
|
556
|
+
it should.
|
|
646
557
|
|
|
647
|
-
##
|
|
558
|
+
## When the boundary is right and the agent cannot hear it
|
|
648
559
|
|
|
649
|
-
|
|
650
|
-
|
|
651
|
-
stops the agent before it starts. Every role therefore also gets:
|
|
560
|
+
seisin answers *whose is this* at the moment of the denial — once, mid-turn — and then the
|
|
561
|
+
sentence is gone. Nothing carries it forward, so the agent tries again.
|
|
652
562
|
|
|
653
|
-
|
|
654
|
-
|
|
655
|
-
|
|
656
|
-
|
|
657
|
-
|
|
658
|
-
writes = ["~/.claude", "~/.codex", "~/.local/share", "~/.local/state",
|
|
659
|
-
"~/.cache", "$TMPDIR", "/tmp"]
|
|
660
|
-
```
|
|
563
|
+
Measured on a real team: of **345 denials, 88 (25%) were a repeat of something that same role
|
|
564
|
+
had already been denied.** One role spent 37 calls on two walls, hitting one of them nineteen
|
|
565
|
+
times. Every denial was correct. Not one was a false positive. It still cost thirty-seven
|
|
566
|
+
calls, because *correct* and *heard* are different properties and only the first was being
|
|
567
|
+
measured.
|
|
661
568
|
|
|
662
|
-
|
|
663
|
-
from somewhere to grant one extra path silently drops the six it did not mention. Start from the
|
|
664
|
-
list above and append.
|
|
569
|
+
So a denial now carries its own history:
|
|
665
570
|
|
|
666
|
-
|
|
667
|
-
|
|
571
|
+
```
|
|
572
|
+
deploy/x.yml belongs to infra. It is not dev's to change — hand it over rather than
|
|
573
|
+
working around it. You have been denied this 3 times now; it is not going to work on
|
|
574
|
+
the fourth try. Already queued for a person to answer…
|
|
575
|
+
```
|
|
668
576
|
|
|
669
|
-
|
|
670
|
-
files. If your repo lives inside the temp dir, territory does not hold at all — `seisin check`
|
|
671
|
-
says so out loud when it detects that.
|
|
672
|
-
- **The home directory itself is never granted.** Only those named subdirectories. A grant that
|
|
673
|
-
reached `~` would hand over the shell profile, the ssh config, and every dotfile with a token
|
|
674
|
-
in it. There is a test that fails if that ever changes.
|
|
675
|
-
- **Reading your home is a different question, and by default it is open.** None of the above
|
|
676
|
-
stops a role from *reading* `~/.ssh`, `~/.aws/credentials`, `~/.npmrc` or
|
|
677
|
-
`~/.config/gh/hosts.yml`. That is the deliberate trade in the table above — an agent that
|
|
678
|
-
cannot read the machine cannot work — and `seisin check` now says it on every run rather than
|
|
679
|
-
leaving you to find out.
|
|
577
|
+
And the standing list is a command:
|
|
680
578
|
|
|
681
|
-
|
|
579
|
+
```
|
|
580
|
+
$ seisin walls dev
|
|
581
|
+
WALLS — you have already been refused these, and the policy still refuses them.
|
|
582
|
+
Do not retry; the reason is where the other way in is.
|
|
583
|
+
19× write ../equipo/.git/index.lock
|
|
584
|
+
../equipo/.git/index.lock belongs to plataforma-dev
|
|
585
|
+
6× read ~/.npmrc
|
|
586
|
+
no role declares ~/.npmrc — add it under a [roles.<name>] keys list
|
|
587
|
+
(23 of your calls went into retrying these.)
|
|
588
|
+
```
|
|
682
589
|
|
|
683
|
-
|
|
590
|
+
**A wall is recomputed against the policy, not read out of the log.** If the role would be
|
|
591
|
+
allowed today, it is not a wall — whatever happened yesterday. The obvious implementation is a
|
|
592
|
+
time window, on the argument that an old wall may have been granted since; the window is a
|
|
593
|
+
proxy for the question, and seisin has the policy right there, so it asks the question. A grant
|
|
594
|
+
makes its wall disappear on the next turn instead of when a window expires. Same move as
|
|
595
|
+
`owners` and the request queue: recompute rather than believe.
|
|
684
596
|
|
|
685
|
-
|
|
686
|
-
|
|
687
|
-
isolate = "credentials" # ~/.ssh, ~/.aws, ~/.npmrc, ~/.config and the rest go dark
|
|
688
|
-
```
|
|
597
|
+
Silent on the first denial, by design. A counter that reads `1×` every time is noise on the
|
|
598
|
+
turn where the sentence is already doing its job.
|
|
689
599
|
|
|
690
|
-
|
|
600
|
+
**Whether it makes an agent stop is not demonstrated, and will not be for a while.** The
|
|
601
|
+
obvious way to check is to compare repeats before and after — but the repeats were 86% one
|
|
602
|
+
lock file, and the release before this one deleted that wall outright with
|
|
603
|
+
`GIT_OPTIONAL_LOCKS=0`. Repeats did fall, by a lot, and **the fall is the wall going away,
|
|
604
|
+
not the sentence landing**: both shipped within days of each other and the bigger cause is the
|
|
605
|
+
other one. Isolating this needs a forward test — walls per role, rounds with the hook wired
|
|
606
|
+
and no git locks in the way — not the history. Measured in the field and written up rather
|
|
607
|
+
than smoothed over, because the alternative is a claim the numbers do not carry.
|
|
691
608
|
|
|
692
|
-
|
|
693
|
-
|---|---|---|---|
|
|
694
|
-
| default | open | open | **OK** |
|
|
695
|
-
| `isolate = "credentials"` | **closed** | open | **OK** |
|
|
696
|
-
| `isolate = "home"` | **closed** | closed | `Not logged in` |
|
|
609
|
+
## Scratch space, and what it costs
|
|
697
610
|
|
|
698
|
-
|
|
699
|
-
|
|
700
|
-
|
|
701
|
-
|
|
702
|
-
|
|
703
|
-
involved at all.
|
|
611
|
+
Territory alone is correct and unusable: an agent writes session state under its own config
|
|
612
|
+
directory and its tools write to the temp dir, so *your folders and nothing else* stops the
|
|
613
|
+
agent from starting. Every role gets `~/.claude`, `~/.cache`, `$TMPDIR` and `/tmp` — and that
|
|
614
|
+
is a real hole, because two roles share a temp directory and territory does not hold in it.
|
|
615
|
+
It is one line to turn off.
|
|
704
616
|
|
|
705
|
-
|
|
706
|
-
one you can turn on today on a machine you are already working on.
|
|
617
|
+
**[What that costs, and how to close it →](docs/scratch.md)**
|
|
707
618
|
|
|
708
619
|
## What it is not
|
|
709
620
|
|
|
@@ -761,19 +672,48 @@ This space already has good work, and seisin is not the first thing here:
|
|
|
761
672
|
- [`anthropics/sandbox-runtime`](https://github.com/anthropics/sandbox-runtime) — the enforcement. seisin is a thin thing on top of a serious one.
|
|
762
673
|
- [`kornysietsma/claude-code-permissions-hook`](https://github.com/kornysietsma/claude-code-permissions-hook) — granular `PreToolUse` rules, one global policy.
|
|
763
674
|
- [`XuebinMa/agent-guard`](https://github.com/XuebinMa/agent-guard) — a permission-enforcement SDK, also one global policy.
|
|
675
|
+
- [`NVIDIA/OpenShell`](https://github.com/NVIDIA/openshell) — a runtime that sandboxes an agent with Landlock and seccomp, under a declarative policy. Serious, and the closest thing here by weight.
|
|
676
|
+
- [`dredozubov/hazmat`](https://github.com/dredozubov/hazmat) — runs the agent as a different system user, with `pf` rules and snapshots.
|
|
677
|
+
- [`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.
|
|
764
678
|
- *Directory ownership* is recommended in half the multi-agent write-ups. As far as I could find, nobody enforces it, and nobody names the owner in the refusal. That gap is the reason for this repo.
|
|
765
679
|
|
|
680
|
+
### One sentence to tell these apart, because the names all sound the same
|
|
681
|
+
|
|
682
|
+
**OpenShell, hazmat and nono isolate the agent from your machine. seisin separates roles from
|
|
683
|
+
each other inside one repository, and when it blocks something it names the owner.**
|
|
684
|
+
|
|
685
|
+
Those are different problems and the first one is not the one this solves. If what you want
|
|
686
|
+
is "this agent cannot touch anything outside its box", they do that and OpenShell does it
|
|
687
|
+
with a large organisation behind it. If what you want is "four agents work in the same
|
|
688
|
+
repository and none of them edits another's files — and when one is stopped it is told whose
|
|
689
|
+
those files are", nothing above answers that, which is why this exists.
|
|
690
|
+
|
|
691
|
+
They compose rather than compete: the agent seisin confines could be running inside one of
|
|
692
|
+
them.
|
|
693
|
+
|
|
694
|
+
*One detail worth knowing before you compare, and it is about placement rather than quality.
|
|
695
|
+
Read from [OpenShell's support matrix][osm] on 2026-09-21, not measured here: macOS is a
|
|
696
|
+
supported platform, and on it "these kernel modules run inside the Docker Desktop Linux VM,
|
|
697
|
+
not on the host kernel". So on a Mac, OpenShell confines an agent inside a Linux VM, and
|
|
698
|
+
seisin confines a process on the host through Seatbelt. Both are kernel enforcement; they are
|
|
699
|
+
not the same kernel, and which one you want depends on whether the thing you are protecting is
|
|
700
|
+
on the host. seisin [says where each of its own claims was
|
|
701
|
+
measured](docs/what-it-has-been-put-through.md#where-each-claim-was-actually-run).*
|
|
702
|
+
|
|
703
|
+
[osm]: https://docs.nvidia.com/openshell/reference/support-matrix
|
|
704
|
+
|
|
766
705
|
## Status
|
|
767
706
|
|
|
768
|
-
`0.
|
|
707
|
+
`0.3.0`, 452 tests, of which **26 need `@anthropic-ai/sandbox-runtime` installed**
|
|
769
708
|
and run real commands through the real kernel — and CI fails if the sandbox half *skips*, because
|
|
770
709
|
a green run that quietly tested nothing looks exactly like a real one. That is not hypothetical:
|
|
771
710
|
those eighteen skipped on Linux for a day, behind a runtime check that looked for the global
|
|
772
711
|
install and missed the bundled one, and hid a defect that broke `seisin run` on that platform
|
|
773
|
-
entirely. **
|
|
712
|
+
entirely. **326/326 on macOS 15 and on `ubuntu-latest` under bubblewrap**, nothing skipped on
|
|
774
713
|
either, Node 18/20/22 in CI at every push — and 225/225 the same way on Debian 12.15
|
|
775
714
|
with bubblewrap 0.8.0, the last time the suite was run in Docker.
|
|
776
|
-
[Which claim was measured where](docs/what-it-has-been-put-through.md#where-each-claim-was-actually-run)
|
|
715
|
+
[Which claim was measured where](docs/what-it-has-been-put-through.md#where-each-claim-was-actually-run),
|
|
716
|
+
and [what running agents behind it cost the people using it](docs/field-notes.md).
|
|
777
717
|
The config format may still move before `1.0` — if it does, `seisin check` will
|
|
778
718
|
tell you what changed.
|
|
779
719
|
|
|
@@ -785,9 +725,17 @@ already done:
|
|
|
785
725
|
| **Windows** | the runtime has a backend. seisin has never been pointed at it, and no CI runner covers it |
|
|
786
726
|
| **Deleting inside your own territory** | not covered, and not coverable here — the ask is upstream as [issue #545](https://github.com/anthropics/sandbox-runtime/issues/545), open and unanswered since 2026-09-13, [with the measurement behind it](docs/upstream/denyUnlink.md) and [a demo](docs/demo/) |
|
|
787
727
|
| **`init` heuristics** | it reads `.claude/agents/` then `CODEOWNERS`. Every other convention is a guess nobody has made yet |
|
|
728
|
+
| **SSH inside a turn** | the `ProxyCommand` the runtime sets can't authenticate to its own proxy, so `git` over SSH fails at the handshake. Fix upstream: [PR #516](https://github.com/anthropics/sandbox-runtime/pull/516). Use an HTTPS remote |
|
|
729
|
+
| **A browser inside a role (macOS)** | Chromium only starts with `--single-process`, which is unstable with several browsers. Fix upstream: [PR #598](https://github.com/anthropics/sandbox-runtime/pull/598). [Measurement](docs/upstream/mach-register.md) |
|
|
730
|
+
|
|
731
|
+
Every word above has one meaning, listed in [the glossary](docs/glossary.md) — the boundary
|
|
732
|
+
**denies**, a person **declines**, seisin **refuses** a config it cannot enforce. A tool whose
|
|
733
|
+
product is a sentence cannot afford two words for one thing.
|
|
788
734
|
|
|
789
|
-
Issues and pull requests welcome
|
|
790
|
-
|
|
735
|
+
Issues and pull requests welcome — [CONTRIBUTING.md](CONTRIBUTING.md) says what is actually
|
|
736
|
+
different about contributing here, which is mostly that a claim has to be downstream of
|
|
737
|
+
something measured. If you are reporting something that got past the boundary, do it
|
|
738
|
+
[privately](SECURITY.md); `seisin log --verdict denied` and the `seisin.toml` are the two
|
|
791
739
|
things that make it reproducible.
|
|
792
740
|
|
|
793
741
|
## The name
|