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 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 days old and has one author.** Around three thousand lines that nobody has
165
- audited except its writer and one field report. "Secure" is not a word earned that fast.
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
- Read from [OpenShell's support matrix][osm] on 2026-09-21, not measured here: macOS is a
696
- supported platform, and on it "these kernel modules run inside the Docker Desktop Linux VM,
697
- not on the host kernel". So on a Mac, OpenShell confines an agent inside a Linux VM, and
698
- seisin confines a process on the host through Seatbelt. Both are kernel enforcement; they are
699
- not the same kernel, and which one you want depends on whether the thing you are protecting is
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.3.0`, 452 tests, of which **26 need `@anthropic-ai/sandbox-runtime` installed**
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. **326/326 on macOS 15 and on `ubuntu-latest` under bubblewrap**, nothing skipped on
713
- either, Node 18/20/22 in CI at every push — and 225/225 the same way on Debian 12.15
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "seisin",
3
- "version": "0.3.0",
3
+ "version": "0.4.2",
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/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] open the console, live
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.
@@ -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
- name: g.replace(/[^A-Za-z0-9]+/g, "-").replace(/^-|-$/g, "") || `role-${i + 1}`,
47
- writes: [g.endsWith("/") ? g + "**" : g],
48
- keys: [],
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
 
@@ -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, verifyChain } from "../log.js";
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 = verifyChain(logPath(config.root));
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
  }
@@ -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 { readFileSync, writeFileSync } from "node:fs";
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
- const hits = queue.filter((r) => r.key === arg || r.key.startsWith(arg));
44
- if (hits.length === 1) return hits[0];
45
- if (!hits.length)
46
- throw new Error(
47
- `no pending request matches "${arg}".\n` +
48
- " Use its number, or the id printed under it by `seisin requests`.");
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}" matches ${hits.length} pending requests:\n` +
51
- hits.slice(0, 5).map((r) => ` ${r.key}`).join("\n") +
52
- "\n Name one exactly. Choosing for you is the mistake this avoids.");
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
- const before = readFileSync(config.path, "utf8");
127
- const { toml, changed } = applyGrant(before, req, reason);
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` +
@@ -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
- const mine = split === -1 ? argv.slice(1) : argv.slice(1, split);
79
- const cmd = split === -1 ? argv.slice(1) : argv.slice(split + 1);
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(", ")}`);
@@ -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
- const server = await serve(config.path, port);
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
- const opener = process.platform === "darwin" ? "open" : process.platform === "win32" ? "start" : "xdg-open";
41
- spawnSync(opener, [url], { stdio: "ignore", shell: process.platform === "win32" });
91
+ open(url);
42
92
 
43
- process.on("SIGINT", () => { server.close(); process.exit(0); });
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
- while (!value.includes("]") && i + 1 < lines.length) value += " " + stripComment(lines[++i]).trim();
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
- // Anything else is off, including a misspelling. A permission tool must not
259
- // read a word it does not know as a stronger setting than the reader meant.
260
- return false;
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
- ask({ role, action: entry.action, target: entry.target, owners: v.owners });
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) {