seisin 0.6.3 → 0.6.5
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 +143 -686
- package/package.json +2 -2
- package/src/canary.js +1 -1
- package/src/cli.js +47 -3
- package/src/commands/check.js +68 -12
- package/src/commands/hook.js +1 -1
- package/src/commands/log.js +21 -14
- package/src/commands/requests.js +10 -15
- package/src/commands/review.js +2 -1
- package/src/commands/run.js +331 -245
- package/src/commands/walls.js +12 -5
- package/src/config-fields.js +305 -0
- package/src/config.js +114 -534
- package/src/controls.js +1 -1
- package/src/decide-request.js +37 -0
- package/src/denial-attribution.js +338 -0
- package/src/grants.js +1 -1
- package/src/help.js +6 -4
- package/src/index.js +1 -1
- package/src/inspect/enforce.js +1 -1
- package/src/inspect/files.js +1 -1
- package/src/inspect/home.js +1 -1
- package/src/inspect/inject.js +1 -1
- package/src/intake.js +3 -2
- package/src/keys.js +2 -2
- package/src/kinds.js +19 -0
- package/src/layout.js +1 -1
- package/src/log.js +3 -2
- package/src/mcp.js +1 -1
- package/src/notify-config.js +58 -0
- package/src/notify.js +4 -52
- package/src/owners.js +2 -2
- package/src/requests.js +9 -4
- package/src/rundir.js +2 -2
- package/src/runs.js +29 -10
- package/src/sandboxes/command.js +0 -2
- package/src/sandboxes/index.js +15 -2
- package/src/sandboxes/srt.js +1 -1
- package/src/sandboxes/tartaros.js +34 -6
- package/src/serve.js +11 -22
- package/src/spool.js +1 -1
- package/src/{srt.js → srt-settings.js} +5 -2
- package/src/state.js +25 -0
- package/src/suggest.js +26 -1
- package/src/surface.js +3 -2
- package/src/territory.js +20 -1
- package/src/toml.js +250 -0
- package/src/violations.js +16 -315
- package/src/walls.js +4 -2
- package/ui/index.html +35 -9
package/README.md
CHANGED
|
@@ -5,9 +5,9 @@
|
|
|
5
5
|
[](https://www.npmjs.com/package/seisin)
|
|
6
6
|
[](https://github.com/carlostapiacl/seisin/blob/main/LICENSE)
|
|
7
7
|
[](https://github.com/carlostapiacl/seisin/blob/main/package.json)
|
|
8
|
-
[](https://github.com/carlostapiacl/seisin/blob/main/package.json)
|
|
9
9
|
|
|
10
|
-
> **seisin** *(n.)
|
|
10
|
+
> **seisin** *(n.)*: the legal possession of a piece of land, held by whoever is standing on it.
|
|
11
11
|
|
|
12
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.
|
|
13
13
|
|
|
@@ -19,35 +19,29 @@ seisin init && seisin check # propose a policy from the repo, then re
|
|
|
19
19
|
seisin run <role> -- claude -p "…" # the roles are the ones seisin check lists
|
|
20
20
|
```
|
|
21
21
|
|
|
22
|
-
**
|
|
23
|
-
|
|
22
|
+
**Before you install:** by default a role can still read your home directory, `~/.ssh`, `~/.aws`
|
|
23
|
+
and `~/.config` included. seisin closes the keys you declare, not every credential on the machine.
|
|
24
|
+
To close the rest, set `[runtime] isolate = "credentials"` ([how it protects secrets](#how-it-protects-secrets)).
|
|
25
|
+
|
|
26
|
+
**Is it for me?** If two or more agents work in one repository, yes. With one agent, its own
|
|
27
|
+
sandbox covers most of this ([what is left over](#what-it-is-for)).
|
|
24
28
|
|
|
25
29
|
**[The short version, with the demos playing →](https://carlostapiacl.github.io/seisin/)** · this page is the long one.
|
|
26
30
|
|
|
27
31
|
<details>
|
|
28
32
|
<summary><b>Contents</b></summary>
|
|
29
33
|
|
|
30
|
-
|
|
31
|
-
|
|
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)
|
|
33
|
-
|
|
34
|
-
**Secrets** · [How it protects secrets](#how-it-protects-secrets) · [Keys in full](https://github.com/carlostapiacl/seisin/blob/main/docs/keys.md)
|
|
35
|
-
|
|
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)
|
|
34
|
+
[What it does](#what-it-does) · [Sixty seconds](#sixty-seconds) · [Why this exists](#why-this-exists) · [What it is for](#what-it-is-for) · [Install](#install) · [Use](#use) · [Configure](#configure) · [Day one](#what-will-look-like-a-bug-on-the-first-day) · [Agents](#which-agents-it-has-been-run-with) · [The first policy](#where-the-first-policy-comes-from) · [Requests](#when-it-says-no-it-leaves-a-request-behind) · [Secrets](#how-it-protects-secrets) · [The log and the console](#the-log-the-console-and-your-assistant) · [How it holds](#how-it-holds) · [Scratch space](#scratch-space-and-what-it-costs) · [Prior art](#prior-art) · [Status](#status)
|
|
37
35
|
|
|
38
|
-
**
|
|
39
|
-
|
|
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)
|
|
36
|
+
**Deeper:** [Configure in full](https://github.com/carlostapiacl/seisin/blob/main/docs/configure.md) · [Keys](https://github.com/carlostapiacl/seisin/blob/main/docs/keys.md) · [Requests](https://github.com/carlostapiacl/seisin/blob/main/docs/requests.md) · [Log, console, MCP and walls](https://github.com/carlostapiacl/seisin/blob/main/docs/log.md) · [Sandboxes](https://github.com/carlostapiacl/seisin/blob/main/docs/sandboxes.md) · [Runs for a launcher](https://github.com/carlostapiacl/seisin/blob/main/docs/runs.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) · [JavaScript API](https://github.com/carlostapiacl/seisin/blob/main/docs/api.md) · [Prior art](https://github.com/carlostapiacl/seisin/blob/main/docs/prior-art.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) · [Decisions](https://github.com/carlostapiacl/seisin/blob/main/docs/decisions.md) · [Contributing](https://github.com/carlostapiacl/seisin/blob/main/CONTRIBUTING.md)
|
|
41
37
|
|
|
42
38
|
</details>
|
|
43
39
|
|
|
44
40
|
## What it does
|
|
45
41
|
|
|
46
|
-
|
|
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)*
|
|
42
|
+
seisin is a permission layer on top of a sandbox. The OS does the isolation. seisin adds what the OS cannot know: which role a path belongs to, and so who to ask next.
|
|
49
43
|
|
|
50
|
-
|
|
44
|
+
Five reviews looked for ways past the boundary: [what they found and what is still open](https://github.com/carlostapiacl/seisin/blob/main/docs/what-it-has-been-put-through.md). What running four teams of agents behind it cost is in the [field notes](https://github.com/carlostapiacl/seisin/blob/main/docs/field-notes.md).
|
|
51
45
|
|
|
52
46
|
```mermaid
|
|
53
47
|
flowchart LR
|
|
@@ -66,9 +60,8 @@ flowchart LR
|
|
|
66
60
|
style H fill:#eef,stroke:#66c
|
|
67
61
|
```
|
|
68
62
|
|
|
69
|
-
**The kernel says no. seisin says whose.**
|
|
70
|
-
|
|
71
|
-
denial that also names an owner turns a dead end into a handoff.
|
|
63
|
+
**The kernel says no. seisin says whose.** Other permission layers answer *yes* or *no*, or
|
|
64
|
+
which rule said no. A denial that names an owner turns a dead end into a handoff.
|
|
72
65
|
|
|
73
66
|
```
|
|
74
67
|
$ seisin run frontend -- sh -c 'echo // fix >> src/api/orders.ts'
|
|
@@ -88,23 +81,13 @@ The first line is the run's own header: what this role may write, which keys it
|
|
|
88
81
|
much of your environment it did not inherit. The last lists only what this run filed; the
|
|
89
82
|
rest of the queue is one count, and `seisin requests`.
|
|
90
83
|
|
|
91
|
-
The line under the request
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
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)).
|
|
84
|
+
The line under the request says where it goes: *handed to backend*, or *a person decides* when
|
|
85
|
+
nobody or several roles own the path. seisin does not start backend; whatever launches your agents
|
|
86
|
+
reads that decision and runs `seisin run backend --handoff frontend:write:src/api`
|
|
87
|
+
([where a request goes](https://github.com/carlostapiacl/seisin/blob/main/docs/requests.md#where-a-request-goes),
|
|
88
|
+
and [denials a caller expects](https://github.com/carlostapiacl/seisin/blob/main/docs/requests.md#a-denial-the-caller-expects)).
|
|
104
89
|
|
|
105
|
-
|
|
106
|
-
|
|
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).
|
|
90
|
+
The kernel denies; the name comes from the policy. `Operation not permitted` is all the boundary itself says, so seisin reads the denial from the kernel's log and looks up the owner. 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).
|
|
108
91
|
|
|
109
92
|
The agent can ask directly too, from inside the sandbox:
|
|
110
93
|
|
|
@@ -116,9 +99,8 @@ seisin: frontend · writes src/web/** public/** (+6 scratch) · keys netlify-tok
|
|
|
116
99
|
you are frontend. It is handed to backend: hand it over rather than working around it.
|
|
117
100
|
```
|
|
118
101
|
|
|
119
|
-
The second line is the same handoff decision the queue shows
|
|
120
|
-
|
|
121
|
-
back would be a cycle. The hook's denial and the note after a failed command say it the same way.
|
|
102
|
+
The second line is the same handoff decision the queue shows. The hook's denial and the note after
|
|
103
|
+
a failed command say it the same way.
|
|
122
104
|
|
|
123
105
|
## Sixty seconds
|
|
124
106
|
|
|
@@ -145,7 +127,7 @@ Two things are true about how agents are kept in bounds today, and both are in t
|
|
|
145
127
|
|
|
146
128
|
A rule is a string comparison against a command someone might not spell that way. A child process does not spell it at all.
|
|
147
129
|
|
|
148
|
-
**2. The tools that *do* ask the OS don't know who anyone is.** They take one global policy. That is fine for one agent.
|
|
130
|
+
**2. The tools that *do* ask the OS don't know who anyone is.** They take one global policy. That is fine for one agent. Once you run two (separate worktrees, a frontend agent and a backend agent, a hotfix agent and a sprint agent), "may this be written?" is the wrong question. The right one is **"whose is it?"**
|
|
149
131
|
|
|
150
132
|
`seisin` answers the second question and hands the first one to the operating system.
|
|
151
133
|
|
|
@@ -160,23 +142,16 @@ A rule is a string comparison against a command someone might not spell that way
|
|
|
160
142
|
the whole repo, all the time → hand it over, do not work around it
|
|
161
143
|
```
|
|
162
144
|
|
|
163
|
-
## What it is for
|
|
164
|
-
|
|
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.**
|
|
167
|
-
>
|
|
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
|
|
170
|
-
> fixed, what was measured, and what is still open.
|
|
145
|
+
## What it is for
|
|
171
146
|
|
|
172
|
-
|
|
173
|
-
|
|
147
|
+
**It contains an agent's mistakes. Against a hostile agent, the guarantee is the one of the
|
|
148
|
+
sandbox you run it under.** Five reviews went looking for ways past the boundary;
|
|
149
|
+
[what they found and what is still open](https://github.com/carlostapiacl/seisin/blob/main/docs/what-it-has-been-put-through.md).
|
|
174
150
|
|
|
175
|
-
**It holds against an agent that is wrong
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
back to when its own file API was denied.
|
|
151
|
+
**It holds against an agent that is wrong:** one that edits a file it was not asked about, reads
|
|
152
|
+
another role's key, or calls an API nobody declared. The kernel denies however the command was
|
|
153
|
+
spelled. Verified against a grandchild process, an absolute path, a symlink, `python -c`, and a
|
|
154
|
+
shell redirect.
|
|
180
155
|
|
|
181
156
|
**Against an agent that is trying, the line is drawn by the sandbox.** seisin writes the
|
|
182
157
|
policy and checks that the sandbox can hold it; [the sandbox you choose](https://github.com/carlostapiacl/seisin/blob/main/docs/sandboxes.md)
|
|
@@ -185,11 +160,11 @@ own fixtures:
|
|
|
185
160
|
|
|
186
161
|
| | |
|
|
187
162
|
|---|---|
|
|
188
|
-
| with the default read mode (`read = "all"`) it can read everything except the declared key directories
|
|
163
|
+
| 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
164
|
| it can write anything inside its own territory | including staging an exfiltration |
|
|
190
165
|
| `base64` defeats the redaction | only the literal value is masked, and only on stdout and stderr |
|
|
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`](
|
|
192
|
-
| the hook is text-based and evadable | on purpose
|
|
166
|
+
| 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`](https://github.com/carlostapiacl/seisin/blob/main/docs/configure.md) keeps an allowed name from resolving into your LAN |
|
|
167
|
+
| the hook is text-based and evadable | on purpose: what escapes the hook still hits the kernel, which denies. On macOS [the kernel's own denials are recorded](https://github.com/carlostapiacl/seisin/blob/main/docs/log.md#seeing-what-happened); on Linux they are not. Attribution is not a control |
|
|
193
168
|
|
|
194
169
|
Others are the sandbox's, and differ between the two built in:
|
|
195
170
|
|
|
@@ -202,9 +177,18 @@ Others are the sandbox's, and differ between the two built in:
|
|
|
202
177
|
|
|
203
178
|
`seisin check` says which sandbox each role gets before anything runs.
|
|
204
179
|
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
180
|
+
It is for several of your own agents, on your own machine, getting in each other's way. For an
|
|
181
|
+
agent you do not control, choose the sandbox for that threat: seisin does not make its guarantee
|
|
182
|
+
stronger.
|
|
183
|
+
|
|
184
|
+
It does not schedule agents or merge their work (it runs one command as one role), and it does not
|
|
185
|
+
manage secrets (it scopes reads of files you already have). With **one** agent, *whose file is
|
|
186
|
+
this* has one answer: turn on your agent's own sandboxing first; Claude Code has it built in, over
|
|
187
|
+
the same runtime. What seisin still adds for one agent is narrow. The environment is built rather
|
|
188
|
+
than inherited (measured on one machine: **15 variables kept, 60 dropped**, among them
|
|
189
|
+
`GITHUB_TOKEN` and the AWS pair), the declared key directory is denied to every process, and the
|
|
190
|
+
network allowlist covers the whole process rather than one tool. It was designed for two agents
|
|
191
|
+
or more.
|
|
208
192
|
|
|
209
193
|
## Install
|
|
210
194
|
|
|
@@ -212,10 +196,10 @@ will not make its guarantee stronger than it is.
|
|
|
212
196
|
npm install -g seisin
|
|
213
197
|
```
|
|
214
198
|
|
|
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. Its one other dependency is optional: [tartaros](https://github.com/carlostapiacl/tartaros), prebuilt for macOS and Linux, which can enforce instead
|
|
199
|
+
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. Its one other dependency is optional: [tartaros](https://github.com/carlostapiacl/tartaros), prebuilt for macOS and Linux, which can enforce instead, as can a sandbox of your own ([docs/sandboxes.md](https://github.com/carlostapiacl/seisin/blob/main/docs/sandboxes.md)).
|
|
216
200
|
|
|
217
|
-
**On Linux you also need three system packages
|
|
218
|
-
|
|
201
|
+
**On Linux you also need three system packages.** Without them the runtime refuses to start
|
|
202
|
+
rather than run unconfined:
|
|
219
203
|
|
|
220
204
|
```bash
|
|
221
205
|
apt install bubblewrap ripgrep socat # or your distro's equivalent
|
|
@@ -229,45 +213,14 @@ namespace`:
|
|
|
229
213
|
sudo sysctl -w kernel.apparmor_restrict_unprivileged_userns=0
|
|
230
214
|
```
|
|
231
215
|
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
no amount of editing `seisin.toml` will move it.
|
|
216
|
+
When this happens seisin seems to deny everything, because the sandbox never starts. Editing
|
|
217
|
+
`seisin.toml` will not fix it.
|
|
235
218
|
|
|
236
219
|
**Inside a container** bubblewrap needs to create namespaces and mount `/proc`, which a
|
|
237
220
|
default Docker container forbids. `--cap-add SYS_ADMIN --security-opt seccomp=unconfined`
|
|
238
221
|
gets namespaces; mounting `/proc` needs `--privileged`. If you cannot grant that, run
|
|
239
222
|
seisin on the host and let the container be what it sandboxes.
|
|
240
223
|
|
|
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
|
-
|
|
271
224
|
## Use
|
|
272
225
|
|
|
273
226
|
```bash
|
|
@@ -286,7 +239,7 @@ seisin ui --runs <dir> # read only, over many runs a launcher
|
|
|
286
239
|
**Three ways in, and they say the same thing.** The **CLI** is what a person types and what
|
|
287
240
|
every agent runs under. The **console** (`seisin ui`) is for a person deciding something. The
|
|
288
241
|
**MCP server** is for an agent asking about its own situation. A change to what any of them
|
|
289
|
-
says lands in all three
|
|
242
|
+
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)
|
|
290
243
|
says why, and a test holds one half of it.
|
|
291
244
|
|
|
292
245
|
`seisin <command> --help` lists a command's flags, examples and exit codes. The exit codes, so
|
|
@@ -305,12 +258,14 @@ the commands compose in a script, a pre-commit hook or CI:
|
|
|
305
258
|
| `whose`, `grant`, `decline`, `log`, `init`, `wire`, `ui`, `watch`, `mcp` | done | — | |
|
|
306
259
|
| `hook` | always | — | |
|
|
307
260
|
|
|
308
|
-
Every command exits **2** on a usage error
|
|
261
|
+
Every command exits **2** on a usage error (an unknown command, flag, role or action), and every
|
|
309
262
|
command that reads the policy exits 2 when there is no `seisin.toml` here or above.
|
|
310
263
|
|
|
264
|
+
To call it from code, the same functions are exported: [docs/api.md](https://github.com/carlostapiacl/seisin/blob/main/docs/api.md).
|
|
265
|
+
|
|
311
266
|
## Configure
|
|
312
267
|
|
|
313
|
-
One file at the root of your repo. Anything not listed is denied
|
|
268
|
+
One file at the root of your repo. Anything not listed is denied; there is no permissive default.
|
|
314
269
|
|
|
315
270
|
```toml
|
|
316
271
|
[keys]
|
|
@@ -326,185 +281,14 @@ keys = ["netlify-token.txt"]
|
|
|
326
281
|
[roles.backend]
|
|
327
282
|
writes = ["src/api/**", "migrations/**"]
|
|
328
283
|
keys = ["database-url.txt", "sentry-dsn.txt"]
|
|
329
|
-
network = ["api.stripe.com"] # this role only
|
|
330
|
-
```
|
|
331
|
-
|
|
332
|
-
An allowed name is only as safe as its DNS: one whose records somebody else controls can be
|
|
333
|
-
pointed at a host on your network. The sandbox already denies loopback, link-local and
|
|
334
|
-
cloud-metadata addresses. Private ranges are left to you, because allowing an intranet name is
|
|
335
|
-
legitimate; to close them, list them:
|
|
336
|
-
|
|
337
|
-
```toml
|
|
338
|
-
[network]
|
|
339
|
-
allow = ["github.com", "*.github.com"]
|
|
340
|
-
deny_addresses = ["10.0.0.0/8", "172.16.0.0/12", "192.168.0.0/16", "100.64.0.0/10", "fc00::/7"]
|
|
341
|
-
```
|
|
342
|
-
|
|
343
|
-
`[runtime] read = "all"` is the default. The opt-in `"territory"` mode keeps homes,
|
|
344
|
-
temporary directories and mounted data roots shut, while retaining the repository and the
|
|
345
|
-
paths each role names:
|
|
346
|
-
|
|
347
|
-
```toml
|
|
348
|
-
[runtime]
|
|
349
|
-
read = "territory"
|
|
350
|
-
|
|
351
|
-
[roles.backend]
|
|
352
|
-
writes = ["src/api/**"]
|
|
353
|
-
reads = ["../shared-schemas/**"] # data needed read-only
|
|
354
|
-
toolchain = ["~/.venvs/api"] # program stack under a data root; bin/ goes first on PATH
|
|
355
|
-
verify = ["python", "-m", "pytest"] # run by seisin check --verify inside this role
|
|
356
|
-
```
|
|
357
|
-
|
|
358
|
-
`seisin check --verify --parallel` is an experimental speed option: role checks run at the same
|
|
359
|
-
time. Use it only when those commands do not share mutable build outputs, caches, ports, or other
|
|
360
|
-
resources; concurrent checks can race and produce misleading failures. Plain `--verify` remains
|
|
361
|
-
serial and is the recommended default. `--parallel` requires `--verify`.
|
|
362
|
-
|
|
363
|
-
`reads` and `toolchain` take literal paths or a subtree ending in `/**`; interior globs,
|
|
364
|
-
`*` and `?` are refused. `verify` is an argv array and may not be empty. Each data root is
|
|
365
|
-
denied whole and what the role keeps is allowed back, so what appears there during the run is
|
|
366
|
-
shut as well. On Linux a kept directory holding a write path is allowed back entry by entry;
|
|
367
|
-
one with more than 1,000 entries is refused, and the error names whether the repo or a `reads`
|
|
368
|
-
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).
|
|
369
|
-
|
|
370
|
-
`[network] allow` is the fallback for roles that do not name their own. A role that needs one
|
|
371
|
-
extra host should say so on its own line: a domain in the global list is reachable by every
|
|
372
|
-
role, including the ones whose whole job is to have nothing to reach.
|
|
373
|
-
|
|
374
|
-
> ⚠️ **HTTP(S) works on both platforms. SSH does not work on macOS.** The allow list is
|
|
375
|
-
> enforced by proxies the runtime starts, and although one of them is a SOCKS5 proxy that could
|
|
376
|
-
> carry SSH, on macOS the chain breaks at its last link: it requires authentication and the
|
|
377
|
-
> `ProxyCommand` the runtime wires up cannot send any. Measured on macOS — `curl
|
|
378
|
-
> https://github.com` returns `200`, `ssh -T git@github.com` cannot resolve the hostname, and
|
|
379
|
-
> `git ls-remote git@github.com:…` reaches the proxy and dies at the handshake. **On Linux** the
|
|
380
|
-
> runtime routes git's SSH through its HTTP proxy with credentials instead, so it is expected to
|
|
381
|
-
> pass — read from the runtime's source, not measured here.
|
|
382
|
-
>
|
|
383
|
-
> **Use an HTTPS remote for git**, and on macOS run SSH deploys outside the confined run.
|
|
384
|
-
> [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)
|
|
385
|
-
|
|
386
|
-
A role can also give something back. `never_writes` subtracts from its own `writes`, and it
|
|
387
|
-
wins however wide the grant is:
|
|
388
|
-
|
|
389
|
-
```toml
|
|
390
|
-
[roles.backend]
|
|
391
|
-
writes = ["services/api/**"]
|
|
392
|
-
never_writes = ["services/api/.git/index.lock"] # works in a worktree; the canonical index is not its business
|
|
393
|
-
```
|
|
394
|
-
|
|
395
|
-
It is per role on purpose — there is no global version, because a global deny on a lock file
|
|
396
|
-
breaks every role that legitimately commits there. A denial it causes says so by name and
|
|
397
|
-
leaves **no request** in the queue: granting one would undo a subtraction somebody wrote. An
|
|
398
|
-
entry that no `writes` of the same role covers, or a misspelling like `never_write`, is named
|
|
399
|
-
by `seisin check` instead of being ignored.
|
|
400
|
-
|
|
401
|
-
A folder only one role may see is `private`. The role reads and writes it; every other role is
|
|
402
|
-
denied both, whatever its `writes` say, in either read mode and under `isolate`:
|
|
403
|
-
|
|
404
|
-
```toml
|
|
405
|
-
[roles.legal]
|
|
406
|
-
writes = ["contracts/drafts/**"]
|
|
407
|
-
private = ["contracts/signed/**"] # part of legal's territory, and no other role reads it
|
|
408
|
-
|
|
409
|
-
[roles.dev]
|
|
410
|
-
writes = ["**"] # still cannot read or write contracts/signed
|
|
411
|
-
```
|
|
412
|
-
|
|
413
|
-
An entry is one folder inside the repo, `dir` or `dir/**`: no patterns, no absolute path, no
|
|
414
|
-
`..`, not the repo itself. Two roles naming the same folder, or one inside the other, is refused
|
|
415
|
-
at load, because each would be denied its own. `seisin check` shows it under the role and says
|
|
416
|
-
when it sits inside another role's territory, since that role loses it. `seisin explain dev read
|
|
417
|
-
contracts/signed/x` answers denied and names the owner, and a denial there leaves **no request**:
|
|
418
|
-
no grant opens it. On Linux a missing private folder is created empty by `seisin run` (never by `check`), because
|
|
419
|
-
bubblewrap cannot deny a path that does not exist without placing something there; and another
|
|
420
|
-
role sees an empty folder in its place, where a write succeeds into memory that is discarded with
|
|
421
|
-
the run and never reaches the real one. On macOS the write is denied.
|
|
422
|
-
|
|
423
|
-
A role that starts a server — a dev server, the backend an end-to-end test drives — needs
|
|
424
|
-
`local_binding = true`. Listening is off by default and per role: most roles never start one.
|
|
425
|
-
It is wider than the name: the role can also listen on every interface and **connect to every
|
|
426
|
-
port on localhost**, so anything listening there without authentication is within its reach.
|
|
427
|
-
`seisin check` says this next to the role. That is macOS: on Linux each role already has a
|
|
428
|
-
private loopback, serves without the key, and reaches nothing on the host either way.
|
|
429
|
-
The canary therefore omits its loopback probe for a role with `local_binding = true`: reaching
|
|
430
|
-
the decoy is allowed by that policy, not evidence that confinement failed.
|
|
431
|
-
|
|
432
|
-
If a role only needs to *reach* a local service, name the ports instead:
|
|
433
|
-
`local_ports = [8001, 8081]`. The role reaches those and nothing else on localhost, on macOS
|
|
434
|
-
and Linux. Under srt the sandbox's proxy enforces the list, so the client has to use the proxy.
|
|
435
|
-
curl, Node's `fetch` and Python's `urllib` do. A database driver or a raw socket don't, and are
|
|
436
|
-
denied. Under tartaros the kernel profile holds the list itself, so any client reaches a listed
|
|
437
|
-
port directly, and loopback stays off its proxy. For a headless browser, see `browsers` below. A listed port is open to anything that
|
|
438
|
-
speaks `CONNECT`, not just HTTP, so list the service, not the protocol.
|
|
439
|
-
|
|
440
|
-
`mcp = ["playwright"]` lists the MCP servers a role may load; `mcp = []` means none, and no
|
|
441
|
-
key means the policy doesn't say. seisin declares this and `explain` answers it
|
|
442
|
-
(`seisin explain qa mcp tradingview`), but the servers are started by the agent's CLI, so the
|
|
443
|
-
launcher has to apply it. With Claude Code: `--mcp-config` holding only those servers, plus
|
|
444
|
-
`--strict-mcp-config`.
|
|
445
|
-
|
|
446
|
-
A role that drives a headless Chromium — Playwright, `@playwright/mcp` — needs
|
|
447
|
-
`browsers = ["chromium"]` on macOS. Without it the browser exits as it starts: its first act
|
|
448
|
-
is to register a Mach service (`org.chromium.Chromium.MachPortRendezvousServer.<pid>`) that its
|
|
449
|
-
own child processes look up, and the sandbox denies both. The preset allows exactly those two,
|
|
450
|
-
by that prefix, and nothing else; every other role keeps the profile it had. The network is
|
|
451
|
-
still `[network] allow`, through the sandbox's proxy, which takes credentials Playwright has to
|
|
452
|
-
be handed apart from the URL:
|
|
453
|
-
|
|
454
|
-
```js
|
|
455
|
-
const u = new URL(process.env.HTTP_PROXY); // http://<user>:<token>@localhost:<port>
|
|
456
|
-
const browser = await chromium.launch({ proxy: {
|
|
457
|
-
server: `${u.protocol}//${u.host}`,
|
|
458
|
-
username: decodeURIComponent(u.username),
|
|
459
|
-
password: decodeURIComponent(u.password),
|
|
460
|
-
} });
|
|
461
|
-
```
|
|
462
|
-
|
|
463
|
-
Passing `HTTP_PROXY` as `server` alone does not work: Playwright gives Chromium the address
|
|
464
|
-
without the credentials and the proxy answers `407`. The token is the run's own, so the traffic
|
|
465
|
-
stays tied to the run. `@playwright/mcp` runs under `[sandbox] provider = "tartaros"`, which
|
|
466
|
-
gives the role a temp directory of its own for the unix socket the server always opens; under
|
|
467
|
-
srt it does not yet. [docs/browsers.md](https://github.com/carlostapiacl/seisin/blob/main/docs/browsers.md) has the measurements, the MCP config,
|
|
468
|
-
and what Chrome and Chrome for Testing still lack.
|
|
469
|
-
|
|
470
|
-
Some files are not code but still make something happen outside the sandbox, and no territory
|
|
471
|
-
reaches them: git hooks and config, a project's `.claude/`, `.mcp.json`, `.envrc`. Two softer
|
|
472
|
-
families sit beside those, and a policy can hand them to one role:
|
|
473
|
-
|
|
474
|
-
```toml
|
|
475
|
-
[roles.frontend]
|
|
476
|
-
writes = ["apps/web/**"]
|
|
477
|
-
control_files = ["ide"] # keeps apps/web/.vscode/launch.json current
|
|
478
|
-
|
|
479
|
-
[protect]
|
|
480
|
-
instructions = true # CLAUDE.md, AGENTS.md… only for roles handed "instructions"
|
|
284
|
+
network = ["api.stripe.com"] # this role only: it replaces [network] rather than adding
|
|
481
285
|
```
|
|
482
286
|
|
|
483
|
-
|
|
484
|
-
|
|
485
|
-
|
|
486
|
-
|
|
487
|
-
|
|
488
|
-
keeping them current is ordinary work for most teams.
|
|
489
|
-
|
|
490
|
-
A role handed a family writes it only inside its own `writes`, and `seisin check` names it on
|
|
491
|
-
every run. A denial says which setting would change it, and leaves no request: it is a decision
|
|
492
|
-
about the policy, not a grant. Inside `node_modules` neither family is touched, so npm still
|
|
493
|
-
unpacks a package's own `.vscode` or `AGENTS.md`. On Linux only the files that already exist are
|
|
494
|
-
protected: bubblewrap would have to create a missing one to deny it.
|
|
495
|
-
|
|
496
|
-
Denied connections are logged too: `connect tcp:<port>` (the kernel gives the port, not the
|
|
497
|
-
host) or the socket path, once per target per run. `seisin walls` says whether the port is
|
|
498
|
-
missing from `local_ports`, listed but dialled directly, or a socket. No request is filed: a
|
|
499
|
-
port isn't anyone's territory. macOS only.
|
|
500
|
-
|
|
501
|
-
So are the denials that are neither a file nor a connection: a Mach service, a `sysctl`, as
|
|
502
|
-
`mach-register org.chromium.Chromium.MachPortRendezvousServer.7354`, once per operation and
|
|
503
|
-
name per run, and never as a request. Most are the sandbox doing its job and the program
|
|
504
|
-
carrying on; one of them is how a browser dies at start, and `seisin run` names the preset
|
|
505
|
-
that answers it. macOS only.
|
|
506
|
-
|
|
507
|
-
`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`.
|
|
287
|
+
Private address ranges, `read = "territory"`, toolchains, `verify`, protected control files,
|
|
288
|
+
browsers, local ports, providers and the sandbox per role:
|
|
289
|
+
**[docs/configure.md](https://github.com/carlostapiacl/seisin/blob/main/docs/configure.md)**.
|
|
290
|
+
`seisin init` proposes a first policy from what the repo already says
|
|
291
|
+
([where the first policy comes from](#where-the-first-policy-comes-from)).
|
|
508
292
|
|
|
509
293
|
## What will look like a bug on the first day
|
|
510
294
|
|
|
@@ -516,7 +300,7 @@ production window where none of them was the boundary misbehaving.
|
|
|
516
300
|
|
|
517
301
|
One more comes with protected files: **a role's `git checkout`, `pull` or `rebase` cannot update
|
|
518
302
|
a file the role may not write.** Git exits 0, the branch moves, and the old `.vscode/settings.json`
|
|
519
|
-
stays behind as a modification
|
|
303
|
+
stays behind as a modification, which a later `commit -a` would turn into a revert. Switch
|
|
520
304
|
branches outside the sandbox, or hand that role the family if keeping those files is its job.
|
|
521
305
|
|
|
522
306
|
**[What each one looks like, and what to do →](https://github.com/carlostapiacl/seisin/blob/main/docs/first-day.md)**
|
|
@@ -524,79 +308,19 @@ branches outside the sandbox, or hand that role the family if keeping those file
|
|
|
524
308
|
## Which agents it has been run with
|
|
525
309
|
|
|
526
310
|
Four: Claude Code, opencode, codex and a LangGraph script, each against a real policy with
|
|
527
|
-
the boundary
|
|
311
|
+
the boundary applied. gemini 0.57.0 has only been started under it,
|
|
528
312
|
which is not the same claim.
|
|
529
313
|
|
|
530
|
-
**
|
|
531
|
-
to stop.** `codex` confines every command it runs, and no OS applies a second profile to a
|
|
314
|
+
**An agent that sandboxes itself has to be told to stop.** `codex` confines every command it runs, and no OS applies a second profile to a
|
|
532
315
|
process that already has one. seisin says so before it starts one, because the error you get
|
|
533
316
|
otherwise names neither seisin, nor the agent, nor the fix.
|
|
534
317
|
|
|
535
318
|
**[Every agent, its version, what it needed, and how each reacted to being stopped →](https://github.com/carlostapiacl/seisin/blob/main/docs/agents.md)**
|
|
536
319
|
|
|
537
|
-
## What the log says about the policy
|
|
538
|
-
|
|
539
|
-
`check` reads the config and tells you what it would do. `review` reads what
|
|
540
|
-
actually happened and tells you where the config was wrong about it.
|
|
541
|
-
|
|
542
|
-
```
|
|
543
|
-
$ seisin review
|
|
544
|
-
|
|
545
|
-
60 entries, 2026-08-23 to 2026-09-11
|
|
546
|
-
|
|
547
|
-
Denied repeatedly
|
|
548
|
-
a role denied on the same place over and over is a policy that is wrong,
|
|
549
|
-
not an agent that is misbehaving
|
|
550
|
-
|
|
551
|
-
9× frontend write src/api/checkout — belongs to backend
|
|
552
|
-
4× backend write src/web — belongs to frontend
|
|
553
|
-
|
|
554
|
-
seisin grant, or move the territory. Either way it is a decision, not noise.
|
|
555
|
-
|
|
556
|
-
Granted, never used
|
|
557
|
-
nothing was written here in the window above. This is the only evidence
|
|
558
|
-
anyone will ever have for making a policy smaller
|
|
559
|
-
|
|
560
|
-
frontend public/**
|
|
561
|
-
backend migrations/**
|
|
562
|
-
|
|
563
|
-
A short window proves nothing. Check the dates before you delete a line.
|
|
564
|
-
|
|
565
|
-
Unowned
|
|
566
|
-
touched by an agent, claimed by no role. Only an ownable path (kind `territory`)
|
|
567
|
-
is a hole in the map; the rest is a kind of thing with its own answer, and none
|
|
568
|
-
of them is an owner
|
|
569
|
-
|
|
570
|
-
4× territory legacy
|
|
571
|
-
|
|
572
|
-
territory: an ownable path no role claims: a real decision — give it an owner, or tell the agent not to write there
|
|
573
|
-
```
|
|
574
|
-
|
|
575
|
-
`--since 2026-10-01` or `--since 7d` reads only that stretch of the log: on a log months long,
|
|
576
|
-
"Denied repeatedly" is otherwise led by paths fixed long ago. A denial that is correct and needs
|
|
577
|
-
nothing — Claude Code updating its plugins from inside the sandbox — is listed apart as "Denied as
|
|
578
|
-
expected" and does not make `review` exit 1.
|
|
579
|
-
|
|
580
|
-
Three questions, and the middle one is the reason this exists. **Every
|
|
581
|
-
permission file only ever grows**, in every system, and always for the same
|
|
582
|
-
reason: nobody can prove a line is dead, so the safe move is to leave it. Here
|
|
583
|
-
the log can prove it — `public/**` was granted and nothing was written there in
|
|
584
|
-
three weeks. That is the only direction that makes a policy *smaller*.
|
|
585
|
-
|
|
586
|
-
The first is the one that changes how a denial reads. Forty denials on one
|
|
587
|
-
directory is not an agent misbehaving; it is a policy that is wrong, and until
|
|
588
|
-
you add them up it looks like forty tidy amber lines.
|
|
589
|
-
|
|
590
|
-
It is arithmetic over the log. No model, no network, no heuristics — the
|
|
591
|
-
argument this whole tool makes is that interpreting text is the wrong way to
|
|
592
|
-
decide things, and a component that reads the log and forms an opinion would
|
|
593
|
-
contradict it on the way in. Every finding carries the window it was computed
|
|
594
|
-
over, because *never used* means nothing without *in how long*.
|
|
595
|
-
|
|
596
320
|
## Where the first policy comes from
|
|
597
321
|
|
|
598
322
|
Every permission tool dodges this question. Written by hand, the first policy is a guess, and
|
|
599
|
-
the first unjustified denial is when the tool gets uninstalled. So don't write it
|
|
323
|
+
the first unjustified denial is when the tool gets uninstalled. So don't write it: watch, then
|
|
600
324
|
write:
|
|
601
325
|
|
|
602
326
|
```bash
|
|
@@ -608,7 +332,7 @@ seisin init --from-observations # writes seisin.toml.observed
|
|
|
608
332
|
What `--observe` changes: the whole repo is writable for that run, so a write outside the
|
|
609
333
|
territory is recorded instead of refused. What it does not: the key directories, the policy and
|
|
610
334
|
`.seisin/` stay denied, and so does every domain not on the allowlist. What gets recorded is what
|
|
611
|
-
the hook sees
|
|
335
|
+
the hook sees: the tool calls of an agent (Claude Code, Codex…) in a repo where `seisin wire` ran.
|
|
612
336
|
A plain shell command (`--observe -- npm test`) goes through no hook and records nothing.
|
|
613
337
|
|
|
614
338
|
It lands as `.observed`, not as your config. A policy generated behind your back is not a
|
|
@@ -616,12 +340,8 @@ policy: diff it, then move it.
|
|
|
616
340
|
|
|
617
341
|
## When it says no, it leaves a request behind
|
|
618
342
|
|
|
619
|
-
A
|
|
620
|
-
|
|
621
|
-
cheapest way to stop it is to widen the policy and never look again. That is how an allowlist
|
|
622
|
-
becomes seven hundred entries nobody reads.
|
|
623
|
-
|
|
624
|
-
So a denial leaves something to act on, and many denials in one directory are **one** request:
|
|
343
|
+
A tool that only says no gets its policy widened until nobody reads it. So a denial leaves
|
|
344
|
+
something to act on, and many denials in one directory are **one** request:
|
|
625
345
|
|
|
626
346
|
```
|
|
627
347
|
$ seisin requests
|
|
@@ -653,135 +373,80 @@ writes = ["src/web/**", "public/**",
|
|
|
653
373
|
```
|
|
654
374
|
|
|
655
375
|
**Granting is never a tool call.** An agent can read the queue and draft the change; only a
|
|
656
|
-
person applies it, in a terminal or in the console.
|
|
657
|
-
the reason the MCP server exists at all.
|
|
658
|
-
|
|
659
|
-
**A person hears about it.** With `[notify]`, the parent process sends one message per new
|
|
660
|
-
request — the role, the path, whose it is, and `seisin grant '<id>'` — to ntfy, Slack or any
|
|
661
|
-
webhook. The URL lives in a key directory no role declares (`url_file`) or in the parent's
|
|
662
|
-
`SEISIN_NOTIFY_URL`; written plainly into `seisin.toml`, which every role can read, it refuses to
|
|
663
|
-
load, because whoever reads it can send you a fake "grant this".
|
|
664
|
-
|
|
665
|
-
```toml
|
|
666
|
-
[notify]
|
|
667
|
-
url_file = ".secrets/notify-url.txt" # e.g. https://ntfy.sh/<your-topic>
|
|
668
|
-
format = "text" # or "slack", "json"
|
|
669
|
-
```
|
|
376
|
+
person applies it, in a terminal or in the console. The MCP server is read-only for that reason.
|
|
670
377
|
|
|
671
|
-
|
|
672
|
-
|
|
673
|
-
from the log and tells the agent whose the path was and how many times it has been denied —
|
|
674
|
-
the half PreToolUse never sees; and when a session starts, resumes or is compacted it hands the
|
|
675
|
-
agent its territory and its walls, before the first one.
|
|
378
|
+
With [`[notify]`](https://github.com/carlostapiacl/seisin/blob/main/docs/configure.md#notifications) a person gets one message per new request (ntfy, Slack or any webhook), and
|
|
379
|
+
`seisin wire` lets the hook tell the agent whose a path was before and after it is denied.
|
|
676
380
|
|
|
677
381
|
**[How a denial becomes a request, why it deduplicates, and what a grant records →](https://github.com/carlostapiacl/seisin/blob/main/docs/requests.md)**
|
|
678
382
|
|
|
679
|
-
##
|
|
680
|
-
|
|
681
|
-
```jsonc
|
|
682
|
-
{ "mcpServers": { "seisin": { "command": "seisin", "args": ["mcp"] } } }
|
|
683
|
-
```
|
|
684
|
-
|
|
685
|
-
> *who owns src/api, and what is frontend waiting on?*
|
|
686
|
-
|
|
687
|
-
Seven tools. Four answer about the policy — `state`, `explain`, `requests`, `draft_grant` —
|
|
688
|
-
and three about what has happened: `activity` is the raw log, and **`causes` and `walls` are
|
|
689
|
-
the two an agent actually wants.** `activity` hands over events and leaves the assistant to
|
|
690
|
-
re-derive the shape without the policy; `causes` returns the day grouped by path and by name
|
|
691
|
-
with how many are on paths nobody owns, and `walls` returns what a role keeps being denied
|
|
692
|
-
*and would still be denied today*. Both are recomputed against the policy, so something
|
|
693
|
-
granted since stops counting — which is the part a log cannot know.
|
|
383
|
+
## How it protects secrets
|
|
694
384
|
|
|
695
|
-
|
|
696
|
-
|
|
697
|
-
request would make; applying it is a command a person runs. Zero dependencies:
|
|
698
|
-
the official SDK wanted 91 packages for a server that speaks JSON on two file
|
|
699
|
-
descriptors.
|
|
385
|
+
Six ways a secret leaves a process. seisin closes four by default, one when you turn it on, and
|
|
386
|
+
one not at all.
|
|
700
387
|
|
|
701
|
-
|
|
388
|
+
| way out | closed by |
|
|
389
|
+
|---|---|
|
|
390
|
+
| reading another role's key file | the kernel: the key directory is denied, each declared key re-allowed |
|
|
391
|
+
| a key riding in the environment | the launcher: the child's environment is **built, not inherited** |
|
|
392
|
+
| spilling a key the role does hold | the launcher: masked on stdout and stderr, even split across buffers |
|
|
393
|
+
| 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) |
|
|
394
|
+
| **credentials in your home** (`~/.ssh`, `~/.aws`, `~/.npmrc`, `~/.config`) | **not by default** (`read = "all"`). `[runtime] isolate = "credentials"` closes them and the agent stays logged in; `read = "territory"` closes more |
|
|
395
|
+
| **a key stored outside the declared directories** | **nothing.** `seisin scan` finds them so you know |
|
|
702
396
|
|
|
703
|
-
```
|
|
704
|
-
|
|
705
|
-
|
|
706
|
-
seisin ui # the same thing, for a person deciding
|
|
707
|
-
```
|
|
397
|
+
```toml
|
|
398
|
+
[keys]
|
|
399
|
+
dir = [".secrets"]
|
|
708
400
|
|
|
401
|
+
[roles.frontend]
|
|
402
|
+
key_mode = "env"
|
|
403
|
+
keys = ["netlify-token.txt", # a file the role may read
|
|
404
|
+
"TOKEN=file://.secrets/all.env#NETLIFY", # one value, and no read at all
|
|
405
|
+
"OP=op://vault/item/field"] # anything that prints a secret
|
|
709
406
|
```
|
|
710
|
-
$ seisin log
|
|
711
407
|
|
|
712
|
-
|
|
713
|
-
|
|
714
|
-
|
|
715
|
-
04:13:31 denied frontend write src/api/orders.ts → backend [kernel]
|
|
408
|
+
A **provider** is a command with a `{ref}` placeholder, so adding 1Password, Bitwarden or
|
|
409
|
+
`sops` is TOML and not code. `file://` ships built in. The parent resolves, never the
|
|
410
|
+
confined process. Nothing degrades: a provider that fails stops the run.
|
|
716
411
|
|
|
717
|
-
|
|
718
|
-
|
|
412
|
+
A key reaches the role as a variable (`key_mode = "env"`), as a file in the run's private
|
|
413
|
+
directory (`"run-file"`), or not at all (on Linux, and on macOS under tartaros): with `"inject"` the agent holds a
|
|
414
|
+
placeholder and the sandbox swaps in the real value only on the way to the hosts you list for
|
|
415
|
+
it. That costs terminating TLS to those hosts, which `seisin check` names, and a host that
|
|
416
|
+
stores what it is sent can still hand the value back, so use a least-scope token.
|
|
719
417
|
|
|
720
|
-
|
|
418
|
+
**[Key directories, references, providers, delivery modes and nine recipes →](https://github.com/carlostapiacl/seisin/blob/main/docs/keys.md)**
|
|
721
419
|
|
|
722
|
-
|
|
723
|
-
|
|
724
|
-
|
|
725
|
-
|
|
420
|
+
For agents you would not trust with your machine, `[runtime] read = "territory"` denies the places
|
|
421
|
+
data lives (homes, temp directories, volumes) and leaves each role its repo, its territory and
|
|
422
|
+
a toolchain it declares, which `seisin check --verify` proves it can run. Every run, in every
|
|
423
|
+
mode, starts with a canary that tries the policy from inside and refuses to start the agent if
|
|
424
|
+
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)**
|
|
726
425
|
|
|
727
|
-
|
|
728
|
-
the
|
|
729
|
-
|
|
730
|
-
|
|
426
|
+
**Measured, over four teams of agents and three days: every denied read was a read of something
|
|
427
|
+
the policy had declared a key.** 148 of them, no exceptions, from five roles, all running
|
|
428
|
+
searches that swept a repository root. That is how a key gets read without anyone deciding
|
|
429
|
+
it should. ([The measurement, ~370 confined runs over one production window →](https://github.com/carlostapiacl/seisin/blob/main/docs/keys.md))
|
|
731
430
|
|
|
732
|
-
|
|
733
|
-
$ seisin log verify
|
|
431
|
+
## The log, the console and your assistant
|
|
734
432
|
|
|
735
|
-
|
|
736
|
-
|
|
737
|
-
|
|
738
|
-
|
|
433
|
+
```bash
|
|
434
|
+
seisin log --verdict denied # what the agents tried and could not do
|
|
435
|
+
seisin review --since 7d # where the policy was wrong, from what happened
|
|
436
|
+
seisin walls dev # what a role keeps being denied, and what it cost
|
|
437
|
+
seisin ui # a console for the person deciding
|
|
739
438
|
```
|
|
740
439
|
|
|
741
|
-
|
|
742
|
-
|
|
743
|
-
|
|
744
|
-
|
|
745
|
-
the recorded process can edit is not a record. The hook sends each line to `seisin run` over
|
|
746
|
-
a unix socket granted by path and the parent does the writing, so the only verb available
|
|
747
|
-
from inside the sandbox is *append one line*. A hostile agent can still add noise to its own
|
|
748
|
-
history; what it cannot do is change what is already there.
|
|
749
|
-
|
|
750
|
-
**Two writers, and they see different things.** The **hook** reports the attempt before it
|
|
751
|
-
happens — which is how an *allowed* action gets recorded at all — and it is text-based and
|
|
752
|
-
evadable, on purpose. The **kernel** reports what it actually denied, and is not fooled by
|
|
753
|
-
anything. What escapes the hook goes unexplained, never unenforced.
|
|
754
|
-
|
|
755
|
-

|
|
756
|
-
|
|
757
|
-
It opens on what needs a decision, not on the policy. Four numbers sit on top — waiting on
|
|
758
|
-
you, denied, the **top cause**, and calls **spent retrying** walls — each a link to the page
|
|
759
|
-
that explains it, and the last two are recomputed against the policy, which a log alone cannot
|
|
760
|
-
do: something granted since stops counting.
|
|
761
|
-
|
|
762
|
-
**A decision needs a reason.** Grant without one asks for it; Decline without one fills in
|
|
763
|
-
a default and asks again, so what is recorded is on screen first. The request says what a
|
|
764
|
-
grant covers before the click — *asked for `src/api/routes.ts` → granting
|
|
765
|
-
covers all of `src/api/**`*, and whose it is today. Sharing another role's territory takes a
|
|
766
|
-
second click that names it. After it, the console shows the line it wrote to `seisin.toml`
|
|
767
|
-
and where.
|
|
768
|
-
|
|
769
|
-
**Denied and Walls say the same thing about one path.** A path no role owns says what kind
|
|
770
|
-
of thing it is — *ownable*, or git's, temporary, build output, a credential — and the move
|
|
771
|
-
that fits each, which for all but the first is not an owner.
|
|
772
|
-
|
|
773
|
-
**Live, the roles are read-only:** territories and keys change in `seisin.toml`. The two
|
|
774
|
-
writes are decisions and the control files: beside each role, a toggle for each family it can
|
|
775
|
-
be handed (editor settings, instruction files), and one for `[protect] instructions`. A
|
|
776
|
-
toggle saves nothing. It shows the sandbox settings each role gains or loses and the exact
|
|
777
|
-
line it would write, and a second button, with a reason, saves it. If the server stops, the
|
|
778
|
-
page says *offline* and turns every write off until it answers again.
|
|
440
|
+
One append-only, hash-chained JSONL under `.seisin/`, which no role can write. `review` finds
|
|
441
|
+
denials that repeat (a policy that is wrong) and grants never used (the only evidence for making a
|
|
442
|
+
policy smaller). The MCP server (`seisin mcp`) is read-only and answers an agent's questions about
|
|
443
|
+
its own situation. **[The log, `review`, the console, the MCP server and walls →](https://github.com/carlostapiacl/seisin/blob/main/docs/log.md)**
|
|
779
444
|
|
|
780
445
|
## How it holds
|
|
781
446
|
|
|
782
447
|
| layer | what it does | can it be talked around? |
|
|
783
448
|
|---|---|---|
|
|
784
|
-
| your agent's `allow`/`deny` rules | match the command string before it runs | yes
|
|
449
|
+
| your agent's `allow`/`deny` rules | match the command string before it runs | yes: `/bin/rm`, a heredoc, a child process |
|
|
785
450
|
| **seisin** | decides what to ask for, and names the owner | it doesn't enforce, it explains |
|
|
786
451
|
| **the sandbox → Seatbelt / bubblewrap / Landlock** | the OS denies the syscall | no |
|
|
787
452
|
|
|
@@ -807,11 +472,11 @@ page says *offline* and turns every write off until it answers again.
|
|
|
807
472
|
explanation — never the boundary
|
|
808
473
|
```
|
|
809
474
|
|
|
810
|
-
`sandbox-runtime` is the default enforcer, not the only one. `[sandbox] provider` picks another
|
|
811
|
-
`tartaros`, or any sandbox declared as a command
|
|
475
|
+
`sandbox-runtime` is the default enforcer, not the only one. `[sandbox] provider` picks another
|
|
476
|
+
(`tartaros`, or any sandbox declared as a command) per role, and only where it enforces the whole
|
|
812
477
|
policy: see [docs/sandboxes.md](https://github.com/carlostapiacl/seisin/blob/main/docs/sandboxes.md).
|
|
813
478
|
|
|
814
|
-
That split
|
|
479
|
+
That split keeps seisin small. Because the kernel is the boundary, seisin does not have to be airtight, only *legible*. A leaky explainer costs you a confusing log line. A leaky enforcer costs you the repo.
|
|
815
480
|
|
|
816
481
|
Verified by the test suite, which runs real commands in a real sandbox:
|
|
817
482
|
|
|
@@ -826,126 +491,14 @@ Verified by the test suite, which runs real commands in a real sandbox:
|
|
|
826
491
|
✔ an absolute path does not walk around the rule
|
|
827
492
|
```
|
|
828
493
|
|
|
829
|
-
## How it protects secrets
|
|
830
|
-
|
|
831
|
-
Four ways a secret leaves a process, and seisin closes three. The fourth is named rather
|
|
832
|
-
than hidden.
|
|
833
|
-
|
|
834
|
-
| way out | closed by |
|
|
835
|
-
|---|---|
|
|
836
|
-
| reading another role's key file | the kernel — the key directory is denied, each declared key re-allowed |
|
|
837
|
-
| a key riding in the environment | the launcher — the child's environment is **built, not inherited** |
|
|
838
|
-
| spilling a key the role does hold | the launcher — masked on stdout and stderr, even split across buffers |
|
|
839
|
-
| 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) |
|
|
840
|
-
| **a key stored outside the declared directories** | **nothing.** `seisin scan` finds them so you know |
|
|
841
|
-
|
|
842
|
-
```toml
|
|
843
|
-
[keys]
|
|
844
|
-
dir = [".secrets"]
|
|
845
|
-
|
|
846
|
-
[roles.frontend]
|
|
847
|
-
key_mode = "env"
|
|
848
|
-
keys = ["netlify-token.txt", # a file the role may read
|
|
849
|
-
"TOKEN=file://.secrets/all.env#NETLIFY", # one value, and no read at all
|
|
850
|
-
"OP=op://vault/item/field"] # anything that prints a secret
|
|
851
|
-
```
|
|
852
|
-
|
|
853
|
-
A **provider** is a command with a `{ref}` placeholder, so adding 1Password, Bitwarden or
|
|
854
|
-
`sops` is TOML and not code. `file://` ships built in. The parent resolves, never the
|
|
855
|
-
confined process. Nothing degrades: a provider that fails stops the run.
|
|
856
|
-
|
|
857
|
-
A key reaches the role as a variable (`key_mode = "env"`), as a file in the run's private
|
|
858
|
-
directory (`"run-file"`), or not at all (on Linux, and on macOS under tartaros): with `"inject"` the agent holds a
|
|
859
|
-
placeholder and the sandbox swaps in the real value only on the way to the hosts you list for
|
|
860
|
-
it. That costs terminating TLS to those hosts, which `seisin check` names, and a host that
|
|
861
|
-
stores what it is sent can still hand the value back — use a least-scope token.
|
|
862
|
-
|
|
863
|
-
**[Key directories, references, providers, delivery modes and nine recipes →](https://github.com/carlostapiacl/seisin/blob/main/docs/keys.md)**
|
|
864
|
-
|
|
865
|
-
For agents you would not trust with your machine, `[runtime] read = "territory"` denies the places
|
|
866
|
-
data lives — homes, temp directories, volumes — and leaves each role its repo, its territory and
|
|
867
|
-
a toolchain it declares, which `seisin check --verify` proves it can run. Every run, in every
|
|
868
|
-
mode, starts with a canary that tries the policy from inside and refuses to start the agent if
|
|
869
|
-
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)**
|
|
870
|
-
|
|
871
|
-
**Measured, over four teams of agents and three days: every denied read was a read of something
|
|
872
|
-
the policy had declared a key.** 148 of them, no exceptions, from five roles — all running
|
|
873
|
-
searches that swept a repository root. That is how a key gets read without anyone deciding
|
|
874
|
-
it should. ([The measurement, ~370 confined runs over one production window →](https://github.com/carlostapiacl/seisin/blob/main/docs/keys.md))
|
|
875
|
-
|
|
876
|
-
## When the boundary is right and the agent cannot hear it
|
|
877
|
-
|
|
878
|
-
seisin answers *whose is this* at the moment of the denial — once, mid-turn — and then the
|
|
879
|
-
sentence is gone. Nothing carries it forward, so the agent tries again.
|
|
880
|
-
|
|
881
|
-
Measured in the author's own multi-agent setup: of **345 denials, 88 (25%) were a repeat of something that same role
|
|
882
|
-
had already been denied.** One role spent 37 calls on two walls, hitting one of them nineteen
|
|
883
|
-
times. Every denial was correct. Not one was a false positive. It still cost thirty-seven
|
|
884
|
-
calls, because *correct* and *heard* are different properties and only the first was being
|
|
885
|
-
measured.
|
|
886
|
-
|
|
887
|
-
So a denial now carries its own history:
|
|
888
|
-
|
|
889
|
-
```
|
|
890
|
-
deploy/x.yml belongs to infra. It is not dev's to change. It is handed to infra: hand it
|
|
891
|
-
over rather than working around it. Do not put the change somewhere else instead (another
|
|
892
|
-
path, a copy, /tmp). You have been denied this 3 times now; it is not going to work on the
|
|
893
|
-
fourth try. Already queued as a handoff to infra…
|
|
894
|
-
```
|
|
895
|
-
|
|
896
|
-
And the standing list is a command:
|
|
897
|
-
|
|
898
|
-
```
|
|
899
|
-
$ seisin walls dev
|
|
900
|
-
|
|
901
|
-
dev keeps hitting:
|
|
902
|
-
|
|
903
|
-
19× write ../shared/.git/index.lock belongs to platform
|
|
904
|
-
6× read ~/.npmrc no role declares ~/.npmrc — add it under a [roles.<name>] keys list
|
|
905
|
-
|
|
906
|
-
23 of its calls went into retrying these.
|
|
907
|
-
```
|
|
908
|
-
|
|
909
|
-
That is the list said to the person at the terminal. The agent gets it in its own voice, at the
|
|
910
|
-
start of every session the hook is wired for:
|
|
911
|
-
|
|
912
|
-
```
|
|
913
|
-
Already denied more than once, and still denied — do not try again, hand it over:
|
|
914
|
-
- write ../shared/.git/index.lock (19×) — belongs to platform
|
|
915
|
-
- read ~/.npmrc (6×) — no role declares ~/.npmrc — add it under a [roles.<name>] keys list
|
|
916
|
-
```
|
|
917
|
-
|
|
918
|
-
and, on request, as data from the MCP server's `seisin_walls`. In a run handed along a chain, a
|
|
919
|
-
wall where the chain stops is listed without its owner: a person decides it, and the agent is
|
|
920
|
-
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)).
|
|
921
|
-
|
|
922
|
-
**A wall is recomputed against the policy, not read out of the log.** If the role would be
|
|
923
|
-
allowed today, it is not a wall — whatever happened yesterday. The obvious implementation is a
|
|
924
|
-
time window, on the argument that an old wall may have been granted since; the window is a
|
|
925
|
-
proxy for the question, and seisin has the policy right there, so it asks the question. A grant
|
|
926
|
-
makes its wall disappear on the next run instead of when a window expires. Same move as
|
|
927
|
-
`owners` and the request queue: recompute rather than believe.
|
|
928
|
-
|
|
929
|
-
Silent on the first denial, by design. A counter that reads `1×` every time is noise on the
|
|
930
|
-
turn where the sentence is already doing its job.
|
|
931
|
-
|
|
932
|
-
**Whether it makes an agent stop is not demonstrated, and will not be for a while.** The
|
|
933
|
-
obvious way to check is to compare repeats before and after — but the repeats were 86% one
|
|
934
|
-
lock file, and the release before this one deleted that wall outright with
|
|
935
|
-
`GIT_OPTIONAL_LOCKS=0`. Repeats did fall, by a lot, and **the fall is the wall going away,
|
|
936
|
-
not the sentence landing**: both shipped within days of each other and the bigger cause is the
|
|
937
|
-
other one. Isolating this needs a forward test — walls per role, runs with the hook wired
|
|
938
|
-
and no git locks in the way — not the history. Measured in the field and written up rather
|
|
939
|
-
than smoothed over, because the alternative is a claim the numbers do not carry.
|
|
940
|
-
|
|
941
494
|
## Scratch space, and what it costs
|
|
942
495
|
|
|
943
496
|
Territory alone is correct and unusable: an agent writes session state under its own config
|
|
944
497
|
directory and its tools write to the temp dir, so *your folders and nothing else* stops the
|
|
945
498
|
agent from starting. Every role gets `~/.cache`, `$TMPDIR` and `/tmp`, and a run of Claude Code
|
|
946
|
-
or Codex gets that agent's own directory (`~/.claude`, `~/.codex`)
|
|
499
|
+
or Codex gets that agent's own directory (`~/.claude`, `~/.codex`), never the other's, nor the
|
|
947
500
|
other's sign-in. Shared scratch is a real hole, because two roles share a temp directory and
|
|
948
|
-
territory does not hold in it. A repo that lives inside scratch
|
|
501
|
+
territory does not hold in it. A repo that lives inside scratch (a toy repo under `/tmp`) is
|
|
949
502
|
writable by every role whatever its `writes` say; `seisin check` says so (*every role can write
|
|
950
503
|
the whole repo*), and the same for a `writes` entry whose real path is in scratch.
|
|
951
504
|
It is one line to turn off. The runtime's own shared temp directory, `/tmp/claude`, is never
|
|
@@ -953,113 +506,26 @@ writable: each run gets a temp directory of its own instead.
|
|
|
953
506
|
|
|
954
507
|
**[What that costs, and how to close it →](https://github.com/carlostapiacl/seisin/blob/main/docs/scratch.md)**
|
|
955
508
|
|
|
956
|
-
## What it is not
|
|
957
|
-
|
|
958
|
-
- **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.
|
|
959
|
-
- **Not an orchestrator.** It does not run your agents, schedule them, or merge their work. It runs one command as one role.
|
|
960
|
-
- **Not a secret manager.** It scopes *reads* of files you already have. Where those files come from is your problem.
|
|
961
|
-
- **Not what a single agent needs first.** If only one agent ever touches the repo,
|
|
962
|
-
*whose file is this* has one answer, and the ownership half of this tool is dead
|
|
963
|
-
weight. Turn on your agent's own sandboxing instead — Claude Code has it built in,
|
|
964
|
-
over this same runtime, with nothing to install.
|
|
965
|
-
|
|
966
|
-
What is left over for one agent is real but narrow: the environment gets built
|
|
967
|
-
rather than inherited (measured on one machine: **15 variables kept, 60 dropped**,
|
|
968
|
-
among them `GITHUB_TOKEN` and the AWS pair), reads of the declared key directory
|
|
969
|
-
are denied to everything, and the network allowlist covers the whole process
|
|
970
|
-
rather than one tool. Worth it if that is a gap you actually have. seisin starts
|
|
971
|
-
earning its setup at **two** agents, and that is where it was designed to be.
|
|
972
|
-
|
|
973
|
-
## Why not a container
|
|
974
|
-
|
|
975
|
-
A fair question, and the honest answer is that a container is *more* isolation
|
|
976
|
-
than this. If what you need is to run something you do not trust at all, run it
|
|
977
|
-
in a VM — [Kaiden](https://openkaiden.ai), [Containarium](https://containarium.dev)
|
|
978
|
-
and a growing shelf of others do that well, and this does not try to.
|
|
979
|
-
|
|
980
|
-
They answer **"can this process reach the host?"** seisin answers a different
|
|
981
|
-
question:
|
|
982
|
-
|
|
983
|
-
```
|
|
984
|
-
container / microVM one box, one boundary, everything inside it is equal
|
|
985
|
-
seisin one repo, several roles, and a boundary between them
|
|
986
|
-
```
|
|
987
|
-
|
|
988
|
-
Put two agents in one container and they are back where they started: the same
|
|
989
|
-
files, the same keys, no answer to *whose is it*. That question is not about
|
|
990
|
-
isolation strength at all — it is about ownership, and it is the one thing an
|
|
991
|
-
operating system cannot know for you.
|
|
992
|
-
|
|
993
|
-
Which is also why this is thin. Enforcement is the sandbox — by default
|
|
994
|
-
[`@anthropic-ai/sandbox-runtime`](https://github.com/anthropics/sandbox-runtime) —
|
|
995
|
-
asking the kernel; seisin is the layer that decides what to ask for and says
|
|
996
|
-
whose file it was when the answer is no. The two compose — put seisin inside a
|
|
997
|
-
container if you want both, and the roles still hold.
|
|
998
|
-
|
|
999
|
-
| | what it answers |
|
|
1000
|
-
|---|---|
|
|
1001
|
-
| VM / microVM | can this reach the host |
|
|
1002
|
-
| container | can this reach the host, cheaply |
|
|
1003
|
-
| **seisin** | **whose file is this, and may this role change it** |
|
|
1004
|
-
|
|
1005
509
|
## Prior art
|
|
1006
510
|
|
|
1007
|
-
|
|
1008
|
-
|
|
1009
|
-
|
|
1010
|
-
|
|
1011
|
-
|
|
1012
|
-
- [`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.
|
|
1013
|
-
- [`dredozubov/hazmat`](https://github.com/dredozubov/hazmat) — runs the agent as a different system user, with `pf` rules and snapshots.
|
|
1014
|
-
- [`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.
|
|
1015
|
-
- *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.
|
|
1016
|
-
|
|
1017
|
-
### One sentence to tell these apart, because the names all sound the same
|
|
1018
|
-
|
|
1019
|
-
**OpenShell, hazmat and nono isolate the agent from your machine. seisin separates roles from
|
|
1020
|
-
each other inside one repository, and when the kernel denies something it names the owner.**
|
|
511
|
+
[`sandbox-runtime`](https://github.com/anthropics/sandbox-runtime) is the enforcement seisin sits on.
|
|
512
|
+
OpenShell, hazmat and nono isolate the agent from your machine; containers and microVMs isolate a
|
|
513
|
+
process from the host. seisin separates roles from each other inside one repository, and names the
|
|
514
|
+
owner when the kernel denies something. They compose: the agent seisin confines can run inside any
|
|
515
|
+
of them. **[Each one, and why not a container →](https://github.com/carlostapiacl/seisin/blob/main/docs/prior-art.md)**
|
|
1021
516
|
|
|
1022
|
-
|
|
1023
|
-
is "this agent cannot touch anything outside its sandbox", they do that and OpenShell does it
|
|
1024
|
-
with a large organisation behind it. If what you want is "four agents work in the same
|
|
1025
|
-
repository and none of them edits another's files — and when one is denied it is told whose
|
|
1026
|
-
those files are", nothing above answers that, which is why this exists.
|
|
1027
|
-
|
|
1028
|
-
They compose rather than compete: the agent seisin confines could be running inside one of
|
|
1029
|
-
them.
|
|
517
|
+
## Status
|
|
1030
518
|
|
|
1031
|
-
|
|
1032
|
-
|
|
1033
|
-
|
|
1034
|
-
|
|
1035
|
-
|
|
1036
|
-
|
|
519
|
+
CI runs the full suite on macOS and `ubuntu-latest`, Node 18/20/22/24, on every pull request and
|
|
520
|
+
push to `main`. On 2026-11-02, on Node 24: macOS **1133 tests, 1124 passing, 9 skipped**;
|
|
521
|
+
Linux **1132, 1103 passing, 29 skipped**. The skips are platform tests; the job fails if the 26
|
|
522
|
+
core boundary tests in `test/sandbox.test.js` skip. A release is published only from CI, after
|
|
523
|
+
that passes, with npm provenance. [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),
|
|
524
|
+
and [what running agents behind it cost](https://github.com/carlostapiacl/seisin/blob/main/docs/field-notes.md).
|
|
1037
525
|
|
|
1038
|
-
|
|
526
|
+
The config format may still change before `1.0`; `seisin check` will say what changed.
|
|
1039
527
|
|
|
1040
|
-
**
|
|
1041
|
-
same day, on `ubuntu-latest` **1100, 1071 passing and 29 platform skips**, and on macOS 1101,
|
|
1042
|
-
1092 passing and 9 skips, Node 18/20/22/24, with the 26 core boundary tests skipping none. On 2026-10-12, 91 of the suite's tests
|
|
1043
|
-
needed `@anthropic-ai/sandbox-runtime` installed — counted by running it with the runtime
|
|
1044
|
-
hidden — and run real commands through the real kernel; 26 of them are the core boundary file,
|
|
1045
|
-
`test/sandbox.test.js`, which the CI gate fails if it *skips*, because a green run that quietly
|
|
1046
|
-
tested nothing looks exactly like a real one. That is not hypothetical:
|
|
1047
|
-
when there were eighteen of them, they skipped on Linux for a day, behind a runtime check that looked for the global
|
|
1048
|
-
install and missed the bundled one, and hid a defect that broke `seisin run` on that platform
|
|
1049
|
-
entirely.
|
|
1050
|
-
|
|
1051
|
-
A release is published only from CI, after the full suite passes on macOS and `ubuntu-latest`,
|
|
1052
|
-
Node 18/20/22/24 (`publish.yml`), with npm provenance.
|
|
1053
|
-
Historically: 811 tests on macOS 15, 809 passing and 2 skipped, on 2026-09-30.
|
|
1054
|
-
[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),
|
|
1055
|
-
and [what running agents behind it cost the people using it](https://github.com/carlostapiacl/seisin/blob/main/docs/field-notes.md).
|
|
1056
|
-
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)
|
|
1057
|
-
added 32 regressions and record their portable checks apart from the kernel measurements above.
|
|
1058
|
-
The config format may still move before `1.0` — if it does, `seisin check` will
|
|
1059
|
-
tell you what changed.
|
|
1060
|
-
|
|
1061
|
-
**What is genuinely open**, so nobody spends an afternoon on something that is
|
|
1062
|
-
already done:
|
|
528
|
+
**Still open:**
|
|
1063
529
|
|
|
1064
530
|
| | |
|
|
1065
531
|
|---|---|
|
|
@@ -1069,23 +535,14 @@ already done:
|
|
|
1069
535
|
| **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 |
|
|
1070
536
|
| **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) |
|
|
1071
537
|
|
|
1072
|
-
|
|
1073
|
-
**denies**, a person **grants** or **declines**, seisin **refuses** a config it cannot enforce.
|
|
1074
|
-
product is a sentence cannot afford two words for one thing.
|
|
538
|
+
Each term has one meaning, listed in [the glossary](https://github.com/carlostapiacl/seisin/blob/main/docs/glossary.md): the boundary
|
|
539
|
+
**denies**, a person **grants** or **declines**, seisin **refuses** a config it cannot enforce.
|
|
1075
540
|
|
|
1076
|
-
Issues and pull requests welcome
|
|
1077
|
-
|
|
1078
|
-
something measured. If you are reporting something that got past the boundary, do it
|
|
541
|
+
Issues and pull requests are welcome. [CONTRIBUTING.md](https://github.com/carlostapiacl/seisin/blob/main/CONTRIBUTING.md) asks mainly that a claim come
|
|
542
|
+
from something measured. If you are reporting something that got past the boundary, do it
|
|
1079
543
|
[privately](https://github.com/carlostapiacl/seisin/blob/main/SECURITY.md); `seisin log --verdict denied` and the `seisin.toml` are the two
|
|
1080
544
|
things that make it reproducible.
|
|
1081
545
|
|
|
1082
|
-
## The name
|
|
1083
|
-
|
|
1084
|
-
A deed says who owns land. **Seisin** is the older idea underneath: who is actually standing on it.
|
|
1085
|
-
|
|
1086
|
-
That is the question this tool answers. Not *may this be written* — the kernel settles that — but
|
|
1087
|
-
*whose is it*, which is the one that tells an agent what to do next.
|
|
1088
|
-
|
|
1089
546
|
I write up what I measure running agents behind it at [carlostapia.cl](https://carlostapia.cl/notes/).
|
|
1090
547
|
|
|
1091
548
|
## License
|