seisin 0.4.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.4.0`, 470 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.4.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",
@@ -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, verifyLog } 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) => {
@@ -50,14 +50,20 @@ function verify(config) {
50
50
  }
51
51
  const head = r.unchained ? `${r.unchained} line(s) from before the chain, then ` : "";
52
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
+ : "";
53
59
  if (!r.breaks.length) {
54
- out(`\n ${C.green}intact${C.off} ${head}${r.chained} chained line(s)${segs}\n\n`);
60
+ out(`\n ${C.green}intact${C.off} ${head}${r.chained} chained line(s)${segs}\n${dropped}\n`);
55
61
  return r;
56
62
  }
57
63
  out(`\n ${C.red}broken${C.off} ${head}${r.chained} chained line(s), ${r.breaks.length} break(s):\n`);
58
64
  for (const b of r.breaks.slice(0, 10))
59
65
  out(` line ${b.line}: expected prev ${b.expected}, found ${b.found ?? "none"}\n`);
60
- 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`);
61
67
  process.exitCode = 1;
62
68
  return r;
63
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(", ")}`);
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/intake.js CHANGED
@@ -161,7 +161,10 @@ export function intake({ config, role, runId, observe = false, settings, notify
161
161
  // The owners explain found: for a key that is who declares it, not who
162
162
  // writes the directory it sits in — the queue said "owned by dev" about
163
163
  // backend's key because dev writes `**`.
164
- 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 });
165
168
  }
166
169
 
167
170
  function fromKernel(d) {
package/src/log.js CHANGED
@@ -13,7 +13,7 @@
13
13
  * tries the parent's socket first for exactly that reason; writing the file
14
14
  * directly is what happens when there is no parent, outside the box.
15
15
  */
16
- import { appendFileSync, closeSync, existsSync, mkdirSync, openSync, readdirSync, readFileSync, readSync, renameSync, statSync, unlinkSync } from "node:fs";
16
+ import { appendFileSync, closeSync, existsSync, mkdirSync, openSync, readdirSync, readFileSync, readSync, renameSync, statSync, unlinkSync, writeSync } from "node:fs";
17
17
  import { createHash } from "node:crypto";
18
18
  import { basename, dirname, join } from "node:path";
19
19
  import { STATE_DIR, LOG_NAME } from "./layout.js";
@@ -47,13 +47,27 @@ export function append(file, entry) {
47
47
  // sits on disk, so verifying needs nothing but the file.
48
48
  const line = JSON.stringify({ at: new Date().toISOString(), ...entry, prev: hashOf(lastLine(file)) });
49
49
  appendFileSync(file, line + "\n");
50
- });
50
+ }, { waitMs: envInt("SEISIN_LOG_LOCK_WAIT_MS", 6000) });
51
51
  return true;
52
- } catch {
52
+ } catch (e) {
53
+ // A line that could not get the lock is gone, and a missing line does not
54
+ // break the chain. Say so beside the log, so `verify` can.
55
+ if (e?.code === "ELOCKED") {
56
+ try { appendFileSync(droppedPath(file), JSON.stringify({ at: new Date().toISOString(), role: entry?.role, event: entry?.event }) + "\n"); } catch {}
57
+ }
53
58
  return false;
54
59
  }
55
60
  }
56
61
 
62
+ /** Where entries that could not be written are counted. */
63
+ export function droppedPath(file) {
64
+ return file + ".dropped";
65
+ }
66
+
67
+ function countLines(file) {
68
+ try { return readFileSync(file, "utf8").split("\n").filter((l) => l.trim()).length; } catch { return 0; }
69
+ }
70
+
57
71
  /**
58
72
  * When the current segment fills, retire it and start a fresh one that still
59
73
  * chains.
@@ -156,23 +170,73 @@ function lastLine(file) {
156
170
  /**
157
171
  * One writer at a time. Several roles of a round run at once and write this
158
172
  * same file; two of them reading the same last line would fork the chain.
159
- * A lock left by a killed process is taken over after a few seconds.
173
+ *
174
+ * The lock names its holder (pid and a nonce). A lock whose holder is gone is
175
+ * taken over at once; one whose holder cannot be read is taken over once it is
176
+ * older than `staleMs`. The wait outlasts that age on purpose: a writer that
177
+ * died holding the lock can never make the next one give up, so the only entry
178
+ * ever dropped is one that waited on a writer still alive — and that drop is
179
+ * counted beside the log (see `append`), because a missing line does not break
180
+ * the chain and would otherwise not show.
160
181
  */
161
- function withLock(file, fn) {
182
+ export function withLock(file, fn, { waitMs = 6000, staleMs = 5000 } = {}) {
162
183
  const lock = file + ".lock";
163
- const deadline = Date.now() + 2000;
184
+ const deadline = Date.now() + waitMs;
185
+ const mine = `${process.pid} ${createHash("sha256").update(String(Math.random()) + Date.now()).digest("hex").slice(0, 12)}\n`;
186
+ let acquired = false;
164
187
  for (;;) {
165
188
  try {
166
- closeSync(openSync(lock, "wx"));
189
+ const fd = openSync(lock, "wx");
190
+ try { writeSync(fd, mine); } finally { closeSync(fd); }
191
+ acquired = true;
167
192
  break;
168
193
  } catch (e) {
169
194
  if (e.code !== "EEXIST") throw e;
170
- try { if (Date.now() - statSync(lock).mtimeMs > 5000) { unlinkSync(lock); continue; } } catch {}
171
- if (Date.now() > deadline) break; // never block the run over bookkeeping
195
+ if (reapable(lock, staleMs)) continue;
196
+ // Never write without the lock. Doing so forks the hash chain; deleting
197
+ // the lock in `finally` then admits still more writers while its owner is
198
+ // active. The log is bookkeeping and append() may fail without taking the
199
+ // agent down, so dropping this entry is safer than fabricating evidence.
200
+ if (Date.now() > deadline) {
201
+ const error = new Error(`could not acquire log lock within ${waitMs} ms`);
202
+ error.code = "ELOCKED";
203
+ throw error;
204
+ }
172
205
  Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, 5);
173
206
  }
174
207
  }
175
- try { return fn(); } finally { try { unlinkSync(lock); } catch {} }
208
+ try { return fn(); }
209
+ finally {
210
+ // Remove it only while it is still ours: if it was taken over, it now
211
+ // belongs to someone else.
212
+ if (acquired) try { if (readFileSync(lock, "utf8") === mine) unlinkSync(lock); } catch {}
213
+ }
214
+ }
215
+
216
+ /** True if the process is still there. EPERM means it is, and not ours to signal. */
217
+ function alive(pid) {
218
+ try { process.kill(pid, 0); return true; }
219
+ catch (e) { return e.code !== "ESRCH"; }
220
+ }
221
+
222
+ /**
223
+ * Removes a lock whose holder is gone, and says whether it did.
224
+ *
225
+ * The content is read again just before removing it, so a waiter that decided
226
+ * on one lock does not remove a newer one another waiter has just taken.
227
+ */
228
+ function reapable(lock, staleMs) {
229
+ let seen, mtime;
230
+ try { seen = readFileSync(lock, "utf8"); mtime = statSync(lock).mtimeMs; } catch { return true; }
231
+ const pid = Number(seen.split(" ")[0]);
232
+ const named = Number.isInteger(pid) && pid > 0;
233
+ // A named holder that is alive is waited on, however long it takes: its age
234
+ // says nothing about whether it is done. An unnamed one (an older seisin, or
235
+ // killed between creating the file and writing its name) goes by age.
236
+ const gone = named ? !alive(pid) : Date.now() - mtime > staleMs;
237
+ if (!gone) return false;
238
+ try { if (readFileSync(lock, "utf8") !== seen) return true; unlinkSync(lock); } catch {}
239
+ return true;
176
240
  }
177
241
 
178
242
  /**
@@ -230,7 +294,7 @@ export function verifyChain(file, seed = GENESIS) {
230
294
  */
231
295
  export function verifyLog(file) {
232
296
  const segs = logSegments(file);
233
- const agg = { lines: 0, unchained: 0, chained: 0, breaks: [], segments: segs.length };
297
+ const agg = { lines: 0, unchained: 0, chained: 0, breaks: [], segments: segs.length, dropped: countLines(droppedPath(file)) };
234
298
  let seed = GENESIS;
235
299
  for (const seg of segs) {
236
300
  const r = walk(seg, { seed, startLine: agg.lines });
@@ -285,6 +349,14 @@ export function observed(entries) {
285
349
  const roles = new Map();
286
350
  for (const e of entries) {
287
351
  if (!e.role || !e.target) continue;
352
+ // A line the parent flagged as `disputed` is one whose verdict did not
353
+ // match the policy the run started with — either the config changed under
354
+ // it, or the line was not written by the hook at all. Either way it is not
355
+ // evidence of what the role legitimately reached for, so it must not become
356
+ // policy: `init --from-observations` builds `writes`/`keys` from here, and a
357
+ // process inside the box could otherwise send `verdict:"observed"` lines for
358
+ // paths outside its territory and have them proposed as its own.
359
+ if (e.disputed) continue;
288
360
  const r = roles.get(e.role) ?? { writes: new Set(), keys: new Set() };
289
361
  if (e.action === "read" && e.kind === "key") r.keys.add(e.target);
290
362
  else if (e.action === "write") r.writes.add(e.target);