seisin 0.3.0 → 0.4.2
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 +12 -16
- package/package.json +1 -1
- package/src/cli.js +1 -1
- package/src/commands/init.js +13 -5
- package/src/commands/log.js +11 -4
- package/src/commands/requests.js +26 -18
- package/src/commands/run.js +15 -2
- package/src/commands/ui.js +59 -7
- package/src/config.js +79 -7
- package/src/hook.js +50 -0
- package/src/intake.js +32 -1
- package/src/log.js +194 -16
- package/src/mcp.js +63 -24
- package/src/notify.js +3 -3
- package/src/owners.js +140 -15
- package/src/render.js +13 -2
- package/src/requests.js +90 -13
- package/src/review.js +15 -7
- package/src/serve.js +68 -24
- package/src/surface.js +15 -1
- package/src/uilink.js +55 -0
- package/ui/index.html +30 -12
package/README.md
CHANGED
|
@@ -38,7 +38,7 @@ says why, and a test holds one half of it.
|
|
|
38
38
|
|
|
39
39
|
**The honest parts** · [How it holds](#how-it-holds) · [What it is not](#what-it-is-not) · [Why not a container](#why-not-a-container) · [Prior art](#prior-art) · [Status](#status)
|
|
40
40
|
|
|
41
|
-
**Deeper** · [Keys](docs/keys.md) · [Agents](docs/agents.md) · [Day one](docs/first-day.md) · [Scratch](docs/scratch.md) · [Glossary](docs/glossary.md) · [What it has been put through](docs/what-it-has-been-put-through.md) · [Field notes](docs/field-notes.md) · [Decisions](docs/decisions.md) · [Permission requests](docs/permission-requests.md) · [Contributing](CONTRIBUTING.md)
|
|
41
|
+
**Deeper** · [Keys](docs/keys.md) · [Agents](docs/agents.md) · [Day one](docs/first-day.md) · [Scratch](docs/scratch.md) · [Glossary](docs/glossary.md) · [What it has been put through](docs/what-it-has-been-put-through.md) · [Field notes](docs/field-notes.md) · [OpenShell compatibility](docs/openshell.md) · [Decisions](docs/decisions.md) · [nono backend contract](docs/nono-backend.md) · [Permission requests](docs/permission-requests.md) · [Contributing](CONTRIBUTING.md)
|
|
42
42
|
|
|
43
43
|
</details>
|
|
44
44
|
|
|
@@ -161,8 +161,8 @@ fixtures:
|
|
|
161
161
|
|
|
162
162
|
And three things that outweigh all of the above:
|
|
163
163
|
|
|
164
|
-
1. **It is
|
|
165
|
-
|
|
164
|
+
1. **It is young and has one author.** The first public release was September 2026, and the
|
|
165
|
+
implementation is still moving quickly. "Secure" is not a word earned by size or test count.
|
|
166
166
|
2. **The enforcement is someone else's beta.** `sandbox-runtime` describes itself as a
|
|
167
167
|
research preview with an evolving API. A hole there is a hole here.
|
|
168
168
|
3. **Two platforms tested, by one person, on one machine each.**
|
|
@@ -672,7 +672,7 @@ This space already has good work, and seisin is not the first thing here:
|
|
|
672
672
|
- [`anthropics/sandbox-runtime`](https://github.com/anthropics/sandbox-runtime) — the enforcement. seisin is a thin thing on top of a serious one.
|
|
673
673
|
- [`kornysietsma/claude-code-permissions-hook`](https://github.com/kornysietsma/claude-code-permissions-hook) — granular `PreToolUse` rules, one global policy.
|
|
674
674
|
- [`XuebinMa/agent-guard`](https://github.com/XuebinMa/agent-guard) — a permission-enforcement SDK, also one global policy.
|
|
675
|
-
- [`NVIDIA/OpenShell`](https://github.com/NVIDIA/openshell) — a runtime that sandboxes an agent with Landlock and seccomp, under a declarative policy. Serious, and the closest thing here by weight.
|
|
675
|
+
- [`NVIDIA/OpenShell`](https://github.com/NVIDIA/openshell) — a runtime that sandboxes an agent with Landlock and seccomp, under a declarative policy. Serious, and the closest thing here by weight. It was evaluated as a possible backend; [the measured compatibility decision](docs/openshell.md) explains why it is currently an execution target candidate rather than a drop-in replacement.
|
|
676
676
|
- [`dredozubov/hazmat`](https://github.com/dredozubov/hazmat) — runs the agent as a different system user, with `pf` rules and snapshots.
|
|
677
677
|
- [`nolabs-ai/nono`](https://github.com/nolabs-ai/nono) — a kernel sandbox for agents on the host (Seatbelt, Landlock), with per-tool child sandboxes, a credential proxy with endpoint filtering, approval webhooks and a tamper-evident audit log. The most complete sandbox here, and on a Mac the one that enforces through the same kernel seisin does. `nono why` explains *which rule* denied something and how to allow it; it has no notion of *whose* it was.
|
|
678
678
|
- *Directory ownership* is recommended in half the multi-agent write-ups. As far as I could find, nobody enforces it, and nobody names the owner in the refusal. That gap is the reason for this repo.
|
|
@@ -692,25 +692,21 @@ They compose rather than compete: the agent seisin confines could be running ins
|
|
|
692
692
|
them.
|
|
693
693
|
|
|
694
694
|
*One detail worth knowing before you compare, and it is about placement rather than quality.
|
|
695
|
-
|
|
696
|
-
|
|
697
|
-
|
|
698
|
-
|
|
699
|
-
|
|
700
|
-
on the host. seisin [says where each of its own claims was
|
|
701
|
-
measured](docs/what-it-has-been-put-through.md#where-each-claim-was-actually-run).*
|
|
702
|
-
|
|
703
|
-
[osm]: https://docs.nvidia.com/openshell/reference/support-matrix
|
|
695
|
+
OpenShell was [evaluated here on 2026-09-27](docs/openshell.md), through its Docker driver on
|
|
696
|
+
an Intel Mac. That confines an agent inside Docker Desktop's Linux VM; seisin confines a
|
|
697
|
+
process on the host through Seatbelt. OpenShell also offers other drivers, including a local
|
|
698
|
+
microVM path on supported Apple Silicon Macs. These are kernel-enforced boundaries, but not
|
|
699
|
+
the same kernel or workspace, and which one you want depends on what you are protecting.*
|
|
704
700
|
|
|
705
701
|
## Status
|
|
706
702
|
|
|
707
|
-
`0.
|
|
703
|
+
`0.4.2`, 500 tests, of which **26 need `@anthropic-ai/sandbox-runtime` installed**
|
|
708
704
|
and run real commands through the real kernel — and CI fails if the sandbox half *skips*, because
|
|
709
705
|
a green run that quietly tested nothing looks exactly like a real one. That is not hypothetical:
|
|
710
706
|
those eighteen skipped on Linux for a day, behind a runtime check that looked for the global
|
|
711
707
|
install and missed the bundled one, and hid a defect that broke `seisin run` on that platform
|
|
712
|
-
entirely. **
|
|
713
|
-
|
|
708
|
+
entirely. **500 tests on macOS 15, 499 passing and 1 skipped** (it is Linux-only) — 2026-09-29;
|
|
709
|
+
`ubuntu-latest` under bubblewrap runs the same suite, Node 18/20/22/24 in CI at every push — and 225/225 the same way on Debian 12.15
|
|
714
710
|
with bubblewrap 0.8.0, the last time the suite was run in Docker.
|
|
715
711
|
[Which claim was measured where](docs/what-it-has-been-put-through.md#where-each-claim-was-actually-run),
|
|
716
712
|
and [what running agents behind it cost the people using it](docs/field-notes.md).
|
package/package.json
CHANGED
package/src/cli.js
CHANGED
|
@@ -57,7 +57,7 @@ ${C.b}seisin${C.off} — give each agent its own folders and its own keys
|
|
|
57
57
|
seisin log verify does the log's hash chain hold
|
|
58
58
|
seisin watch follow the log live
|
|
59
59
|
seisin init [--from-observations] propose a ${CONFIG_NAME} for this repo
|
|
60
|
-
seisin ui [--port n]
|
|
60
|
+
seisin ui [--port n] [--link] open the console, live (--link reopens a running one)
|
|
61
61
|
seisin mcp an MCP server on stdio — read-only
|
|
62
62
|
|
|
63
63
|
Enforcement comes from @anthropic-ai/sandbox-runtime, which asks the OS.
|
package/src/commands/init.js
CHANGED
|
@@ -42,11 +42,19 @@ export function discover(root) {
|
|
|
42
42
|
if (paths.length)
|
|
43
43
|
return {
|
|
44
44
|
source: p,
|
|
45
|
-
roles: [...new Set(paths)].slice(0, 8).map((g, i) =>
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
45
|
+
roles: [...new Set(paths)].slice(0, 8).map((g, i) => {
|
|
46
|
+
// CODEOWNERS anchors a leading "/" at the repo root; seisin's writes
|
|
47
|
+
// are already repo-relative, so a literal "/apps/**" would ask the
|
|
48
|
+
// kernel for the machine's /apps and make `explain` (which reads it as
|
|
49
|
+
// outside the repo) disagree with what the profile grants. Strip the
|
|
50
|
+
// anchor. A trailing "/" means a directory, so it becomes "/**".
|
|
51
|
+
const rel = g.replace(/^\/+/, "");
|
|
52
|
+
return {
|
|
53
|
+
name: rel.replace(/[^A-Za-z0-9]+/g, "-").replace(/^-|-$/g, "") || `role-${i + 1}`,
|
|
54
|
+
writes: [rel.endsWith("/") ? rel + "**" : rel],
|
|
55
|
+
keys: [],
|
|
56
|
+
};
|
|
57
|
+
}),
|
|
50
58
|
};
|
|
51
59
|
}
|
|
52
60
|
|
package/src/commands/log.js
CHANGED
|
@@ -10,7 +10,7 @@
|
|
|
10
10
|
*/
|
|
11
11
|
import { createReadStream, watch as watchDir } from "node:fs";
|
|
12
12
|
import { dirname, relative } from "node:path";
|
|
13
|
-
import { read, logPath, size,
|
|
13
|
+
import { read, logPath, size, verifyLog, LOG_NAME } from "../log.js";
|
|
14
14
|
import { renderEntry, C, out } from "../render.js";
|
|
15
15
|
|
|
16
16
|
const flag = (argv, name) => {
|
|
@@ -43,20 +43,27 @@ export function log(config, argv = []) {
|
|
|
43
43
|
* before the chain existed are reported as such, not as tampering.
|
|
44
44
|
*/
|
|
45
45
|
function verify(config) {
|
|
46
|
-
const r =
|
|
46
|
+
const r = verifyLog(logPath(config.root));
|
|
47
47
|
if (r.lines === 0) {
|
|
48
48
|
out(`\n ${C.dim}nothing recorded yet${C.off}\n\n`);
|
|
49
49
|
return r;
|
|
50
50
|
}
|
|
51
51
|
const head = r.unchained ? `${r.unchained} line(s) from before the chain, then ` : "";
|
|
52
|
+
const segs = r.segments > 1 ? ` across ${r.segments} segments` : "";
|
|
53
|
+
// A dropped entry leaves no hole in the chain, so it is reported on its own
|
|
54
|
+
// line rather than as a break: nothing was tampered with, but something is
|
|
55
|
+
// missing, and an intact chain must not read as a complete record.
|
|
56
|
+
const dropped = r.dropped
|
|
57
|
+
? ` ${C.yellow}${r.dropped} entr${r.dropped === 1 ? "y was" : "ies were"} never written${C.off} another writer held the lock too long; see ${LOG_NAME}.dropped\n`
|
|
58
|
+
: "";
|
|
52
59
|
if (!r.breaks.length) {
|
|
53
|
-
out(`\n ${C.green}intact${C.off} ${head}${r.chained} chained line(s)\n\n`);
|
|
60
|
+
out(`\n ${C.green}intact${C.off} ${head}${r.chained} chained line(s)${segs}\n${dropped}\n`);
|
|
54
61
|
return r;
|
|
55
62
|
}
|
|
56
63
|
out(`\n ${C.red}broken${C.off} ${head}${r.chained} chained line(s), ${r.breaks.length} break(s):\n`);
|
|
57
64
|
for (const b of r.breaks.slice(0, 10))
|
|
58
65
|
out(` line ${b.line}: expected prev ${b.expected}, found ${b.found ?? "none"}\n`);
|
|
59
|
-
out(` ${C.dim}a line was edited, removed or reordered just before each of these${C.off}\n\n`);
|
|
66
|
+
out(` ${C.dim}a line was edited, removed or reordered just before each of these${C.off}\n${dropped}\n`);
|
|
60
67
|
process.exitCode = 1;
|
|
61
68
|
return r;
|
|
62
69
|
}
|
package/src/commands/requests.js
CHANGED
|
@@ -6,10 +6,9 @@
|
|
|
6
6
|
* person acting in a channel the agent does not have. That is the invariant the
|
|
7
7
|
* whole feature rests on — see docs/permission-requests.md.
|
|
8
8
|
*/
|
|
9
|
-
import {
|
|
10
|
-
import { pending, settle, applyGrant, refuseIfBarred, requestsPath, markStale } from "../requests.js";
|
|
9
|
+
import { pending, settle, applyGrant, refuseIfBarred, requestsPath, markStale, editPolicy } from "../requests.js";
|
|
11
10
|
import { read, logPath } from "../log.js";
|
|
12
|
-
import { C, out } from "../render.js";
|
|
11
|
+
import { C, out, safe } from "../render.js";
|
|
13
12
|
|
|
14
13
|
/**
|
|
15
14
|
* Which request you meant — by position, or by the identity it carries.
|
|
@@ -40,16 +39,18 @@ function pick(config, n) {
|
|
|
40
39
|
return req;
|
|
41
40
|
}
|
|
42
41
|
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
42
|
+
// Exactly, never by prefix. A prefix let an id that had just been settled
|
|
43
|
+
// select a newer request whose id merely starts the same (`lib` → `lib-x`),
|
|
44
|
+
// and an agent chooses the paths ids are made of.
|
|
45
|
+
const hit = queue.find((r) => r.key === arg);
|
|
46
|
+
if (hit) return hit;
|
|
47
|
+
const near = queue.filter((r) => r.key.startsWith(arg));
|
|
49
48
|
throw new Error(
|
|
50
|
-
`"${arg}"
|
|
51
|
-
|
|
52
|
-
|
|
49
|
+
`no pending request has the id "${arg}".\n` +
|
|
50
|
+
(near.length
|
|
51
|
+
? ` ${near.length} start with it:\n` + near.slice(0, 5).map((r) => ` ${r.key}`).join("\n") +
|
|
52
|
+
"\n Name one exactly. Choosing for you is the mistake this avoids."
|
|
53
|
+
: " Use its number, or the id printed under it by `seisin requests`."));
|
|
53
54
|
}
|
|
54
55
|
|
|
55
56
|
export function requests(config) {
|
|
@@ -66,11 +67,11 @@ export function renderQueue(queue) {
|
|
|
66
67
|
queue.forEach((r, i) => {
|
|
67
68
|
const owners = r.owners.length ? ` ${C.dim}(owned by ${r.owners.join(", ")})${C.off}` : ` ${C.dim}(unowned)${C.off}`;
|
|
68
69
|
const times = r.times > 1 ? ` ${C.dim}· asked ${r.times}×${C.off}` : "";
|
|
69
|
-
lines.push(` ${C.b}#${i + 1}${C.off} ${r.role} wants ${r.action} on ${C.b}${r.grant}${C.off}${owners}${times}\n`);
|
|
70
|
-
lines.push(` ${C.dim}first asked over ${r.target}${C.off}\n`);
|
|
70
|
+
lines.push(` ${C.b}#${i + 1}${C.off} ${safe(r.role)} wants ${r.action} on ${C.b}${safe(r.grant)}${C.off}${owners}${times}\n`);
|
|
71
|
+
lines.push(` ${C.dim}first asked over ${safe(r.target)}${C.off}\n`);
|
|
71
72
|
// The stable way to name it: a number is a position in a queue that agents
|
|
72
73
|
// are still writing to, and this does not move when the queue does.
|
|
73
|
-
lines.push(` ${C.dim}id ${r.key}${C.off}\n`);
|
|
74
|
+
lines.push(` ${C.dim}id ${safe(r.key)}${C.off}\n`);
|
|
74
75
|
// Marked, not moved: the number above is what `grant <n>` is typed against.
|
|
75
76
|
if (r.stale)
|
|
76
77
|
lines.push(` ${C.dim}not asked again in ${r.stale.runs} runs of ${r.role} since — likely no longer needed${C.off}\n`);
|
|
@@ -123,11 +124,18 @@ export function grant(config, argv = []) {
|
|
|
123
124
|
const reason = i === -1 ? "" : argv[i + 1] ?? "";
|
|
124
125
|
|
|
125
126
|
refuseIfBarred(config, req);
|
|
126
|
-
|
|
127
|
-
|
|
127
|
+
// Under a lock, and written atomically: a concurrent grant (another terminal
|
|
128
|
+
// or the console) must not clobber this one. See editPolicy.
|
|
129
|
+
let changed;
|
|
130
|
+
try {
|
|
131
|
+
({ changed } = editPolicy(config, (before) => applyGrant(before, req, reason)));
|
|
132
|
+
} catch (e) {
|
|
133
|
+
if (e.code === "ELOCKED")
|
|
134
|
+
throw new Error("another grant is in progress — run this again in a moment");
|
|
135
|
+
throw e;
|
|
136
|
+
}
|
|
128
137
|
if (!changed) throw new Error(`${req.role} already has ${req.grant} — nothing to add`);
|
|
129
138
|
|
|
130
|
-
writeFileSync(config.path, toml);
|
|
131
139
|
settle(requestsPath(config.root), req.key, "granted", reason);
|
|
132
140
|
out(
|
|
133
141
|
`\n ${C.green}granted${C.off} ${req.role} → ${C.b}${req.grant}${C.off}\n` +
|
package/src/commands/run.js
CHANGED
|
@@ -75,8 +75,21 @@ export async function run(config, argv) {
|
|
|
75
75
|
*/
|
|
76
76
|
const split = argv.indexOf("--");
|
|
77
77
|
const role = argv[0];
|
|
78
|
-
|
|
79
|
-
|
|
78
|
+
let mine, cmd;
|
|
79
|
+
if (split !== -1) {
|
|
80
|
+
mine = argv.slice(1, split);
|
|
81
|
+
cmd = argv.slice(split + 1);
|
|
82
|
+
} else {
|
|
83
|
+
// No `--`: seisin's own flags are the leading `-options` only. From the
|
|
84
|
+
// first token that is not an option, everything is the command — its own
|
|
85
|
+
// flags are its business. `seisin run x echo hi --observe` used to read
|
|
86
|
+
// `--observe` as seisin's and put the run in observe mode; now that flag
|
|
87
|
+
// belongs to `echo`, and observe needs `seisin run x --observe -- echo hi`.
|
|
88
|
+
let i = 1;
|
|
89
|
+
while (i < argv.length && argv[i].startsWith("-")) i++;
|
|
90
|
+
mine = argv.slice(1, i);
|
|
91
|
+
cmd = argv.slice(i);
|
|
92
|
+
}
|
|
80
93
|
if (!role || cmd.length === 0) throw new Error("usage: seisin run <role> -- <command...>");
|
|
81
94
|
if (!config.roles[role])
|
|
82
95
|
throw new Error(`unknown role "${role}". Known: ${Object.keys(config.roles).join(", ")}`);
|
package/src/commands/ui.js
CHANGED
|
@@ -5,22 +5,72 @@
|
|
|
5
5
|
* foreground command you start and stop, serving one page and one JSON
|
|
6
6
|
* document, on loopback. Nothing in the enforcement path touches it. If it is
|
|
7
7
|
* not running you lose a window, not a boundary.
|
|
8
|
+
*
|
|
9
|
+
* The token that authorizes the page travels only in the link's fragment and is
|
|
10
|
+
* never in an HTTP response (see serve.js). So a lost link cannot be recovered
|
|
11
|
+
* over the wire — only from the terminal that printed it. `writeUiLink` keeps a
|
|
12
|
+
* user-only copy on disk so it can be recovered without reopening that leak:
|
|
13
|
+
* `--link` prints it, and starting over a busy port reopens it instead of just
|
|
14
|
+
* failing. See uilink.js.
|
|
8
15
|
*/
|
|
9
16
|
import { spawnSync } from "node:child_process";
|
|
10
17
|
import { relative } from "node:path";
|
|
11
18
|
import { serve } from "../serve.js";
|
|
19
|
+
import { writeUiLink, readUiLink, clearUiLink } from "../uilink.js";
|
|
12
20
|
import { C, out } from "../render.js";
|
|
13
21
|
|
|
22
|
+
function open(url) {
|
|
23
|
+
const opener = process.platform === "darwin" ? "open" : process.platform === "win32" ? "start" : "xdg-open";
|
|
24
|
+
spawnSync(opener, [url], { stdio: "ignore", shell: process.platform === "win32" });
|
|
25
|
+
}
|
|
26
|
+
|
|
14
27
|
export async function ui(config, argv = []) {
|
|
15
28
|
const i = argv.indexOf("--port");
|
|
16
29
|
const port = i === -1 ? 4178 : Number(argv[i + 1]);
|
|
17
30
|
|
|
18
|
-
|
|
31
|
+
// `seisin ui --link`: print the live link a running console left, and open
|
|
32
|
+
// it, without starting anything. For when the terminal that had it is gone.
|
|
33
|
+
if (argv.includes("--link")) {
|
|
34
|
+
const found = readUiLink(port);
|
|
35
|
+
if (found && found.alive !== false) {
|
|
36
|
+
out(`\n ${C.b}${found.url}${C.off}\n ${C.dim}the link a seisin ui on ${port} is serving. open it here, do not paste it anywhere.${C.off}\n\n`);
|
|
37
|
+
open(found.url);
|
|
38
|
+
return;
|
|
39
|
+
}
|
|
40
|
+
if (found && found.alive === false) clearUiLink(port);
|
|
41
|
+
out(`\n ${C.dim}no seisin ui running on ${port} — start one with: seisin ui${port === 4178 ? "" : ` --port ${port}`}${C.off}\n\n`);
|
|
42
|
+
return;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
let server;
|
|
46
|
+
try {
|
|
47
|
+
server = await serve(config.path, port);
|
|
48
|
+
} catch (e) {
|
|
49
|
+
// A console is already on this port: hand back its live link rather than
|
|
50
|
+
// erroring, which is the case that sends people digging in the terminal.
|
|
51
|
+
if (e.code === "EADDRINUSE") {
|
|
52
|
+
const found = readUiLink(port);
|
|
53
|
+
if (found && found.alive !== false) {
|
|
54
|
+
out(
|
|
55
|
+
`\n ${C.b}${found.url}${C.off}\n` +
|
|
56
|
+
` ${C.dim}a seisin ui is already running on ${port}; reopening its link. ctrl-c that terminal to stop it.${C.off}\n\n`
|
|
57
|
+
);
|
|
58
|
+
open(found.url);
|
|
59
|
+
return;
|
|
60
|
+
}
|
|
61
|
+
// The port is busy but no live console left a link — a stale record, or
|
|
62
|
+
// something else is on the port. Drop the stale one and let the original
|
|
63
|
+
// "port is busy" message stand.
|
|
64
|
+
if (found && found.alive === false) clearUiLink(port);
|
|
65
|
+
}
|
|
66
|
+
throw e;
|
|
67
|
+
}
|
|
68
|
+
|
|
19
69
|
const bound = server.address().port;
|
|
20
|
-
// The token rides in the fragment, which the browser keeps to itself: no
|
|
21
|
-
// request to this server, or to anything else, ever carries it. The page
|
|
22
|
-
// moves it into the tab's storage and strips it from the address bar.
|
|
23
70
|
const url = `http://127.0.0.1:${bound}/#t=${server.seisinToken}`;
|
|
71
|
+
// Recorded before we print, so `--link` and a second `seisin ui` can find it.
|
|
72
|
+
writeUiLink(bound, url);
|
|
73
|
+
|
|
24
74
|
// local_binding opens every port (macOS); local_ports opens the ones it
|
|
25
75
|
// names, on every platform — so only a role that names THIS port counts.
|
|
26
76
|
const reach = Object.values(config.roles)
|
|
@@ -31,15 +81,17 @@ export async function ui(config, argv = []) {
|
|
|
31
81
|
`\n ${C.b}${url}${C.off}\n` +
|
|
32
82
|
` ${C.dim}reading ${relative(process.cwd(), config.path)} and the log, live. ctrl-c to stop.${C.off}\n` +
|
|
33
83
|
` ${C.dim}the link carries this run's token: open it here, do not paste it anywhere.${C.off}\n` +
|
|
84
|
+
` ${C.dim}lost it? this terminal has it above, or run: seisin ui --link${bound === 4178 ? "" : ` --port ${bound}`}${C.off}\n` +
|
|
34
85
|
(reach.length
|
|
35
86
|
? ` ${C.yellow}${reach.join(", ")} can reach this port on localhost: they can load this page, ` +
|
|
36
87
|
`but not the token, which only travels in the link above.${C.off}\n`
|
|
37
88
|
: "") + "\n"
|
|
38
89
|
);
|
|
39
90
|
|
|
40
|
-
|
|
41
|
-
spawnSync(opener, [url], { stdio: "ignore", shell: process.platform === "win32" });
|
|
91
|
+
open(url);
|
|
42
92
|
|
|
43
|
-
|
|
93
|
+
const stop = () => { clearUiLink(bound); server.close(); process.exit(0); };
|
|
94
|
+
process.on("SIGINT", stop);
|
|
95
|
+
process.on("SIGTERM", stop);
|
|
44
96
|
return server;
|
|
45
97
|
}
|
package/src/config.js
CHANGED
|
@@ -52,6 +52,8 @@ function dict() {
|
|
|
52
52
|
return Object.create(null);
|
|
53
53
|
}
|
|
54
54
|
|
|
55
|
+
const isTable = (v) => v !== null && typeof v === "object" && !Array.isArray(v);
|
|
56
|
+
|
|
55
57
|
/** Read a property only if the object actually has it. */
|
|
56
58
|
export function own(obj, key) {
|
|
57
59
|
return obj && Object.prototype.hasOwnProperty.call(obj, key) ? obj[key] : undefined;
|
|
@@ -88,8 +90,12 @@ export function parseToml(text) {
|
|
|
88
90
|
`A later block would silently override the earlier one — put every setting for ` +
|
|
89
91
|
`${tableName} in one place.`);
|
|
90
92
|
seenTables.add(tableName);
|
|
91
|
-
table = tableName.split(".").reduce((node, key) => {
|
|
93
|
+
table = tableName.split(".").reduce((node, key, k, parts) => {
|
|
92
94
|
if (!Object.prototype.hasOwnProperty.call(node, key)) node[key] = dict();
|
|
95
|
+
else if (!isTable(node[key]))
|
|
96
|
+
throw new Error(
|
|
97
|
+
`${CONFIG_NAME}:${i + 1}: [${tableName}] — "${parts.slice(0, k + 1).join(".")}" is already ` +
|
|
98
|
+
`a value, not a table. One name cannot be both.`);
|
|
93
99
|
return node[key];
|
|
94
100
|
}, out);
|
|
95
101
|
continue;
|
|
@@ -99,9 +105,18 @@ export function parseToml(text) {
|
|
|
99
105
|
if (!pair) throw new Error(`${CONFIG_NAME}:${i + 1}: cannot read "${raw.trim()}"`);
|
|
100
106
|
|
|
101
107
|
let [, key, value] = pair;
|
|
102
|
-
// An array may span lines; keep pulling until the brackets balance.
|
|
108
|
+
// An array may span lines; keep pulling until the brackets balance. Only
|
|
109
|
+
// the new line is searched: searching the whole growing value made an
|
|
110
|
+
// unclosed array of 100k lines take 20 s to be refused.
|
|
103
111
|
if (value.startsWith("[") && !value.includes("]")) {
|
|
104
|
-
|
|
112
|
+
const parts = [value];
|
|
113
|
+
let closed = false;
|
|
114
|
+
while (!closed && i + 1 < lines.length) {
|
|
115
|
+
const next = stripComment(lines[++i]).trim();
|
|
116
|
+
parts.push(next);
|
|
117
|
+
closed = next.includes("]");
|
|
118
|
+
}
|
|
119
|
+
value = parts.join(" ");
|
|
105
120
|
}
|
|
106
121
|
if (FORBIDDEN.has(key))
|
|
107
122
|
throw new Error(`${CONFIG_NAME}:${i + 1}: "${key}" is a reserved name and cannot be a setting.`);
|
|
@@ -111,6 +126,20 @@ export function parseToml(text) {
|
|
|
111
126
|
`${CONFIG_NAME}:${i + 1}: "${key}" is set twice in [${tableName || "the root"}]. ` +
|
|
112
127
|
`The second would win, which is not a thing a permission file should do quietly.`);
|
|
113
128
|
seenKeys.add(seen);
|
|
129
|
+
/**
|
|
130
|
+
* A key may not be a table that is declared elsewhere, in either order.
|
|
131
|
+
*
|
|
132
|
+
* `[roles.a]` with `network = []`, then `[roles]` and `a = true` forty lines
|
|
133
|
+
* down, used to replace the whole role with `true`: its `network = []` came
|
|
134
|
+
* back as "not set", which means the global allow list. `check` said
|
|
135
|
+
* nothing. It is the "last one wins" this parser refuses everywhere else,
|
|
136
|
+
* arriving by a different spelling.
|
|
137
|
+
*/
|
|
138
|
+
if (Object.prototype.hasOwnProperty.call(table, key))
|
|
139
|
+
throw new Error(
|
|
140
|
+
`${CONFIG_NAME}:${i + 1}: "${key}" in [${tableName || "the root"}] is already the table ` +
|
|
141
|
+
`[${tableName ? tableName + "." : ""}${key}]. One name cannot be both — the value would replace ` +
|
|
142
|
+
`everything that table says.`);
|
|
114
143
|
table[key] = readValue(value, i + 1);
|
|
115
144
|
}
|
|
116
145
|
return out;
|
|
@@ -255,9 +284,10 @@ function readValue(value, lineNo) {
|
|
|
255
284
|
export function readIsolate(v) {
|
|
256
285
|
if (v === true || v === "home") return "home";
|
|
257
286
|
if (v === "credentials") return "credentials";
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
287
|
+
if (v === false || v === undefined) return false;
|
|
288
|
+
throw new Error(
|
|
289
|
+
`runtime.isolate must be false, true, "credentials", or "home", not ${JSON.stringify(v)}. ` +
|
|
290
|
+
`An unknown value cannot silently turn credential isolation off.`);
|
|
261
291
|
}
|
|
262
292
|
|
|
263
293
|
export function loadConfig(path) {
|
|
@@ -269,6 +299,16 @@ export function loadConfig(path) {
|
|
|
269
299
|
// they prevent is invisible in review — the config reads one way and the
|
|
270
300
|
// policy is another.
|
|
271
301
|
const roles = own(parsed, "roles") ?? {};
|
|
302
|
+
// `roles = "x"` or `roles = [...]` at the root, or `[roles]` with `a = true`,
|
|
303
|
+
// would read as roles named "0", "1" or "a" with no settings of their own —
|
|
304
|
+
// and a role with nothing set gets the global defaults.
|
|
305
|
+
if (!isTable(roles))
|
|
306
|
+
throw new Error(`${path}: "roles" must be tables — [roles.<name>] — not ${JSON.stringify(roles)}`);
|
|
307
|
+
for (const [name, r] of Object.entries(roles))
|
|
308
|
+
if (!isTable(r))
|
|
309
|
+
throw new Error(
|
|
310
|
+
`${path}: roles.${name} = ${JSON.stringify(r)} — a role is a table, [roles.${name}], ` +
|
|
311
|
+
`with its settings under it.`);
|
|
272
312
|
|
|
273
313
|
/**
|
|
274
314
|
* A role is a leaf. `[roles.a.b]` is a typo, with certainty.
|
|
@@ -295,6 +335,19 @@ export function loadConfig(path) {
|
|
|
295
335
|
}
|
|
296
336
|
const names = Object.keys(roles);
|
|
297
337
|
if (names.length === 0) throw new Error(`${path}: no [roles.<name>] sections found`);
|
|
338
|
+
// Case-colliding names would share a HOME under `isolate = "home"` on a
|
|
339
|
+
// case-insensitive disk (APFS), so `dev` and `Dev` would read each other's
|
|
340
|
+
// secrets while `check` said nothing. Refuse the ambiguity at load.
|
|
341
|
+
{
|
|
342
|
+
const byLower = new Map();
|
|
343
|
+
for (const n of names) {
|
|
344
|
+
const k = n.toLowerCase();
|
|
345
|
+
if (byLower.has(k))
|
|
346
|
+
throw new Error(`${path}: roles "${byLower.get(k)}" and "${n}" differ only in case. ` +
|
|
347
|
+
`On a case-insensitive disk they would share an isolated HOME. Rename one.`);
|
|
348
|
+
byLower.set(k, n);
|
|
349
|
+
}
|
|
350
|
+
}
|
|
298
351
|
|
|
299
352
|
// One directory or several. Several is the common case once a repo has more
|
|
300
353
|
// than one kind of secret, and making people flatten them to satisfy the tool
|
|
@@ -323,7 +376,7 @@ export function loadConfig(path) {
|
|
|
323
376
|
|
|
324
377
|
for (const name of names) {
|
|
325
378
|
const r = own(roles, name);
|
|
326
|
-
const writes = asArray(own(r, "writes"), `roles.${name}.writes`);
|
|
379
|
+
const writes = assertNotAbsolute(asArray(own(r, "writes"), `${path}: roles.${name}.writes`), `${path}: roles.${name}.writes`);
|
|
327
380
|
const keys = asArray(own(r, "keys"), `roles.${name}.keys`);
|
|
328
381
|
// Read once, here, so every consumer sees the same answer about what an
|
|
329
382
|
// entry is. `keys` itself stays the array of strings that was written: it
|
|
@@ -614,6 +667,25 @@ function readNeverWrites(value, where) {
|
|
|
614
667
|
return list;
|
|
615
668
|
}
|
|
616
669
|
|
|
670
|
+
/**
|
|
671
|
+
* A territory is relative to the repo root. An absolute one would ask the kernel
|
|
672
|
+
* for a path on the machine and make `explain` (which reads absolute as outside
|
|
673
|
+
* the repo) disagree with what the profile grants — see the CODEOWNERS `/apps/`
|
|
674
|
+
* case. `..` is allowed on purpose: a role legitimately writes a sibling
|
|
675
|
+
* directory (`../bitacora/...`), and it is resolved relative to the root, so a
|
|
676
|
+
* territory that climbs above it grants what it resolves to, by the operator's
|
|
677
|
+
* choice.
|
|
678
|
+
*/
|
|
679
|
+
function assertNotAbsolute(list, where) {
|
|
680
|
+
for (const g of list) {
|
|
681
|
+
if (typeof g !== "string" || g.trim() === "")
|
|
682
|
+
throw new Error(`${where}: every entry must be a non-empty string`);
|
|
683
|
+
if (g.startsWith("/"))
|
|
684
|
+
throw new Error(`${where}: "${g}" is absolute. Territories are relative to the repo root.`);
|
|
685
|
+
}
|
|
686
|
+
return list;
|
|
687
|
+
}
|
|
688
|
+
|
|
617
689
|
function asArray(value, where) {
|
|
618
690
|
if (value === undefined) return [];
|
|
619
691
|
if (typeof value === "string") return [value];
|
package/src/hook.js
CHANGED
|
@@ -38,12 +38,41 @@ const FILE_TOOLS = {
|
|
|
38
38
|
const REDIRECT = /(?:^|\s|;|&&|\|\|)>{1,2}\s*("[^"]+"|'[^']+'|[^\s;|&<>]+)/g;
|
|
39
39
|
const WRITERS = /(?:^|\s|;|&&|\|\|)(?:cp|mv|install|tee|touch|mkdir|rm|rmdir|truncate|dd)\s+([^\n;|&]+)/g;
|
|
40
40
|
|
|
41
|
+
/**
|
|
42
|
+
* The server in an `mcp__<server>__<tool>` name, or null if it does not parse.
|
|
43
|
+
*
|
|
44
|
+
* The split is on the double underscore: a server name carries single ones
|
|
45
|
+
* (`claude_ai_Gmail`) and so does a tool name (`execute_sql`), so the server is
|
|
46
|
+
* everything between `mcp__` and the next `__`. Shared with the parent's intake,
|
|
47
|
+
* which recomputes the same verdict rather than trusting the line from the box.
|
|
48
|
+
*/
|
|
49
|
+
export function mcpServer(tool) {
|
|
50
|
+
const m = typeof tool === "string" && /^mcp__(.+?)__(.+)$/.exec(tool);
|
|
51
|
+
return m ? m[1] : null;
|
|
52
|
+
}
|
|
53
|
+
|
|
41
54
|
export function targetsOf(tool, input = {}) {
|
|
42
55
|
const kind = FILE_TOOLS[tool];
|
|
43
56
|
if (kind) {
|
|
44
57
|
const p = input.file_path ?? input.path ?? input.notebook_path;
|
|
45
58
|
return p ? [{ action: kind, path: p }] : [];
|
|
46
59
|
}
|
|
60
|
+
/**
|
|
61
|
+
* An MCP tool call names a resource, not a path: `mcp__<server>__<tool>`.
|
|
62
|
+
*
|
|
63
|
+
* The kernel never sees it — the MCP server runs outside the box — so a call
|
|
64
|
+
* that goes unrecorded here goes unrecorded everywhere. Measured 2026-09-24:
|
|
65
|
+
* the event reaches PreToolUse with the tool's own arguments as `tool_input`,
|
|
66
|
+
* which is exactly why the file-tool code above returns nothing for it. We
|
|
67
|
+
* resolve the call to the *server* it belongs to, the unit the policy already
|
|
68
|
+
* declares in `mcp = [...]`, and leave the finer resource (which table, which
|
|
69
|
+
* project) for later — deriving it means a per-server adapter, and that is the
|
|
70
|
+
* line past which this stops being "whose is it" and becomes a tool gateway.
|
|
71
|
+
* A name that does not parse is returned as an unknown tool, not dropped.
|
|
72
|
+
*/
|
|
73
|
+
if (typeof tool === "string" && tool.startsWith("mcp__")) {
|
|
74
|
+
return [{ action: "use", tool, server: mcpServer(tool) }];
|
|
75
|
+
}
|
|
47
76
|
if (tool !== "Bash" || typeof input.command !== "string") return [];
|
|
48
77
|
|
|
49
78
|
const out = [];
|
|
@@ -140,6 +169,25 @@ export function decide(config, role, event, { observe = false, now = append, ask
|
|
|
140
169
|
const verdicts = [];
|
|
141
170
|
|
|
142
171
|
for (const t of targets) {
|
|
172
|
+
// A tool call, resolved to its MCP server. "Whose is it" is the question
|
|
173
|
+
// `mcp = [...]` already answers, so it is asked through the same map rather
|
|
174
|
+
// than a second one. No request is filed on a denial: a server absent from
|
|
175
|
+
// a role's list is a policy subtraction a person edits — the shape of
|
|
176
|
+
// never_writes — not a grant a person approves, and filing one would be a
|
|
177
|
+
// request nobody can grant. An unparsable name is closed, not waved through.
|
|
178
|
+
if (t.action === "use") {
|
|
179
|
+
const v = t.server
|
|
180
|
+
? explain(config, role, "mcp", t.server)
|
|
181
|
+
: { allowed: false, owners: [], mcp: true, reason: `${t.tool} is not a recognisable mcp__<server>__<tool> name` };
|
|
182
|
+
now(file, {
|
|
183
|
+
role, tool: event.tool_name, action: "use", kind: "tool", target: t.tool,
|
|
184
|
+
verdict: observe ? "observed" : v.allowed ? "allowed" : "denied",
|
|
185
|
+
owners: v.owners ?? [], reason: v.reason,
|
|
186
|
+
});
|
|
187
|
+
verdicts.push({ ...v, target: t.tool, kind: "tool", action: "use" });
|
|
188
|
+
continue;
|
|
189
|
+
}
|
|
190
|
+
|
|
143
191
|
const rel = toRepoRelative(config, t.path);
|
|
144
192
|
const kind = kindOf(config, rel);
|
|
145
193
|
|
|
@@ -225,6 +273,8 @@ export function decide(config, role, event, { observe = false, now = append, ask
|
|
|
225
273
|
? `${denied.reason}. Do not ask for it: nothing was queued, because the answer is already written down.` + again
|
|
226
274
|
: denied.gitMetadata
|
|
227
275
|
? `${denied.reason}. Nothing was queued.` + again
|
|
276
|
+
: denied.mcp
|
|
277
|
+
? `${denied.reason}. Nothing was queued: which MCP servers a role may load is set in its mcp list, a policy change a person makes, not a grant they approve.` + again
|
|
228
278
|
: (denied.owners?.length
|
|
229
279
|
? wasRead
|
|
230
280
|
? `${denied.target} is declared for ${denied.owners.join(", ")}, not ${role}. Ask for what you need from it rather than reading the key.`
|
package/src/intake.js
CHANGED
|
@@ -30,6 +30,7 @@ import { explain, ownersOf } from "./owners.js";
|
|
|
30
30
|
import { append, logPath } from "./log.js";
|
|
31
31
|
import { record, requestsPath } from "./requests.js";
|
|
32
32
|
import { inScope, scopeOf, reachedForContent } from "./violations.js";
|
|
33
|
+
import { mcpServer } from "./hook.js";
|
|
33
34
|
|
|
34
35
|
/** A hash of the policy file as it is now, short: it names a version, it proves nothing. */
|
|
35
36
|
export function policyId(config) {
|
|
@@ -73,6 +74,33 @@ export function intake({ config, role, runId, observe = false, settings, notify
|
|
|
73
74
|
*/
|
|
74
75
|
function fromHook(to, entry) {
|
|
75
76
|
if (!entry || typeof entry !== "object") return;
|
|
77
|
+
|
|
78
|
+
/**
|
|
79
|
+
* An MCP tool call. It names a resource, not a path, so it is recorded and
|
|
80
|
+
* never queued — which servers a role may load is a change to its `mcp`
|
|
81
|
+
* list, not a grant a person approves, the same shape as never_writes. The
|
|
82
|
+
* verdict is recomputed here from the same list rather than believed from
|
|
83
|
+
* inside the box: the server is parsed from the tool name and asked of the
|
|
84
|
+
* policy, so a line claiming "allowed" for a server the role may not load
|
|
85
|
+
* comes back marked `disputed`.
|
|
86
|
+
*/
|
|
87
|
+
if (to === "log" && entry.action === "use") {
|
|
88
|
+
if (typeof entry.target !== "string" || !entry.target.trim()) return;
|
|
89
|
+
if (!["allowed", "denied", "observed"].includes(entry.verdict)) return;
|
|
90
|
+
const server = mcpServer(entry.target);
|
|
91
|
+
const v = server ? explain(config, role, "mcp", server) : { allowed: false };
|
|
92
|
+
const expected = observe ? "observed" : v.allowed ? "allowed" : "denied";
|
|
93
|
+
return void log({
|
|
94
|
+
at: entry.at, role,
|
|
95
|
+
tool: String(entry.tool ?? "").slice(0, 40),
|
|
96
|
+
action: "use", kind: "tool",
|
|
97
|
+
target: entry.target.slice(0, 1000),
|
|
98
|
+
verdict: entry.verdict, owners: [],
|
|
99
|
+
reason: String(entry.reason ?? "").slice(0, 500),
|
|
100
|
+
...(expected !== entry.verdict ? { disputed: expected } : {}),
|
|
101
|
+
});
|
|
102
|
+
}
|
|
103
|
+
|
|
76
104
|
if (entry.action !== "read" && entry.action !== "write") return;
|
|
77
105
|
if (typeof entry.target !== "string" || !entry.target.trim()) return;
|
|
78
106
|
|
|
@@ -133,7 +161,10 @@ export function intake({ config, role, runId, observe = false, settings, notify
|
|
|
133
161
|
// The owners explain found: for a key that is who declares it, not who
|
|
134
162
|
// writes the directory it sits in — the queue said "owned by dev" about
|
|
135
163
|
// backend's key because dev writes `**`.
|
|
136
|
-
|
|
164
|
+
// Capped like the log line above: a request's target is a path, and an
|
|
165
|
+
// uncapped one (5k+ chars were accepted) is a way to bloat the queue a
|
|
166
|
+
// person reads. 1000 is more than any real path and matches the log.
|
|
167
|
+
ask({ role, action: entry.action, target: entry.target.slice(0, 1000), owners: v.owners });
|
|
137
168
|
}
|
|
138
169
|
|
|
139
170
|
function fromKernel(d) {
|