seisin 0.1.0 → 0.1.1
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 +77 -7
- package/package.json +1 -1
- package/src/env.js +35 -1
- package/src/index.js +1 -1
- package/ui/index.html +1 -1
package/README.md
CHANGED
|
@@ -31,7 +31,7 @@ $ seisin run frontend -- sh -c 'echo // fix >> src/api/orders.ts'
|
|
|
31
31
|
|
|
32
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
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).
|
|
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) as [issue #582](https://github.com/anthropics/sandbox-runtime/issues/582).
|
|
35
35
|
|
|
36
36
|
The agent can ask directly too, from inside the box:
|
|
37
37
|
|
|
@@ -54,7 +54,7 @@ seisin run frontend -- claude -p "…" # run an agent inside its own territory
|
|
|
54
54
|
|
|
55
55
|
On Linux, [three system packages first](#install). Everything below is why it works and where it does not.
|
|
56
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)
|
|
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) · [Day one](#what-will-look-like-a-bug-on-the-first-day) · [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
58
|
|
|
59
59
|
---
|
|
60
60
|
|
|
@@ -196,6 +196,61 @@ role, including the ones whose whole job is to have nothing to reach.
|
|
|
196
196
|
|
|
197
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
198
|
|
|
199
|
+
## What will look like a bug on the first day
|
|
200
|
+
|
|
201
|
+
Four shapes that a correct policy still produces, and that a reader reads as breakage. All
|
|
202
|
+
four come out of one production window — four agent cells, ~370 confined turns over three
|
|
203
|
+
days, 1,409 kernel refusals — where none of them was the boundary misbehaving. They are here
|
|
204
|
+
because every one of them cost someone an afternoon before it cost this paragraph.
|
|
205
|
+
|
|
206
|
+
**1 · Granting a file does not grant its neighbours.** The kernel grants exactly the path you
|
|
207
|
+
wrote. Anything a tool creates *beside* it — a database journal, a lock file, the temporary
|
|
208
|
+
file of an atomic write — is a sibling, and a sibling is outside the grant. The failure then
|
|
209
|
+
arrives in the tool's own words rather than as a permission error, which is why it survives a
|
|
210
|
+
policy review: the policy looks right because it *is* right about the file you named.
|
|
211
|
+
|
|
212
|
+
`seisin check` says so before you hit it:
|
|
213
|
+
|
|
214
|
+
```
|
|
215
|
+
N role(s) grant individual files rather than folders: reviewer (3 of 11)
|
|
216
|
+
```
|
|
217
|
+
|
|
218
|
+
Grant the folder where the tool needs neighbours. `seisin check <role>` names the paths.
|
|
219
|
+
|
|
220
|
+
**The Claude Code case is worth stating outright**, because it is the agent most people will
|
|
221
|
+
point this at first: `Write` and `Edit` do not write the file you named. They write
|
|
222
|
+
`<name>.tmp.<pid>.<random>` next to it and rename over the target. So a grant on a literal
|
|
223
|
+
file path is both correct and useless — `seisin explain` answers *allowed* about the
|
|
224
|
+
destination, the kernel answers `Operation not permitted` about the temporary, and neither is
|
|
225
|
+
lying. Grant the directory.
|
|
226
|
+
|
|
227
|
+
**2 · `.git/index.lock`, in a repo the role does not own.** In that window this single
|
|
228
|
+
filename was **1,071 of the 1,409 refusals** — three quarters of everything the kernel said no
|
|
229
|
+
to. A role runs `git status`, git tries to refresh the index of a checkout that belongs to
|
|
230
|
+
another role, and the lock write is refused.
|
|
231
|
+
|
|
232
|
+
It is noise, not a wall, and the distinction is measurable: inside the box
|
|
233
|
+
`git status --short --branch` and `git log` still exit 0. Git cannot refresh its index cache
|
|
234
|
+
and carries on without it. If a role genuinely needs to commit, give it **its own worktree**
|
|
235
|
+
rather than a share of the main index — two agents staging into one index corrupt each other
|
|
236
|
+
regardless of who is allowed to write it.
|
|
237
|
+
|
|
238
|
+
**3 · Worktrees are two paths for one repo.** A policy names the canonical checkout; the
|
|
239
|
+
process is running in a linked worktree somewhere else entirely, and to the kernel that is a
|
|
240
|
+
different place. seisin resolves the pair rather than making you write both — `seisin whose`
|
|
241
|
+
and `seisin explain` answer the same thing from either side, and say which checkout they are
|
|
242
|
+
talking about. Worth knowing it is handled, because the symptom when a tool does *not* handle
|
|
243
|
+
it is a role denied inside its own territory.
|
|
244
|
+
|
|
245
|
+
**4 · SQLite reports a refused write as `attempt to write a readonly database`** — see
|
|
246
|
+
[below](#which-agents-it-has-been-run-with); it is the one that sends you to debug the
|
|
247
|
+
database instead of the policy, and the reason `seisin wire` exists.
|
|
248
|
+
|
|
249
|
+
Where these stand: the sibling case is a `check` warning instead of a surprise, the worktree
|
|
250
|
+
case is resolved in the tool, the SQLite case is named by the hook, and the git one is
|
|
251
|
+
friction that gets logged rather than silenced — a boundary that hides what it refused is the
|
|
252
|
+
thing this project exists to argue against.
|
|
253
|
+
|
|
199
254
|
## Which agents it has been run with
|
|
200
255
|
|
|
201
256
|
seisin wraps a process, so in principle it works with any agent that runs as one — CLI or not. In
|
|
@@ -498,7 +553,10 @@ until you run it.
|
|
|
498
553
|
**On Linux only the hook writes.** bubblewrap does not log refusals and the
|
|
499
554
|
runtime's substitute is not readable from outside it, so `seisin run` says so
|
|
500
555
|
once and records nothing from the kernel. [The ask is
|
|
501
|
-
upstream](docs/upstream/cli-violations.md)
|
|
556
|
+
upstream](docs/upstream/cli-violations.md) as [issue #582][582], filed 2026-09-20
|
|
557
|
+
against 0.0.77, open and unanswered.
|
|
558
|
+
|
|
559
|
+
[582]: https://github.com/anthropics/sandbox-runtime/issues/582
|
|
502
560
|
|
|
503
561
|
## How it holds
|
|
504
562
|
|
|
@@ -569,6 +627,17 @@ env = ["BUILD_ID"] # everything else in the environment is dropped
|
|
|
569
627
|
|
|
570
628
|
**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
629
|
|
|
630
|
+
**What this bought, measured rather than argued.** Over the same production window — four
|
|
631
|
+
agent cells, ~370 confined turns, three days — **every refused read was a read of something
|
|
632
|
+
the policy had declared a key.** 148 of them, no exceptions, from five different roles, and
|
|
633
|
+
not one was doing anything unusual: they were running searches that swept a repository root.
|
|
634
|
+
That is how a key gets read without anyone deciding it should, and it is the whole case for
|
|
635
|
+
declaring the key directory rather than trusting the instruction not to look.
|
|
636
|
+
|
|
637
|
+
Refused *writes* were a different story, and both halves are worth reading: those were
|
|
638
|
+
ordinary collisions between roles, the boundary keeping two agents out of each other's work.
|
|
639
|
+
It is the reads where the policy was the only thing there.
|
|
640
|
+
|
|
572
641
|
**Two things this deliberately does not claim.** Redaction masks a literal value on the way
|
|
573
642
|
through the launcher — a key written straight to a file never passes through it, and neither
|
|
574
643
|
does one the agent base64'd first. It narrows a careless print; it is not a containment
|
|
@@ -696,13 +765,14 @@ This space already has good work, and seisin is not the first thing here:
|
|
|
696
765
|
|
|
697
766
|
## Status
|
|
698
767
|
|
|
699
|
-
`0.1.
|
|
768
|
+
`0.1.1`, 228 tests, of which **18 need `@anthropic-ai/sandbox-runtime` installed**
|
|
700
769
|
and run real commands through the real kernel — and CI fails if the sandbox half *skips*, because
|
|
701
770
|
a green run that quietly tested nothing looks exactly like a real one. That is not hypothetical:
|
|
702
771
|
those eighteen skipped on Linux for a day, behind a runtime check that looked for the global
|
|
703
772
|
install and missed the bundled one, and hid a defect that broke `seisin run` on that platform
|
|
704
|
-
entirely. **
|
|
705
|
-
either,
|
|
773
|
+
entirely. **228/228 on macOS 15 and on `ubuntu-latest` under bubblewrap**, nothing skipped on
|
|
774
|
+
either, Node 18/20/22 in CI at every push — and 225/225 the same way on Debian 12.15
|
|
775
|
+
with bubblewrap 0.8.0, the last time the suite was run in Docker.
|
|
706
776
|
[Which claim was measured where](docs/what-it-has-been-put-through.md#where-each-claim-was-actually-run).
|
|
707
777
|
The config format may still move before `1.0` — if it does, `seisin check` will
|
|
708
778
|
tell you what changed.
|
|
@@ -713,7 +783,7 @@ already done:
|
|
|
713
783
|
| | |
|
|
714
784
|
|---|---|
|
|
715
785
|
| **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 —
|
|
786
|
+
| **Deleting inside your own territory** | not covered, and not coverable here — the ask is upstream as [issue #545](https://github.com/anthropics/sandbox-runtime/issues/545), open and unanswered since 2026-09-13, [with the measurement behind it](docs/upstream/denyUnlink.md) and [a demo](docs/demo/) |
|
|
717
787
|
| **`init` heuristics** | it reads `.claude/agents/` then `CODEOWNERS`. Every other convention is a guess nobody has made yet |
|
|
718
788
|
|
|
719
789
|
Issues and pull requests welcome. If you are reporting something that got past
|
package/package.json
CHANGED
package/src/env.js
CHANGED
|
@@ -28,6 +28,37 @@ export const BASE = [
|
|
|
28
28
|
"SSL_CERT_FILE", "SSL_CERT_DIR", "NODE_EXTRA_CA_CERTS",
|
|
29
29
|
];
|
|
30
30
|
|
|
31
|
+
/**
|
|
32
|
+
* Set for the child whether or not the parent has them.
|
|
33
|
+
*
|
|
34
|
+
* `BASE` is an allowlist: it decides what *crosses*, so a variable the parent
|
|
35
|
+
* never set cannot reach the child through it. These are the ones the sandbox
|
|
36
|
+
* needs **set**, not forwarded.
|
|
37
|
+
*
|
|
38
|
+
* `GIT_OPTIONAL_LOCKS=0` is the whole list, and it is here because of what the
|
|
39
|
+
* denial log looks like without it. Measured on a real portfolio: of the last
|
|
40
|
+
* 60 refusals, **58 were `.git/index.lock`** — 97% — and most carried no owner
|
|
41
|
+
* at all, so not one of them was a territory question. `git status` and
|
|
42
|
+
* `git diff` refresh the index as a courtesy, refreshing it takes the lock, and
|
|
43
|
+
* a role reading a repo it does not own trips the boundary while doing nothing
|
|
44
|
+
* but looking. The human approving those is arbitrating a mutex.
|
|
45
|
+
*
|
|
46
|
+
* Git's own switch turns that courtesy off, and only that. Measured here rather
|
|
47
|
+
* than read from the manual: with the variable set, `status` leaves `.git/index`
|
|
48
|
+
* untouched (same mtime across a forced refresh), while `add`, `commit` and
|
|
49
|
+
* `checkout -b` still take the locks they require and still work — the commit
|
|
50
|
+
* contains the staged file.
|
|
51
|
+
*
|
|
52
|
+
* So this **removes the need for a grant** instead of widening one. The
|
|
53
|
+
* boundary does not move; the noise against it stops. That distinction is the
|
|
54
|
+
* point: the alternative on the table was declaring git's machinery as
|
|
55
|
+
* territory in every role, which is more policy describing less confinement.
|
|
56
|
+
*
|
|
57
|
+
* A role that wants the old behaviour names `GIT_OPTIONAL_LOCKS` in its `env`
|
|
58
|
+
* and sets it in the parent — the loop below then overwrites this.
|
|
59
|
+
*/
|
|
60
|
+
export const DEFAULTS = { GIT_OPTIONAL_LOCKS: "0" };
|
|
61
|
+
|
|
31
62
|
/**
|
|
32
63
|
* Names that must never ride along, even if a role asks for them by pattern.
|
|
33
64
|
*
|
|
@@ -47,7 +78,10 @@ const NEVER = /(^|_)(KEY|TOKEN|SECRET|PASSWORD|PASSWD|CREDENTIAL|AUTH|SESSION|CO
|
|
|
47
78
|
*/
|
|
48
79
|
export function buildEnv(parent, role, extra = []) {
|
|
49
80
|
const wanted = new Set([...BASE, ...extra, ...(role.env ?? [])]);
|
|
50
|
-
|
|
81
|
+
// Seeded, not merged afterwards: the loop below must be able to overwrite a
|
|
82
|
+
// default when the role named it and the parent carries it. Merging the other
|
|
83
|
+
// way round would make the opt-out silently do nothing.
|
|
84
|
+
const env = { ...DEFAULTS };
|
|
51
85
|
const dropped = [];
|
|
52
86
|
|
|
53
87
|
for (const [name, value] of Object.entries(parent)) {
|
package/src/index.js
CHANGED
|
@@ -23,7 +23,7 @@ export { covers, ownersOf, keyHolders, explain } from "./owners.js";
|
|
|
23
23
|
|
|
24
24
|
/** A policy, resolved into sandbox settings and a child environment. */
|
|
25
25
|
export { settingsFor, RUNTIME_WRITES, expand } from "./srt.js";
|
|
26
|
-
export { buildEnv, BASE as BASE_ENV } from "./env.js";
|
|
26
|
+
export { buildEnv, BASE as BASE_ENV, DEFAULTS as DEFAULT_ENV } from "./env.js";
|
|
27
27
|
|
|
28
28
|
/** The report `check` prints, without the printing. */
|
|
29
29
|
export { inspect, sharedPaths } from "./inspect.js";
|
package/ui/index.html
CHANGED
|
@@ -457,7 +457,7 @@ button.btn:active,button.tag:active,button.add:active,button.sw:active{transform
|
|
|
457
457
|
}
|
|
458
458
|
// Below the threshold there is nothing to summarise and a fold would only hide what
|
|
459
459
|
// already fits. Above it, the folder counts ARE the reading: "this role writes 43
|
|
460
|
-
// files under
|
|
460
|
+
// files under src/" is the fact, and the forty-three names are the footnote.
|
|
461
461
|
if(r.writes.length<=FOLD_AT||OPEN[r.name]){
|
|
462
462
|
r.writes.forEach(function(p,i){ t2.appendChild(tagFor(p,i)); });
|
|
463
463
|
if(r.writes.length>FOLD_AT){
|