seisin 0.2.0 → 0.3.0
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 +72 -10
- package/package.json +7 -6
- package/src/cli.js +3 -2
- package/src/commands/explain.js +9 -2
- package/src/commands/hook.js +11 -0
- package/src/commands/log.js +27 -1
- package/src/commands/requests.js +7 -2
- package/src/commands/review.js +33 -5
- package/src/commands/run.js +112 -151
- package/src/commands/ui.js +16 -2
- package/src/commands/walls.js +8 -2
- package/src/commands/whose.js +2 -1
- package/src/commands/wire.js +30 -5
- package/src/config.js +150 -2
- package/src/diagnose.js +172 -0
- package/src/env.js +54 -1
- package/src/grants.js +239 -0
- package/src/hook.js +12 -3
- package/src/inspect.js +178 -5
- package/src/intake.js +215 -0
- package/src/keys.js +108 -6
- package/src/log.js +96 -2
- package/src/mcp.js +28 -10
- package/src/notify.js +146 -0
- package/src/owners.js +208 -6
- package/src/paths.js +50 -0
- package/src/render.js +29 -0
- package/src/requests.js +88 -0
- package/src/review.js +9 -4
- package/src/rundir.js +113 -0
- package/src/serve.js +131 -21
- package/src/spool.js +98 -20
- package/src/srt.js +123 -225
- package/src/surface.js +518 -0
- package/src/violations.js +37 -5
- package/src/walls.js +19 -5
- package/ui/favicon.svg +1 -1
- package/ui/index.html +221 -28
package/src/rundir.js
ADDED
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* One private directory per run, for everything that belongs to that run only:
|
|
3
|
+
* the audit socket, the sandbox settings, and `scratch` keys.
|
|
4
|
+
*
|
|
5
|
+
* ── Why ──
|
|
6
|
+
* These lived in two places and both leaked. The settings were written to
|
|
7
|
+
* `.seisin/<role>.json`, one file per ROLE, so two runs of the same role — an
|
|
8
|
+
* orchestrator does that all day — wrote the same file with different socket
|
|
9
|
+
* paths, and one could start with the other's. The socket and the scratch keys
|
|
10
|
+
* lived in `$TMPDIR/seisin-XXXX/`, private by mode, but every role runs as the
|
|
11
|
+
* same user and every role can read the temp dir: measured on 2026-09-23, one
|
|
12
|
+
* role read another's `scratch` key while it ran (`cat …/keys/S` → the value).
|
|
13
|
+
*
|
|
14
|
+
* ── How ──
|
|
15
|
+
* All runs live under one root, `$TMPDIR/snr/`, and each role's profile denies
|
|
16
|
+
* reading that root and allows reading its own run's directory — the shape the
|
|
17
|
+
* key directories and the isolated homes already use. The root is denied for
|
|
18
|
+
* writing too, so no role can swap it for a symlink between runs.
|
|
19
|
+
*
|
|
20
|
+
* Short names on purpose: the socket path is capped near 104 bytes on macOS.
|
|
21
|
+
*/
|
|
22
|
+
import { mkdirSync, mkdtempSync, lstatSync, chmodSync, writeFileSync, readdirSync, readFileSync, rmSync } from "node:fs";
|
|
23
|
+
import { join, basename, dirname } from "node:path";
|
|
24
|
+
import { tmpdir } from "node:os";
|
|
25
|
+
import { randomUUID } from "node:crypto";
|
|
26
|
+
import { realOrSelf } from "./grants.js";
|
|
27
|
+
import { channelName } from "./spool.js";
|
|
28
|
+
|
|
29
|
+
export const RUNS_NAME = "snr";
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* The root every run lives under, created private, and checked.
|
|
33
|
+
*
|
|
34
|
+
* Real path, because the kernel enforces on real paths and on macOS the temp
|
|
35
|
+
* dir sits behind /var → /private/var. Refused rather than used when it is not
|
|
36
|
+
* a directory of this user's with nobody else's bits on it: it holds keys.
|
|
37
|
+
*/
|
|
38
|
+
export function runsRoot(base = tmpdir()) {
|
|
39
|
+
const root = join(realOrSelf(base), RUNS_NAME);
|
|
40
|
+
try { mkdirSync(root, { mode: 0o700 }); } catch (e) { if (e.code !== "EEXIST") throw e; }
|
|
41
|
+
const st = lstatSync(root);
|
|
42
|
+
const mine = typeof process.getuid !== "function" || st.uid === process.getuid();
|
|
43
|
+
if (st.isSymbolicLink() || !st.isDirectory() || !mine)
|
|
44
|
+
throw new Error(
|
|
45
|
+
`${root} is not a directory of yours (${st.isSymbolicLink() ? "it is a symlink" : "wrong owner or type"}).\n` +
|
|
46
|
+
` It holds each run's socket and keys, so seisin will not use it. Remove it and run again.`);
|
|
47
|
+
if (st.mode & 0o077) chmodSync(root, 0o700);
|
|
48
|
+
return root;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/** Is the process that owns a run directory still alive? */
|
|
52
|
+
function alive(pid) {
|
|
53
|
+
try { process.kill(pid, 0); return true; } catch (e) { return e.code === "EPERM"; }
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* Removes the directories of runs whose process is gone.
|
|
58
|
+
*
|
|
59
|
+
* A run cleans up after itself on exit. A run that is killed does not, and
|
|
60
|
+
* its directory keeps its `scratch` keys on disk indefinitely — measured by
|
|
61
|
+
* SIGKILL on a keyed run. Swept at the start of the next one; a directory
|
|
62
|
+
* without a pid file is younger than the write that creates it, and is left.
|
|
63
|
+
*/
|
|
64
|
+
function sweep(root) {
|
|
65
|
+
for (const name of readdirSync(root)) {
|
|
66
|
+
const dir = join(root, name);
|
|
67
|
+
let pid;
|
|
68
|
+
try { pid = Number(readFileSync(join(dir, "pid"), "utf8")); } catch { continue; }
|
|
69
|
+
if (pid && !alive(pid)) rmSync(dir, { recursive: true, force: true });
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/**
|
|
74
|
+
* Opens a run: its id, its directory, and the paths inside it.
|
|
75
|
+
*
|
|
76
|
+
* The id is the one the rest of the run uses — the nonce in the kernel's
|
|
77
|
+
* command tag, the `run` field on every log line — so one value ties the
|
|
78
|
+
* socket, the settings, the log and the kernel's reports together.
|
|
79
|
+
*/
|
|
80
|
+
export function openRun({ base = tmpdir(), id = randomUUID() } = {}) {
|
|
81
|
+
const root = runsRoot(base);
|
|
82
|
+
try { sweep(root); } catch { /* a sweep that fails is not a reason not to run */ }
|
|
83
|
+
const dir = mkdtempSync(join(root, `${id.slice(0, 6)}-`));
|
|
84
|
+
writeFileSync(join(dir, "pid"), String(process.pid), { mode: 0o600 });
|
|
85
|
+
return {
|
|
86
|
+
id,
|
|
87
|
+
dir,
|
|
88
|
+
root,
|
|
89
|
+
// A socket on macOS, a FIFO on Linux, where the runtime blocks every unix
|
|
90
|
+
// socket inside the box (spool.js).
|
|
91
|
+
sock: join(dir, channelName()),
|
|
92
|
+
keys: join(dir, "keys"),
|
|
93
|
+
/**
|
|
94
|
+
* `wx`: created by this call or not at all. A settings file that already
|
|
95
|
+
* exists is not this run's, and the runtime would read whatever it says.
|
|
96
|
+
*/
|
|
97
|
+
writeSettings(settings) {
|
|
98
|
+
const file = join(dir, "settings.json");
|
|
99
|
+
writeFileSync(file, JSON.stringify(settings, null, 2) + "\n", { flag: "wx", mode: 0o600 });
|
|
100
|
+
return file;
|
|
101
|
+
},
|
|
102
|
+
close() {
|
|
103
|
+
try { rmSync(dir, { recursive: true, force: true }); } catch {}
|
|
104
|
+
},
|
|
105
|
+
};
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
/** The runs root a socket path belongs to, or null when it is not one of ours. */
|
|
109
|
+
export function runsRootOf(sock) {
|
|
110
|
+
if (!sock) return null;
|
|
111
|
+
const root = dirname(dirname(sock));
|
|
112
|
+
return basename(root) === RUNS_NAME ? root : null;
|
|
113
|
+
}
|
package/src/serve.js
CHANGED
|
@@ -18,7 +18,7 @@ import { fileURLToPath } from "node:url";
|
|
|
18
18
|
import { loadConfig } from "./config.js";
|
|
19
19
|
import { read, logPath } from "./log.js";
|
|
20
20
|
import { settingsFor } from "./srt.js";
|
|
21
|
-
import { pending, requestsPath, settle, applyGrant } from "./requests.js";
|
|
21
|
+
import { pending, requestsPath, settle, applyGrant, refuseIfBarred, markStale } from "./requests.js";
|
|
22
22
|
import { walls } from "./walls.js";
|
|
23
23
|
import { explain } from "./owners.js";
|
|
24
24
|
|
|
@@ -143,26 +143,53 @@ export function causesOf(cfg, entries) {
|
|
|
143
143
|
};
|
|
144
144
|
}
|
|
145
145
|
|
|
146
|
-
|
|
146
|
+
/**
|
|
147
|
+
* `?since=<ISO date>`, or null for no filter. Anything that does not parse is
|
|
148
|
+
* ignored rather than refused: a bad filter should show everything, not an error.
|
|
149
|
+
*/
|
|
150
|
+
export function sinceOf(url) {
|
|
151
|
+
const raw = new URLSearchParams((url ?? "").split("?")[1] ?? "").get("since");
|
|
152
|
+
const t = raw ? Date.parse(raw) : NaN;
|
|
153
|
+
return Number.isNaN(t) ? null : new Date(t).toISOString();
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
export function state(configPath, { since = null } = {}) {
|
|
147
157
|
const cfg = loadConfig(configPath);
|
|
148
|
-
|
|
149
|
-
//
|
|
150
|
-
//
|
|
151
|
-
const
|
|
158
|
+
// The date filter narrows everything read from the log: activity, causes,
|
|
159
|
+
// walls and the per-role counts. Not the request queue, which is what is
|
|
160
|
+
// waiting now whatever the window.
|
|
161
|
+
const entries = read(logPath(cfg.root), { limit: 200, since });
|
|
162
|
+
// The whole window, not a tail. "All" used to mean the last 4000 lines, so a
|
|
163
|
+
// 30-day window could show more denials than "all" did (6k against 4k on a
|
|
164
|
+
// real log). Every count on the page comes from this list.
|
|
165
|
+
const forShape = read(logPath(cfg.root), since ? { since } : {});
|
|
152
166
|
|
|
153
167
|
const roles = Object.values(cfg.roles).map((r) => ({
|
|
154
168
|
name: r.name,
|
|
155
169
|
writes: r.writes,
|
|
156
170
|
keys: r.keys,
|
|
157
171
|
env: r.env,
|
|
158
|
-
|
|
172
|
+
neverWrites: r.neverWritesDeclared ?? r.neverWrites ?? [],
|
|
173
|
+
localBinding: r.localBinding === true,
|
|
174
|
+
localPorts: r.localPorts ?? [],
|
|
175
|
+
mcp: r.mcp ?? null,
|
|
176
|
+
trustd: r.trustd === true,
|
|
177
|
+
// Counted over the same window as the causes, so the KPI and the screen it
|
|
178
|
+
// links to agree.
|
|
179
|
+
blocks: forShape.filter((e) => e.role === r.name && e.verdict === "denied").length,
|
|
159
180
|
settings: settingsFor(cfg, r.name),
|
|
160
181
|
}));
|
|
161
182
|
|
|
183
|
+
// Read once for every role's walls, rather than once per role.
|
|
184
|
+
const whole = read(logPath(cfg.root), { verdict: "denied", since });
|
|
185
|
+
// Staleness is about the role's whole history, never the window: a request
|
|
186
|
+
// is not old just because the filter hides the runs that made it old.
|
|
187
|
+
const history = since ? read(logPath(cfg.root), { verdict: "denied" }) : whole;
|
|
162
188
|
return {
|
|
163
189
|
root: cfg.root,
|
|
164
190
|
config: cfg.path,
|
|
165
|
-
requests: pending(requestsPath(cfg.root)),
|
|
191
|
+
requests: markStale(pending(requestsPath(cfg.root)), history),
|
|
192
|
+
since,
|
|
166
193
|
// The file itself, not a regeneration of it. The page can render a policy
|
|
167
194
|
// from its own model, and that model has no comments — so showing it under
|
|
168
195
|
// the heading "seisin.toml" next to "copy this back" invites someone to
|
|
@@ -176,7 +203,7 @@ function state(configPath) {
|
|
|
176
203
|
// Empty for a role that has hit nothing twice, which is most of them.
|
|
177
204
|
walls: Object.fromEntries(
|
|
178
205
|
Object.keys(cfg.roles)
|
|
179
|
-
.map((r) => [r, walls(cfg, r, {
|
|
206
|
+
.map((r) => [r, walls(cfg, r, { entries: whole })])
|
|
180
207
|
.filter(([, w]) => w.length)),
|
|
181
208
|
log: entries.slice(-60).reverse(),
|
|
182
209
|
live: true,
|
|
@@ -205,6 +232,7 @@ function decide(configPath, { number, decision, reason }) {
|
|
|
205
232
|
// The config is edited as text, so comments and order survive. Written
|
|
206
233
|
// before the queue is settled: if this throws, the request is still open
|
|
207
234
|
// rather than marked done against a file that never changed.
|
|
235
|
+
refuseIfBarred(cfg, req);
|
|
208
236
|
const { toml, changed } = applyGrant(readFileSync(cfg.path, "utf8"), req, reason);
|
|
209
237
|
if (changed) writeFileSync(cfg.path, toml);
|
|
210
238
|
}
|
|
@@ -213,17 +241,49 @@ function decide(configPath, { number, decision, reason }) {
|
|
|
213
241
|
}
|
|
214
242
|
|
|
215
243
|
/** The body of a POST, capped: this endpoint takes three short fields. */
|
|
216
|
-
|
|
244
|
+
/**
|
|
245
|
+
* A request body, capped. The cap is per route: one decision is a few hundred
|
|
246
|
+
* bytes, but "decline all" carries every key on screen — 109 of them were
|
|
247
|
+
* 9.3 KB, over the old flat 4 KB, and the connection was cut with nothing but
|
|
248
|
+
* "Failed to fetch" in the browser. Found by Carlos on the first real use.
|
|
249
|
+
*/
|
|
250
|
+
function readBody(req, max = 4096) {
|
|
217
251
|
return new Promise((ok, fail) => {
|
|
218
252
|
let s = "";
|
|
219
253
|
req.on("data", (c) => {
|
|
220
254
|
s += c;
|
|
221
|
-
if (s.length >
|
|
255
|
+
if (s.length > max) { fail(new Error("body too large")); req.destroy(); }
|
|
222
256
|
});
|
|
223
257
|
req.on("end", () => ok(s));
|
|
224
258
|
});
|
|
225
259
|
}
|
|
226
260
|
|
|
261
|
+
/**
|
|
262
|
+
* Refuse every request the page was showing, in one go.
|
|
263
|
+
*
|
|
264
|
+
* Only refusing. There is no "approve all" and there will not be: a grant is a
|
|
265
|
+
* decision about one path and one owner, and doing forty of them without
|
|
266
|
+
* reading them is the permissive default this tool exists to stand against.
|
|
267
|
+
* Refusing in bulk is safe — it grants nothing and changes no policy — and it
|
|
268
|
+
* is what a queue full of noise from a bug already fixed needs.
|
|
269
|
+
*
|
|
270
|
+
* By key, not "whatever is pending now": a request that arrived after the page
|
|
271
|
+
* drew is one the person has not seen, and it stays.
|
|
272
|
+
*/
|
|
273
|
+
function declineAll(configPath, { keys, reason }) {
|
|
274
|
+
if (!Array.isArray(keys) || !keys.length) throw new Error("keys must list the requests on screen");
|
|
275
|
+
const cfg = loadConfig(configPath);
|
|
276
|
+
const file = requestsPath(cfg.root);
|
|
277
|
+
const wanted = new Set(keys.map(String));
|
|
278
|
+
let declined = 0;
|
|
279
|
+
for (const req of pending(file)) {
|
|
280
|
+
if (!wanted.has(req.key)) continue;
|
|
281
|
+
settle(file, req.key, "denied", reason ?? "");
|
|
282
|
+
declined++;
|
|
283
|
+
}
|
|
284
|
+
return { ok: true, declined };
|
|
285
|
+
}
|
|
286
|
+
|
|
227
287
|
export function serve(configPath, port = 4178) {
|
|
228
288
|
const page = join(HERE, "..", "ui", "index.html");
|
|
229
289
|
if (!existsSync(page)) throw new Error("the console is missing from this install");
|
|
@@ -238,6 +298,38 @@ export function serve(configPath, port = 4178) {
|
|
|
238
298
|
*/
|
|
239
299
|
const token = randomBytes(24).toString("hex");
|
|
240
300
|
|
|
301
|
+
/**
|
|
302
|
+
* Every route under /api/ asks for the token, reads included.
|
|
303
|
+
*
|
|
304
|
+
* The page used to be the only thing that carried the token and /api/state
|
|
305
|
+
* answered anyone. Both rested on "an agent cannot reach loopback", and
|
|
306
|
+
* `local_binding = true` on macOS makes that false: the runtime lets that
|
|
307
|
+
* role connect to every localhost port, and loopback bypasses the egress
|
|
308
|
+
* proxy. Reproduced end to end by a read-only review on 2026-09-22 — a role
|
|
309
|
+
* read the token off `GET /`, posted to /api/decide, and approved its own
|
|
310
|
+
* request into another role's territory. /api/state handed it the policy, the
|
|
311
|
+
* log and where every key lives, with no token at all.
|
|
312
|
+
*
|
|
313
|
+
* So the token no longer travels in any response. `seisin ui` puts it in the
|
|
314
|
+
* URL fragment, which a browser never sends to a server; the page takes it
|
|
315
|
+
* from there, keeps it for the tab, and removes it from the address bar and
|
|
316
|
+
* the history entry. Anything that fetches `/` gets a page with no token in it.
|
|
317
|
+
*/
|
|
318
|
+
const authorized = (req) => req.headers["x-seisin-token"] === token;
|
|
319
|
+
|
|
320
|
+
/**
|
|
321
|
+
* The Host header has to name this server.
|
|
322
|
+
*
|
|
323
|
+
* A page on the operator's browser can point a hostname it controls at
|
|
324
|
+
* 127.0.0.1 (DNS rebinding) and then read responses as same-origin. It still
|
|
325
|
+
* could not produce the token, but the check is one line and makes the
|
|
326
|
+
* question moot: the only names this server answers to are its own.
|
|
327
|
+
*/
|
|
328
|
+
const hostOk = (req, port) => {
|
|
329
|
+
const h = String(req.headers.host ?? "");
|
|
330
|
+
return h === `127.0.0.1:${port}` || h === `localhost:${port}`;
|
|
331
|
+
};
|
|
332
|
+
|
|
241
333
|
/**
|
|
242
334
|
* The path, without whatever came after `?`.
|
|
243
335
|
*
|
|
@@ -249,11 +341,26 @@ export function serve(configPath, port = 4178) {
|
|
|
249
341
|
const pathOf = (u) => (u ?? "/").split("?")[0];
|
|
250
342
|
|
|
251
343
|
const server = createServer(async (req, res) => {
|
|
252
|
-
if (req.
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
344
|
+
if (!hostOk(req, server.address()?.port)) {
|
|
345
|
+
res.writeHead(403, { "content-type": "text/plain" });
|
|
346
|
+
return res.end("wrong host");
|
|
347
|
+
}
|
|
348
|
+
if (pathOf(req.url).startsWith("/api/") && !authorized(req)) {
|
|
349
|
+
res.writeHead(403, { "content-type": "application/json" });
|
|
350
|
+
return res.end(JSON.stringify({ error: "bad or missing token — open the URL `seisin ui` printed" }));
|
|
351
|
+
}
|
|
352
|
+
if (req.method === "POST" && pathOf(req.url) === "/api/decline-all") {
|
|
353
|
+
try {
|
|
354
|
+
// ~85 bytes per key: 1 MB is ten thousand requests on one screen.
|
|
355
|
+
const out = declineAll(configPath, JSON.parse((await readBody(req, 1024 * 1024)) || "{}"));
|
|
356
|
+
res.writeHead(200, { "content-type": "application/json", "cache-control": "no-store" });
|
|
357
|
+
return res.end(JSON.stringify(out));
|
|
358
|
+
} catch (e) {
|
|
359
|
+
res.writeHead(400, { "content-type": "application/json" });
|
|
360
|
+
return res.end(JSON.stringify({ error: e.message }));
|
|
256
361
|
}
|
|
362
|
+
}
|
|
363
|
+
if (req.method === "POST" && pathOf(req.url) === "/api/decide") {
|
|
257
364
|
try {
|
|
258
365
|
const out = decide(configPath, JSON.parse((await readBody(req)) || "{}"));
|
|
259
366
|
res.writeHead(200, { "content-type": "application/json", "cache-control": "no-store" });
|
|
@@ -270,7 +377,7 @@ export function serve(configPath, port = 4178) {
|
|
|
270
377
|
if (pathOf(req.url) === "/api/state") {
|
|
271
378
|
let body;
|
|
272
379
|
try {
|
|
273
|
-
body = JSON.stringify(state(configPath));
|
|
380
|
+
body = JSON.stringify(state(configPath, { since: sinceOf(req.url) }));
|
|
274
381
|
} catch (e) {
|
|
275
382
|
res.writeHead(500, { "content-type": "application/json" });
|
|
276
383
|
return res.end(JSON.stringify({ error: e.message }));
|
|
@@ -280,10 +387,9 @@ export function serve(configPath, port = 4178) {
|
|
|
280
387
|
return res.end(body);
|
|
281
388
|
}
|
|
282
389
|
if (pathOf(req.url) === "/" || pathOf(req.url) === "/index.html") {
|
|
283
|
-
//
|
|
284
|
-
//
|
|
285
|
-
const html = readFileSync(page, "utf8")
|
|
286
|
-
.replace("</head>", `<script>window.SEISIN_TOKEN=${JSON.stringify(token)}</script></head>`);
|
|
390
|
+
// No token in here. It used to be inlined, which handed it to anything
|
|
391
|
+
// that could GET this page — see `authorized` above.
|
|
392
|
+
const html = readFileSync(page, "utf8");
|
|
287
393
|
res.writeHead(200, { "content-type": "text/html; charset=utf-8", "cache-control": "no-store" });
|
|
288
394
|
return res.end(html);
|
|
289
395
|
}
|
|
@@ -293,7 +399,11 @@ export function serve(configPath, port = 4178) {
|
|
|
293
399
|
return new Promise((ok, fail) => {
|
|
294
400
|
// Loopback only. The page maps where every credential lives; it has no
|
|
295
401
|
// business being reachable from the network the laptop happens to be on.
|
|
296
|
-
server.listen(port, "127.0.0.1", () =>
|
|
402
|
+
server.listen(port, "127.0.0.1", () => {
|
|
403
|
+
// For `seisin ui`, which builds the URL, and for tests. Never sent.
|
|
404
|
+
Object.defineProperty(server, "seisinToken", { value: token });
|
|
405
|
+
ok(server);
|
|
406
|
+
});
|
|
297
407
|
server.on("error", (e) =>
|
|
298
408
|
fail(e.code === "EADDRINUSE"
|
|
299
409
|
? new Error(`port ${port} is busy — pass another with: seisin ui --port <n>`)
|
package/src/spool.js
CHANGED
|
@@ -26,8 +26,9 @@
|
|
|
26
26
|
* solved here; the log is evidence of what happened, not proof that nothing
|
|
27
27
|
* else did.
|
|
28
28
|
*/
|
|
29
|
-
import { createServer, createConnection } from "node:net";
|
|
30
|
-
import { unlinkSync, existsSync, mkdtempSync, chmodSync } from "node:fs";
|
|
29
|
+
import { createServer, createConnection, Socket } from "node:net";
|
|
30
|
+
import { unlinkSync, existsSync, mkdtempSync, chmodSync, openSync, writeSync, closeSync, constants } from "node:fs";
|
|
31
|
+
import { execFileSync } from "node:child_process";
|
|
31
32
|
import { tmpdir } from "node:os";
|
|
32
33
|
import { join, dirname } from "node:path";
|
|
33
34
|
|
|
@@ -59,29 +60,78 @@ export function spoolPath() {
|
|
|
59
60
|
* in. The parent decides what that means; the sender does not get to pick a
|
|
60
61
|
* path, which is the other half of why this is not just a slower file write.
|
|
61
62
|
*/
|
|
63
|
+
/**
|
|
64
|
+
* Which channel this platform can use: a unix socket, or a FIFO.
|
|
65
|
+
*
|
|
66
|
+
* On Linux the runtime blocks creating ANY unix socket inside the box with a
|
|
67
|
+
* seccomp filter — it cannot filter by path, so `allowUnixSockets` is ignored
|
|
68
|
+
* there (its README says so). The socket this file was built on therefore
|
|
69
|
+
* never carried a line on Linux: measured in Docker on 2026-09-23, with the
|
|
70
|
+
* code as published in 0.2.0 and after, the hook "sent" and the log stayed
|
|
71
|
+
* empty. A FIFO is a file, not a socket — seccomp does not see it, bubblewrap
|
|
72
|
+
* grants it by path, and a write of up to PIPE_BUF bytes is atomic, so lines
|
|
73
|
+
* from concurrent tool calls do not interleave. Measured the same day: the
|
|
74
|
+
* role wrote a line through it, could not create anything beside it, and
|
|
75
|
+
* could not remove it.
|
|
76
|
+
*/
|
|
77
|
+
export const FIFO_SUFFIX = ".fifo";
|
|
78
|
+
export const channelName = (platform = process.platform) => (platform === "linux" ? "s.fifo" : "s.sock");
|
|
79
|
+
|
|
80
|
+
/** The most a FIFO write carries atomically on Linux (PIPE_BUF). */
|
|
81
|
+
const PIPE_BUF = 4096;
|
|
82
|
+
|
|
83
|
+
/** Parses newline-separated JSON messages and hands each to `sink`. */
|
|
84
|
+
function lineReader(sink, onTooLong) {
|
|
85
|
+
let buffer = "";
|
|
86
|
+
return (chunk) => {
|
|
87
|
+
buffer += chunk;
|
|
88
|
+
// A 1 MB line is not a log entry, it is something trying to fill a disk.
|
|
89
|
+
if (buffer.length > 1_000_000) { buffer = ""; return onTooLong?.(); }
|
|
90
|
+
let nl;
|
|
91
|
+
while ((nl = buffer.indexOf("\n")) !== -1) {
|
|
92
|
+
const line = buffer.slice(0, nl);
|
|
93
|
+
buffer = buffer.slice(nl + 1);
|
|
94
|
+
if (!line.trim()) continue;
|
|
95
|
+
try {
|
|
96
|
+
const msg = JSON.parse(line);
|
|
97
|
+
if (msg && (msg.to === "log" || msg.to === "requests")) sink(msg.to, msg.entry);
|
|
98
|
+
} catch {
|
|
99
|
+
// Garbage from inside the box is not the parent's emergency.
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
};
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
/**
|
|
106
|
+
* The FIFO end of the spool. Opened read-write so it never sees end-of-file
|
|
107
|
+
* when a writer closes — every hook call opens, writes one line and closes —
|
|
108
|
+
* and read through a net.Socket, which takes a pipe descriptor and reads it
|
|
109
|
+
* without holding a thread.
|
|
110
|
+
*/
|
|
111
|
+
function fifoSpool(sink, path) {
|
|
112
|
+
if (existsSync(path)) unlinkSync(path);
|
|
113
|
+
execFileSync("mkfifo", ["-m", "600", path]);
|
|
114
|
+
const fd = openSync(path, constants.O_RDWR | constants.O_NONBLOCK);
|
|
115
|
+
const stream = new Socket({ fd, readable: true, writable: false });
|
|
116
|
+
stream.setEncoding("utf8");
|
|
117
|
+
stream.on("data", lineReader(sink));
|
|
118
|
+
stream.on("error", () => {});
|
|
119
|
+
return Promise.resolve({
|
|
120
|
+
path,
|
|
121
|
+
close() {
|
|
122
|
+
stream.destroy();
|
|
123
|
+
try { unlinkSync(path); } catch {}
|
|
124
|
+
},
|
|
125
|
+
});
|
|
126
|
+
}
|
|
127
|
+
|
|
62
128
|
export function spool(sink, path = spoolPath()) {
|
|
129
|
+
if (path.endsWith(FIFO_SUFFIX)) return fifoSpool(sink, path);
|
|
63
130
|
if (existsSync(path)) unlinkSync(path);
|
|
64
131
|
|
|
65
132
|
const server = createServer((conn) => {
|
|
66
|
-
let buffer = "";
|
|
67
133
|
conn.setEncoding("utf8");
|
|
68
|
-
conn.on("data", (
|
|
69
|
-
buffer += chunk;
|
|
70
|
-
// A 1 MB line is not a log entry, it is something trying to fill a disk.
|
|
71
|
-
if (buffer.length > 1_000_000) return conn.destroy();
|
|
72
|
-
let nl;
|
|
73
|
-
while ((nl = buffer.indexOf("\n")) !== -1) {
|
|
74
|
-
const line = buffer.slice(0, nl);
|
|
75
|
-
buffer = buffer.slice(nl + 1);
|
|
76
|
-
if (!line.trim()) continue;
|
|
77
|
-
try {
|
|
78
|
-
const msg = JSON.parse(line);
|
|
79
|
-
if (msg && (msg.to === "log" || msg.to === "requests")) sink(msg.to, msg.entry);
|
|
80
|
-
} catch {
|
|
81
|
-
// Garbage from inside the box is not the parent's emergency.
|
|
82
|
-
}
|
|
83
|
-
}
|
|
84
|
-
});
|
|
134
|
+
conn.on("data", lineReader(sink, () => conn.destroy()));
|
|
85
135
|
conn.on("error", () => {});
|
|
86
136
|
});
|
|
87
137
|
|
|
@@ -136,6 +186,7 @@ const inFlight = new Set();
|
|
|
136
186
|
|
|
137
187
|
export function send(to, entry, path = process.env[SOCK_ENV]) {
|
|
138
188
|
if (!path) return false;
|
|
189
|
+
if (path.endsWith(FIFO_SUFFIX)) return sendFifo(to, entry, path);
|
|
139
190
|
const p = new Promise((done) => {
|
|
140
191
|
let settled = false;
|
|
141
192
|
const finish = () => { if (!settled) { settled = true; done(true); } };
|
|
@@ -162,3 +213,30 @@ export function send(to, entry, path = process.env[SOCK_ENV]) {
|
|
|
162
213
|
export async function flush() {
|
|
163
214
|
await Promise.all([...inFlight]);
|
|
164
215
|
}
|
|
216
|
+
|
|
217
|
+
/**
|
|
218
|
+
* One line into the FIFO, synchronously: nothing to flush, nothing to lose to
|
|
219
|
+
* `process.exit`. Non-blocking, so a parent that is gone (no reader) costs an
|
|
220
|
+
* ENXIO and not a hung tool call. Longer than PIPE_BUF, the free-text fields
|
|
221
|
+
* are shortened first — past that size a write is no longer atomic and two
|
|
222
|
+
* tool calls could interleave their lines.
|
|
223
|
+
*/
|
|
224
|
+
function sendFifo(to, entry, path) {
|
|
225
|
+
const full = { at: new Date().toISOString(), ...entry };
|
|
226
|
+
let line = JSON.stringify({ to, entry: full }) + "\n";
|
|
227
|
+
if (Buffer.byteLength(line) > PIPE_BUF) {
|
|
228
|
+
const short = { ...full, reason: String(full.reason ?? "").slice(0, 300), target: String(full.target ?? "").slice(0, 1000) };
|
|
229
|
+
line = JSON.stringify({ to, entry: short }) + "\n";
|
|
230
|
+
if (Buffer.byteLength(line) > PIPE_BUF) return false;
|
|
231
|
+
}
|
|
232
|
+
let fd;
|
|
233
|
+
try {
|
|
234
|
+
fd = openSync(path, constants.O_WRONLY | constants.O_NONBLOCK);
|
|
235
|
+
writeSync(fd, line);
|
|
236
|
+
return true;
|
|
237
|
+
} catch {
|
|
238
|
+
return false;
|
|
239
|
+
} finally {
|
|
240
|
+
if (fd !== undefined) try { closeSync(fd); } catch {}
|
|
241
|
+
}
|
|
242
|
+
}
|