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 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.0`, 225 tests, of which **18 need `@anthropic-ai/sandbox-runtime` installed**
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. **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.
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 — [the ask is upstream](docs/upstream/denyUnlink.md), with the measurement behind it and [a demo](docs/demo/) |
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "seisin",
3
- "version": "0.1.0",
3
+ "version": "0.1.1",
4
4
  "description": "A permission layer for AI coding agents: role-scoped folders and keys, enforced by the OS, and when it blocks it tells you whose file it was.",
5
5
  "keywords": [
6
6
  "ai-agents",
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
- const env = {};
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 equipo/" is the fact, and the forty-three names are the footnote.
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){