seisin 0.4.0 → 0.5.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 +151 -67
- package/package.json +3 -3
- package/src/cli.js +145 -46
- package/src/commands/check.js +3 -2
- package/src/commands/explain.js +34 -13
- package/src/commands/hook.js +14 -4
- package/src/commands/init.js +126 -30
- package/src/commands/log.js +55 -10
- package/src/commands/requests.js +124 -39
- package/src/commands/review.js +41 -16
- package/src/commands/run.js +174 -39
- package/src/commands/scan.js +18 -3
- package/src/commands/ui.js +18 -5
- package/src/commands/walls.js +25 -6
- package/src/commands/whose.js +7 -6
- package/src/commands/wire.js +156 -17
- package/src/config.js +261 -27
- package/src/controls.js +244 -0
- package/src/diagnose.js +35 -33
- package/src/env.js +11 -8
- package/src/grants.js +15 -7
- package/src/help.js +159 -0
- package/src/hook.js +49 -19
- package/src/index.js +4 -4
- package/src/inspect.js +137 -71
- package/src/intake.js +24 -16
- package/src/keys.js +13 -11
- package/src/kinds.js +207 -0
- package/src/log.js +312 -55
- package/src/mcp.js +96 -43
- package/src/notify.js +15 -10
- package/src/owners.js +323 -41
- package/src/paths.js +37 -1
- package/src/redact.js +19 -5
- package/src/render.js +81 -12
- package/src/requests.js +250 -72
- package/src/review.js +42 -21
- package/src/rundir.js +4 -6
- package/src/scan.js +177 -19
- package/src/serve.js +395 -205
- package/src/spool.js +9 -29
- package/src/srt.js +38 -21
- package/src/suggest.js +58 -0
- package/src/surface.js +203 -42
- package/src/uilink.js +10 -4
- package/src/views.js +289 -0
- package/src/violations.js +104 -16
- package/src/walls.js +19 -42
- package/src/worktree.js +3 -11
- package/ui/index.html +1366 -529
package/README.md
CHANGED
|
@@ -11,13 +11,13 @@
|
|
|
11
11
|
|
|
12
12
|
> **seisin** *(n.)* — the legal possession of a piece of land. Not who owns it on paper: who holds it now.
|
|
13
13
|
|
|
14
|
-
**Give each AI agent its own folders and its own keys.** The kernel enforces it, and when it
|
|
14
|
+
**Give each AI agent its own folders and its own keys.** The kernel enforces it, and when it denies something, seisin tells you *whose* file it was.
|
|
15
15
|
|
|
16
16
|
A **permission layer**, not a sandbox — it sits on top of one. The isolation comes from the OS; what seisin adds is the part an OS cannot know: which role a path belongs to, and therefore who to ask next.
|
|
17
17
|
|
|
18
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)*
|
|
19
19
|
|
|
20
|
-
*And what running four
|
|
20
|
+
*And what running four teams of agents behind it actually cost — including the numbers that did not survive a re-check. [Field notes →](docs/field-notes.md)*
|
|
21
21
|
|
|
22
22
|
**Three ways in, and they say the same thing.** The **CLI** is what a person types and what
|
|
23
23
|
every agent runs under. The **console** (`seisin ui`) is for a person deciding something. The
|
|
@@ -38,7 +38,7 @@ says why, and a test holds one half of it.
|
|
|
38
38
|
|
|
39
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
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) · [
|
|
41
|
+
**Deeper** · [Keys](docs/keys.md) · [Agents](docs/agents.md) · [Day one](docs/first-day.md) · [Scratch](docs/scratch.md) · [Glossary](docs/glossary.md) · [What it has been put through](docs/what-it-has-been-put-through.md) · [Field notes](docs/field-notes.md) · [OpenShell compatibility](docs/openshell.md) · [Decisions](docs/decisions.md) · [nono backend contract](docs/nono-backend.md) · [Requests](docs/permission-requests.md) · [Contributing](CONTRIBUTING.md)
|
|
42
42
|
|
|
43
43
|
</details>
|
|
44
44
|
|
|
@@ -46,7 +46,7 @@ says why, and a test holds one half of it.
|
|
|
46
46
|
flowchart LR
|
|
47
47
|
A["agent<br/>running as a role"] -->|writes src/api/orders.ts| K{{"the kernel<br/>Seatbelt · bubblewrap"}}
|
|
48
48
|
K -->|not this role's| D["denied"]
|
|
49
|
-
D --> S["seisin reads the
|
|
49
|
+
D --> S["seisin reads the denial<br/>and looks up the policy"]
|
|
50
50
|
S --> W["<b>whose it was</b><br/>belongs to backend"]
|
|
51
51
|
S --> Q["a request, queued"]
|
|
52
52
|
Q --> H(["a person<br/>grants or declines"])
|
|
@@ -60,7 +60,7 @@ flowchart LR
|
|
|
60
60
|
|
|
61
61
|
**The kernel says no. seisin says whose.** That second half is the whole thing: other
|
|
62
62
|
permission layers in this space answer *yes* or *no*, or at best *which rule* said no — and a
|
|
63
|
-
|
|
63
|
+
denial that also names an owner turns a dead end into a handoff.
|
|
64
64
|
|
|
65
65
|

|
|
66
66
|
|
|
@@ -77,11 +77,11 @@ $ seisin run frontend -- sh -c 'echo // fix >> src/api/orders.ts'
|
|
|
77
77
|
seisin grant <n> [--reason "…"] · seisin decline <n> [--reason "…"]
|
|
78
78
|
```
|
|
79
79
|
|
|
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
|
|
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 denial into a handoff — and one a person can grant in a command, rather than a line somebody has to remember to go and read.
|
|
81
81
|
|
|
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
|
|
82
|
+
The kernel is what denies; the name comes from the policy. That first line is all the boundary itself can say — no path, no reason, nothing to read afterwards — so seisin reads the denial out of the kernel's own log and answers the question it leaves open. On Linux only the hook can do that; [the ask is upstream](docs/upstream/cli-violations.md) as [issue #582](https://github.com/anthropics/sandbox-runtime/issues/582).
|
|
83
83
|
|
|
84
|
-
The agent can ask directly too, from inside the
|
|
84
|
+
The agent can ask directly too, from inside the sandbox:
|
|
85
85
|
|
|
86
86
|
```
|
|
87
87
|
$ seisin run frontend -- seisin whose src/api/orders.ts
|
|
@@ -106,7 +106,7 @@ On Linux, [three system packages first](#install). Everything below is why it wo
|
|
|
106
106
|
|
|
107
107
|
## Why this exists
|
|
108
108
|
|
|
109
|
-
Two things are true about
|
|
109
|
+
Two things are true about how agents are kept in bounds today, and both are in the official docs.
|
|
110
110
|
|
|
111
111
|
**1. Permission rules match text, not behaviour.** From the Claude Code documentation:
|
|
112
112
|
|
|
@@ -157,12 +157,12 @@ fixtures:
|
|
|
157
157
|
| it can write anything inside its own territory | including staging an exfiltration, or destroying its own uncommitted work with `rm` |
|
|
158
158
|
| `base64` defeats the redaction | only the literal value is masked, and only on stdout and stderr |
|
|
159
159
|
| an allowed domain is an allowed channel | if the agent's own API is reachable, so is everything that goes through it |
|
|
160
|
-
| the hook is text-based and evadable | on purpose — what escapes it goes **
|
|
160
|
+
| the hook is text-based and evadable | on purpose — what escapes it goes **past the hook, not past the kernel**: the kernel still denies. On macOS it is no longer unexplained either, because [the kernel's own denials are recorded](#seeing-what-happened); on Linux it is. Either way, attribution is not a control |
|
|
161
161
|
|
|
162
162
|
And three things that outweigh all of the above:
|
|
163
163
|
|
|
164
|
-
1. **It is
|
|
165
|
-
|
|
164
|
+
1. **It is young and has one author.** The first public release was September 2026, and the
|
|
165
|
+
implementation is still moving quickly. "Secure" is not a word earned by size or test count.
|
|
166
166
|
2. **The enforcement is someone else's beta.** `sandbox-runtime` describes itself as a
|
|
167
167
|
research preview with an evolving API. A hole there is a hole here.
|
|
168
168
|
3. **Two platforms tested, by one person, on one machine each.**
|
|
@@ -215,6 +215,25 @@ seisin wire # install the PreToolUse hook in this r
|
|
|
215
215
|
seisin ui # a console for editing the map
|
|
216
216
|
```
|
|
217
217
|
|
|
218
|
+
`seisin <command> --help` lists a command's flags, examples and exit codes. The exit codes, so
|
|
219
|
+
the commands compose in a script, a pre-commit hook or CI:
|
|
220
|
+
|
|
221
|
+
| command | 0 | 1 | other |
|
|
222
|
+
|---|---|---|---|
|
|
223
|
+
| `run` | — | — | the command's own status; 127 if it is not on the role's PATH; 128+n when killed by signal n (130 for ctrl-c) |
|
|
224
|
+
| `check` | the policy can be enforced (warnings included) | a role cannot be enforced as written | |
|
|
225
|
+
| `explain` | allowed | denied | |
|
|
226
|
+
| `scan` | no certain credential | a certain credential outside the key dirs | |
|
|
227
|
+
| `review` | no role denied repeatedly | a role denied repeatedly | |
|
|
228
|
+
| `walls` | no walls | walls listed | |
|
|
229
|
+
| `requests` | nothing pending | requests pending | |
|
|
230
|
+
| `log verify` | the hash chain holds | the hash chain is broken | |
|
|
231
|
+
| `whose`, `grant`, `decline`, `log`, `init`, `wire`, `ui`, `watch`, `mcp` | done | — | |
|
|
232
|
+
| `hook` | always | — | |
|
|
233
|
+
|
|
234
|
+
Every command exits **2** on a usage error — an unknown command, flag, role or action — and when
|
|
235
|
+
there is no `seisin.toml` here or above.
|
|
236
|
+
|
|
218
237
|
## Configure
|
|
219
238
|
|
|
220
239
|
One file at the root of your repo. Anything not listed is denied — there is no permissive default.
|
|
@@ -247,7 +266,7 @@ role, including the ones whose whole job is to have nothing to reach.
|
|
|
247
266
|
> cannot resolve the hostname, and `git ls-remote git@github.com:…` reaches the proxy and
|
|
248
267
|
> dies at the handshake.
|
|
249
268
|
>
|
|
250
|
-
> **Use an HTTPS remote for git**, and run SSH deploys outside the confined
|
|
269
|
+
> **Use an HTTPS remote for git**, and run SSH deploys outside the confined run.
|
|
251
270
|
> [Why, exactly, and whose gap it is →](docs/decisions.md#ssh-does-not-work-and-it-is-not-seisin-that-decided-that)
|
|
252
271
|
|
|
253
272
|
A role can also give something back. `never_writes` subtracts from its own `writes`, and it
|
|
@@ -260,8 +279,8 @@ never_writes = ["services/api/.git/index.lock"] # works in a worktree; the can
|
|
|
260
279
|
```
|
|
261
280
|
|
|
262
281
|
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
|
|
264
|
-
leaves **no request** in the queue:
|
|
282
|
+
breaks every role that legitimately commits there. A denial it causes says so by name and
|
|
283
|
+
leaves **no request** in the queue: granting one would undo a subtraction somebody wrote. An
|
|
265
284
|
entry that no `writes` of the same role covers, or a misspelling like `never_write`, is named
|
|
266
285
|
by `seisin check` instead of being ignored.
|
|
267
286
|
|
|
@@ -275,8 +294,8 @@ private loopback, serves without the key, and reaches nothing on the host either
|
|
|
275
294
|
If a role only needs to *reach* a local service, name the ports instead:
|
|
276
295
|
`local_ports = [8001, 8081]`. The role reaches those and nothing else on localhost, on macOS
|
|
277
296
|
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
|
|
279
|
-
|
|
297
|
+
Node's `fetch` and Python's `urllib` do. A database driver or a raw socket don't, and are
|
|
298
|
+
denied. For Chromium, pass Playwright's `proxy` option from `HTTP_PROXY`; on macOS it also
|
|
280
299
|
needs `--single-process` to start. A listed port is open to anything that speaks `CONNECT`, not
|
|
281
300
|
just HTTP, so list the service, not the protocol.
|
|
282
301
|
|
|
@@ -286,7 +305,33 @@ key means the policy doesn't say. seisin declares this and `explain` answers it
|
|
|
286
305
|
launcher has to apply it. With Claude Code: `--mcp-config` holding only those servers, plus
|
|
287
306
|
`--strict-mcp-config`.
|
|
288
307
|
|
|
289
|
-
|
|
308
|
+
Some files are not code but still make something happen outside the sandbox, and no territory
|
|
309
|
+
reaches them: git hooks and config, a project's `.claude/`, `.mcp.json`, `.envrc`. Two softer
|
|
310
|
+
families sit beside those, and a policy can hand them to one role:
|
|
311
|
+
|
|
312
|
+
```toml
|
|
313
|
+
[roles.frontend]
|
|
314
|
+
writes = ["apps/web/**"]
|
|
315
|
+
control_files = ["ide"] # keeps apps/web/.vscode/launch.json current
|
|
316
|
+
|
|
317
|
+
[protect]
|
|
318
|
+
instructions = true # CLAUDE.md, AGENTS.md… only for roles handed "instructions"
|
|
319
|
+
```
|
|
320
|
+
|
|
321
|
+
- **`ide`** — `.vscode/`, `.cursor/`, `.windsurf/`. Your editor applies them as soon as they
|
|
322
|
+
change, which is how an injected agent turns on tool auto-approval. **Protected by default.**
|
|
323
|
+
- **`instructions`** — `CLAUDE.md`, `AGENTS.md`, `GEMINI.md`, Cursor and Windsurf rules, Copilot
|
|
324
|
+
instructions. Nothing runs them; the next agent session reads them as its instructions, so
|
|
325
|
+
one role writing them reaches another role's context. **Off unless `[protect]` turns it on**:
|
|
326
|
+
keeping them current is ordinary work for most teams.
|
|
327
|
+
|
|
328
|
+
A role handed a family writes it only inside its own `writes`, and `seisin check` names it on
|
|
329
|
+
every run. A denial says which setting would change it, and leaves no request: it is a decision
|
|
330
|
+
about the policy, not a grant. Inside `node_modules` neither family is touched, so npm still
|
|
331
|
+
unpacks a package's own `.vscode` or `AGENTS.md`. On Linux only the files that already exist are
|
|
332
|
+
protected: bubblewrap would have to create a missing one to deny it.
|
|
333
|
+
|
|
334
|
+
Denied connections are logged too: `connect tcp:<port>` (the kernel gives the port, not the
|
|
290
335
|
host) or the socket path, once per target per run. `seisin walls` says whether the port is
|
|
291
336
|
missing from `local_ports`, listed but dialled directly, or a socket. No request is filed: a
|
|
292
337
|
port isn't anyone's territory. macOS only.
|
|
@@ -302,6 +347,11 @@ answer is ownership.
|
|
|
302
347
|
|
|
303
348
|
All four came out of one production window where none of them was the boundary misbehaving.
|
|
304
349
|
|
|
350
|
+
A fifth comes with protected files: **a role's `git checkout`, `pull` or `rebase` cannot update
|
|
351
|
+
a file the role may not write.** Git exits 0, the branch moves, and the old `.vscode/settings.json`
|
|
352
|
+
stays behind as a modification — which a later `commit -a` would turn into a revert. Switch
|
|
353
|
+
branches outside the sandbox, or hand that role the family if keeping those files is its job.
|
|
354
|
+
|
|
305
355
|
**[What each one looks like, and what to do →](docs/first-day.md)**
|
|
306
356
|
|
|
307
357
|
## Which agents it has been run with
|
|
@@ -324,18 +374,34 @@ actually happened and tells you where the config was wrong about it.
|
|
|
324
374
|
```
|
|
325
375
|
$ seisin review
|
|
326
376
|
|
|
327
|
-
60
|
|
377
|
+
60 entries, 2026-08-23 to 2026-09-11
|
|
378
|
+
|
|
379
|
+
Denied repeatedly
|
|
380
|
+
a role denied on the same place over and over is a policy that is wrong,
|
|
381
|
+
not an agent that is misbehaving
|
|
328
382
|
|
|
329
|
-
Stopped, repeatedly
|
|
330
383
|
9× frontend write src/api/checkout — belongs to backend
|
|
331
384
|
4× backend write src/web — belongs to frontend
|
|
332
385
|
|
|
386
|
+
seisin grant, or move the territory. Either way it is a decision, not noise.
|
|
387
|
+
|
|
333
388
|
Granted, never used
|
|
389
|
+
nothing was written here in the window above. This is the only evidence
|
|
390
|
+
anyone will ever have for making a policy smaller
|
|
391
|
+
|
|
334
392
|
frontend public/**
|
|
335
|
-
backend
|
|
393
|
+
backend migrations/**
|
|
336
394
|
|
|
337
|
-
|
|
338
|
-
|
|
395
|
+
A short window proves nothing. Check the dates before you delete a line.
|
|
396
|
+
|
|
397
|
+
Unowned
|
|
398
|
+
touched by an agent, claimed by no role. Only an ownable path (kind `territory`)
|
|
399
|
+
is a hole in the map; the rest is a kind of thing with its own answer, and none
|
|
400
|
+
of them is an owner
|
|
401
|
+
|
|
402
|
+
4× territory legacy
|
|
403
|
+
|
|
404
|
+
territory: an ownable path no role claims: a real decision — give it an owner, or tell the agent not to write there
|
|
339
405
|
```
|
|
340
406
|
|
|
341
407
|
Three questions, and the middle one is the reason this exists. **Every
|
|
@@ -344,7 +410,7 @@ reason: nobody can prove a line is dead, so the safe move is to leave it. Here
|
|
|
344
410
|
the log can prove it — `public/**` was granted and nothing was written there in
|
|
345
411
|
three weeks. That is the only direction that makes a policy *smaller*.
|
|
346
412
|
|
|
347
|
-
The first is the one that changes how a denial reads. Forty
|
|
413
|
+
The first is the one that changes how a denial reads. Forty denials on one
|
|
348
414
|
directory is not an agent misbehaving; it is a policy that is wrong, and until
|
|
349
415
|
you add them up it looks like forty tidy amber lines.
|
|
350
416
|
|
|
@@ -361,10 +427,17 @@ the first unjustified denial is when the tool gets uninstalled. So don't write i
|
|
|
361
427
|
write:
|
|
362
428
|
|
|
363
429
|
```bash
|
|
364
|
-
seisin
|
|
430
|
+
seisin wire # the hook is what records
|
|
431
|
+
seisin run frontend --observe -- claude -p "…" # the repo opens, the network does not
|
|
365
432
|
seisin init --from-observations # writes seisin.toml.observed
|
|
366
433
|
```
|
|
367
434
|
|
|
435
|
+
What `--observe` changes: the whole repo is writable for that run, so a write outside the
|
|
436
|
+
territory is recorded instead of refused. What it does not: the key directories, the policy and
|
|
437
|
+
`.seisin/` stay denied, and so does every domain not on the allowlist. What gets recorded is what
|
|
438
|
+
the hook sees — the tool calls of an agent (Claude Code, Codex…) in a repo where `seisin wire` ran.
|
|
439
|
+
A plain shell command (`--observe -- npm test`) goes through no hook and records nothing.
|
|
440
|
+
|
|
368
441
|
It lands as `.observed`, not as your config. A policy generated behind your back is not a
|
|
369
442
|
policy: diff it, then move it.
|
|
370
443
|
|
|
@@ -372,8 +445,8 @@ policy: diff it, then move it.
|
|
|
372
445
|
|
|
373
446
|
A permission tool that can only say no is a tool people uninstall — the loop of *denied,
|
|
374
447
|
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
|
|
376
|
-
|
|
448
|
+
cheapest way to stop it is to widen the policy and never look again. That is how an allowlist
|
|
449
|
+
becomes seven hundred entries nobody reads.
|
|
377
450
|
|
|
378
451
|
So a denial leaves something to act on, and many denials in one directory are **one** request:
|
|
379
452
|
|
|
@@ -392,7 +465,7 @@ The grant writes its own provenance next to the line it adds:
|
|
|
392
465
|
writes = ["src/api/**"] # granted 2026-09-12 · asked 3× · "frontend owns checkout now"
|
|
393
466
|
```
|
|
394
467
|
|
|
395
|
-
**
|
|
468
|
+
**Granting is never a tool call.** An agent can read the queue and draft the change; only a
|
|
396
469
|
person applies it, in a terminal or in the console. That asymmetry is deliberate and it is
|
|
397
470
|
the reason the MCP server exists at all.
|
|
398
471
|
|
|
@@ -400,7 +473,7 @@ the reason the MCP server exists at all.
|
|
|
400
473
|
request — the role, the path, whose it is, and `seisin grant <n>` — to ntfy, Slack or any
|
|
401
474
|
webhook. The URL lives in a key directory no role declares (`url_file`) or in the parent's
|
|
402
475
|
`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 "
|
|
476
|
+
load, because whoever reads it can send you a fake "grant this".
|
|
404
477
|
|
|
405
478
|
```toml
|
|
406
479
|
[notify]
|
|
@@ -409,8 +482,8 @@ format = "text" # or "slack", "json"
|
|
|
409
482
|
```
|
|
410
483
|
|
|
411
484
|
**And so does the agent.** `seisin wire` installs the hook for four events. Before a tool call
|
|
412
|
-
it explains a
|
|
413
|
-
from the log and tells the agent whose the path was and how many times it has been
|
|
485
|
+
it explains a denial it can see coming; after a command fails it reads the kernel's own denial
|
|
486
|
+
from the log and tells the agent whose the path was and how many times it has been denied —
|
|
414
487
|
the half PreToolUse never sees; and when a session starts, resumes or is compacted it hands the
|
|
415
488
|
agent its territory and its walls, before the first one.
|
|
416
489
|
|
|
@@ -458,23 +531,27 @@ database, and `watch` is a tail.
|
|
|
458
531
|
**The agent cannot reach that file.** `.seisin/` is in no role's territory, because a record
|
|
459
532
|
the recorded process can edit is not a record. The hook sends each line to `seisin run` over
|
|
460
533
|
a unix socket granted by path and the parent does the writing, so the only verb available
|
|
461
|
-
from inside the
|
|
534
|
+
from inside the sandbox is *append one line*. A hostile agent can still add noise to its own
|
|
462
535
|
history; what it cannot do is change what is already there.
|
|
463
536
|
|
|
464
537
|
**Two writers, and they see different things.** The **hook** reports the attempt before it
|
|
465
538
|
happens — which is how an *allowed* action gets recorded at all — and it is text-based and
|
|
466
539
|
evadable, on purpose. The **kernel** reports what it actually denied, and is not fooled by
|
|
467
|
-
anything. What escapes the hook goes unexplained, never
|
|
540
|
+
anything. What escapes the hook goes unexplained, never unenforced.
|
|
468
541
|
|
|
469
542
|
|
|
470
543
|
|
|
471
544
|

|
|
472
545
|
|
|
473
|
-
It opens on what needs a decision, not on the policy.
|
|
546
|
+
It opens on what needs a decision, not on the policy. Four numbers ride on every screen and
|
|
474
547
|
each is a link to the page that explains it — including two a log cannot answer, because
|
|
475
548
|
they are recomputed against the policy: how much of the day is **one cause**, and how many
|
|
476
549
|
of those causes are on paths **nobody owns**.
|
|
477
550
|
|
|
551
|
+
Beside each role it also holds the two families a policy can hand out, editor settings and
|
|
552
|
+
instruction files, and the `[protect] instructions` switch. A change there is shown first as
|
|
553
|
+
what it does to each role's sandbox settings, and written to `seisin.toml` only when you confirm.
|
|
554
|
+
|
|
478
555
|
## How it holds
|
|
479
556
|
|
|
480
557
|
| layer | what it does | can it be talked around? |
|
|
@@ -550,7 +627,7 @@ confined process. Nothing degrades: a provider that fails stops the run.
|
|
|
550
627
|
|
|
551
628
|
**[Key directories, references, providers, delivery modes and nine recipes →](docs/keys.md)**
|
|
552
629
|
|
|
553
|
-
**Measured, over four
|
|
630
|
+
**Measured, over four teams of agents and three days: every denied read was a read of something
|
|
554
631
|
the policy had declared a key.** 148 of them, no exceptions, from five roles — all running
|
|
555
632
|
searches that swept a repository root. That is how a key gets read without anyone deciding
|
|
556
633
|
it should.
|
|
@@ -560,7 +637,7 @@ it should.
|
|
|
560
637
|
seisin answers *whose is this* at the moment of the denial — once, mid-turn — and then the
|
|
561
638
|
sentence is gone. Nothing carries it forward, so the agent tries again.
|
|
562
639
|
|
|
563
|
-
Measured
|
|
640
|
+
Measured in the author's own multi-agent setup: of **345 denials, 88 (25%) were a repeat of something that same role
|
|
564
641
|
had already been denied.** One role spent 37 calls on two walls, hitting one of them nineteen
|
|
565
642
|
times. Every denial was correct. Not one was a false positive. It still cost thirty-seven
|
|
566
643
|
calls, because *correct* and *heard* are different properties and only the first was being
|
|
@@ -578,20 +655,31 @@ And the standing list is a command:
|
|
|
578
655
|
|
|
579
656
|
```
|
|
580
657
|
$ seisin walls dev
|
|
581
|
-
|
|
582
|
-
|
|
583
|
-
|
|
584
|
-
|
|
585
|
-
|
|
586
|
-
|
|
587
|
-
|
|
658
|
+
|
|
659
|
+
dev keeps hitting:
|
|
660
|
+
|
|
661
|
+
19× write ../shared/.git/index.lock belongs to platform
|
|
662
|
+
6× read ~/.npmrc no role declares ~/.npmrc — add it under a [roles.<name>] keys list
|
|
663
|
+
|
|
664
|
+
23 of its calls went into retrying these.
|
|
588
665
|
```
|
|
589
666
|
|
|
667
|
+
That is the list said to the person at the terminal. The agent gets it in its own voice, at the
|
|
668
|
+
start of every session the hook is wired for:
|
|
669
|
+
|
|
670
|
+
```
|
|
671
|
+
Already denied more than once, and still denied — do not try again, hand it over:
|
|
672
|
+
- write ../shared/.git/index.lock (19×) — belongs to platform
|
|
673
|
+
- read ~/.npmrc (6×) — no role declares ~/.npmrc — add it under a [roles.<name>] keys list
|
|
674
|
+
```
|
|
675
|
+
|
|
676
|
+
and, on request, as data from the MCP server's `seisin_walls`.
|
|
677
|
+
|
|
590
678
|
**A wall is recomputed against the policy, not read out of the log.** If the role would be
|
|
591
679
|
allowed today, it is not a wall — whatever happened yesterday. The obvious implementation is a
|
|
592
680
|
time window, on the argument that an old wall may have been granted since; the window is a
|
|
593
681
|
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
|
|
682
|
+
makes its wall disappear on the next run instead of when a window expires. Same move as
|
|
595
683
|
`owners` and the request queue: recompute rather than believe.
|
|
596
684
|
|
|
597
685
|
Silent on the first denial, by design. A counter that reads `1×` every time is noise on the
|
|
@@ -602,7 +690,7 @@ obvious way to check is to compare repeats before and after — but the repeats
|
|
|
602
690
|
lock file, and the release before this one deleted that wall outright with
|
|
603
691
|
`GIT_OPTIONAL_LOCKS=0`. Repeats did fall, by a lot, and **the fall is the wall going away,
|
|
604
692
|
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,
|
|
693
|
+
other one. Isolating this needs a forward test — walls per role, runs with the hook wired
|
|
606
694
|
and no git locks in the way — not the history. Measured in the field and written up rather
|
|
607
695
|
than smoothed over, because the alternative is a claim the numbers do not carry.
|
|
608
696
|
|
|
@@ -618,7 +706,7 @@ It is one line to turn off.
|
|
|
618
706
|
|
|
619
707
|
## What it is not
|
|
620
708
|
|
|
621
|
-
- **Not a sandbox.** [`sandbox-runtime`](https://github.com/anthropics/sandbox-runtime) is the sandbox, and it is Anthropic's. seisin writes its settings and explains its
|
|
709
|
+
- **Not a sandbox.** [`sandbox-runtime`](https://github.com/anthropics/sandbox-runtime) is the sandbox, and it is Anthropic's. seisin writes its settings and explains its denials.
|
|
622
710
|
- **Not an orchestrator.** It does not run your agents, schedule them, or merge their work. It runs one command as one role.
|
|
623
711
|
- **Not a secret manager.** It scopes *reads* of files you already have. Where those files come from is your problem.
|
|
624
712
|
- **Not what a single agent needs first.** If only one agent ever touches the repo,
|
|
@@ -671,46 +759,42 @@ This space already has good work, and seisin is not the first thing here:
|
|
|
671
759
|
|
|
672
760
|
- [`anthropics/sandbox-runtime`](https://github.com/anthropics/sandbox-runtime) — the enforcement. seisin is a thin thing on top of a serious one.
|
|
673
761
|
- [`kornysietsma/claude-code-permissions-hook`](https://github.com/kornysietsma/claude-code-permissions-hook) — granular `PreToolUse` rules, one global policy.
|
|
674
|
-
- [`XuebinMa/agent-guard`](https://github.com/XuebinMa/agent-guard) —
|
|
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.
|
|
762
|
+
- [`XuebinMa/agent-guard`](https://github.com/XuebinMa/agent-guard) — an SDK that enforces what an agent may do, also one global policy.
|
|
763
|
+
- [`NVIDIA/OpenShell`](https://github.com/NVIDIA/openshell) — a runtime that sandboxes an agent with Landlock and seccomp, under a declarative policy. Serious, and the closest thing here by weight. It was evaluated as a possible backend; [the measured compatibility decision](docs/openshell.md) explains why it is currently an execution target candidate rather than a drop-in replacement.
|
|
676
764
|
- [`dredozubov/hazmat`](https://github.com/dredozubov/hazmat) — runs the agent as a different system user, with `pf` rules and snapshots.
|
|
677
765
|
- [`nolabs-ai/nono`](https://github.com/nolabs-ai/nono) — a kernel sandbox for agents on the host (Seatbelt, Landlock), with per-tool child sandboxes, a credential proxy with endpoint filtering, approval webhooks and a tamper-evident audit log. The most complete sandbox here, and on a Mac the one that enforces through the same kernel seisin does. `nono why` explains *which rule* denied something and how to allow it; it has no notion of *whose* it was.
|
|
678
|
-
- *Directory ownership* is recommended in half the multi-agent write-ups. As far as I could find, nobody enforces it, and nobody names the owner in the
|
|
766
|
+
- *Directory ownership* is recommended in half the multi-agent write-ups. As far as I could find, nobody enforces it, and nobody names the owner in the denial. That gap is the reason for this repo.
|
|
679
767
|
|
|
680
768
|
### One sentence to tell these apart, because the names all sound the same
|
|
681
769
|
|
|
682
770
|
**OpenShell, hazmat and nono isolate the agent from your machine. seisin separates roles from
|
|
683
|
-
each other inside one repository, and when
|
|
771
|
+
each other inside one repository, and when the kernel denies something it names the owner.**
|
|
684
772
|
|
|
685
773
|
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
|
|
774
|
+
is "this agent cannot touch anything outside its sandbox", they do that and OpenShell does it
|
|
687
775
|
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
|
|
776
|
+
repository and none of them edits another's files — and when one is denied it is told whose
|
|
689
777
|
those files are", nothing above answers that, which is why this exists.
|
|
690
778
|
|
|
691
779
|
They compose rather than compete: the agent seisin confines could be running inside one of
|
|
692
780
|
them.
|
|
693
781
|
|
|
694
782
|
*One detail worth knowing before you compare, and it is about placement rather than quality.
|
|
695
|
-
|
|
696
|
-
|
|
697
|
-
|
|
698
|
-
|
|
699
|
-
|
|
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
|
|
783
|
+
OpenShell was [evaluated here on 2026-09-27](docs/openshell.md), through its Docker driver on
|
|
784
|
+
an Intel Mac. That confines an agent inside Docker Desktop's Linux VM; seisin confines a
|
|
785
|
+
process on the host through Seatbelt. OpenShell also offers other drivers, including a local
|
|
786
|
+
microVM path on supported Apple Silicon Macs. These are kernel-enforced boundaries, but not
|
|
787
|
+
the same kernel or workspace, and which one you want depends on what you are protecting.*
|
|
704
788
|
|
|
705
789
|
## Status
|
|
706
790
|
|
|
707
|
-
`0.
|
|
791
|
+
`0.5.0`, 780 tests, of which **26 need `@anthropic-ai/sandbox-runtime` installed**
|
|
708
792
|
and run real commands through the real kernel — and CI fails if the sandbox half *skips*, because
|
|
709
793
|
a green run that quietly tested nothing looks exactly like a real one. That is not hypothetical:
|
|
710
|
-
|
|
794
|
+
when there were eighteen of them, they skipped on Linux for a day, behind a runtime check that looked for the global
|
|
711
795
|
install and missed the bundled one, and hid a defect that broke `seisin run` on that platform
|
|
712
|
-
entirely. **
|
|
713
|
-
|
|
796
|
+
entirely. **780 tests on macOS 15, 778 passing and 2 skipped** (they are Linux-only) — 2026-09-30;
|
|
797
|
+
`ubuntu-latest` under bubblewrap runs the same suite, Node 18/20/22/24 in CI at every push — and 225/225 the same way on Debian 12.15
|
|
714
798
|
with bubblewrap 0.8.0, the last time the suite was run in Docker.
|
|
715
799
|
[Which claim was measured where](docs/what-it-has-been-put-through.md#where-each-claim-was-actually-run),
|
|
716
800
|
and [what running agents behind it cost the people using it](docs/field-notes.md).
|
|
@@ -725,11 +809,11 @@ already done:
|
|
|
725
809
|
| **Windows** | the runtime has a backend. seisin has never been pointed at it, and no CI runner covers it |
|
|
726
810
|
| **Deleting inside your own territory** | not covered, and not coverable here — the ask is upstream as [issue #545](https://github.com/anthropics/sandbox-runtime/issues/545), open and unanswered since 2026-09-13, [with the measurement behind it](docs/upstream/denyUnlink.md) and [a demo](docs/demo/) |
|
|
727
811
|
| **`init` heuristics** | it reads `.claude/agents/` then `CODEOWNERS`. Every other convention is a guess nobody has made yet |
|
|
728
|
-
| **SSH inside a
|
|
812
|
+
| **SSH inside a run (macOS)** | on macOS the `ProxyCommand` the runtime sets can't authenticate to its own proxy, so `git` over SSH fails at the handshake (on Linux the runtime routes it through an authenticated proxy). Fix upstream: [PR #516](https://github.com/anthropics/sandbox-runtime/pull/516). Use an HTTPS remote |
|
|
729
813
|
| **A browser inside a role (macOS)** | Chromium only starts with `--single-process`, which is unstable with several browsers. Fix upstream: [PR #598](https://github.com/anthropics/sandbox-runtime/pull/598). [Measurement](docs/upstream/mach-register.md) |
|
|
730
814
|
|
|
731
815
|
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
|
|
816
|
+
**denies**, a person **grants** or **declines**, seisin **refuses** a config it cannot enforce. A tool whose
|
|
733
817
|
product is a sentence cannot afford two words for one thing.
|
|
734
818
|
|
|
735
819
|
Issues and pull requests welcome — [CONTRIBUTING.md](CONTRIBUTING.md) says what is actually
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "seisin",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"description": "A permission layer for AI coding agents: role-scoped folders and keys, enforced by the OS, and when it
|
|
3
|
+
"version": "0.5.0",
|
|
4
|
+
"description": "A permission layer for AI coding agents: role-scoped folders and keys, enforced by the OS, and when it denies something it tells you whose file it was.",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"ai-agents",
|
|
7
7
|
"claude-code",
|
|
@@ -39,7 +39,7 @@
|
|
|
39
39
|
"node": ">=18"
|
|
40
40
|
},
|
|
41
41
|
"dependencies": {
|
|
42
|
-
"@anthropic-ai/sandbox-runtime": "0.0.
|
|
42
|
+
"@anthropic-ai/sandbox-runtime": "0.0.78"
|
|
43
43
|
},
|
|
44
44
|
"scripts": {
|
|
45
45
|
"test": "node --test test/*.test.js",
|