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 +12 -16
- package/package.json +1 -1
- package/src/commands/init.js +13 -5
- package/src/commands/log.js +9 -3
- package/src/commands/requests.js +26 -18
- package/src/commands/run.js +15 -2
- package/src/config.js +79 -7
- package/src/intake.js +4 -1
- package/src/log.js +83 -11
- 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 +65 -23
- package/src/surface.js +15 -1
- 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.4.
|
|
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/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, 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
|
}
|
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/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/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
|
-
|
|
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
|
-
*
|
|
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() +
|
|
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
|
-
|
|
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
|
-
|
|
171
|
-
|
|
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(); }
|
|
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);
|