seisin 0.5.0 → 0.6.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +385 -129
- package/package.json +13 -5
- package/src/browsers.js +52 -0
- package/src/canary.js +149 -0
- package/src/cli.js +47 -23
- package/src/commands/check.js +154 -3
- package/src/commands/explain.js +2 -5
- package/src/commands/hook.js +7 -3
- package/src/commands/init.js +57 -50
- package/src/commands/requests.js +31 -56
- package/src/commands/review.js +33 -6
- package/src/commands/run.js +332 -110
- package/src/commands/walls.js +39 -7
- package/src/commands/whose.js +2 -1
- package/src/config.js +321 -15
- package/src/controls.js +5 -5
- package/src/diagnose.js +67 -16
- package/src/env.js +3 -0
- package/src/grants.js +107 -2
- package/src/handoff.js +285 -10
- package/src/help.js +39 -19
- package/src/hook.js +59 -30
- package/src/index.d.ts +217 -0
- package/src/index.js +20 -12
- package/src/inspect/codex.js +64 -0
- package/src/inspect/credentials.js +48 -0
- package/src/inspect/enforce.js +24 -0
- package/src/inspect/files.js +71 -0
- package/src/inspect/home.js +43 -0
- package/src/inspect/inject.js +68 -0
- package/src/inspect/keys.js +74 -0
- package/src/inspect/layout.js +258 -0
- package/src/inspect/limits.js +98 -0
- package/src/inspect/machine.js +107 -0
- package/src/inspect/network.js +37 -0
- package/src/inspect/providers.js +71 -0
- package/src/inspect/read-territory.js +105 -0
- package/src/inspect/role-keys.js +136 -0
- package/src/inspect/shared.js +37 -0
- package/src/inspect/warnings.js +58 -0
- package/src/inspect.js +13 -815
- package/src/intake.js +131 -21
- package/src/keys.js +223 -67
- package/src/kinds.js +42 -0
- package/src/lines.js +26 -0
- package/src/log.js +7 -2
- package/src/mcp.js +28 -43
- package/src/nested.js +12 -3
- package/src/notify.js +53 -12
- package/src/owners.js +165 -3
- package/src/paths.js +17 -1
- package/src/provider-run.js +81 -0
- package/src/redact.js +34 -15
- package/src/render.js +35 -9
- package/src/requests.js +363 -45
- package/src/review.js +21 -9
- package/src/rundir.js +29 -8
- package/src/sandboxes/command.js +93 -0
- package/src/sandboxes/index.js +113 -0
- package/src/sandboxes/mach-profile.js +31 -0
- package/src/sandboxes/srt-mach.mjs +43 -0
- package/src/sandboxes/srt.js +163 -0
- package/src/sandboxes/tartaros.js +80 -0
- package/src/scan.js +7 -3
- package/src/serve.js +35 -17
- package/src/spool.js +8 -17
- package/src/srt.js +263 -33
- package/src/surface.js +129 -16
- package/src/territory.js +261 -0
- package/src/trace.js +33 -0
- package/src/uilink.js +15 -6
- package/src/views.js +40 -14
- package/src/violations.js +143 -7
- package/src/walls.js +62 -9
- package/ui/index.html +85 -13
- package/ui/favicon.svg +0 -1
package/README.md
CHANGED
|
@@ -1,47 +1,54 @@
|
|
|
1
1
|
# seisin
|
|
2
2
|
|
|
3
|
-
[](https://github.com/carlostapiacl/seisin/actions/workflows/test.yml)
|
|
3
|
+
[](https://github.com/carlostapiacl/seisin/actions/workflows/test.yml)
|
|
4
4
|
[](https://scorecard.dev/viewer/?uri=github.com/carlostapiacl/seisin)
|
|
5
5
|
[](https://www.npmjs.com/package/seisin)
|
|
6
|
-
[](LICENSE)
|
|
7
|
-
[](package.json)
|
|
8
|
-
[** · this page is the long one.
|
|
6
|
+
[](https://github.com/carlostapiacl/seisin/blob/main/LICENSE)
|
|
7
|
+
[](https://github.com/carlostapiacl/seisin/blob/main/package.json)
|
|
8
|
+
[](https://github.com/carlostapiacl/seisin/blob/main/package.json)
|
|
11
9
|
|
|
12
10
|
> **seisin** *(n.)* — the legal possession of a piece of land. Not who owns it on paper: who holds it now.
|
|
13
11
|
|
|
14
12
|
**Give each AI agent its own folders and its own keys.** The kernel enforces it, and when it denies something, seisin tells you *whose* file it was.
|
|
15
13
|
|
|
16
|
-
|
|
14
|
+

|
|
17
15
|
|
|
18
|
-
|
|
16
|
+
```bash
|
|
17
|
+
npm install -g seisin # on Linux, three system packages first: see Install
|
|
18
|
+
seisin init && seisin check # propose a policy from the repo, then read it
|
|
19
|
+
seisin run <role> -- claude -p "…" # the roles are the ones seisin check lists
|
|
20
|
+
```
|
|
19
21
|
|
|
20
|
-
|
|
22
|
+
**Is it for me?** If two or more agents work in one repository, yes — that is what it is for.
|
|
23
|
+
With one agent, its own sandbox covers most of this ([what is left over](#what-it-is-not)).
|
|
21
24
|
|
|
22
|
-
**
|
|
23
|
-
every agent runs under. The **console** (`seisin ui`) is for a person deciding something. The
|
|
24
|
-
**MCP server** is for an agent asking about its own situation. A change to what any of them
|
|
25
|
-
says lands in all three — [CONTRIBUTING](CONTRIBUTING.md#three-surfaces-and-a-change-lands-in-all-of-them)
|
|
26
|
-
says why, and a test holds one half of it.
|
|
25
|
+
**[The short version, with the demos playing →](https://carlostapiacl.github.io/seisin/)** · this page is the long one.
|
|
27
26
|
|
|
28
27
|
<details>
|
|
29
28
|
<summary><b>Contents</b></summary>
|
|
30
29
|
|
|
31
|
-
**Start here** · [Sixty seconds](#sixty-seconds) · [Why this exists](#why-this-exists) · [What it is for, and what it is not for](#what-it-is-for-and-what-it-is-not-for) · [Install](#install) · [Use](#use) · [Configure](#configure)
|
|
30
|
+
**Start here** · [What it does](#what-it-does) · [Sixty seconds](#sixty-seconds) · [Why this exists](#why-this-exists) · [What it is for, and what it is not for](#what-it-is-for-and-what-it-is-not-for) · [Install](#install) · [Use](#use) · [Configure](#configure)
|
|
32
31
|
|
|
33
32
|
**Living with it** · [Day one](#what-will-look-like-a-bug-on-the-first-day) · [Which agents it has been run with](#which-agents-it-has-been-run-with) · [Where the first policy comes from](#where-the-first-policy-comes-from) · [When it says no, it leaves a request behind](#when-it-says-no-it-leaves-a-request-behind) · [When the boundary is right and the agent cannot hear it](#when-the-boundary-is-right-and-the-agent-cannot-hear-it) · [Scratch space, and what it costs](#scratch-space-and-what-it-costs)
|
|
34
33
|
|
|
35
|
-
**Secrets** · [How it protects secrets](#how-it-protects-secrets) · [Keys in full](docs/keys.md)
|
|
34
|
+
**Secrets** · [How it protects secrets](#how-it-protects-secrets) · [Keys in full](https://github.com/carlostapiacl/seisin/blob/main/docs/keys.md)
|
|
36
35
|
|
|
37
36
|
**Looking at it** · [What the log says about the policy](#what-the-log-says-about-the-policy) · [Seeing what happened](#seeing-what-happened) · [Ask your own assistant](#ask-your-own-assistant)
|
|
38
37
|
|
|
39
38
|
**The honest parts** · [How it holds](#how-it-holds) · [What it is not](#what-it-is-not) · [Why not a container](#why-not-a-container) · [Prior art](#prior-art) · [Status](#status)
|
|
40
39
|
|
|
41
|
-
**Deeper** · [Keys](docs/keys.md) · [Agents](docs/agents.md) · [Day one](docs/first-day.md) · [Scratch](docs/scratch.md) · [Glossary](docs/glossary.md) · [What it has been put through](docs/what-it-has-been-put-through.md) · [Field notes](docs/field-notes.md) · [OpenShell compatibility](docs/openshell.md) · [Decisions](docs/decisions.md) · [nono backend contract](docs/nono-backend.md) · [Requests](docs/
|
|
40
|
+
**Deeper** · [Keys](https://github.com/carlostapiacl/seisin/blob/main/docs/keys.md) · [Agents](https://github.com/carlostapiacl/seisin/blob/main/docs/agents.md) · [Day one](https://github.com/carlostapiacl/seisin/blob/main/docs/first-day.md) · [Scratch](https://github.com/carlostapiacl/seisin/blob/main/docs/scratch.md) · [Glossary](https://github.com/carlostapiacl/seisin/blob/main/docs/glossary.md) · [What it has been put through](https://github.com/carlostapiacl/seisin/blob/main/docs/what-it-has-been-put-through.md) · [Field notes](https://github.com/carlostapiacl/seisin/blob/main/docs/field-notes.md) · [OpenShell compatibility](https://github.com/carlostapiacl/seisin/blob/main/docs/openshell.md) · [Decisions](https://github.com/carlostapiacl/seisin/blob/main/docs/decisions.md) · [nono backend contract](https://github.com/carlostapiacl/seisin/blob/main/docs/nono-backend.md) · [Requests](https://github.com/carlostapiacl/seisin/blob/main/docs/requests.md) · [Contributing](https://github.com/carlostapiacl/seisin/blob/main/CONTRIBUTING.md)
|
|
42
41
|
|
|
43
42
|
</details>
|
|
44
43
|
|
|
44
|
+
## What it does
|
|
45
|
+
|
|
46
|
+
A **permission layer**, not a sandbox — it sits on top of one. The isolation comes from the OS; what seisin adds is the part an OS cannot know: which role a path belongs to, and therefore who to ask next.
|
|
47
|
+
|
|
48
|
+
*Outside reviews went looking for ways past the boundary. [What they found, what broke, and what is still open →](https://github.com/carlostapiacl/seisin/blob/main/docs/what-it-has-been-put-through.md)*
|
|
49
|
+
|
|
50
|
+
*And what running four teams of agents behind it actually cost — including the numbers that did not survive a re-check. [Field notes →](https://github.com/carlostapiacl/seisin/blob/main/docs/field-notes.md)*
|
|
51
|
+
|
|
45
52
|
```mermaid
|
|
46
53
|
flowchart LR
|
|
47
54
|
A["agent<br/>running as a role"] -->|writes src/api/orders.ts| K{{"the kernel<br/>Seatbelt · bubblewrap"}}
|
|
@@ -49,9 +56,10 @@ flowchart LR
|
|
|
49
56
|
D --> S["seisin reads the denial<br/>and looks up the policy"]
|
|
50
57
|
S --> W["<b>whose it was</b><br/>belongs to backend"]
|
|
51
58
|
S --> Q["a request, queued"]
|
|
52
|
-
|
|
53
|
-
H
|
|
54
|
-
|
|
59
|
+
S --> L[(".seisin/log.jsonl<br/>one hash-chained entry")]
|
|
60
|
+
Q --> H(["a person grants or declines<br/>in a terminal or the console"])
|
|
61
|
+
H -->|edits, with provenance| P[("seisin.toml")]
|
|
62
|
+
P -.->|"seisin run turns it into<br/>the sandbox settings"| K
|
|
55
63
|
|
|
56
64
|
style D fill:#fde,stroke:#c66
|
|
57
65
|
style W fill:#dfd,stroke:#6a6
|
|
@@ -62,33 +70,56 @@ flowchart LR
|
|
|
62
70
|
permission layers in this space answer *yes* or *no*, or at best *which rule* said no — and a
|
|
63
71
|
denial that also names an owner turns a dead end into a handoff.
|
|
64
72
|
|
|
65
|
-

|
|
66
|
-
|
|
67
73
|
```
|
|
68
74
|
$ seisin run frontend -- sh -c 'echo // fix >> src/api/orders.ts'
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
1 pending request(s)
|
|
75
|
+
seisin: frontend · writes src/web/** public/** (+6 scratch) · keys netlify-token.txt · runs no agent seisin knows · env 20 kept, 58 dropped · read all
|
|
76
|
+
sh: src/api/orders.ts: Operation not permitted
|
|
77
|
+
seisin: 1 kernel denial(s) recorded
|
|
73
78
|
|
|
74
79
|
#1 frontend wants write on src/api/** (owned by backend)
|
|
75
80
|
first asked over src/api/orders.ts
|
|
81
|
+
id frontend:write:src/api
|
|
82
|
+
handed to backend
|
|
76
83
|
|
|
77
|
-
|
|
84
|
+
this run: 1 new request(s) — seisin requests
|
|
78
85
|
```
|
|
79
86
|
|
|
87
|
+
The first line is the run's own header: what this role may write, which keys it holds, and how
|
|
88
|
+
much of your environment it did not inherit. The last lists only what this run filed; the
|
|
89
|
+
rest of the queue is one count, and `seisin requests`.
|
|
90
|
+
|
|
91
|
+
The line under the request is where it goes, decided from the policy as it stands: *handed to
|
|
92
|
+
backend*, or *a person decides* when nobody or several roles own it. seisin does not start
|
|
93
|
+
backend. Whatever does reads that decision (`seisin requests --json`, `seisin_requests`) and runs
|
|
94
|
+
`seisin run backend --handoff frontend:write:src/api`, which starts only if the policy hands that
|
|
95
|
+
request to backend — and anything backend is then denied joins the same handoff chain, so work
|
|
96
|
+
cannot bounce back to frontend ([where a request goes](https://github.com/carlostapiacl/seisin/blob/main/docs/requests.md#where-a-request-goes)).
|
|
97
|
+
A role that is a pipeline phase rather than a worker says `handoff = false`: it keeps its
|
|
98
|
+
territory and is never handed work — what it owns goes to another owner, or to a person.
|
|
99
|
+
|
|
100
|
+
A caller that probes the sandbox with a write it expects to be denied — its own canary — names
|
|
101
|
+
that path with `--expect-denied <path>` (repeatable). The denial is logged as expected and asked
|
|
102
|
+
of nobody: no request, no notification, no handoff. Only that exact path; any other is a request
|
|
103
|
+
as usual ([expected denials](https://github.com/carlostapiacl/seisin/blob/main/docs/requests.md#a-denial-the-caller-expects)).
|
|
104
|
+
|
|
80
105
|
**Whose it was, at the moment it was denied.** Other permission layers answer *yes* or *no*, and the good ones say which rule it was and how to widen it. Answering **"no, and it belongs to `backend`"** turns a denial into a handoff — and one a person can grant in a command, rather than a line somebody has to remember to go and read.
|
|
81
106
|
|
|
82
|
-
The kernel is what denies; the name comes from the policy. That first line is all the boundary itself can say — no path, no reason, nothing to read afterwards — so seisin reads the 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).
|
|
107
|
+
The kernel is what denies; the name comes from the policy. That first line is all the boundary itself can say — no path, no reason, nothing to read afterwards — so seisin reads the denial out of the kernel's own log and answers the question it leaves open. On Linux only the hook can do that; [the ask is upstream](https://github.com/carlostapiacl/seisin/blob/main/docs/upstream/cli-violations.md) as [issue #582](https://github.com/anthropics/sandbox-runtime/issues/582).
|
|
83
108
|
|
|
84
109
|
The agent can ask directly too, from inside the sandbox:
|
|
85
110
|
|
|
86
111
|
```
|
|
87
112
|
$ seisin run frontend -- seisin whose src/api/orders.ts
|
|
113
|
+
seisin: frontend · writes src/web/** public/** (+6 scratch) · keys netlify-token.txt · runs no agent seisin knows · env 20 kept, 58 dropped · read all
|
|
114
|
+
|
|
88
115
|
src/api/orders.ts belongs to backend
|
|
89
|
-
you are frontend.
|
|
116
|
+
you are frontend. It is handed to backend: hand it over rather than working around it.
|
|
90
117
|
```
|
|
91
118
|
|
|
119
|
+
The second line is the same handoff decision the queue shows: with two owners it says a person
|
|
120
|
+
decides and not to pick one, and in a run started with `--handoff` it says when handing the work
|
|
121
|
+
back would be a cycle. The hook's denial and the note after a failed command say it the same way.
|
|
122
|
+
|
|
92
123
|
## Sixty seconds
|
|
93
124
|
|
|
94
125
|
```bash
|
|
@@ -97,7 +128,7 @@ cd your-repo
|
|
|
97
128
|
seisin init # proposes a policy from what the repo already says
|
|
98
129
|
seisin check # read it before you trust it
|
|
99
130
|
seisin wire # let the agent record what it does
|
|
100
|
-
seisin run
|
|
131
|
+
seisin run <role> -- claude -p "…" # a role from seisin check, inside its own territory
|
|
101
132
|
```
|
|
102
133
|
|
|
103
134
|
On Linux, [three system packages first](#install). Everything below is why it works and where it does not.
|
|
@@ -131,12 +162,11 @@ A rule is a string comparison against a command someone might not spell that way
|
|
|
131
162
|
|
|
132
163
|
## What it is for, and what it is not for
|
|
133
164
|
|
|
134
|
-
> **It turns an agent's mistake into a contained mistake.
|
|
135
|
-
>
|
|
165
|
+
> **It turns an agent's mistake into a contained mistake. Against a hostile agent, the
|
|
166
|
+
> guarantee is the one of the sandbox you run it under.**
|
|
136
167
|
>
|
|
137
|
-
> Two outside reviews have gone through it looking for ways past the boundary
|
|
138
|
-
> found
|
|
139
|
-
> through](docs/what-it-has-been-put-through.md) lists all of them — what was
|
|
168
|
+
> Two outside reviews, plus three follow-ups, have gone through it looking for ways past the boundary.
|
|
169
|
+
> [What they found and what remains open](https://github.com/carlostapiacl/seisin/blob/main/docs/what-it-has-been-put-through.md) lists all of them — what was
|
|
140
170
|
> fixed, what was measured, and what is still open.
|
|
141
171
|
|
|
142
172
|
That distinction is the whole threat model, and it is worth reading before you install
|
|
@@ -148,28 +178,33 @@ denies, and it does not care how the command was spelled — verified against a
|
|
|
148
178
|
process, an absolute path, a symlink, a `python -c`, and a shell redirect the agent fell
|
|
149
179
|
back to when its own file API was denied.
|
|
150
180
|
|
|
151
|
-
**
|
|
152
|
-
|
|
181
|
+
**Against an agent that is trying, the line is drawn by the sandbox.** seisin writes the
|
|
182
|
+
policy and checks that the sandbox can hold it; [the sandbox you choose](https://github.com/carlostapiacl/seisin/blob/main/docs/sandboxes.md)
|
|
183
|
+
enforces it. Some limits are the policy's and hold under any sandbox, measured in this repo's
|
|
184
|
+
own fixtures:
|
|
153
185
|
|
|
154
186
|
| | |
|
|
155
187
|
|---|---|
|
|
156
|
-
| it can read everything except the declared
|
|
157
|
-
| it can write anything inside its own territory | including staging an exfiltration
|
|
188
|
+
| with the default read mode (`read = "all"`) it can read everything except the declared key directories — **including `~/.ssh`, `~/.aws`, `~/.npmrc` and `~/.config`** | by design — an agent that cannot read the repo cannot work. `read = "territory"` closes those homes (as does `[runtime] isolate = "credentials"`); `seisin scan` finds secrets stored elsewhere |
|
|
189
|
+
| it can write anything inside its own territory | including staging an exfiltration |
|
|
158
190
|
| `base64` defeats the redaction | only the literal value is masked, and only on stdout and stderr |
|
|
159
|
-
| an allowed domain is an allowed channel | if the agent's own API is reachable, so is everything that goes through it |
|
|
191
|
+
| an allowed domain is an allowed channel | if the agent's own API is reachable, so is everything that goes through it. [`[network] deny_addresses`](#configure) keeps an allowed name from resolving into your LAN |
|
|
160
192
|
| the hook is text-based and evadable | on purpose — what escapes it goes **past the hook, not past the kernel**: the kernel still denies. On macOS it is no longer unexplained either, because [the kernel's own denials are recorded](#seeing-what-happened); on Linux it is. Either way, attribution is not a control |
|
|
161
193
|
|
|
162
|
-
|
|
194
|
+
Others are the sandbox's, and differ between the two built in:
|
|
163
195
|
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
196
|
+
| | `srt` (the default) | `tartaros` |
|
|
197
|
+
|---|---|---|
|
|
198
|
+
| a process the agent leaves running | **macOS:** it outlives the run, whether it double-forks, ignores signals under `nohup` or calls `setsid` (measured 2026-10-16). **Linux:** the PID namespace ends with the run (read from its source) | **macOS:** the run's process group is killed; one that calls `setsid` outlives it (measured 2026-10-16). **Linux:** the run's cgroup is killed whole (read from its source) |
|
|
199
|
+
| deleting inside the territory | write includes delete; the ask is upstream as [issue #545](https://github.com/anthropics/sandbox-runtime/issues/545) | not measured here |
|
|
200
|
+
| `[network] deny_addresses` | enforced | not yet: a role that has it runs under `srt`, and `check` says why |
|
|
201
|
+
| what it is | Anthropic's, described by its authors as a research preview | by the author of seisin, alpha |
|
|
169
202
|
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
203
|
+
`seisin check` says which sandbox each role gets before anything runs.
|
|
204
|
+
|
|
205
|
+
For several of your own agents, on your own machine, getting in each other's way, this is
|
|
206
|
+
what it is for. For an agent you do not control, choose the sandbox for that threat: seisin
|
|
207
|
+
will not make its guarantee stronger than it is.
|
|
173
208
|
|
|
174
209
|
## Install
|
|
175
210
|
|
|
@@ -177,7 +212,7 @@ not control in front of data you care about, it is not.
|
|
|
177
212
|
npm install -g seisin
|
|
178
213
|
```
|
|
179
214
|
|
|
180
|
-
That pulls in [`@anthropic-ai/sandbox-runtime`](https://github.com/anthropics/sandbox-runtime),
|
|
215
|
+
That pulls in [`@anthropic-ai/sandbox-runtime`](https://github.com/anthropics/sandbox-runtime), the default sandbox: Seatbelt on macOS, bubblewrap on Linux. No containers, no VMs, no daemon. seisin has **no other dependencies**. [tartaros](https://github.com/carlostapiacl/tartaros), or a sandbox of your own, can enforce instead ([docs/sandboxes.md](https://github.com/carlostapiacl/seisin/blob/main/docs/sandboxes.md)).
|
|
181
216
|
|
|
182
217
|
**On Linux you also need three system packages** — the runtime refuses to start without
|
|
183
218
|
them rather than running unconfined, which is the right failure and an unhelpful message:
|
|
@@ -203,25 +238,63 @@ default Docker container forbids. `--cap-add SYS_ADMIN --security-opt seccomp=un
|
|
|
203
238
|
gets namespaces; mounting `/proc` needs `--privileged`. If you cannot grant that, run
|
|
204
239
|
seisin on the host and let the container be what it sandboxes.
|
|
205
240
|
|
|
241
|
+
## JavaScript API
|
|
242
|
+
|
|
243
|
+
The package is ESM. Import policy decisions from `seisin`; the same functions
|
|
244
|
+
back the CLI. TypeScript declarations ship with the package. Install it in the project that
|
|
245
|
+
imports it — a global install puts the CLI on your PATH, not the package where `import` looks:
|
|
246
|
+
|
|
247
|
+
```bash
|
|
248
|
+
npm install seisin
|
|
249
|
+
```
|
|
250
|
+
|
|
251
|
+
```js
|
|
252
|
+
import { loadConfig, explain, ownersOf } from "seisin";
|
|
253
|
+
|
|
254
|
+
const config = loadConfig("seisin.toml");
|
|
255
|
+
console.log(ownersOf(config, "src/api/server.js"));
|
|
256
|
+
console.log(explain(config, "frontend", "write", "src/api/server.js"));
|
|
257
|
+
```
|
|
258
|
+
|
|
259
|
+
The stable surface covers policy loading (`findConfig`, `loadConfig`,
|
|
260
|
+
`parseToml`) and where it lives (`CONFIG_NAME`, `STATE_DIR`, `LOG_NAME`), ownership and
|
|
261
|
+
explanations (`covers`, `ownersOf`, `keyHolders`, `explain`, `explainFileRead`, `readTarget`),
|
|
262
|
+
resolved sandbox settings (`settingsFor`), key references (`parseKey`, `defaultName`,
|
|
263
|
+
`entriesOf`, `MODES`), inspection and scanning (`inspect`, `sharedPaths`, `scan`), activity
|
|
264
|
+
and requests (`readLog`, `logPath`, `observed`, `pendingRequests`, `requestQueue`,
|
|
265
|
+
`requestsPath`, `grantFor`, `generalise`), handoffs (`decideHandoff`, `MAX_DEPTH`,
|
|
266
|
+
`recordHandoff`), walls (`walls`, `wasted`), and hook decisions (`targetsOf`, `decide`).
|
|
267
|
+
Granting is not in it: a grant is a person's, through the CLI or the console.
|
|
268
|
+
Sandbox tuning constants, environment construction and output masking remain
|
|
269
|
+
internal; they may change without an API compatibility promise.
|
|
270
|
+
|
|
206
271
|
## Use
|
|
207
272
|
|
|
208
273
|
```bash
|
|
209
274
|
seisin init # proposes a seisin.toml for this repo
|
|
210
275
|
seisin check # print the map, run nothing
|
|
276
|
+
seisin check --system-log # macOS: is the system log dropping denials right now?
|
|
277
|
+
seisin help limits # what seisin cannot do, in any repo
|
|
211
278
|
seisin run frontend -- claude -p "…" # run an agent as that role
|
|
212
279
|
seisin explain frontend write src/api/x # ask one question, exit 0 or 1
|
|
213
|
-
seisin review
|
|
280
|
+
seisin review --since 7d # what the log says about the policy, this week
|
|
214
281
|
seisin wire # install the PreToolUse hook in this repo
|
|
215
|
-
seisin ui # a console for
|
|
282
|
+
seisin ui # a console for deciding what is waiting
|
|
216
283
|
```
|
|
217
284
|
|
|
285
|
+
**Three ways in, and they say the same thing.** The **CLI** is what a person types and what
|
|
286
|
+
every agent runs under. The **console** (`seisin ui`) is for a person deciding something. The
|
|
287
|
+
**MCP server** is for an agent asking about its own situation. A change to what any of them
|
|
288
|
+
says lands in all three — [CONTRIBUTING](https://github.com/carlostapiacl/seisin/blob/main/CONTRIBUTING.md#three-surfaces-and-a-change-lands-in-all-of-them)
|
|
289
|
+
says why, and a test holds one half of it.
|
|
290
|
+
|
|
218
291
|
`seisin <command> --help` lists a command's flags, examples and exit codes. The exit codes, so
|
|
219
292
|
the commands compose in a script, a pre-commit hook or CI:
|
|
220
293
|
|
|
221
294
|
| command | 0 | 1 | other |
|
|
222
295
|
|---|---|---|---|
|
|
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 | |
|
|
296
|
+
| `run` | — | — | the command's own status; 86 if the canary proves the boundary does not hold; 127 if it is not on the role's PATH; 128+n when killed by signal n (130 for ctrl-c) |
|
|
297
|
+
| `check` | the policy can be enforced (warnings included) | a role cannot be enforced as written, `--verify` failed, or `--system-log` measured messages dropped | |
|
|
225
298
|
| `explain` | allowed | denied | |
|
|
226
299
|
| `scan` | no certain credential | a certain credential outside the key dirs | |
|
|
227
300
|
| `review` | no role denied repeatedly | a role denied repeatedly | |
|
|
@@ -231,8 +304,8 @@ the commands compose in a script, a pre-commit hook or CI:
|
|
|
231
304
|
| `whose`, `grant`, `decline`, `log`, `init`, `wire`, `ui`, `watch`, `mcp` | done | — | |
|
|
232
305
|
| `hook` | always | — | |
|
|
233
306
|
|
|
234
|
-
Every command exits **2** on a usage error — an unknown command, flag, role or action — and
|
|
235
|
-
there is no `seisin.toml` here or above.
|
|
307
|
+
Every command exits **2** on a usage error — an unknown command, flag, role or action — and every
|
|
308
|
+
command that reads the policy exits 2 when there is no `seisin.toml` here or above.
|
|
236
309
|
|
|
237
310
|
## Configure
|
|
238
311
|
|
|
@@ -255,19 +328,59 @@ keys = ["database-url.txt", "sentry-dsn.txt"]
|
|
|
255
328
|
network = ["api.stripe.com"] # this role only — it replaces [network] rather than adding
|
|
256
329
|
```
|
|
257
330
|
|
|
331
|
+
An allowed name is only as safe as its DNS: one whose records somebody else controls can be
|
|
332
|
+
pointed at a host on your network. The sandbox already denies loopback, link-local and
|
|
333
|
+
cloud-metadata addresses. Private ranges are left to you, because allowing an intranet name is
|
|
334
|
+
legitimate; to close them, list them:
|
|
335
|
+
|
|
336
|
+
```toml
|
|
337
|
+
[network]
|
|
338
|
+
allow = ["github.com", "*.github.com"]
|
|
339
|
+
deny_addresses = ["10.0.0.0/8", "172.16.0.0/12", "192.168.0.0/16", "100.64.0.0/10", "fc00::/7"]
|
|
340
|
+
```
|
|
341
|
+
|
|
342
|
+
`[runtime] read = "all"` is the default. The opt-in `"territory"` mode keeps homes,
|
|
343
|
+
temporary directories and mounted data roots shut, while retaining the repository and the
|
|
344
|
+
paths each role names:
|
|
345
|
+
|
|
346
|
+
```toml
|
|
347
|
+
[runtime]
|
|
348
|
+
read = "territory"
|
|
349
|
+
|
|
350
|
+
[roles.backend]
|
|
351
|
+
writes = ["src/api/**"]
|
|
352
|
+
reads = ["../shared-schemas/**"] # data needed read-only
|
|
353
|
+
toolchain = ["~/.venvs/api"] # program stack under a data root; bin/ goes first on PATH
|
|
354
|
+
verify = ["python", "-m", "pytest"] # run by seisin check --verify inside this role
|
|
355
|
+
```
|
|
356
|
+
|
|
357
|
+
`seisin check --verify --parallel` is an experimental speed option: role checks run at the same
|
|
358
|
+
time. Use it only when those commands do not share mutable build outputs, caches, ports, or other
|
|
359
|
+
resources; concurrent checks can race and produce misleading failures. Plain `--verify` remains
|
|
360
|
+
serial and is the recommended default. `--parallel` requires `--verify`.
|
|
361
|
+
|
|
362
|
+
`reads` and `toolchain` take literal paths or a subtree ending in `/**`; interior globs,
|
|
363
|
+
`*` and `?` are refused. `verify` is an argv array and may not be empty. Each data root is
|
|
364
|
+
denied whole and what the role keeps is allowed back, so what appears there during the run is
|
|
365
|
+
shut as well. On Linux a kept directory holding a write path is allowed back entry by entry;
|
|
366
|
+
one with more than 1,000 entries is refused, and the error names whether the repo or a `reads`
|
|
367
|
+
path must move or narrow. Details and platform costs are in [Scratch and read territory](https://github.com/carlostapiacl/seisin/blob/main/docs/scratch.md).
|
|
368
|
+
|
|
258
369
|
`[network] allow` is the fallback for roles that do not name their own. A role that needs one
|
|
259
370
|
extra host should say so on its own line: a domain in the global list is reachable by every
|
|
260
371
|
role, including the ones whose whole job is to have nothing to reach.
|
|
261
372
|
|
|
262
|
-
> ⚠️ **HTTP(S) works
|
|
263
|
-
> starts, and although one of them is a SOCKS5 proxy that could
|
|
264
|
-
> at its last link: it requires authentication and the
|
|
265
|
-
> cannot send any. Measured — `curl
|
|
266
|
-
>
|
|
267
|
-
> dies at the handshake.
|
|
373
|
+
> ⚠️ **HTTP(S) works on both platforms. SSH does not work on macOS.** The allow list is
|
|
374
|
+
> enforced by proxies the runtime starts, and although one of them is a SOCKS5 proxy that could
|
|
375
|
+
> carry SSH, on macOS the chain breaks at its last link: it requires authentication and the
|
|
376
|
+
> `ProxyCommand` the runtime wires up cannot send any. Measured on macOS — `curl
|
|
377
|
+
> https://github.com` returns `200`, `ssh -T git@github.com` cannot resolve the hostname, and
|
|
378
|
+
> `git ls-remote git@github.com:…` reaches the proxy and dies at the handshake. **On Linux** the
|
|
379
|
+
> runtime routes git's SSH through its HTTP proxy with credentials instead, so it is expected to
|
|
380
|
+
> pass — read from the runtime's source, not measured here.
|
|
268
381
|
>
|
|
269
|
-
> **Use an HTTPS remote for git**, and run SSH deploys outside the confined run.
|
|
270
|
-
> [Why, exactly, and whose gap it is →](docs/decisions.md#ssh-does-not-work-and-it-is-not-seisin-that-decided-that)
|
|
382
|
+
> **Use an HTTPS remote for git**, and on macOS run SSH deploys outside the confined run.
|
|
383
|
+
> [Why, exactly, and whose gap it is →](https://github.com/carlostapiacl/seisin/blob/main/docs/decisions.md#ssh-does-not-work-and-it-is-not-seisin-that-decided-that)
|
|
271
384
|
|
|
272
385
|
A role can also give something back. `never_writes` subtracts from its own `writes`, and it
|
|
273
386
|
wins however wide the grant is:
|
|
@@ -284,20 +397,44 @@ leaves **no request** in the queue: granting one would undo a subtraction somebo
|
|
|
284
397
|
entry that no `writes` of the same role covers, or a misspelling like `never_write`, is named
|
|
285
398
|
by `seisin check` instead of being ignored.
|
|
286
399
|
|
|
400
|
+
A folder only one role may see is `private`. The role reads and writes it; every other role is
|
|
401
|
+
denied both, whatever its `writes` say, in either read mode and under `isolate`:
|
|
402
|
+
|
|
403
|
+
```toml
|
|
404
|
+
[roles.legal]
|
|
405
|
+
writes = ["contracts/drafts/**"]
|
|
406
|
+
private = ["contracts/signed/**"] # part of legal's territory, and no other role reads it
|
|
407
|
+
|
|
408
|
+
[roles.dev]
|
|
409
|
+
writes = ["**"] # still cannot read or write contracts/signed
|
|
410
|
+
```
|
|
411
|
+
|
|
412
|
+
An entry is one folder inside the repo, `dir` or `dir/**`: no patterns, no absolute path, no
|
|
413
|
+
`..`, not the repo itself. Two roles naming the same folder, or one inside the other, is refused
|
|
414
|
+
at load, because each would be denied its own. `seisin check` shows it under the role and says
|
|
415
|
+
when it sits inside another role's territory, since that role loses it. `seisin explain dev read
|
|
416
|
+
contracts/signed/x` answers denied and names the owner, and a denial there leaves **no request**:
|
|
417
|
+
no grant opens it. On Linux a missing private folder is created empty by `seisin run` (never by `check`), because
|
|
418
|
+
bubblewrap cannot deny a path that does not exist without placing something there; and another
|
|
419
|
+
role sees an empty folder in its place, where a write succeeds into memory that is discarded with
|
|
420
|
+
the run and never reaches the real one. On macOS the write is denied.
|
|
421
|
+
|
|
287
422
|
A role that starts a server — a dev server, the backend an end-to-end test drives — needs
|
|
288
423
|
`local_binding = true`. Listening is off by default and per role: most roles never start one.
|
|
289
424
|
It is wider than the name: the role can also listen on every interface and **connect to every
|
|
290
425
|
port on localhost**, so anything listening there without authentication is within its reach.
|
|
291
426
|
`seisin check` says this next to the role. That is macOS: on Linux each role already has a
|
|
292
427
|
private loopback, serves without the key, and reaches nothing on the host either way.
|
|
428
|
+
The canary therefore omits its loopback probe for a role with `local_binding = true`: reaching
|
|
429
|
+
the decoy is allowed by that policy, not evidence that confinement failed.
|
|
293
430
|
|
|
294
431
|
If a role only needs to *reach* a local service, name the ports instead:
|
|
295
432
|
`local_ports = [8001, 8081]`. The role reaches those and nothing else on localhost, on macOS
|
|
296
|
-
and Linux.
|
|
297
|
-
Node's `fetch` and Python's `urllib` do. A database driver or a raw socket don't, and are
|
|
298
|
-
denied.
|
|
299
|
-
|
|
300
|
-
just HTTP, so list the service, not the protocol.
|
|
433
|
+
and Linux. Under srt the sandbox's proxy enforces the list, so the client has to use the proxy.
|
|
434
|
+
curl, Node's `fetch` and Python's `urllib` do. A database driver or a raw socket don't, and are
|
|
435
|
+
denied. Under tartaros the kernel profile holds the list itself, so any client reaches a listed
|
|
436
|
+
port directly, and loopback stays off its proxy. For a headless browser, see `browsers` below. A listed port is open to anything that
|
|
437
|
+
speaks `CONNECT`, not just HTTP, so list the service, not the protocol.
|
|
301
438
|
|
|
302
439
|
`mcp = ["playwright"]` lists the MCP servers a role may load; `mcp = []` means none, and no
|
|
303
440
|
key means the policy doesn't say. seisin declares this and `explain` answers it
|
|
@@ -305,6 +442,30 @@ key means the policy doesn't say. seisin declares this and `explain` answers it
|
|
|
305
442
|
launcher has to apply it. With Claude Code: `--mcp-config` holding only those servers, plus
|
|
306
443
|
`--strict-mcp-config`.
|
|
307
444
|
|
|
445
|
+
A role that drives a headless Chromium — Playwright, `@playwright/mcp` — needs
|
|
446
|
+
`browsers = ["chromium"]` on macOS. Without it the browser exits as it starts: its first act
|
|
447
|
+
is to register a Mach service (`org.chromium.Chromium.MachPortRendezvousServer.<pid>`) that its
|
|
448
|
+
own child processes look up, and the sandbox denies both. The preset allows exactly those two,
|
|
449
|
+
by that prefix, and nothing else; every other role keeps the profile it had. The network is
|
|
450
|
+
still `[network] allow`, through the sandbox's proxy, which takes credentials Playwright has to
|
|
451
|
+
be handed apart from the URL:
|
|
452
|
+
|
|
453
|
+
```js
|
|
454
|
+
const u = new URL(process.env.HTTP_PROXY); // http://<user>:<token>@localhost:<port>
|
|
455
|
+
const browser = await chromium.launch({ proxy: {
|
|
456
|
+
server: `${u.protocol}//${u.host}`,
|
|
457
|
+
username: decodeURIComponent(u.username),
|
|
458
|
+
password: decodeURIComponent(u.password),
|
|
459
|
+
} });
|
|
460
|
+
```
|
|
461
|
+
|
|
462
|
+
Passing `HTTP_PROXY` as `server` alone does not work: Playwright gives Chromium the address
|
|
463
|
+
without the credentials and the proxy answers `407`. The token is the run's own, so the traffic
|
|
464
|
+
stays tied to the run. `@playwright/mcp` runs under `[sandbox] provider = "tartaros"`, which
|
|
465
|
+
gives the role a temp directory of its own for the unix socket the server always opens; under
|
|
466
|
+
srt it does not yet. [docs/browsers.md](https://github.com/carlostapiacl/seisin/blob/main/docs/browsers.md) has the measurements, the MCP config,
|
|
467
|
+
and what Chrome and Chrome for Testing still lack.
|
|
468
|
+
|
|
308
469
|
Some files are not code but still make something happen outside the sandbox, and no territory
|
|
309
470
|
reaches them: git hooks and config, a project's `.claude/`, `.mcp.json`, `.envrc`. Two softer
|
|
310
471
|
families sit beside those, and a policy can hand them to one role:
|
|
@@ -336,35 +497,41 @@ host) or the socket path, once per target per run. `seisin walls` says whether t
|
|
|
336
497
|
missing from `local_ports`, listed but dialled directly, or a socket. No request is filed: a
|
|
337
498
|
port isn't anyone's territory. macOS only.
|
|
338
499
|
|
|
339
|
-
|
|
500
|
+
So are the denials that are neither a file nor a connection: a Mach service, a `sysctl`, as
|
|
501
|
+
`mach-register org.chromium.Chromium.MachPortRendezvousServer.7354`, once per operation and
|
|
502
|
+
name per run, and never as a request. Most are the sandbox doing its job and the program
|
|
503
|
+
carrying on; one of them is how a browser dies at start, and `seisin run` names the preset
|
|
504
|
+
that answers it. macOS only.
|
|
340
505
|
|
|
341
|
-
|
|
506
|
+
`seisin init` will propose this from whatever your repo already says: `.claude/agents/`, then `CODEOWNERS` (one role per owner, with all of that owner's paths), then one role per top-level folder that holds code. It declares `[keys] dir` when `.secrets/`, `secrets/` or `.keys/` is there, and adds `.seisin/` to an existing `.gitignore`. It **proposes** — a generated policy you did not read is not a policy — and a second `init` changes nothing without `--force`, which keeps the old one as `seisin.toml.bak`.
|
|
342
507
|
|
|
343
|
-
|
|
344
|
-
neighbours are not granted, a lock file in somebody else's repository, a tool that reports a
|
|
345
|
-
denied write as a *readonly database*, and an agent that diagnoses file permissions when the
|
|
346
|
-
answer is ownership.
|
|
508
|
+
## What will look like a bug on the first day
|
|
347
509
|
|
|
348
|
-
|
|
510
|
+
Shapes a correct policy still produces that read as breakage: a granted file whose
|
|
511
|
+
neighbours are not granted, a lock file in somebody else's repository, a worktree that is two
|
|
512
|
+
paths for one repository, a tool that reports a denied write as a *readonly database*, and Go
|
|
513
|
+
or Dart failing TLS on macOS with the domain allowed. The first four came out of one
|
|
514
|
+
production window where none of them was the boundary misbehaving.
|
|
349
515
|
|
|
350
|
-
|
|
516
|
+
One more comes with protected files: **a role's `git checkout`, `pull` or `rebase` cannot update
|
|
351
517
|
a file the role may not write.** Git exits 0, the branch moves, and the old `.vscode/settings.json`
|
|
352
518
|
stays behind as a modification — which a later `commit -a` would turn into a revert. Switch
|
|
353
519
|
branches outside the sandbox, or hand that role the family if keeping those files is its job.
|
|
354
520
|
|
|
355
|
-
**[What each one looks like, and what to do →](docs/first-day.md)**
|
|
521
|
+
**[What each one looks like, and what to do →](https://github.com/carlostapiacl/seisin/blob/main/docs/first-day.md)**
|
|
356
522
|
|
|
357
523
|
## Which agents it has been run with
|
|
358
524
|
|
|
359
|
-
Claude Code, opencode, codex
|
|
360
|
-
the boundary actually applied — not a smoke test.
|
|
525
|
+
Four: Claude Code, opencode, codex and a LangGraph script, each against a real policy with
|
|
526
|
+
the boundary actually applied — not a smoke test. gemini 0.57.0 has only been started under it,
|
|
527
|
+
which is not the same claim.
|
|
361
528
|
|
|
362
529
|
**One thing is worth knowing before you start: an agent that sandboxes itself has to be told
|
|
363
530
|
to stop.** `codex` confines every command it runs, and no OS applies a second profile to a
|
|
364
531
|
process that already has one. seisin says so before it starts one, because the error you get
|
|
365
532
|
otherwise names neither seisin, nor the agent, nor the fix.
|
|
366
533
|
|
|
367
|
-
**[Every agent, its version, what it needed, and how each reacted to being stopped →](docs/agents.md)**
|
|
534
|
+
**[Every agent, its version, what it needed, and how each reacted to being stopped →](https://github.com/carlostapiacl/seisin/blob/main/docs/agents.md)**
|
|
368
535
|
|
|
369
536
|
## What the log says about the policy
|
|
370
537
|
|
|
@@ -404,6 +571,11 @@ $ seisin review
|
|
|
404
571
|
territory: an ownable path no role claims: a real decision — give it an owner, or tell the agent not to write there
|
|
405
572
|
```
|
|
406
573
|
|
|
574
|
+
`--since 2026-10-01` or `--since 7d` reads only that stretch of the log: on a log months long,
|
|
575
|
+
"Denied repeatedly" is otherwise led by paths fixed long ago. A denial that is correct and needs
|
|
576
|
+
nothing — Claude Code updating its plugins from inside the sandbox — is listed apart as "Denied as
|
|
577
|
+
expected" and does not make `review` exit 1.
|
|
578
|
+
|
|
407
579
|
Three questions, and the middle one is the reason this exists. **Every
|
|
408
580
|
permission file only ever grows**, in every system, and always for the same
|
|
409
581
|
reason: nobody can prove a line is dead, so the safe move is to leave it. Here
|
|
@@ -451,18 +623,32 @@ becomes seven hundred entries nobody reads.
|
|
|
451
623
|
So a denial leaves something to act on, and many denials in one directory are **one** request:
|
|
452
624
|
|
|
453
625
|
```
|
|
626
|
+
$ seisin requests
|
|
627
|
+
|
|
454
628
|
1 pending request(s)
|
|
455
629
|
|
|
456
630
|
#1 frontend wants write on src/api/** (owned by backend) · asked 3×
|
|
457
631
|
first asked over src/api/orders.ts
|
|
632
|
+
id frontend:write:src/api
|
|
458
633
|
|
|
459
|
-
seisin grant
|
|
634
|
+
seisin grant <n|id> [--reason "…"] · seisin decline <n|id> [--reason "…"]
|
|
635
|
+
#n is a position and shifts as requests are settled; the id does not.
|
|
460
636
|
```
|
|
461
637
|
|
|
462
638
|
The grant writes its own provenance next to the line it adds:
|
|
463
639
|
|
|
640
|
+
```
|
|
641
|
+
$ seisin grant 1 --reason "frontend owns checkout now"
|
|
642
|
+
|
|
643
|
+
granted frontend → src/api/**
|
|
644
|
+
written into /path/to/repo/seisin.toml with its provenance. It applies on the next run.
|
|
645
|
+
```
|
|
646
|
+
|
|
464
647
|
```toml
|
|
465
|
-
|
|
648
|
+
[roles.frontend]
|
|
649
|
+
writes = ["src/web/**", "public/**",
|
|
650
|
+
"src/api/**" # granted 2026-09-12 · asked 3× · «frontend owns checkout now»
|
|
651
|
+
]
|
|
466
652
|
```
|
|
467
653
|
|
|
468
654
|
**Granting is never a tool call.** An agent can read the queue and draft the change; only a
|
|
@@ -470,7 +656,7 @@ person applies it, in a terminal or in the console. That asymmetry is deliberate
|
|
|
470
656
|
the reason the MCP server exists at all.
|
|
471
657
|
|
|
472
658
|
**A person hears about it.** With `[notify]`, the parent process sends one message per new
|
|
473
|
-
request — the role, the path, whose it is, and `seisin grant <
|
|
659
|
+
request — the role, the path, whose it is, and `seisin grant '<id>'` — to ntfy, Slack or any
|
|
474
660
|
webhook. The URL lives in a key directory no role declares (`url_file`) or in the parent's
|
|
475
661
|
`SEISIN_NOTIFY_URL`; written plainly into `seisin.toml`, which every role can read, it refuses to
|
|
476
662
|
load, because whoever reads it can send you a fake "grant this".
|
|
@@ -487,7 +673,7 @@ from the log and tells the agent whose the path was and how many times it has be
|
|
|
487
673
|
the half PreToolUse never sees; and when a session starts, resumes or is compacted it hands the
|
|
488
674
|
agent its territory and its walls, before the first one.
|
|
489
675
|
|
|
490
|
-
**[How a denial becomes a request, why it deduplicates, and what a grant records →](docs/
|
|
676
|
+
**[How a denial becomes a request, why it deduplicates, and what a grant records →](https://github.com/carlostapiacl/seisin/blob/main/docs/requests.md)**
|
|
491
677
|
|
|
492
678
|
## Ask your own assistant
|
|
493
679
|
|
|
@@ -520,13 +706,39 @@ seisin ui # the same thing, for a person deciding
|
|
|
520
706
|
```
|
|
521
707
|
|
|
522
708
|
```
|
|
523
|
-
|
|
524
|
-
|
|
525
|
-
04:13:
|
|
709
|
+
$ seisin log
|
|
710
|
+
|
|
711
|
+
04:13:26 allowed frontend write src/web/app.ts
|
|
712
|
+
04:13:26 denied frontend write src/api/orders.ts → backend
|
|
713
|
+
04:13:27 denied frontend read .secrets/database-url.txt → backend
|
|
714
|
+
04:13:31 denied frontend write src/api/orders.ts → backend [kernel]
|
|
715
|
+
|
|
716
|
+
4 entries · 3 denied
|
|
526
717
|
```
|
|
527
718
|
|
|
719
|
+
`[kernel]` marks a line the kernel reported; the others came from the hook.
|
|
720
|
+
|
|
528
721
|
One append-only JSONL under `.seisin/`, and that is the whole storage design — no daemon, no
|
|
529
|
-
database, and `watch` is a tail.
|
|
722
|
+
database, and `watch` is a tail. A run started with a W3C `TRACEPARENT` (a launcher's trace)
|
|
723
|
+
stamps its `trace_id` and `span_id` on every line, so the log joins that trace in any
|
|
724
|
+
OpenTelemetry tool.
|
|
725
|
+
|
|
726
|
+
Each line carries the hash of the one before it, and the first time a chained line is written
|
|
727
|
+
the start of the chain is recorded beside the log (`log.jsonl.genesis`). `seisin log verify`
|
|
728
|
+
checks both, so a line edited, removed or reordered breaks it, and so does stripping the chain
|
|
729
|
+
from every line to make the log read as one written before chaining:
|
|
730
|
+
|
|
731
|
+
```
|
|
732
|
+
$ seisin log verify
|
|
733
|
+
|
|
734
|
+
broken 4 chained line(s), 2 break(s):
|
|
735
|
+
line 1: expected prev 106c24967e0bdde077d73073b648f218, found 43918a17f2089effc44394a72e4289e9
|
|
736
|
+
line 2: expected prev 43918a17f2089effc44394a72e4289e9, found 106c24967e0bdde077d73073b648f218
|
|
737
|
+
a line was edited, removed or reordered just before each of these
|
|
738
|
+
```
|
|
739
|
+
|
|
740
|
+
Tamper-evident, not tamper-proof: whoever can write `.seisin/` could rewrite both files
|
|
741
|
+
together, which is why no role can write it.
|
|
530
742
|
|
|
531
743
|
**The agent cannot reach that file.** `.seisin/` is in no role's territory, because a record
|
|
532
744
|
the recorded process can edit is not a record. The hook sends each line to `seisin run` over
|
|
@@ -539,18 +751,30 @@ happens — which is how an *allowed* action gets recorded at all — and it is
|
|
|
539
751
|
evadable, on purpose. The **kernel** reports what it actually denied, and is not fooled by
|
|
540
752
|
anything. What escapes the hook goes unexplained, never unenforced.
|
|
541
753
|
|
|
754
|
+

|
|
542
755
|
|
|
756
|
+
It opens on what needs a decision, not on the policy. Four numbers sit on top — waiting on
|
|
757
|
+
you, denied, the **top cause**, and calls **spent retrying** walls — each a link to the page
|
|
758
|
+
that explains it, and the last two are recomputed against the policy, which a log alone cannot
|
|
759
|
+
do: something granted since stops counting.
|
|
543
760
|
|
|
544
|
-
|
|
761
|
+
**A decision needs a reason.** Grant without one asks for it; Decline without one fills in
|
|
762
|
+
a default and asks again, so what is recorded is on screen first. The request says what a
|
|
763
|
+
grant covers before the click — *asked for `src/api/routes.ts` → granting
|
|
764
|
+
covers all of `src/api/**`*, and whose it is today. Sharing another role's territory takes a
|
|
765
|
+
second click that names it. After it, the console shows the line it wrote to `seisin.toml`
|
|
766
|
+
and where.
|
|
545
767
|
|
|
546
|
-
|
|
547
|
-
|
|
548
|
-
|
|
549
|
-
of those causes are on paths **nobody owns**.
|
|
768
|
+
**Denied and Walls say the same thing about one path.** A path no role owns says what kind
|
|
769
|
+
of thing it is — *ownable*, or git's, temporary, build output, a credential — and the move
|
|
770
|
+
that fits each, which for all but the first is not an owner.
|
|
550
771
|
|
|
551
|
-
|
|
552
|
-
|
|
553
|
-
|
|
772
|
+
**Live, the roles are read-only:** territories and keys change in `seisin.toml`. The two
|
|
773
|
+
writes are decisions and the control files: beside each role, a toggle for each family it can
|
|
774
|
+
be handed (editor settings, instruction files), and one for `[protect] instructions`. A
|
|
775
|
+
toggle saves nothing. It shows the sandbox settings each role gains or loses and the exact
|
|
776
|
+
line it would write, and a second button, with a reason, saves it. If the server stops, the
|
|
777
|
+
page says *offline* and turns every write off until it answers again.
|
|
554
778
|
|
|
555
779
|
## How it holds
|
|
556
780
|
|
|
@@ -558,7 +782,7 @@ what it does to each role's sandbox settings, and written to `seisin.toml` only
|
|
|
558
782
|
|---|---|---|
|
|
559
783
|
| your agent's `allow`/`deny` rules | match the command string before it runs | yes — `/bin/rm`, a heredoc, a child process |
|
|
560
784
|
| **seisin** | decides what to ask for, and names the owner | it doesn't enforce, it explains |
|
|
561
|
-
| **sandbox
|
|
785
|
+
| **the sandbox → Seatbelt / bubblewrap / Landlock** | the OS denies the syscall | no |
|
|
562
786
|
|
|
563
787
|
```
|
|
564
788
|
Write tool · Bash `rm -rf` · /bin/rm · python -c "open(…)" · a grandchild
|
|
@@ -582,6 +806,10 @@ what it does to each role's sandbox settings, and written to `seisin.toml` only
|
|
|
582
806
|
explanation — never the boundary
|
|
583
807
|
```
|
|
584
808
|
|
|
809
|
+
`sandbox-runtime` is the default enforcer, not the only one. `[sandbox] provider` picks another —
|
|
810
|
+
`tartaros`, or any sandbox declared as a command — per role, and only where it enforces the whole
|
|
811
|
+
policy: see [docs/sandboxes.md](https://github.com/carlostapiacl/seisin/blob/main/docs/sandboxes.md).
|
|
812
|
+
|
|
585
813
|
That split is deliberate, and it is why seisin is small. Because the kernel is the boundary, seisin never has to be airtight — it only has to be *legible*. A leaky explainer costs you a confusing log line. A leaky enforcer costs you the repo.
|
|
586
814
|
|
|
587
815
|
Verified by the test suite, which runs real commands in a real sandbox:
|
|
@@ -607,7 +835,7 @@ than hidden.
|
|
|
607
835
|
| reading another role's key file | the kernel — the key directory is denied, each declared key re-allowed |
|
|
608
836
|
| a key riding in the environment | the launcher — the child's environment is **built, not inherited** |
|
|
609
837
|
| spilling a key the role does hold | the launcher — masked on stdout and stderr, even split across buffers |
|
|
610
|
-
| sending it somewhere | the kernel — egress is allow-only, by hostname. HTTP(S) works; [SSH does not](docs/decisions.md#ssh-does-not-work-and-it-is-not-seisin-that-decided-that) |
|
|
838
|
+
| sending it somewhere | the kernel — egress is allow-only, by hostname. HTTP(S) works; [SSH does not on macOS](https://github.com/carlostapiacl/seisin/blob/main/docs/decisions.md#ssh-does-not-work-and-it-is-not-seisin-that-decided-that) |
|
|
611
839
|
| **a key stored outside the declared directories** | **nothing.** `seisin scan` finds them so you know |
|
|
612
840
|
|
|
613
841
|
```toml
|
|
@@ -625,12 +853,24 @@ A **provider** is a command with a `{ref}` placeholder, so adding 1Password, Bit
|
|
|
625
853
|
`sops` is TOML and not code. `file://` ships built in. The parent resolves, never the
|
|
626
854
|
confined process. Nothing degrades: a provider that fails stops the run.
|
|
627
855
|
|
|
628
|
-
|
|
856
|
+
A key reaches the role as a variable (`key_mode = "env"`), as a file in the run's private
|
|
857
|
+
directory (`"run-file"`), or, on Linux, not at all: with `"inject"` the agent holds a
|
|
858
|
+
placeholder and the sandbox swaps in the real value only on the way to the hosts you list for
|
|
859
|
+
it. That costs terminating TLS to those hosts, which `seisin check` names, and a host that
|
|
860
|
+
stores what it is sent can still hand the value back — use a least-scope token.
|
|
861
|
+
|
|
862
|
+
**[Key directories, references, providers, delivery modes and nine recipes →](https://github.com/carlostapiacl/seisin/blob/main/docs/keys.md)**
|
|
863
|
+
|
|
864
|
+
For agents you would not trust with your machine, `[runtime] read = "territory"` denies the places
|
|
865
|
+
data lives — homes, temp directories, volumes — and leaves each role its repo, its territory and
|
|
866
|
+
a toolchain it declares, which `seisin check --verify` proves it can run. Every run, in every
|
|
867
|
+
mode, starts with a canary that tries the policy from inside and refuses to start the agent if
|
|
868
|
+
it does not hold. **[What it closes, what it costs →](https://github.com/carlostapiacl/seisin/blob/main/docs/scratch.md#reading-only-what-the-role-needs)**
|
|
629
869
|
|
|
630
870
|
**Measured, over four teams of agents and three days: every denied read was a read of something
|
|
631
871
|
the policy had declared a key.** 148 of them, no exceptions, from five roles — all running
|
|
632
872
|
searches that swept a repository root. That is how a key gets read without anyone deciding
|
|
633
|
-
it should.
|
|
873
|
+
it should. ([The measurement, ~370 confined runs over one production window →](https://github.com/carlostapiacl/seisin/blob/main/docs/keys.md))
|
|
634
874
|
|
|
635
875
|
## When the boundary is right and the agent cannot hear it
|
|
636
876
|
|
|
@@ -646,9 +886,10 @@ measured.
|
|
|
646
886
|
So a denial now carries its own history:
|
|
647
887
|
|
|
648
888
|
```
|
|
649
|
-
deploy/x.yml belongs to infra. It is not dev's to change
|
|
650
|
-
working around it.
|
|
651
|
-
|
|
889
|
+
deploy/x.yml belongs to infra. It is not dev's to change. It is handed to infra: hand it
|
|
890
|
+
over rather than working around it. Do not put the change somewhere else instead (another
|
|
891
|
+
path, a copy, /tmp). You have been denied this 3 times now; it is not going to work on the
|
|
892
|
+
fourth try. Already queued as a handoff to infra…
|
|
652
893
|
```
|
|
653
894
|
|
|
654
895
|
And the standing list is a command:
|
|
@@ -673,7 +914,9 @@ Already denied more than once, and still denied — do not try again, hand it ov
|
|
|
673
914
|
- read ~/.npmrc (6×) — no role declares ~/.npmrc — add it under a [roles.<name>] keys list
|
|
674
915
|
```
|
|
675
916
|
|
|
676
|
-
and, on request, as data from the MCP server's `seisin_walls`.
|
|
917
|
+
and, on request, as data from the MCP server's `seisin_walls`. In a run handed along a chain, a
|
|
918
|
+
wall where the chain stops is listed without its owner: a person decides it, and the agent is
|
|
919
|
+
to pass on the one sentence its denial gives ([where a request goes](https://github.com/carlostapiacl/seisin/blob/main/docs/requests.md#where-a-request-goes)).
|
|
677
920
|
|
|
678
921
|
**A wall is recomputed against the policy, not read out of the log.** If the role would be
|
|
679
922
|
allowed today, it is not a wall — whatever happened yesterday. The obvious implementation is a
|
|
@@ -698,15 +941,20 @@ than smoothed over, because the alternative is a claim the numbers do not carry.
|
|
|
698
941
|
|
|
699
942
|
Territory alone is correct and unusable: an agent writes session state under its own config
|
|
700
943
|
directory and its tools write to the temp dir, so *your folders and nothing else* stops the
|
|
701
|
-
agent from starting. Every role gets `~/.
|
|
702
|
-
|
|
703
|
-
|
|
944
|
+
agent from starting. Every role gets `~/.cache`, `$TMPDIR` and `/tmp`, and a run of Claude Code
|
|
945
|
+
or Codex gets that agent's own directory (`~/.claude`, `~/.codex`) — never the other's, nor the
|
|
946
|
+
other's sign-in. Shared scratch is a real hole, because two roles share a temp directory and
|
|
947
|
+
territory does not hold in it. A repo that lives inside scratch — a toy repo under `/tmp` — is
|
|
948
|
+
writable by every role whatever its `writes` say; `seisin check` says so (*every role can write
|
|
949
|
+
the whole repo*), and the same for a `writes` entry whose real path is in scratch.
|
|
950
|
+
It is one line to turn off. The runtime's own shared temp directory, `/tmp/claude`, is never
|
|
951
|
+
writable: each run gets a temp directory of its own instead.
|
|
704
952
|
|
|
705
|
-
**[What that costs, and how to close it →](docs/scratch.md)**
|
|
953
|
+
**[What that costs, and how to close it →](https://github.com/carlostapiacl/seisin/blob/main/docs/scratch.md)**
|
|
706
954
|
|
|
707
955
|
## What it is not
|
|
708
956
|
|
|
709
|
-
- **Not a sandbox.** [`sandbox-runtime`](https://github.com/anthropics/sandbox-runtime)
|
|
957
|
+
- **Not a sandbox.** The sandbox is the one you choose: [`sandbox-runtime`](https://github.com/anthropics/sandbox-runtime) by default, or [tartaros](https://github.com/carlostapiacl/tartaros). seisin writes its settings, checks that it can hold them and explains its denials.
|
|
710
958
|
- **Not an orchestrator.** It does not run your agents, schedule them, or merge their work. It runs one command as one role.
|
|
711
959
|
- **Not a secret manager.** It scopes *reads* of files you already have. Where those files come from is your problem.
|
|
712
960
|
- **Not what a single agent needs first.** If only one agent ever touches the repo,
|
|
@@ -741,8 +989,8 @@ files, the same keys, no answer to *whose is it*. That question is not about
|
|
|
741
989
|
isolation strength at all — it is about ownership, and it is the one thing an
|
|
742
990
|
operating system cannot know for you.
|
|
743
991
|
|
|
744
|
-
Which is also why this is thin. Enforcement is
|
|
745
|
-
[`@anthropic-ai/sandbox-runtime`](https://github.com/anthropics/sandbox-runtime)
|
|
992
|
+
Which is also why this is thin. Enforcement is the sandbox — by default
|
|
993
|
+
[`@anthropic-ai/sandbox-runtime`](https://github.com/anthropics/sandbox-runtime) —
|
|
746
994
|
asking the kernel; seisin is the layer that decides what to ask for and says
|
|
747
995
|
whose file it was when the answer is no. The two compose — put seisin inside a
|
|
748
996
|
container if you want both, and the roles still hold.
|
|
@@ -760,7 +1008,7 @@ This space already has good work, and seisin is not the first thing here:
|
|
|
760
1008
|
- [`anthropics/sandbox-runtime`](https://github.com/anthropics/sandbox-runtime) — the enforcement. seisin is a thin thing on top of a serious one.
|
|
761
1009
|
- [`kornysietsma/claude-code-permissions-hook`](https://github.com/kornysietsma/claude-code-permissions-hook) — granular `PreToolUse` rules, one global policy.
|
|
762
1010
|
- [`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.
|
|
1011
|
+
- [`NVIDIA/OpenShell`](https://github.com/NVIDIA/openshell) — a runtime that sandboxes an agent with Landlock and seccomp, under a declarative policy. Serious, and the closest thing here by weight. It was evaluated as a possible backend; [the measured compatibility decision](https://github.com/carlostapiacl/seisin/blob/main/docs/openshell.md) explains why it is currently an execution target candidate rather than a drop-in replacement.
|
|
764
1012
|
- [`dredozubov/hazmat`](https://github.com/dredozubov/hazmat) — runs the agent as a different system user, with `pf` rules and snapshots.
|
|
765
1013
|
- [`nolabs-ai/nono`](https://github.com/nolabs-ai/nono) — a kernel sandbox for agents on the host (Seatbelt, Landlock), with per-tool child sandboxes, a credential proxy with endpoint filtering, approval webhooks and a tamper-evident audit log. The most complete sandbox here, and on a Mac the one that enforces through the same kernel seisin does. `nono why` explains *which rule* denied something and how to allow it; it has no notion of *whose* it was.
|
|
766
1014
|
- *Directory ownership* is recommended in half the multi-agent write-ups. As far as I could find, nobody enforces it, and nobody names the owner in the denial. That gap is the reason for this repo.
|
|
@@ -780,7 +1028,7 @@ They compose rather than compete: the agent seisin confines could be running ins
|
|
|
780
1028
|
them.
|
|
781
1029
|
|
|
782
1030
|
*One detail worth knowing before you compare, and it is about placement rather than quality.
|
|
783
|
-
OpenShell was [evaluated here on 2026-09-27](docs/openshell.md), through its Docker driver on
|
|
1031
|
+
OpenShell was [evaluated here on 2026-09-27](https://github.com/carlostapiacl/seisin/blob/main/docs/openshell.md), through its Docker driver on
|
|
784
1032
|
an Intel Mac. That confines an agent inside Docker Desktop's Linux VM; seisin confines a
|
|
785
1033
|
process on the host through Seatbelt. OpenShell also offers other drivers, including a local
|
|
786
1034
|
microVM path on supported Apple Silicon Macs. These are kernel-enforced boundaries, but not
|
|
@@ -788,16 +1036,24 @@ the same kernel or workspace, and which one you want depends on what you are pro
|
|
|
788
1036
|
|
|
789
1037
|
## Status
|
|
790
1038
|
|
|
791
|
-
|
|
792
|
-
|
|
793
|
-
|
|
1039
|
+
**1089 tests on macOS, 1082 passing and 7 platform skips** (0.6.0, 2026-10-23, local); in CI the
|
|
1040
|
+
same day, on `ubuntu-latest` **1088, 1059 passing and 29 platform skips**, and on macOS 1089,
|
|
1041
|
+
1079 passing and 10 skips, Node 18/20/22/24, with the 26 core boundary tests skipping none. On 2026-10-12, 91 of the suite's tests
|
|
1042
|
+
needed `@anthropic-ai/sandbox-runtime` installed — counted by running it with the runtime
|
|
1043
|
+
hidden — and run real commands through the real kernel; 26 of them are the core boundary file,
|
|
1044
|
+
`test/sandbox.test.js`, which the CI gate fails if it *skips*, because a green run that quietly
|
|
1045
|
+
tested nothing looks exactly like a real one. That is not hypothetical:
|
|
794
1046
|
when there were eighteen of them, they skipped on Linux for a day, behind a runtime check that looked for the global
|
|
795
1047
|
install and missed the bundled one, and hid a defect that broke `seisin run` on that platform
|
|
796
|
-
entirely.
|
|
797
|
-
|
|
798
|
-
|
|
799
|
-
|
|
800
|
-
|
|
1048
|
+
entirely.
|
|
1049
|
+
|
|
1050
|
+
A release is published only from CI, after the full suite passes on macOS and `ubuntu-latest`,
|
|
1051
|
+
Node 18/20/22/24 (`publish.yml`), with npm provenance.
|
|
1052
|
+
Historically: 811 tests on macOS 15, 809 passing and 2 skipped, on 2026-09-30.
|
|
1053
|
+
[Which claim was measured where](https://github.com/carlostapiacl/seisin/blob/main/docs/what-it-has-been-put-through.md#where-each-claim-was-actually-run),
|
|
1054
|
+
and [what running agents behind it cost the people using it](https://github.com/carlostapiacl/seisin/blob/main/docs/field-notes.md).
|
|
1055
|
+
The [2026-10-02 review](https://github.com/carlostapiacl/seisin/blob/main/docs/reviews/2026-10-02.md) and its [follow-up](https://github.com/carlostapiacl/seisin/blob/main/docs/reviews/2026-10-02-followup.md)
|
|
1056
|
+
added 32 regressions and record their portable checks apart from the kernel measurements above.
|
|
801
1057
|
The config format may still move before `1.0` — if it does, `seisin check` will
|
|
802
1058
|
tell you what changed.
|
|
803
1059
|
|
|
@@ -807,19 +1063,19 @@ already done:
|
|
|
807
1063
|
| | |
|
|
808
1064
|
|---|---|
|
|
809
1065
|
| **Windows** | the runtime has a backend. seisin has never been pointed at it, and no CI runner covers it |
|
|
810
|
-
| **Deleting inside your own territory** |
|
|
811
|
-
| **`init` heuristics** | it reads `.claude/agents
|
|
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 |
|
|
813
|
-
| **A browser inside a role (macOS)** | Chromium
|
|
1066
|
+
| **Deleting inside your own territory** | the sandbox's to enforce, and `srt` cannot: write includes delete. The ask is upstream as [issue #545](https://github.com/anthropics/sandbox-runtime/issues/545), open and unanswered since 2026-09-13, [with the measurement behind it](https://github.com/carlostapiacl/seisin/blob/main/docs/upstream/denyUnlink.md) and [a demo](https://github.com/carlostapiacl/seisin/tree/main/docs/demo/) |
|
|
1067
|
+
| **`init` heuristics** | it reads `.claude/agents/`, then `CODEOWNERS`, then the top-level folders that hold code. Every other convention is a guess nobody has made yet |
|
|
1068
|
+
| **SSH inside a run (macOS)** | on macOS the `ProxyCommand` the runtime sets can't authenticate to its own proxy, so `git` over SSH fails at the handshake (on Linux the runtime routes it through an authenticated proxy — read from its source, not measured here). Fix upstream: [PR #516](https://github.com/anthropics/sandbox-runtime/pull/516). Use an HTTPS remote |
|
|
1069
|
+
| **A browser inside a role (macOS)** | headless Chromium runs with `browsers = ["chromium"]`. Not covered yet: Chrome and Chrome for Testing, and `@playwright/mcp` under srt (it runs under tartaros). [docs/browsers.md](https://github.com/carlostapiacl/seisin/blob/main/docs/browsers.md#what-is-not-covered-yet) |
|
|
814
1070
|
|
|
815
|
-
Every word above has one meaning, listed in [the glossary](docs/glossary.md) — the boundary
|
|
1071
|
+
Every word above has one meaning, listed in [the glossary](https://github.com/carlostapiacl/seisin/blob/main/docs/glossary.md) — the boundary
|
|
816
1072
|
**denies**, a person **grants** or **declines**, seisin **refuses** a config it cannot enforce. A tool whose
|
|
817
1073
|
product is a sentence cannot afford two words for one thing.
|
|
818
1074
|
|
|
819
|
-
Issues and pull requests welcome — [CONTRIBUTING.md](CONTRIBUTING.md) says what is actually
|
|
1075
|
+
Issues and pull requests welcome — [CONTRIBUTING.md](https://github.com/carlostapiacl/seisin/blob/main/CONTRIBUTING.md) says what is actually
|
|
820
1076
|
different about contributing here, which is mostly that a claim has to be downstream of
|
|
821
1077
|
something measured. If you are reporting something that got past the boundary, do it
|
|
822
|
-
[privately](SECURITY.md); `seisin log --verdict denied` and the `seisin.toml` are the two
|
|
1078
|
+
[privately](https://github.com/carlostapiacl/seisin/blob/main/SECURITY.md); `seisin log --verdict denied` and the `seisin.toml` are the two
|
|
823
1079
|
things that make it reproducible.
|
|
824
1080
|
|
|
825
1081
|
## The name
|