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/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
- function state(configPath) {
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
- const entries = read(logPath(cfg.root), { limit: 200 });
149
- // A wider window than the activity tail, because grouping by cause is an
150
- // arithmetic question and 200 lines of a busy day is one role's morning.
151
- const forShape = read(logPath(cfg.root), { limit: 4000 });
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
- blocks: entries.filter((e) => e.role === r.name && e.verdict === "denied").length,
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, { file: logPath(cfg.root) })])
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
- function readBody(req) {
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 > 4096) { fail(new Error("body too large")); req.destroy(); }
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.method === "POST" && pathOf(req.url) === "/api/decide") {
253
- if (req.headers["x-seisin-token"] !== token) {
254
- res.writeHead(403, { "content-type": "application/json" });
255
- return res.end(JSON.stringify({ error: "bad or missing token" }));
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
- // Inlined rather than put in the URL: a fragment survives in history, in
284
- // a screenshot, and in whatever the operator pastes into a chat.
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", () => ok(server));
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", (chunk) => {
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
+ }