seisin 0.4.2 → 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 +506 -162
- package/package.json +14 -6
- package/src/browsers.js +52 -0
- package/src/canary.js +149 -0
- package/src/cli.js +175 -52
- package/src/commands/check.js +157 -5
- package/src/commands/explain.js +31 -13
- package/src/commands/hook.js +20 -6
- package/src/commands/init.js +183 -88
- package/src/commands/log.js +48 -9
- package/src/commands/requests.js +122 -70
- package/src/commands/review.js +73 -21
- package/src/commands/run.js +422 -78
- package/src/commands/scan.js +18 -3
- package/src/commands/ui.js +18 -5
- package/src/commands/walls.js +62 -11
- package/src/commands/whose.js +9 -7
- package/src/commands/wire.js +156 -17
- package/src/config.js +505 -37
- package/src/controls.js +244 -0
- package/src/diagnose.js +102 -49
- package/src/env.js +14 -8
- package/src/grants.js +122 -9
- package/src/handoff.js +285 -10
- package/src/help.js +179 -0
- package/src/hook.js +101 -42
- package/src/index.d.ts +217 -0
- package/src/index.js +24 -16
- 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 +20 -756
- package/src/intake.js +150 -35
- package/src/keys.js +231 -73
- package/src/kinds.js +249 -0
- package/src/lines.js +26 -0
- package/src/log.js +242 -52
- package/src/mcp.js +57 -58
- package/src/nested.js +12 -3
- package/src/notify.js +66 -20
- package/src/owners.js +358 -39
- package/src/paths.js +54 -2
- package/src/provider-run.js +81 -0
- package/src/redact.js +46 -13
- package/src/render.js +101 -17
- package/src/requests.js +506 -87
- package/src/review.js +50 -25
- package/src/rundir.js +33 -14
- 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 +181 -19
- package/src/serve.js +396 -230
- package/src/spool.js +16 -45
- package/src/srt.js +296 -49
- package/src/suggest.js +58 -0
- package/src/surface.js +314 -54
- package/src/territory.js +261 -0
- package/src/trace.js +33 -0
- package/src/uilink.js +24 -9
- package/src/views.js +315 -0
- package/src/violations.js +247 -23
- package/src/walls.js +76 -46
- package/src/worktree.js +3 -11
- package/ui/index.html +1429 -538
- package/ui/favicon.svg +0 -1
package/README.md
CHANGED
|
@@ -1,57 +1,65 @@
|
|
|
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
|
-
**Give each AI agent its own folders and its own keys.** The kernel enforces it, and when it
|
|
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) · [
|
|
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"}}
|
|
48
55
|
K -->|not this role's| D["denied"]
|
|
49
|
-
D --> S["seisin reads the
|
|
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
|
|
@@ -60,35 +68,58 @@ flowchart LR
|
|
|
60
68
|
|
|
61
69
|
**The kernel says no. seisin says whose.** That second half is the whole thing: other
|
|
62
70
|
permission layers in this space answer *yes* or *no*, or at best *which rule* said no — and a
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-

|
|
71
|
+
denial that also names an owner turns a dead end into a handoff.
|
|
66
72
|
|
|
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
|
|
|
80
|
-
|
|
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
|
+
|
|
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
|
|
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
|
-
The agent can ask directly too, from inside the
|
|
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.
|
|
@@ -106,7 +137,7 @@ On Linux, [three system packages first](#install). Everything below is why it wo
|
|
|
106
137
|
|
|
107
138
|
## Why this exists
|
|
108
139
|
|
|
109
|
-
Two things are true about
|
|
140
|
+
Two things are true about how agents are kept in bounds today, and both are in the official docs.
|
|
110
141
|
|
|
111
142
|
**1. Permission rules match text, not behaviour.** From the Claude Code documentation:
|
|
112
143
|
|
|
@@ -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 |
|
|
160
|
-
| the hook is text-based and evadable | on purpose — what escapes it goes **
|
|
191
|
+
| an allowed domain is an allowed channel | if the agent's own API is reachable, so is everything that goes through it. [`[network] deny_addresses`](#configure) keeps an allowed name from resolving into your LAN |
|
|
192
|
+
| the hook is text-based and evadable | on purpose — what escapes it goes **past the hook, not past the kernel**: the kernel still denies. On macOS it is no longer unexplained either, because [the kernel's own denials are recorded](#seeing-what-happened); on Linux it is. Either way, attribution is not a control |
|
|
193
|
+
|
|
194
|
+
Others are the sandbox's, and differ between the two built in:
|
|
161
195
|
|
|
162
|
-
|
|
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 |
|
|
163
202
|
|
|
164
|
-
|
|
165
|
-
implementation is still moving quickly. "Secure" is not a word earned by size or test count.
|
|
166
|
-
2. **The enforcement is someone else's beta.** `sandbox-runtime` describes itself as a
|
|
167
|
-
research preview with an evolving API. A hole there is a hole here.
|
|
168
|
-
3. **Two platforms tested, by one person, on one machine each.**
|
|
203
|
+
`seisin check` says which sandbox each role gets before anything runs.
|
|
169
204
|
|
|
170
|
-
For several of your own agents, on your own machine, getting in each other's way
|
|
171
|
-
what
|
|
172
|
-
not
|
|
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,18 +238,75 @@ 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
|
+
|
|
291
|
+
`seisin <command> --help` lists a command's flags, examples and exit codes. The exit codes, so
|
|
292
|
+
the commands compose in a script, a pre-commit hook or CI:
|
|
293
|
+
|
|
294
|
+
| command | 0 | 1 | other |
|
|
295
|
+
|---|---|---|---|
|
|
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 | |
|
|
298
|
+
| `explain` | allowed | denied | |
|
|
299
|
+
| `scan` | no certain credential | a certain credential outside the key dirs | |
|
|
300
|
+
| `review` | no role denied repeatedly | a role denied repeatedly | |
|
|
301
|
+
| `walls` | no walls | walls listed | |
|
|
302
|
+
| `requests` | nothing pending | requests pending | |
|
|
303
|
+
| `log verify` | the hash chain holds | the hash chain is broken | |
|
|
304
|
+
| `whose`, `grant`, `decline`, `log`, `init`, `wire`, `ui`, `watch`, `mcp` | done | — | |
|
|
305
|
+
| `hook` | always | — | |
|
|
306
|
+
|
|
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.
|
|
309
|
+
|
|
218
310
|
## Configure
|
|
219
311
|
|
|
220
312
|
One file at the root of your repo. Anything not listed is denied — there is no permissive default.
|
|
@@ -236,19 +328,59 @@ keys = ["database-url.txt", "sentry-dsn.txt"]
|
|
|
236
328
|
network = ["api.stripe.com"] # this role only — it replaces [network] rather than adding
|
|
237
329
|
```
|
|
238
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
|
+
|
|
239
369
|
`[network] allow` is the fallback for roles that do not name their own. A role that needs one
|
|
240
370
|
extra host should say so on its own line: a domain in the global list is reachable by every
|
|
241
371
|
role, including the ones whose whole job is to have nothing to reach.
|
|
242
372
|
|
|
243
|
-
> ⚠️ **HTTP(S) works
|
|
244
|
-
> starts, and although one of them is a SOCKS5 proxy that could
|
|
245
|
-
> at its last link: it requires authentication and the
|
|
246
|
-
> cannot send any. Measured — `curl
|
|
247
|
-
>
|
|
248
|
-
> 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.
|
|
249
381
|
>
|
|
250
|
-
> **Use an HTTPS remote for git**, and run SSH deploys outside the confined
|
|
251
|
-
> [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)
|
|
252
384
|
|
|
253
385
|
A role can also give something back. `never_writes` subtracts from its own `writes`, and it
|
|
254
386
|
wins however wide the grant is:
|
|
@@ -260,25 +392,49 @@ never_writes = ["services/api/.git/index.lock"] # works in a worktree; the can
|
|
|
260
392
|
```
|
|
261
393
|
|
|
262
394
|
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:
|
|
395
|
+
breaks every role that legitimately commits there. A denial it causes says so by name and
|
|
396
|
+
leaves **no request** in the queue: granting one would undo a subtraction somebody wrote. An
|
|
265
397
|
entry that no `writes` of the same role covers, or a misspelling like `never_write`, is named
|
|
266
398
|
by `seisin check` instead of being ignored.
|
|
267
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
|
+
|
|
268
422
|
A role that starts a server — a dev server, the backend an end-to-end test drives — needs
|
|
269
423
|
`local_binding = true`. Listening is off by default and per role: most roles never start one.
|
|
270
424
|
It is wider than the name: the role can also listen on every interface and **connect to every
|
|
271
425
|
port on localhost**, so anything listening there without authentication is within its reach.
|
|
272
426
|
`seisin check` says this next to the role. That is macOS: on Linux each role already has a
|
|
273
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.
|
|
274
430
|
|
|
275
431
|
If a role only needs to *reach* a local service, name the ports instead:
|
|
276
432
|
`local_ports = [8001, 8081]`. The role reaches those and nothing else on localhost, on macOS
|
|
277
|
-
and Linux.
|
|
278
|
-
Node's `fetch` and Python's `urllib` do. A database driver or a raw socket don't, and
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
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.
|
|
282
438
|
|
|
283
439
|
`mcp = ["playwright"]` lists the MCP servers a role may load; `mcp = []` means none, and no
|
|
284
440
|
key means the policy doesn't say. seisin declares this and `explain` answers it
|
|
@@ -286,35 +442,96 @@ key means the policy doesn't say. seisin declares this and `explain` answers it
|
|
|
286
442
|
launcher has to apply it. With Claude Code: `--mcp-config` holding only those servers, plus
|
|
287
443
|
`--strict-mcp-config`.
|
|
288
444
|
|
|
289
|
-
|
|
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
|
+
|
|
469
|
+
Some files are not code but still make something happen outside the sandbox, and no territory
|
|
470
|
+
reaches them: git hooks and config, a project's `.claude/`, `.mcp.json`, `.envrc`. Two softer
|
|
471
|
+
families sit beside those, and a policy can hand them to one role:
|
|
472
|
+
|
|
473
|
+
```toml
|
|
474
|
+
[roles.frontend]
|
|
475
|
+
writes = ["apps/web/**"]
|
|
476
|
+
control_files = ["ide"] # keeps apps/web/.vscode/launch.json current
|
|
477
|
+
|
|
478
|
+
[protect]
|
|
479
|
+
instructions = true # CLAUDE.md, AGENTS.md… only for roles handed "instructions"
|
|
480
|
+
```
|
|
481
|
+
|
|
482
|
+
- **`ide`** — `.vscode/`, `.cursor/`, `.windsurf/`. Your editor applies them as soon as they
|
|
483
|
+
change, which is how an injected agent turns on tool auto-approval. **Protected by default.**
|
|
484
|
+
- **`instructions`** — `CLAUDE.md`, `AGENTS.md`, `GEMINI.md`, Cursor and Windsurf rules, Copilot
|
|
485
|
+
instructions. Nothing runs them; the next agent session reads them as its instructions, so
|
|
486
|
+
one role writing them reaches another role's context. **Off unless `[protect]` turns it on**:
|
|
487
|
+
keeping them current is ordinary work for most teams.
|
|
488
|
+
|
|
489
|
+
A role handed a family writes it only inside its own `writes`, and `seisin check` names it on
|
|
490
|
+
every run. A denial says which setting would change it, and leaves no request: it is a decision
|
|
491
|
+
about the policy, not a grant. Inside `node_modules` neither family is touched, so npm still
|
|
492
|
+
unpacks a package's own `.vscode` or `AGENTS.md`. On Linux only the files that already exist are
|
|
493
|
+
protected: bubblewrap would have to create a missing one to deny it.
|
|
494
|
+
|
|
495
|
+
Denied connections are logged too: `connect tcp:<port>` (the kernel gives the port, not the
|
|
290
496
|
host) or the socket path, once per target per run. `seisin walls` says whether the port is
|
|
291
497
|
missing from `local_ports`, listed but dialled directly, or a socket. No request is filed: a
|
|
292
498
|
port isn't anyone's territory. macOS only.
|
|
293
499
|
|
|
294
|
-
|
|
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.
|
|
505
|
+
|
|
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`.
|
|
295
507
|
|
|
296
508
|
## What will look like a bug on the first day
|
|
297
509
|
|
|
298
|
-
|
|
299
|
-
neighbours are not granted, a lock file in somebody else's repository, a
|
|
300
|
-
denied write as a *readonly database*, and
|
|
301
|
-
|
|
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.
|
|
302
515
|
|
|
303
|
-
|
|
516
|
+
One more comes with protected files: **a role's `git checkout`, `pull` or `rebase` cannot update
|
|
517
|
+
a file the role may not write.** Git exits 0, the branch moves, and the old `.vscode/settings.json`
|
|
518
|
+
stays behind as a modification — which a later `commit -a` would turn into a revert. Switch
|
|
519
|
+
branches outside the sandbox, or hand that role the family if keeping those files is its job.
|
|
304
520
|
|
|
305
|
-
**[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)**
|
|
306
522
|
|
|
307
523
|
## Which agents it has been run with
|
|
308
524
|
|
|
309
|
-
Claude Code, opencode, codex
|
|
310
|
-
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.
|
|
311
528
|
|
|
312
529
|
**One thing is worth knowing before you start: an agent that sandboxes itself has to be told
|
|
313
530
|
to stop.** `codex` confines every command it runs, and no OS applies a second profile to a
|
|
314
531
|
process that already has one. seisin says so before it starts one, because the error you get
|
|
315
532
|
otherwise names neither seisin, nor the agent, nor the fix.
|
|
316
533
|
|
|
317
|
-
**[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)**
|
|
318
535
|
|
|
319
536
|
## What the log says about the policy
|
|
320
537
|
|
|
@@ -324,27 +541,48 @@ actually happened and tells you where the config was wrong about it.
|
|
|
324
541
|
```
|
|
325
542
|
$ seisin review
|
|
326
543
|
|
|
327
|
-
60
|
|
544
|
+
60 entries, 2026-08-23 to 2026-09-11
|
|
545
|
+
|
|
546
|
+
Denied repeatedly
|
|
547
|
+
a role denied on the same place over and over is a policy that is wrong,
|
|
548
|
+
not an agent that is misbehaving
|
|
328
549
|
|
|
329
|
-
Stopped, repeatedly
|
|
330
550
|
9× frontend write src/api/checkout — belongs to backend
|
|
331
551
|
4× backend write src/web — belongs to frontend
|
|
332
552
|
|
|
553
|
+
seisin grant, or move the territory. Either way it is a decision, not noise.
|
|
554
|
+
|
|
333
555
|
Granted, never used
|
|
556
|
+
nothing was written here in the window above. This is the only evidence
|
|
557
|
+
anyone will ever have for making a policy smaller
|
|
558
|
+
|
|
334
559
|
frontend public/**
|
|
335
|
-
backend
|
|
560
|
+
backend migrations/**
|
|
336
561
|
|
|
337
|
-
|
|
338
|
-
|
|
562
|
+
A short window proves nothing. Check the dates before you delete a line.
|
|
563
|
+
|
|
564
|
+
Unowned
|
|
565
|
+
touched by an agent, claimed by no role. Only an ownable path (kind `territory`)
|
|
566
|
+
is a hole in the map; the rest is a kind of thing with its own answer, and none
|
|
567
|
+
of them is an owner
|
|
568
|
+
|
|
569
|
+
4× territory legacy
|
|
570
|
+
|
|
571
|
+
territory: an ownable path no role claims: a real decision — give it an owner, or tell the agent not to write there
|
|
339
572
|
```
|
|
340
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
|
+
|
|
341
579
|
Three questions, and the middle one is the reason this exists. **Every
|
|
342
580
|
permission file only ever grows**, in every system, and always for the same
|
|
343
581
|
reason: nobody can prove a line is dead, so the safe move is to leave it. Here
|
|
344
582
|
the log can prove it — `public/**` was granted and nothing was written there in
|
|
345
583
|
three weeks. That is the only direction that makes a policy *smaller*.
|
|
346
584
|
|
|
347
|
-
The first is the one that changes how a denial reads. Forty
|
|
585
|
+
The first is the one that changes how a denial reads. Forty denials on one
|
|
348
586
|
directory is not an agent misbehaving; it is a policy that is wrong, and until
|
|
349
587
|
you add them up it looks like forty tidy amber lines.
|
|
350
588
|
|
|
@@ -361,10 +599,17 @@ the first unjustified denial is when the tool gets uninstalled. So don't write i
|
|
|
361
599
|
write:
|
|
362
600
|
|
|
363
601
|
```bash
|
|
364
|
-
seisin
|
|
602
|
+
seisin wire # the hook is what records
|
|
603
|
+
seisin run frontend --observe -- claude -p "…" # the repo opens, the network does not
|
|
365
604
|
seisin init --from-observations # writes seisin.toml.observed
|
|
366
605
|
```
|
|
367
606
|
|
|
607
|
+
What `--observe` changes: the whole repo is writable for that run, so a write outside the
|
|
608
|
+
territory is recorded instead of refused. What it does not: the key directories, the policy and
|
|
609
|
+
`.seisin/` stay denied, and so does every domain not on the allowlist. What gets recorded is what
|
|
610
|
+
the hook sees — the tool calls of an agent (Claude Code, Codex…) in a repo where `seisin wire` ran.
|
|
611
|
+
A plain shell command (`--observe -- npm test`) goes through no hook and records nothing.
|
|
612
|
+
|
|
368
613
|
It lands as `.observed`, not as your config. A policy generated behind your back is not a
|
|
369
614
|
policy: diff it, then move it.
|
|
370
615
|
|
|
@@ -372,35 +617,49 @@ policy: diff it, then move it.
|
|
|
372
617
|
|
|
373
618
|
A permission tool that can only say no is a tool people uninstall — the loop of *denied,
|
|
374
619
|
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
|
-
|
|
620
|
+
cheapest way to stop it is to widen the policy and never look again. That is how an allowlist
|
|
621
|
+
becomes seven hundred entries nobody reads.
|
|
377
622
|
|
|
378
623
|
So a denial leaves something to act on, and many denials in one directory are **one** request:
|
|
379
624
|
|
|
380
625
|
```
|
|
626
|
+
$ seisin requests
|
|
627
|
+
|
|
381
628
|
1 pending request(s)
|
|
382
629
|
|
|
383
630
|
#1 frontend wants write on src/api/** (owned by backend) · asked 3×
|
|
384
631
|
first asked over src/api/orders.ts
|
|
632
|
+
id frontend:write:src/api
|
|
385
633
|
|
|
386
|
-
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.
|
|
387
636
|
```
|
|
388
637
|
|
|
389
638
|
The grant writes its own provenance next to the line it adds:
|
|
390
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
|
+
|
|
391
647
|
```toml
|
|
392
|
-
|
|
648
|
+
[roles.frontend]
|
|
649
|
+
writes = ["src/web/**", "public/**",
|
|
650
|
+
"src/api/**" # granted 2026-09-12 · asked 3× · «frontend owns checkout now»
|
|
651
|
+
]
|
|
393
652
|
```
|
|
394
653
|
|
|
395
|
-
**
|
|
654
|
+
**Granting is never a tool call.** An agent can read the queue and draft the change; only a
|
|
396
655
|
person applies it, in a terminal or in the console. That asymmetry is deliberate and it is
|
|
397
656
|
the reason the MCP server exists at all.
|
|
398
657
|
|
|
399
658
|
**A person hears about it.** With `[notify]`, the parent process sends one message per new
|
|
400
|
-
request — the role, the path, whose it is, and `seisin grant <
|
|
659
|
+
request — the role, the path, whose it is, and `seisin grant '<id>'` — to ntfy, Slack or any
|
|
401
660
|
webhook. The URL lives in a key directory no role declares (`url_file`) or in the parent's
|
|
402
661
|
`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 "
|
|
662
|
+
load, because whoever reads it can send you a fake "grant this".
|
|
404
663
|
|
|
405
664
|
```toml
|
|
406
665
|
[notify]
|
|
@@ -409,12 +668,12 @@ format = "text" # or "slack", "json"
|
|
|
409
668
|
```
|
|
410
669
|
|
|
411
670
|
**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
|
|
671
|
+
it explains a denial it can see coming; after a command fails it reads the kernel's own denial
|
|
672
|
+
from the log and tells the agent whose the path was and how many times it has been denied —
|
|
414
673
|
the half PreToolUse never sees; and when a session starts, resumes or is compacted it hands the
|
|
415
674
|
agent its territory and its walls, before the first one.
|
|
416
675
|
|
|
417
|
-
**[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)**
|
|
418
677
|
|
|
419
678
|
## Ask your own assistant
|
|
420
679
|
|
|
@@ -447,33 +706,75 @@ seisin ui # the same thing, for a person deciding
|
|
|
447
706
|
```
|
|
448
707
|
|
|
449
708
|
```
|
|
450
|
-
|
|
451
|
-
|
|
452
|
-
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
|
|
453
717
|
```
|
|
454
718
|
|
|
719
|
+
`[kernel]` marks a line the kernel reported; the others came from the hook.
|
|
720
|
+
|
|
455
721
|
One append-only JSONL under `.seisin/`, and that is the whole storage design — no daemon, no
|
|
456
|
-
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.
|
|
457
742
|
|
|
458
743
|
**The agent cannot reach that file.** `.seisin/` is in no role's territory, because a record
|
|
459
744
|
the recorded process can edit is not a record. The hook sends each line to `seisin run` over
|
|
460
745
|
a unix socket granted by path and the parent does the writing, so the only verb available
|
|
461
|
-
from inside the
|
|
746
|
+
from inside the sandbox is *append one line*. A hostile agent can still add noise to its own
|
|
462
747
|
history; what it cannot do is change what is already there.
|
|
463
748
|
|
|
464
749
|
**Two writers, and they see different things.** The **hook** reports the attempt before it
|
|
465
750
|
happens — which is how an *allowed* action gets recorded at all — and it is text-based and
|
|
466
751
|
evadable, on purpose. The **kernel** reports what it actually denied, and is not fooled by
|
|
467
|
-
anything. What escapes the hook goes unexplained, never
|
|
752
|
+
anything. What escapes the hook goes unexplained, never unenforced.
|
|
753
|
+
|
|
754
|
+

|
|
468
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.
|
|
469
760
|
|
|
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.
|
|
470
767
|
|
|
471
|
-
|
|
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.
|
|
472
771
|
|
|
473
|
-
|
|
474
|
-
|
|
475
|
-
|
|
476
|
-
|
|
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.
|
|
477
778
|
|
|
478
779
|
## How it holds
|
|
479
780
|
|
|
@@ -481,7 +782,7 @@ of those causes are on paths **nobody owns**.
|
|
|
481
782
|
|---|---|---|
|
|
482
783
|
| your agent's `allow`/`deny` rules | match the command string before it runs | yes — `/bin/rm`, a heredoc, a child process |
|
|
483
784
|
| **seisin** | decides what to ask for, and names the owner | it doesn't enforce, it explains |
|
|
484
|
-
| **sandbox
|
|
785
|
+
| **the sandbox → Seatbelt / bubblewrap / Landlock** | the OS denies the syscall | no |
|
|
485
786
|
|
|
486
787
|
```
|
|
487
788
|
Write tool · Bash `rm -rf` · /bin/rm · python -c "open(…)" · a grandchild
|
|
@@ -505,6 +806,10 @@ of those causes are on paths **nobody owns**.
|
|
|
505
806
|
explanation — never the boundary
|
|
506
807
|
```
|
|
507
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
|
+
|
|
508
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.
|
|
509
814
|
|
|
510
815
|
Verified by the test suite, which runs real commands in a real sandbox:
|
|
@@ -530,7 +835,7 @@ than hidden.
|
|
|
530
835
|
| reading another role's key file | the kernel — the key directory is denied, each declared key re-allowed |
|
|
531
836
|
| a key riding in the environment | the launcher — the child's environment is **built, not inherited** |
|
|
532
837
|
| spilling a key the role does hold | the launcher — masked on stdout and stderr, even split across buffers |
|
|
533
|
-
| sending it somewhere | the kernel — egress is allow-only, by hostname. HTTP(S) works; [SSH does not](docs/decisions.md#ssh-does-not-work-and-it-is-not-seisin-that-decided-that) |
|
|
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) |
|
|
534
839
|
| **a key stored outside the declared directories** | **nothing.** `seisin scan` finds them so you know |
|
|
535
840
|
|
|
536
841
|
```toml
|
|
@@ -548,19 +853,31 @@ A **provider** is a command with a `{ref}` placeholder, so adding 1Password, Bit
|
|
|
548
853
|
`sops` is TOML and not code. `file://` ships built in. The parent resolves, never the
|
|
549
854
|
confined process. Nothing degrades: a provider that fails stops the run.
|
|
550
855
|
|
|
551
|
-
|
|
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)**
|
|
552
863
|
|
|
553
|
-
|
|
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)**
|
|
869
|
+
|
|
870
|
+
**Measured, over four teams of agents and three days: every denied read was a read of something
|
|
554
871
|
the policy had declared a key.** 148 of them, no exceptions, from five roles — all running
|
|
555
872
|
searches that swept a repository root. That is how a key gets read without anyone deciding
|
|
556
|
-
it should.
|
|
873
|
+
it should. ([The measurement, ~370 confined runs over one production window →](https://github.com/carlostapiacl/seisin/blob/main/docs/keys.md))
|
|
557
874
|
|
|
558
875
|
## When the boundary is right and the agent cannot hear it
|
|
559
876
|
|
|
560
877
|
seisin answers *whose is this* at the moment of the denial — once, mid-turn — and then the
|
|
561
878
|
sentence is gone. Nothing carries it forward, so the agent tries again.
|
|
562
879
|
|
|
563
|
-
Measured
|
|
880
|
+
Measured in the author's own multi-agent setup: of **345 denials, 88 (25%) were a repeat of something that same role
|
|
564
881
|
had already been denied.** One role spent 37 calls on two walls, hitting one of them nineteen
|
|
565
882
|
times. Every denial was correct. Not one was a false positive. It still cost thirty-seven
|
|
566
883
|
calls, because *correct* and *heard* are different properties and only the first was being
|
|
@@ -569,29 +886,43 @@ measured.
|
|
|
569
886
|
So a denial now carries its own history:
|
|
570
887
|
|
|
571
888
|
```
|
|
572
|
-
deploy/x.yml belongs to infra. It is not dev's to change
|
|
573
|
-
working around it.
|
|
574
|
-
|
|
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…
|
|
575
893
|
```
|
|
576
894
|
|
|
577
895
|
And the standing list is a command:
|
|
578
896
|
|
|
579
897
|
```
|
|
580
898
|
$ seisin walls dev
|
|
581
|
-
|
|
582
|
-
|
|
583
|
-
|
|
584
|
-
|
|
585
|
-
|
|
586
|
-
|
|
587
|
-
|
|
899
|
+
|
|
900
|
+
dev keeps hitting:
|
|
901
|
+
|
|
902
|
+
19× write ../shared/.git/index.lock belongs to platform
|
|
903
|
+
6× read ~/.npmrc no role declares ~/.npmrc — add it under a [roles.<name>] keys list
|
|
904
|
+
|
|
905
|
+
23 of its calls went into retrying these.
|
|
906
|
+
```
|
|
907
|
+
|
|
908
|
+
That is the list said to the person at the terminal. The agent gets it in its own voice, at the
|
|
909
|
+
start of every session the hook is wired for:
|
|
910
|
+
|
|
911
|
+
```
|
|
912
|
+
Already denied more than once, and still denied — do not try again, hand it over:
|
|
913
|
+
- write ../shared/.git/index.lock (19×) — belongs to platform
|
|
914
|
+
- read ~/.npmrc (6×) — no role declares ~/.npmrc — add it under a [roles.<name>] keys list
|
|
588
915
|
```
|
|
589
916
|
|
|
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)).
|
|
920
|
+
|
|
590
921
|
**A wall is recomputed against the policy, not read out of the log.** If the role would be
|
|
591
922
|
allowed today, it is not a wall — whatever happened yesterday. The obvious implementation is a
|
|
592
923
|
time window, on the argument that an old wall may have been granted since; the window is a
|
|
593
924
|
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
|
|
925
|
+
makes its wall disappear on the next run instead of when a window expires. Same move as
|
|
595
926
|
`owners` and the request queue: recompute rather than believe.
|
|
596
927
|
|
|
597
928
|
Silent on the first denial, by design. A counter that reads `1×` every time is noise on the
|
|
@@ -602,7 +933,7 @@ obvious way to check is to compare repeats before and after — but the repeats
|
|
|
602
933
|
lock file, and the release before this one deleted that wall outright with
|
|
603
934
|
`GIT_OPTIONAL_LOCKS=0`. Repeats did fall, by a lot, and **the fall is the wall going away,
|
|
604
935
|
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,
|
|
936
|
+
other one. Isolating this needs a forward test — walls per role, runs with the hook wired
|
|
606
937
|
and no git locks in the way — not the history. Measured in the field and written up rather
|
|
607
938
|
than smoothed over, because the alternative is a claim the numbers do not carry.
|
|
608
939
|
|
|
@@ -610,15 +941,20 @@ than smoothed over, because the alternative is a claim the numbers do not carry.
|
|
|
610
941
|
|
|
611
942
|
Territory alone is correct and unusable: an agent writes session state under its own config
|
|
612
943
|
directory and its tools write to the temp dir, so *your folders and nothing else* stops the
|
|
613
|
-
agent from starting. Every role gets `~/.
|
|
614
|
-
|
|
615
|
-
|
|
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.
|
|
616
952
|
|
|
617
|
-
**[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)**
|
|
618
954
|
|
|
619
955
|
## What it is not
|
|
620
956
|
|
|
621
|
-
- **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.
|
|
622
958
|
- **Not an orchestrator.** It does not run your agents, schedule them, or merge their work. It runs one command as one role.
|
|
623
959
|
- **Not a secret manager.** It scopes *reads* of files you already have. Where those files come from is your problem.
|
|
624
960
|
- **Not what a single agent needs first.** If only one agent ever touches the repo,
|
|
@@ -653,8 +989,8 @@ files, the same keys, no answer to *whose is it*. That question is not about
|
|
|
653
989
|
isolation strength at all — it is about ownership, and it is the one thing an
|
|
654
990
|
operating system cannot know for you.
|
|
655
991
|
|
|
656
|
-
Which is also why this is thin. Enforcement is
|
|
657
|
-
[`@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) —
|
|
658
994
|
asking the kernel; seisin is the layer that decides what to ask for and says
|
|
659
995
|
whose file it was when the answer is no. The two compose — put seisin inside a
|
|
660
996
|
container if you want both, and the roles still hold.
|
|
@@ -671,28 +1007,28 @@ This space already has good work, and seisin is not the first thing here:
|
|
|
671
1007
|
|
|
672
1008
|
- [`anthropics/sandbox-runtime`](https://github.com/anthropics/sandbox-runtime) — the enforcement. seisin is a thin thing on top of a serious one.
|
|
673
1009
|
- [`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. 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.
|
|
1010
|
+
- [`XuebinMa/agent-guard`](https://github.com/XuebinMa/agent-guard) — an SDK that enforces what an agent may do, also one global policy.
|
|
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.
|
|
676
1012
|
- [`dredozubov/hazmat`](https://github.com/dredozubov/hazmat) — runs the agent as a different system user, with `pf` rules and snapshots.
|
|
677
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.
|
|
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
|
|
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.
|
|
679
1015
|
|
|
680
1016
|
### One sentence to tell these apart, because the names all sound the same
|
|
681
1017
|
|
|
682
1018
|
**OpenShell, hazmat and nono isolate the agent from your machine. seisin separates roles from
|
|
683
|
-
each other inside one repository, and when
|
|
1019
|
+
each other inside one repository, and when the kernel denies something it names the owner.**
|
|
684
1020
|
|
|
685
1021
|
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
|
|
1022
|
+
is "this agent cannot touch anything outside its sandbox", they do that and OpenShell does it
|
|
687
1023
|
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
|
|
1024
|
+
repository and none of them edits another's files — and when one is denied it is told whose
|
|
689
1025
|
those files are", nothing above answers that, which is why this exists.
|
|
690
1026
|
|
|
691
1027
|
They compose rather than compete: the agent seisin confines could be running inside one of
|
|
692
1028
|
them.
|
|
693
1029
|
|
|
694
1030
|
*One detail worth knowing before you compare, and it is about placement rather than quality.
|
|
695
|
-
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
|
|
696
1032
|
an Intel Mac. That confines an agent inside Docker Desktop's Linux VM; seisin confines a
|
|
697
1033
|
process on the host through Seatbelt. OpenShell also offers other drivers, including a local
|
|
698
1034
|
microVM path on supported Apple Silicon Macs. These are kernel-enforced boundaries, but not
|
|
@@ -700,16 +1036,24 @@ the same kernel or workspace, and which one you want depends on what you are pro
|
|
|
700
1036
|
|
|
701
1037
|
## Status
|
|
702
1038
|
|
|
703
|
-
|
|
704
|
-
|
|
705
|
-
|
|
706
|
-
|
|
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:
|
|
1046
|
+
when there were eighteen of them, they skipped on Linux for a day, behind a runtime check that looked for the global
|
|
707
1047
|
install and missed the bundled one, and hid a defect that broke `seisin run` on that platform
|
|
708
|
-
entirely.
|
|
709
|
-
|
|
710
|
-
|
|
711
|
-
|
|
712
|
-
|
|
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.
|
|
713
1057
|
The config format may still move before `1.0` — if it does, `seisin check` will
|
|
714
1058
|
tell you what changed.
|
|
715
1059
|
|
|
@@ -719,19 +1063,19 @@ already done:
|
|
|
719
1063
|
| | |
|
|
720
1064
|
|---|---|
|
|
721
1065
|
| **Windows** | the runtime has a backend. seisin has never been pointed at it, and no CI runner covers it |
|
|
722
|
-
| **Deleting inside your own territory** |
|
|
723
|
-
| **`init` heuristics** | it reads `.claude/agents
|
|
724
|
-
| **SSH inside a
|
|
725
|
-
| **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) |
|
|
726
1070
|
|
|
727
|
-
Every word above has one meaning, listed in [the glossary](docs/glossary.md) — the boundary
|
|
728
|
-
**denies**, a person **declines**, seisin **refuses** a config it cannot enforce. A tool whose
|
|
1071
|
+
Every word above has one meaning, listed in [the glossary](https://github.com/carlostapiacl/seisin/blob/main/docs/glossary.md) — the boundary
|
|
1072
|
+
**denies**, a person **grants** or **declines**, seisin **refuses** a config it cannot enforce. A tool whose
|
|
729
1073
|
product is a sentence cannot afford two words for one thing.
|
|
730
1074
|
|
|
731
|
-
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
|
|
732
1076
|
different about contributing here, which is mostly that a claim has to be downstream of
|
|
733
1077
|
something measured. If you are reporting something that got past the boundary, do it
|
|
734
|
-
[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
|
|
735
1079
|
things that make it reproducible.
|
|
736
1080
|
|
|
737
1081
|
## The name
|