seisin 0.1.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/LICENSE +21 -0
- package/README.md +732 -0
- package/package.json +52 -0
- package/src/cli.js +125 -0
- package/src/commands/check.js +9 -0
- package/src/commands/explain.js +87 -0
- package/src/commands/hook.js +42 -0
- package/src/commands/init.js +181 -0
- package/src/commands/log.js +68 -0
- package/src/commands/requests.js +145 -0
- package/src/commands/review.js +94 -0
- package/src/commands/run.js +395 -0
- package/src/commands/scan.js +21 -0
- package/src/commands/ui.js +31 -0
- package/src/commands/whose.js +94 -0
- package/src/commands/wire.js +74 -0
- package/src/config.js +317 -0
- package/src/env.js +61 -0
- package/src/handoff.js +111 -0
- package/src/hook.js +195 -0
- package/src/index.js +52 -0
- package/src/inspect.js +477 -0
- package/src/layout.js +47 -0
- package/src/log.js +124 -0
- package/src/mcp.js +290 -0
- package/src/owners.js +166 -0
- package/src/redact.js +82 -0
- package/src/render.js +150 -0
- package/src/requests.js +259 -0
- package/src/review.js +167 -0
- package/src/scan.js +175 -0
- package/src/serve.js +168 -0
- package/src/spool.js +164 -0
- package/src/srt.js +424 -0
- package/src/violations.js +540 -0
- package/src/worktree.js +216 -0
- package/ui/favicon.svg +1 -0
- package/ui/index.html +937 -0
package/README.md
ADDED
|
@@ -0,0 +1,732 @@
|
|
|
1
|
+
# seisin
|
|
2
|
+
|
|
3
|
+
[](https://github.com/carlostapiaolguin3-stack/seisin/actions/workflows/test.yml)
|
|
4
|
+
[](https://www.npmjs.com/package/seisin)
|
|
5
|
+
[](LICENSE)
|
|
6
|
+
[](package.json)
|
|
7
|
+
[](package.json)
|
|
8
|
+
|
|
9
|
+
> **seisin** *(n.)* — the legal possession of a piece of land. Not who owns it on paper: who holds it now.
|
|
10
|
+
|
|
11
|
+
**Give each AI agent its own folders and its own keys.** The kernel enforces it, and when it blocks something it tells you *whose* file it was.
|
|
12
|
+
|
|
13
|
+
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.
|
|
14
|
+
|
|
15
|
+
*Two outside reviews went looking for ways past the boundary. [What they found, what broke, and what is still open →](docs/what-it-has-been-put-through.md)*
|
|
16
|
+
|
|
17
|
+

|
|
18
|
+
|
|
19
|
+
```
|
|
20
|
+
$ seisin run frontend -- sh -c 'echo // fix >> src/api/orders.ts'
|
|
21
|
+
sh: src/api/orders.ts: Operation not permitted
|
|
22
|
+
seisin: 1 kernel denial(s) recorded
|
|
23
|
+
|
|
24
|
+
1 pending request(s)
|
|
25
|
+
|
|
26
|
+
#1 frontend wants write on src/api/** (owned by backend)
|
|
27
|
+
first asked over src/api/orders.ts
|
|
28
|
+
|
|
29
|
+
seisin grant <n> [--reason "…"] · seisin deny <n> [--reason "…"]
|
|
30
|
+
```
|
|
31
|
+
|
|
32
|
+
**Whose it was, at the moment it was refused.** Every other permission layer in this space answers *yes* or *no*. Answering **"no, and it belongs to `backend`"** turns a block into a handoff — and one a person can approve in a command, rather than a line somebody has to remember to go and read.
|
|
33
|
+
|
|
34
|
+
The kernel is what refuses; the name comes from the policy. That first line is all the boundary itself can say — no path, no reason, nothing to read afterwards — so seisin reads the refusal out of the kernel's own log and answers the question it leaves open. On Linux only the hook can do that; [the ask is upstream](docs/upstream/cli-violations.md).
|
|
35
|
+
|
|
36
|
+
The agent can ask directly too, from inside the box:
|
|
37
|
+
|
|
38
|
+
```
|
|
39
|
+
$ seisin run frontend -- seisin whose src/api/orders.ts
|
|
40
|
+
src/api/orders.ts belongs to backend
|
|
41
|
+
you are frontend. Hand it over rather than working around it.
|
|
42
|
+
```
|
|
43
|
+
|
|
44
|
+
## Sixty seconds
|
|
45
|
+
|
|
46
|
+
```bash
|
|
47
|
+
npm install -g seisin
|
|
48
|
+
cd your-repo
|
|
49
|
+
seisin init # proposes a policy from what the repo already says
|
|
50
|
+
seisin check # read it before you trust it
|
|
51
|
+
seisin wire # let the agent record what it does
|
|
52
|
+
seisin run frontend -- claude -p "…" # run an agent inside its own territory
|
|
53
|
+
```
|
|
54
|
+
|
|
55
|
+
On Linux, [three system packages first](#install). Everything below is why it works and where it does not.
|
|
56
|
+
|
|
57
|
+
**Contents** · [Why this exists](#why-this-exists) · [What it is *not* for](#what-it-is-for-and-what-it-is-not-for) · [Install](#install) · [Configure](#configure) · [Agents it runs](#which-agents-it-has-been-run-with) · [Requests](#when-it-says-no-it-leaves-a-request-behind) · [MCP](#ask-your-own-assistant) · [Why not a container](#why-not-a-container) · [How it holds](#how-it-holds) · [Secrets](#how-it-protects-secrets) · [Decisions](docs/decisions.md) · [What it survived](docs/what-it-has-been-put-through.md) · [Status](#status)
|
|
58
|
+
|
|
59
|
+
---
|
|
60
|
+
|
|
61
|
+
## Why this exists
|
|
62
|
+
|
|
63
|
+
Two things are true about agent permissions today, and both are in the official docs.
|
|
64
|
+
|
|
65
|
+
**1. Permission rules match text, not behaviour.** From the Claude Code documentation:
|
|
66
|
+
|
|
67
|
+
> Calls matching `rm *` **as written** are denied […] Other `Bash` calls, **including `/bin/rm`**, fall through to the permission mode.
|
|
68
|
+
|
|
69
|
+
A rule is a string comparison against a command someone might not spell that way. A child process does not spell it at all.
|
|
70
|
+
|
|
71
|
+
**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. The moment 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?"**
|
|
72
|
+
|
|
73
|
+
`seisin` answers the second question and hands the first one to the operating system.
|
|
74
|
+
|
|
75
|
+
```
|
|
76
|
+
ONE GLOBAL POLICY WHOSE IS IT?
|
|
77
|
+
|
|
78
|
+
frontend ──┐ frontend ──► src/web/** ok
|
|
79
|
+
├──► may this be written? backend ──► src/api/** ok
|
|
80
|
+
backend ──┘ yes
|
|
81
|
+
frontend ──► src/api/** no
|
|
82
|
+
two agents, one answer, belongs to backend
|
|
83
|
+
the whole repo, all the time → hand it over, do not work around it
|
|
84
|
+
```
|
|
85
|
+
|
|
86
|
+
## What it is for, and what it is not for
|
|
87
|
+
|
|
88
|
+
> **It turns an agent's mistake into a contained mistake. It does not turn a hostile
|
|
89
|
+
> agent into a harmless one.**
|
|
90
|
+
>
|
|
91
|
+
> Two outside reviews have gone through it looking for ways past the boundary and
|
|
92
|
+
> found eleven things between them. [What it has been put
|
|
93
|
+
> through](docs/what-it-has-been-put-through.md) lists all of them — what was
|
|
94
|
+
> fixed, what was measured, and what is still open.
|
|
95
|
+
|
|
96
|
+
That distinction is the whole threat model, and it is worth reading before you install
|
|
97
|
+
rather than after.
|
|
98
|
+
|
|
99
|
+
**It holds against an agent that is wrong.** One that edits the file it was not asked
|
|
100
|
+
about, reads the key meant for another role, or calls an API nobody declared. The kernel
|
|
101
|
+
refuses, and it does not care how the command was spelled — verified against a grandchild
|
|
102
|
+
process, an absolute path, a symlink, a `python -c`, and a shell redirect the agent fell
|
|
103
|
+
back to when its own file API was refused.
|
|
104
|
+
|
|
105
|
+
**It is not built to hold against an agent that is trying.** Measured, in this repo's own
|
|
106
|
+
fixtures:
|
|
107
|
+
|
|
108
|
+
| | |
|
|
109
|
+
|---|---|
|
|
110
|
+
| it can read everything except the declared keys | by design — an agent that cannot read the repo cannot work. `seisin scan` exists because secrets live elsewhere too |
|
|
111
|
+
| it can write anything inside its own territory | including staging an exfiltration, or destroying its own uncommitted work with `rm` |
|
|
112
|
+
| `base64` defeats the redaction | only the literal value is masked, and only on stdout and stderr |
|
|
113
|
+
| an allowed domain is an allowed channel | if the agent's own API is reachable, so is everything that goes through it |
|
|
114
|
+
| the hook is text-based and evadable | on purpose — what escapes it goes **unblocked by nothing**: the kernel still refuses. On macOS it is no longer unexplained either, because [the kernel's own refusals are recorded](#seeing-what-happened); on Linux it is. Either way, attribution is not a control |
|
|
115
|
+
|
|
116
|
+
And three things that outweigh all of the above:
|
|
117
|
+
|
|
118
|
+
1. **It is days old and has one author.** Around three thousand lines that nobody has
|
|
119
|
+
audited except its writer and one field report. "Secure" is not a word earned that fast.
|
|
120
|
+
2. **The enforcement is someone else's beta.** `sandbox-runtime` describes itself as a
|
|
121
|
+
research preview with an evolving API. A hole there is a hole here.
|
|
122
|
+
3. **Two platforms tested, by one person, on one machine each.**
|
|
123
|
+
|
|
124
|
+
For several of your own agents, on your own machine, getting in each other's way — that is
|
|
125
|
+
what this is for, and it is a great deal better than nothing. For putting an agent you do
|
|
126
|
+
not control in front of data you care about, it is not.
|
|
127
|
+
|
|
128
|
+
## Install
|
|
129
|
+
|
|
130
|
+
```bash
|
|
131
|
+
npm install -g seisin
|
|
132
|
+
```
|
|
133
|
+
|
|
134
|
+
That pulls in [`@anthropic-ai/sandbox-runtime`](https://github.com/anthropics/sandbox-runtime), which does the enforcing: Seatbelt on macOS, bubblewrap on Linux. No containers, no VMs, no daemon. seisin has **no other dependencies**.
|
|
135
|
+
|
|
136
|
+
**On Linux you also need three system packages** — the runtime refuses to start without
|
|
137
|
+
them rather than running unconfined, which is the right failure and an unhelpful message:
|
|
138
|
+
|
|
139
|
+
```bash
|
|
140
|
+
apt install bubblewrap ripgrep socat # or your distro's equivalent
|
|
141
|
+
```
|
|
142
|
+
|
|
143
|
+
**On Ubuntu 24.04 and newer**, unprivileged user namespaces are off by default and
|
|
144
|
+
bubblewrap needs one. Every run dies with `bwrap: No permissions to create new
|
|
145
|
+
namespace`:
|
|
146
|
+
|
|
147
|
+
```bash
|
|
148
|
+
sudo sysctl -w kernel.apparmor_restrict_unprivileged_userns=0
|
|
149
|
+
```
|
|
150
|
+
|
|
151
|
+
Worth knowing what this looks like when you hit it: seisin appears to deny
|
|
152
|
+
*everything*, because the sandbox never starts. It is not a policy problem and
|
|
153
|
+
no amount of editing `seisin.toml` will move it.
|
|
154
|
+
|
|
155
|
+
**Inside a container** bubblewrap needs to create namespaces and mount `/proc`, which a
|
|
156
|
+
default Docker container forbids. `--cap-add SYS_ADMIN --security-opt seccomp=unconfined`
|
|
157
|
+
gets namespaces; mounting `/proc` needs `--privileged`. If you cannot grant that, run
|
|
158
|
+
seisin on the host and let the container be what it sandboxes.
|
|
159
|
+
|
|
160
|
+
## Use
|
|
161
|
+
|
|
162
|
+
```bash
|
|
163
|
+
seisin init # proposes a seisin.toml for this repo
|
|
164
|
+
seisin check # print the map, run nothing
|
|
165
|
+
seisin run frontend -- claude -p "…" # run an agent as that role
|
|
166
|
+
seisin explain frontend write src/api/x # ask one question, exit 0 or 1
|
|
167
|
+
seisin review # what the log says about the policy
|
|
168
|
+
seisin wire # install the PreToolUse hook in this repo
|
|
169
|
+
seisin ui # a console for editing the map
|
|
170
|
+
```
|
|
171
|
+
|
|
172
|
+
## Configure
|
|
173
|
+
|
|
174
|
+
One file at the root of your repo. Anything not listed is denied — there is no permissive default.
|
|
175
|
+
|
|
176
|
+
```toml
|
|
177
|
+
[keys]
|
|
178
|
+
dir = ".secrets" # every key lives here; roles name the files they may read
|
|
179
|
+
|
|
180
|
+
[network]
|
|
181
|
+
allow = ["github.com", "*.github.com"]
|
|
182
|
+
|
|
183
|
+
[roles.frontend]
|
|
184
|
+
writes = ["src/web/**", "public/**"]
|
|
185
|
+
keys = ["netlify-token.txt"]
|
|
186
|
+
|
|
187
|
+
[roles.backend]
|
|
188
|
+
writes = ["src/api/**", "migrations/**"]
|
|
189
|
+
keys = ["database-url.txt", "sentry-dsn.txt"]
|
|
190
|
+
network = ["api.stripe.com"] # this role only — it replaces [network] rather than adding
|
|
191
|
+
```
|
|
192
|
+
|
|
193
|
+
`[network] allow` is the fallback for roles that do not name their own. A role that needs one
|
|
194
|
+
extra host should say so on its own line: a domain in the global list is reachable by every
|
|
195
|
+
role, including the ones whose whole job is to have nothing to reach.
|
|
196
|
+
|
|
197
|
+
`seisin init` will propose this from whatever your repo already says: `.claude/agents/`, then `CODEOWNERS`, then a blank start. It **proposes** — a generated policy you did not read is not a policy.
|
|
198
|
+
|
|
199
|
+
## Which agents it has been run with
|
|
200
|
+
|
|
201
|
+
seisin wraps a process, so in principle it works with any agent that runs as one — CLI or not. In
|
|
202
|
+
practice "in principle" is not a claim worth making about a permission tool, so here is what has
|
|
203
|
+
actually been exercised:
|
|
204
|
+
|
|
205
|
+
| agent | version | result |
|
|
206
|
+
|---|---|---|
|
|
207
|
+
| **Claude Code** (`claude -p`) | 2.1.x | Territory and keys enforced; network egress refused an undeclared domain by name |
|
|
208
|
+
| **opencode** (`opencode run`) | **1.18.30** | Same, **on a free model with no API key at all** |
|
|
209
|
+
| **LangGraph** (`python graph.py`) | **1.2.11** | Same, **enforced against the interpreter's own `open()`** — in-process tools, no child command to match |
|
|
210
|
+
| **codex** (`codex exec`) | **0.150.1** | Same, with its own sandbox off — see below. Refused twice: its patch tool, then the shell redirect it fell back to |
|
|
211
|
+
|
|
212
|
+
The codex run is the one that needed a flag. **An agent that sandboxes itself has to stop.** `codex` confines each command it runs with its
|
|
213
|
+
own `sandbox-exec` profile, and macOS refuses to apply a second Seatbelt profile to a process
|
|
214
|
+
that already has one — `sandbox_apply: Operation not permitted`, with the most permissive
|
|
215
|
+
profile that can be written, on both layers. So an agent like that runs under seisin with its
|
|
216
|
+
own sandbox turned off — `codex exec --dangerously-bypass-approvals-and-sandbox`, whose own
|
|
217
|
+
help says it is "intended solely for running in environments that are externally sandboxed".
|
|
218
|
+
It starts fine either way (`codex 0.150.1`, `gemini 0.57.0`, both verified), which is why this
|
|
219
|
+
is worth saying out loud: the failure arrives later, on the first command the agent tries to
|
|
220
|
+
confine.
|
|
221
|
+
|
|
222
|
+
Asked to write into another role's territory, codex failed through its patch tool, went looking
|
|
223
|
+
for a file ACL with `ls -le@` and `stat`, found nothing, and fell back to a shell redirect that
|
|
224
|
+
failed too:
|
|
225
|
+
|
|
226
|
+
```
|
|
227
|
+
/bin/zsh -lc "printf 'pisado\n' > qa/informe.md"
|
|
228
|
+
exited 1: zsh:1: operation not permitted
|
|
229
|
+
```
|
|
230
|
+
|
|
231
|
+
Same shape as opencode, including the wrong diagnosis — it reasoned about file permissions,
|
|
232
|
+
not about ownership. That is the case for the hook: the boundary holds either way, but only the
|
|
233
|
+
hook can say *whose* it was.
|
|
234
|
+
|
|
235
|
+
All four on macOS 15 (Seatbelt). Linux is no longer a one-off measurement on one machine:
|
|
236
|
+
[CI](.github/workflows/test.yml) runs the whole suite on Ubuntu and macOS, Node 18/20/22,
|
|
237
|
+
on every push — and fails if the sandbox half *skips*.
|
|
238
|
+
|
|
239
|
+
The opencode run is the interesting one, because **opencode has no per-path sandbox flag** —
|
|
240
|
+
its only permission control is `--auto`, "auto-approve permissions that are not explicitly
|
|
241
|
+
denied". Asked to write into another role's territory it failed twice: once through its own
|
|
242
|
+
`FileSystem.writeFile`, and again through the shell redirect it fell back to.
|
|
243
|
+
|
|
244
|
+
```
|
|
245
|
+
Error: Unknown: FileSystem.writeFile (.../qa/informe.md)
|
|
246
|
+
$ echo "revisado" > .../qa/informe.md
|
|
247
|
+
zsh:1: operation not permitted
|
|
248
|
+
```
|
|
249
|
+
|
|
250
|
+
That is the point of putting the boundary in the kernel instead of in the agent: **an agent
|
|
251
|
+
that cannot confine itself is confined anyway**, and adding a new one costs no adapter.
|
|
252
|
+
|
|
253
|
+
It also found a real bug. The scratch list named `~/.claude` and `~/.codex` and stopped there,
|
|
254
|
+
so the first agent that was neither did not fail a task — it failed to start, on its own log
|
|
255
|
+
file. A boundary that only fits the agents its author happened to use is a coincidence, not a
|
|
256
|
+
boundary. The XDG directories are in the list now.
|
|
257
|
+
|
|
258
|
+
The LangGraph run answers a different question: **an agent framework is not a CLI.** Its tools
|
|
259
|
+
are Python calls inside the same process, so there is no child command for a rule to match and no
|
|
260
|
+
argv to inspect — the write is the interpreter's own `open()`. A six-node graph asked to write into
|
|
261
|
+
another role's territory was refused there, refused again through the shell redirect it fell back
|
|
262
|
+
to, refused the other role's key, and refused an undeclared domain. Run as the other role, the same
|
|
263
|
+
graph gave the mirror image — every territory and key answer flipped, the undeclared domain refused
|
|
264
|
+
for both — and its SQLite checkpointer persisted normally inside whichever territory was its own.
|
|
265
|
+
|
|
266
|
+
One caveat belongs beside that row rather than after it: **a role is scoped to a process, and a
|
|
267
|
+
graph is one process.** Every node of one graph shares one territory and one set of keys. Per-node
|
|
268
|
+
territory means per-node subprocess, which is the thing an in-process framework exists to avoid.
|
|
269
|
+
seisin fits a multi-agent framework at the boundary of the whole graph, not between its agents.
|
|
270
|
+
|
|
271
|
+
And one failure mode worth meeting here rather than at 2am: SQLite reports a refused write as
|
|
272
|
+
`attempt to write a readonly database`, not as a permission error. That reads like a misconfigured
|
|
273
|
+
database, and you will debug the database. The hook names the owner instead — it is the case
|
|
274
|
+
`seisin wire` is for.
|
|
275
|
+
|
|
276
|
+
**Re-verified without an agent in the loop**, which is better evidence: an agent in the middle
|
|
277
|
+
makes a permission test non-deterministic — in that first run the agent reported the opposite
|
|
278
|
+
of what it had actually done. Wrapping a plain shell and a plain interpreter instead:
|
|
279
|
+
|
|
280
|
+
| as `dev`, with `qa/**` owned by `qa` | result |
|
|
281
|
+
|---|---|
|
|
282
|
+
| `sh -c 'echo x > proyecto/FILE.txt'` (own territory) | rc=0, created |
|
|
283
|
+
| `sh -c 'echo x > qa/report.md'` (shell redirect) | `Operation not permitted`, rc=1 |
|
|
284
|
+
| `python3 -c "open('qa/report.md','w')"` (the program's own syscall) | `PermissionError: [Errno 1]` |
|
|
285
|
+
| `rm -f proyecto/FILE.txt` (**destructive, inside** own territory) | **rc=0, file gone** |
|
|
286
|
+
|
|
287
|
+
The last row is not a bug, it is the shape of the tool: seisin answers *where*, not *what*.
|
|
288
|
+
A team running this in front of an agent fleet measured how often it matters: across ~330
|
|
289
|
+
rounds, sixty-six times a role reached for a command that would have destroyed its own
|
|
290
|
+
uncommitted work, and the kernel boundary permitted every one. The ask that would close it
|
|
291
|
+
is written up in [docs/upstream/denyUnlink.md](docs/upstream/denyUnlink.md), filed against
|
|
292
|
+
the sandbox runtime rather than worked around here.
|
|
293
|
+
|
|
294
|
+
If you would rather see it than read it, [thirty seconds in a temp
|
|
295
|
+
directory](docs/demo/) — an agent confined to its own project deletes the git history of the
|
|
296
|
+
repo it is working in, and the `.git/` left behind looks intact. Nothing installed, seisin not
|
|
297
|
+
used, because the gap is the runtime's and every tool on top of it inherits the same one.
|
|
298
|
+
|
|
299
|
+
## What the log says about the policy
|
|
300
|
+
|
|
301
|
+
`check` reads the config and tells you what it would do. `review` reads what
|
|
302
|
+
actually happened and tells you where the config was wrong about it.
|
|
303
|
+
|
|
304
|
+
```
|
|
305
|
+
$ seisin review
|
|
306
|
+
|
|
307
|
+
60 decisions, 2026-08-23 to 2026-09-11
|
|
308
|
+
|
|
309
|
+
Stopped, repeatedly
|
|
310
|
+
9× frontend write src/api/checkout — belongs to backend
|
|
311
|
+
4× backend write src/web — belongs to frontend
|
|
312
|
+
|
|
313
|
+
Granted, never used
|
|
314
|
+
frontend public/**
|
|
315
|
+
backend migrations/**
|
|
316
|
+
|
|
317
|
+
Owned by nobody
|
|
318
|
+
5× legacy
|
|
319
|
+
```
|
|
320
|
+
|
|
321
|
+
Three questions, and the middle one is the reason this exists. **Every
|
|
322
|
+
permission file only ever grows**, in every system, and always for the same
|
|
323
|
+
reason: nobody can prove a line is dead, so the safe move is to leave it. Here
|
|
324
|
+
the log can prove it — `public/**` was granted and nothing was written there in
|
|
325
|
+
three weeks. That is the only direction that makes a policy *smaller*.
|
|
326
|
+
|
|
327
|
+
The first is the one that changes how a denial reads. Forty blocks on one
|
|
328
|
+
directory is not an agent misbehaving; it is a policy that is wrong, and until
|
|
329
|
+
you add them up it looks like forty tidy amber lines.
|
|
330
|
+
|
|
331
|
+
It is arithmetic over the log. No model, no network, no heuristics — the
|
|
332
|
+
argument this whole tool makes is that interpreting text is the wrong way to
|
|
333
|
+
decide things, and a component that reads the log and forms an opinion would
|
|
334
|
+
contradict it on the way in. Every finding carries the window it was computed
|
|
335
|
+
over, because *never used* means nothing without *in how long*.
|
|
336
|
+
|
|
337
|
+
## Where the first policy comes from
|
|
338
|
+
|
|
339
|
+
Every permission tool dodges this question. Written by hand, the first policy is a guess, and
|
|
340
|
+
the first unjustified denial is when the tool gets uninstalled. So don't write it — watch, then
|
|
341
|
+
write:
|
|
342
|
+
|
|
343
|
+
```bash
|
|
344
|
+
seisin run frontend --observe -- claude -p "…" # records, denies nothing
|
|
345
|
+
seisin init --from-observations # writes seisin.toml.observed
|
|
346
|
+
```
|
|
347
|
+
|
|
348
|
+
It lands as `.observed`, not as your config. A policy generated behind your back is not a
|
|
349
|
+
policy: diff it, then move it.
|
|
350
|
+
|
|
351
|
+
## When it says no, it leaves a request behind
|
|
352
|
+
|
|
353
|
+
A permission tool that can only say no is a tool people uninstall. The loop —
|
|
354
|
+
denied, stop, go edit a config, run again — is three context switches for one
|
|
355
|
+
line of policy, and the cheapest way to make it stop is to widen the policy
|
|
356
|
+
generously and never look again. That is how a permission file becomes seven
|
|
357
|
+
hundred entries nobody can read.
|
|
358
|
+
|
|
359
|
+
So a denial leaves something you can act on:
|
|
360
|
+
|
|
361
|
+
```
|
|
362
|
+
frontend write src/api/orders.ts
|
|
363
|
+
denied — src/api/orders.ts belongs to backend
|
|
364
|
+
|
|
365
|
+
1 pending request(s)
|
|
366
|
+
|
|
367
|
+
#1 frontend wants write on src/api/** (owned by backend) · asked 3×
|
|
368
|
+
first asked over src/api/orders.ts
|
|
369
|
+
|
|
370
|
+
seisin grant 1 --reason "…" · seisin deny 1 --reason "…"
|
|
371
|
+
```
|
|
372
|
+
|
|
373
|
+
Many refusals in one directory are **one** request, not many — an agent denied
|
|
374
|
+
on `a.ts` and then on `b.ts` is not asking two questions. And the grant records
|
|
375
|
+
where it came from, next to the line it adds:
|
|
376
|
+
|
|
377
|
+
```toml
|
|
378
|
+
[roles.frontend]
|
|
379
|
+
writes = [
|
|
380
|
+
"src/web/**",
|
|
381
|
+
"src/api/**" # granted 2026-09-12 · asked 3× · "frontend owns checkout now"
|
|
382
|
+
]
|
|
383
|
+
```
|
|
384
|
+
|
|
385
|
+
```
|
|
386
|
+
frontend denied on src/api/orders.ts
|
|
387
|
+
│
|
|
388
|
+
▼
|
|
389
|
+
request #1 ◄──── denied again on b.ts ──── same request, asked 2×
|
|
390
|
+
│
|
|
391
|
+
│ nothing inside the box moves it from here
|
|
392
|
+
▼
|
|
393
|
+
┌───────┴────────┐
|
|
394
|
+
▼ ▼
|
|
395
|
+
seisin grant 1 seisin deny 1
|
|
396
|
+
│ ▼
|
|
397
|
+
│ policy unchanged,
|
|
398
|
+
▼ the reason recorded
|
|
399
|
+
one line added to seisin.toml,
|
|
400
|
+
carrying where it came from
|
|
401
|
+
```
|
|
402
|
+
|
|
403
|
+
Without that, a policy is a list of permissions with no history, and the only
|
|
404
|
+
safe thing to do with a line nobody remembers is leave it there.
|
|
405
|
+
|
|
406
|
+
**Approving is deliberately not a tool call.** An agent — or an MCP server on
|
|
407
|
+
your behalf — can read the queue and draft the change. Turning it into policy
|
|
408
|
+
takes a person in a channel the agent does not have. The public API reflects
|
|
409
|
+
that: `pendingRequests` is exported, the functions that approve are not.
|
|
410
|
+
[The reasoning is written down](docs/permission-requests.md).
|
|
411
|
+
|
|
412
|
+

|
|
413
|
+
|
|
414
|
+
Two places, both human: `seisin grant <n>` in a terminal, or the console, where
|
|
415
|
+
the queue is a panel with a reason field and two buttons. Approving there edits
|
|
416
|
+
your `seisin.toml` in place and the territory on screen changes with it.
|
|
417
|
+
|
|
418
|
+
The console is allowed to hold that half and the MCP server is not, and the
|
|
419
|
+
reason is measured rather than asserted: a confined role curling the console's
|
|
420
|
+
own port gets the same nothing it gets from a domain outside its allowlist. The
|
|
421
|
+
MCP server, by contrast, speaks on the agent's own stdio.
|
|
422
|
+
|
|
423
|
+
The notice rides on what you are already looking at: `seisin run` prints the
|
|
424
|
+
queue when the run ends, in the same terminal that just showed you the denial.
|
|
425
|
+
There is no daemon and nothing to leave running.
|
|
426
|
+
|
|
427
|
+
## Ask your own assistant
|
|
428
|
+
|
|
429
|
+
```jsonc
|
|
430
|
+
{ "mcpServers": { "seisin": { "command": "seisin", "args": ["mcp"] } } }
|
|
431
|
+
```
|
|
432
|
+
|
|
433
|
+
> *who owns src/api, and what is frontend waiting on?*
|
|
434
|
+
|
|
435
|
+
Read-only **by construction** — the server opens nothing for writing, and a test
|
|
436
|
+
asserts that no tool in it mutates anything. It can draft the exact change a
|
|
437
|
+
request would make; applying it is a command a person runs. Zero dependencies:
|
|
438
|
+
the official SDK wanted 91 packages for a server that speaks JSON on two file
|
|
439
|
+
descriptors.
|
|
440
|
+
|
|
441
|
+
## Seeing what happened
|
|
442
|
+
|
|
443
|
+
```bash
|
|
444
|
+
seisin log --verdict denied # what the agents tried and could not do
|
|
445
|
+
seisin watch # follow it live
|
|
446
|
+
```
|
|
447
|
+
|
|
448
|
+
```
|
|
449
|
+
04:13:26 allowed frontend write src/web/app.ts
|
|
450
|
+
04:13:26 denied frontend write src/api/server.ts → backend
|
|
451
|
+
04:13:27 denied frontend read .secrets/database.txt → backend
|
|
452
|
+
```
|
|
453
|
+
|
|
454
|
+
One append-only JSONL under `.seisin/`, and that is the whole storage design — no daemon, no
|
|
455
|
+
database. `watch` is a tail. The file is the shared state, so anything that can read it can
|
|
456
|
+
watch it.
|
|
457
|
+
|
|
458
|
+
**The agent cannot reach that file.** `.seisin/` is not in any role's territory, because a
|
|
459
|
+
record the recorded process can edit is not a record. The hook sends each line to `seisin run`
|
|
460
|
+
over a unix socket granted by path, and the parent does the writing — so the only verb
|
|
461
|
+
available from inside the box is *append one line*: no seek, no truncate, no unlink. What a
|
|
462
|
+
hostile agent can still do is send lines, so it can add noise to its own history. What it can
|
|
463
|
+
no longer do is change what is already there.
|
|
464
|
+
|
|
465
|
+
**Two writers, and they see different things.**
|
|
466
|
+
|
|
467
|
+
The **hook** reports the attempt before it happens, which is how an allowed action
|
|
468
|
+
gets recorded at all. It has to be installed: `seisin wire` writes the
|
|
469
|
+
`PreToolUse` entry into this repo's `.claude/settings.json` — the project's,
|
|
470
|
+
never your machine's. It is also text-based and evadable, on purpose.
|
|
471
|
+
|
|
472
|
+
The **kernel** reports what it actually refused. On macOS every Seatbelt denial
|
|
473
|
+
lands in the system log with its operation, its absolute path and the runtime's
|
|
474
|
+
own attribution tag, and `seisin run` reads that stream for the duration of the
|
|
475
|
+
run. Nothing to install, nothing to evade — and it catches exactly what the hook
|
|
476
|
+
missed:
|
|
477
|
+
|
|
478
|
+
```
|
|
479
|
+
$ seisin run frontend -- sh -c 'echo // fix >> src/api/orders.ts'
|
|
480
|
+
sh: src/api/orders.ts: Operation not permitted
|
|
481
|
+
seisin: 1 kernel denial(s) recorded
|
|
482
|
+
|
|
483
|
+
1 pending request(s)
|
|
484
|
+
#1 frontend wants write on src/api/** (owned by backend)
|
|
485
|
+
first asked over src/api/orders.ts
|
|
486
|
+
```
|
|
487
|
+
|
|
488
|
+
That is with no hook installed. Before this, the same run left `Operation not
|
|
489
|
+
permitted` on the terminal and an empty log — which is how four real defects in
|
|
490
|
+
production each cost a night to find: every one of them looked like a file that
|
|
491
|
+
had quietly stopped growing.
|
|
492
|
+
|
|
493
|
+
Skip `wire` and the boundary holds exactly as well, denials are still recorded
|
|
494
|
+
and still queue a request; what you lose is the record of what was *allowed*,
|
|
495
|
+
which is what `init --from-observations` is built out of. `seisin check` says so
|
|
496
|
+
until you run it.
|
|
497
|
+
|
|
498
|
+
**On Linux only the hook writes.** bubblewrap does not log refusals and the
|
|
499
|
+
runtime's substitute is not readable from outside it, so `seisin run` says so
|
|
500
|
+
once and records nothing from the kernel. [The ask is
|
|
501
|
+
upstream](docs/upstream/cli-violations.md).
|
|
502
|
+
|
|
503
|
+
## How it holds
|
|
504
|
+
|
|
505
|
+
| layer | what it does | can it be talked around? |
|
|
506
|
+
|---|---|---|
|
|
507
|
+
| your agent's `allow`/`deny` rules | match the command string before it runs | yes — `/bin/rm`, a heredoc, a child process |
|
|
508
|
+
| **seisin** | decides what to ask for, and names the owner | it doesn't enforce, it explains |
|
|
509
|
+
| **sandbox-runtime → Seatbelt / bubblewrap** | the OS refuses the syscall | no |
|
|
510
|
+
|
|
511
|
+
```
|
|
512
|
+
Write tool · Bash `rm -rf` · /bin/rm · python -c "open(…)" · a grandchild
|
|
513
|
+
│
|
|
514
|
+
│ however it was spelled, it arrives as one syscall
|
|
515
|
+
▼
|
|
516
|
+
KERNEL
|
|
517
|
+
│
|
|
518
|
+
│ against the policy `seisin run` installed
|
|
519
|
+
▼
|
|
520
|
+
inside this role's territory?
|
|
521
|
+
│ │
|
|
522
|
+
yes no
|
|
523
|
+
│ │
|
|
524
|
+
▼ ▼
|
|
525
|
+
done Operation not permitted
|
|
526
|
+
│
|
|
527
|
+
│ and, on a separate path, the hook
|
|
528
|
+
▼
|
|
529
|
+
"belongs to backend"
|
|
530
|
+
explanation — never the boundary
|
|
531
|
+
```
|
|
532
|
+
|
|
533
|
+
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.
|
|
534
|
+
|
|
535
|
+
Verified by the test suite, which runs real commands in a real sandbox:
|
|
536
|
+
|
|
537
|
+
```
|
|
538
|
+
✔ a role reads the key it declares
|
|
539
|
+
✔ a role cannot read another role's key
|
|
540
|
+
✔ each role reads its own, so the deny is not just blanket
|
|
541
|
+
✔ a role writes inside its territory
|
|
542
|
+
✔ a role cannot write outside it
|
|
543
|
+
✔ reading another role's code still works
|
|
544
|
+
✔ the boundary survives a grandchild process
|
|
545
|
+
✔ an absolute path does not walk around the rule
|
|
546
|
+
```
|
|
547
|
+
|
|
548
|
+
## How it protects secrets
|
|
549
|
+
|
|
550
|
+
Four ways out of a process, and seisin closes three of them. The fourth is named below,
|
|
551
|
+
because a security tool that lists only its wins is not one.
|
|
552
|
+
|
|
553
|
+
| way out | closed by | how |
|
|
554
|
+
|---|---|---|
|
|
555
|
+
| reading another role's key file | the kernel | the key directory is denied, each declared key re-allowed. Survives a grandchild process and an absolute path |
|
|
556
|
+
| a key riding in the environment | the launcher | the child's environment is **built, not inherited**. Measured before this existed: 93 variables crossed into every turn, a planted token among them |
|
|
557
|
+
| spilling a key the role does hold | the launcher | the value is masked on stdout and stderr, including when it lands split across two buffers |
|
|
558
|
+
| sending it somewhere | the kernel | egress is allow-only. `curl` to a domain you did not list gets nothing |
|
|
559
|
+
| **a key stored outside the declared directories** | **nothing** | `seisin scan` finds them so you know what is not covered |
|
|
560
|
+
|
|
561
|
+
```toml
|
|
562
|
+
[keys]
|
|
563
|
+
dir = [".secrets", "config/credentials"] # one directory or several
|
|
564
|
+
|
|
565
|
+
[roles.frontend]
|
|
566
|
+
keys = ["netlify-token.txt"] # everything else in those directories is denied
|
|
567
|
+
env = ["BUILD_ID"] # everything else in the environment is dropped
|
|
568
|
+
```
|
|
569
|
+
|
|
570
|
+
**If your agent runs hooks of its own, they need a line here too.** Whatever a hook reads to learn which role it is gets dropped with everything else, and the symptom is two layers disagreeing about one file — your hook refusing a write that seisin just allowed, and the lower one is the one that is right — until the variable is named in `env`.
|
|
571
|
+
|
|
572
|
+
**Two things this deliberately does not claim.** Redaction masks a literal value on the way
|
|
573
|
+
through the launcher — a key written straight to a file never passes through it, and neither
|
|
574
|
+
does one the agent base64'd first. It narrows a careless print; it is not a containment
|
|
575
|
+
boundary. And to mask a value seisin must read it, so a role's own keys pass through this
|
|
576
|
+
process in memory. `[runtime] redact = false` turns that off.
|
|
577
|
+
|
|
578
|
+
## Scratch space, and what it costs
|
|
579
|
+
|
|
580
|
+
Territory alone is correct and unusable. An agent writes session state under its own config
|
|
581
|
+
directory and its tools write to the temp dir, so a policy of *your folders and nothing else*
|
|
582
|
+
stops the agent before it starts. Every role therefore also gets:
|
|
583
|
+
|
|
584
|
+
```toml
|
|
585
|
+
[runtime]
|
|
586
|
+
# the default, in full — `~/.local/{share,state}` is where an agent that is neither
|
|
587
|
+
# claude nor codex keeps its log, and one that cannot write it does not fail a task,
|
|
588
|
+
# it fails to start
|
|
589
|
+
writes = ["~/.claude", "~/.codex", "~/.local/share", "~/.local/state",
|
|
590
|
+
"~/.cache", "$TMPDIR", "/tmp"]
|
|
591
|
+
```
|
|
592
|
+
|
|
593
|
+
**Writing this key replaces that list; it does not add to it.** So a `[runtime] writes` copied
|
|
594
|
+
from somewhere to grant one extra path silently drops the six it did not mention. Start from the
|
|
595
|
+
list above and append.
|
|
596
|
+
|
|
597
|
+
Two things follow, and both are the kind of thing you want to hear from the tool rather than
|
|
598
|
+
discover:
|
|
599
|
+
|
|
600
|
+
- **Scratch is shared.** Every role can write it, so two agents can reach each other's temp
|
|
601
|
+
files. If your repo lives inside the temp dir, territory does not hold at all — `seisin check`
|
|
602
|
+
says so out loud when it detects that.
|
|
603
|
+
- **The home directory itself is never granted.** Only those named subdirectories. A grant that
|
|
604
|
+
reached `~` would hand over the shell profile, the ssh config, and every dotfile with a token
|
|
605
|
+
in it. There is a test that fails if that ever changes.
|
|
606
|
+
- **Reading your home is a different question, and by default it is open.** None of the above
|
|
607
|
+
stops a role from *reading* `~/.ssh`, `~/.aws/credentials`, `~/.npmrc` or
|
|
608
|
+
`~/.config/gh/hosts.yml`. That is the deliberate trade in the table above — an agent that
|
|
609
|
+
cannot read the machine cannot work — and `seisin check` now says it on every run rather than
|
|
610
|
+
leaving you to find out.
|
|
611
|
+
|
|
612
|
+
Set `writes = []` to opt out and find out why it is there.
|
|
613
|
+
|
|
614
|
+
### Closing your credentials, without signing the agent out
|
|
615
|
+
|
|
616
|
+
```toml
|
|
617
|
+
[runtime]
|
|
618
|
+
isolate = "credentials" # ~/.ssh, ~/.aws, ~/.npmrc, ~/.config and the rest go dark
|
|
619
|
+
```
|
|
620
|
+
|
|
621
|
+
Measured, on the same machine, same policy, same command:
|
|
622
|
+
|
|
623
|
+
| | `~/.ssh` `~/.aws` `~/.npmrc` `~/.config/gh` | `~/.claude` | `claude -p` |
|
|
624
|
+
|---|---|---|---|
|
|
625
|
+
| default | open | open | **OK** |
|
|
626
|
+
| `isolate = "credentials"` | **closed** | open | **OK** |
|
|
627
|
+
| `isolate = "home"` | **closed** | closed | `Not logged in` |
|
|
628
|
+
|
|
629
|
+
`"home"` also gives each role its own `HOME`, `TMPDIR` and XDG directories, so one role cannot
|
|
630
|
+
read the session another's CLI just wrote. It is the stronger claim and it has a real price:
|
|
631
|
+
**every CLI in the box sees an empty home and asks you to log in again.** That is not this
|
|
632
|
+
sandbox being strict — on macOS the agent's credential is in the login keychain, the keychain is
|
|
633
|
+
found through `$HOME`, and `HOME=/empty claude -p` reproduces the same message with no sandbox
|
|
634
|
+
involved at all.
|
|
635
|
+
|
|
636
|
+
Which of the two you want is your threat model, so neither is a default. `"credentials"` is the
|
|
637
|
+
one you can turn on today on a machine you are already working on.
|
|
638
|
+
|
|
639
|
+
## What it is not
|
|
640
|
+
|
|
641
|
+
- **Not a sandbox.** [`sandbox-runtime`](https://github.com/anthropics/sandbox-runtime) is the sandbox, and it is Anthropic's. seisin writes its settings and explains its refusals.
|
|
642
|
+
- **Not an orchestrator.** It does not run your agents, schedule them, or merge their work. It runs one command as one role.
|
|
643
|
+
- **Not a secret manager.** It scopes *reads* of files you already have. Where those files come from is your problem.
|
|
644
|
+
- **Not what a single agent needs first.** If only one agent ever touches the repo,
|
|
645
|
+
*whose file is this* has one answer, and the ownership half of this tool is dead
|
|
646
|
+
weight. Turn on your agent's own sandboxing instead — Claude Code has it built in,
|
|
647
|
+
over this same runtime, with nothing to install.
|
|
648
|
+
|
|
649
|
+
What is left over for one agent is real but narrow: the environment gets built
|
|
650
|
+
rather than inherited (measured on one machine: **15 variables kept, 60 dropped**,
|
|
651
|
+
among them `GITHUB_TOKEN` and the AWS pair), reads of the declared key directory
|
|
652
|
+
are denied to everything, and the network allowlist covers the whole process
|
|
653
|
+
rather than one tool. Worth it if that is a gap you actually have. seisin starts
|
|
654
|
+
earning its setup at **two** agents, and that is where it was designed to be.
|
|
655
|
+
|
|
656
|
+
## Why not a container
|
|
657
|
+
|
|
658
|
+
A fair question, and the honest answer is that a container is *more* isolation
|
|
659
|
+
than this. If what you need is to run something you do not trust at all, run it
|
|
660
|
+
in a VM — [Kaiden](https://openkaiden.ai), [Containarium](https://containarium.dev)
|
|
661
|
+
and a growing shelf of others do that well, and this does not try to.
|
|
662
|
+
|
|
663
|
+
They answer **"can this process reach the host?"** seisin answers a different
|
|
664
|
+
question:
|
|
665
|
+
|
|
666
|
+
```
|
|
667
|
+
container / microVM one box, one boundary, everything inside it is equal
|
|
668
|
+
seisin one repo, several roles, and a boundary between them
|
|
669
|
+
```
|
|
670
|
+
|
|
671
|
+
Put two agents in one container and they are back where they started: the same
|
|
672
|
+
files, the same keys, no answer to *whose is it*. That question is not about
|
|
673
|
+
isolation strength at all — it is about ownership, and it is the one thing an
|
|
674
|
+
operating system cannot know for you.
|
|
675
|
+
|
|
676
|
+
Which is also why this is thin. Enforcement is
|
|
677
|
+
[`@anthropic-ai/sandbox-runtime`](https://github.com/anthropics/sandbox-runtime)
|
|
678
|
+
asking the kernel; seisin is the layer that decides what to ask for and says
|
|
679
|
+
whose file it was when the answer is no. The two compose — put seisin inside a
|
|
680
|
+
container if you want both, and the roles still hold.
|
|
681
|
+
|
|
682
|
+
| | what it answers |
|
|
683
|
+
|---|---|
|
|
684
|
+
| VM / microVM | can this reach the host |
|
|
685
|
+
| container | can this reach the host, cheaply |
|
|
686
|
+
| **seisin** | **whose file is this, and may this role change it** |
|
|
687
|
+
|
|
688
|
+
## Prior art
|
|
689
|
+
|
|
690
|
+
This space already has good work, and seisin is not the first thing here:
|
|
691
|
+
|
|
692
|
+
- [`anthropics/sandbox-runtime`](https://github.com/anthropics/sandbox-runtime) — the enforcement. seisin is a thin thing on top of a serious one.
|
|
693
|
+
- [`kornysietsma/claude-code-permissions-hook`](https://github.com/kornysietsma/claude-code-permissions-hook) — granular `PreToolUse` rules, one global policy.
|
|
694
|
+
- [`XuebinMa/agent-guard`](https://github.com/XuebinMa/agent-guard) — a permission-enforcement SDK, also one global policy.
|
|
695
|
+
- *Directory ownership* is recommended in half the multi-agent write-ups. As far as I could find, nobody enforces it, and nobody names the owner in the refusal. That gap is the reason for this repo.
|
|
696
|
+
|
|
697
|
+
## Status
|
|
698
|
+
|
|
699
|
+
`0.1.0`, 225 tests, of which **18 need `@anthropic-ai/sandbox-runtime` installed**
|
|
700
|
+
and run real commands through the real kernel — and CI fails if the sandbox half *skips*, because
|
|
701
|
+
a green run that quietly tested nothing looks exactly like a real one. That is not hypothetical:
|
|
702
|
+
those eighteen skipped on Linux for a day, behind a runtime check that looked for the global
|
|
703
|
+
install and missed the bundled one, and hid a defect that broke `seisin run` on that platform
|
|
704
|
+
entirely. **225/225 on macOS 15 and on Debian 12.15 with bubblewrap 0.8.0**, nothing skipped on
|
|
705
|
+
either, plus Node 18/20/22 in CI.
|
|
706
|
+
[Which claim was measured where](docs/what-it-has-been-put-through.md#where-each-claim-was-actually-run).
|
|
707
|
+
The config format may still move before `1.0` — if it does, `seisin check` will
|
|
708
|
+
tell you what changed.
|
|
709
|
+
|
|
710
|
+
**What is genuinely open**, so nobody spends an afternoon on something that is
|
|
711
|
+
already done:
|
|
712
|
+
|
|
713
|
+
| | |
|
|
714
|
+
|---|---|
|
|
715
|
+
| **Windows** | the runtime has a backend. seisin has never been pointed at it, and no CI runner covers it |
|
|
716
|
+
| **Deleting inside your own territory** | not covered, and not coverable here — [the ask is upstream](docs/upstream/denyUnlink.md), with the measurement behind it and [a demo](docs/demo/) |
|
|
717
|
+
| **`init` heuristics** | it reads `.claude/agents/` then `CODEOWNERS`. Every other convention is a guess nobody has made yet |
|
|
718
|
+
|
|
719
|
+
Issues and pull requests welcome. If you are reporting something that got past
|
|
720
|
+
the boundary, `seisin log --verdict denied` and the `seisin.toml` are the two
|
|
721
|
+
things that make it reproducible.
|
|
722
|
+
|
|
723
|
+
## The name
|
|
724
|
+
|
|
725
|
+
A deed says who owns land. **Seisin** is the older idea underneath: who is actually standing on it.
|
|
726
|
+
|
|
727
|
+
That is the question this tool answers. Not *may this be written* — the kernel settles that — but
|
|
728
|
+
*whose is it*, which is the one that tells an agent what to do next.
|
|
729
|
+
|
|
730
|
+
## License
|
|
731
|
+
|
|
732
|
+
MIT
|