seisin 0.2.0 → 0.4.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 +4 -3
- package/src/commands/explain.js +9 -2
- package/src/commands/hook.js +11 -0
- package/src/commands/log.js +28 -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 +72 -6
- 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 +62 -3
- package/src/inspect.js +178 -5
- package/src/intake.js +243 -0
- package/src/keys.js +108 -6
- package/src/log.js +203 -3
- 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 +134 -22
- package/src/spool.js +98 -20
- package/src/srt.js +123 -225
- package/src/surface.js +518 -0
- package/src/uilink.js +55 -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/requests.js
CHANGED
|
@@ -26,6 +26,7 @@
|
|
|
26
26
|
* rewritten is not evidence. A request that is granted or refused is not
|
|
27
27
|
* deleted — it is followed by a line saying what happened to it.
|
|
28
28
|
*/
|
|
29
|
+
import { neverWrites, isGitMetadata, covers } from "./owners.js";
|
|
29
30
|
import { appendFileSync, existsSync, mkdirSync, readFileSync } from "node:fs";
|
|
30
31
|
import { dirname, join } from "node:path";
|
|
31
32
|
import { STATE_DIR, tomlString } from "./layout.js";
|
|
@@ -173,6 +174,68 @@ export function pending(file, { includeSettled = false } = {}) {
|
|
|
173
174
|
return includeSettled ? all : all.filter((r) => r.state === "pending");
|
|
174
175
|
}
|
|
175
176
|
|
|
177
|
+
/**
|
|
178
|
+
* How many runs of the role, after the last time it asked, make a request old.
|
|
179
|
+
*
|
|
180
|
+
* A request is recorded again on every refusal, so one the role still needs
|
|
181
|
+
* keeps a fresh `last`. One whose need went away — the work moved somewhere the
|
|
182
|
+
* role may write, or the task ended — stops being asked while the role keeps
|
|
183
|
+
* running. Three runs is enough to say "not since" and few enough that a queue
|
|
184
|
+
* does not carry a dead request for a week.
|
|
185
|
+
*/
|
|
186
|
+
export const STALE_RUNS = 3;
|
|
187
|
+
|
|
188
|
+
/**
|
|
189
|
+
* Two log lines of a role this far apart belong to different runs — for lines
|
|
190
|
+
* written before the log had a run marker. Lines with `run` are counted by it.
|
|
191
|
+
*/
|
|
192
|
+
export const RUN_GAP_MS = 10 * 60_000;
|
|
193
|
+
|
|
194
|
+
/**
|
|
195
|
+
* How many runs produced these lines, strictly after `since` (ms).
|
|
196
|
+
*
|
|
197
|
+
* By the `run` id when a line has one (intake.js writes it on every line since
|
|
198
|
+
* 2026-09-23), by the gap otherwise. The gap guessed: three runs started a
|
|
199
|
+
* minute apart by an orchestrator counted as one, and one run idle for eleven
|
|
200
|
+
* minutes counted as two.
|
|
201
|
+
*/
|
|
202
|
+
export function runsAfter(entries, since, gapMs = RUN_GAP_MS) {
|
|
203
|
+
const ids = new Set();
|
|
204
|
+
const untagged = [];
|
|
205
|
+
for (const e of entries) {
|
|
206
|
+
const t = Date.parse(e.at);
|
|
207
|
+
if (Number.isNaN(t) || t <= since) continue;
|
|
208
|
+
if (e.run) ids.add(e.run);
|
|
209
|
+
else untagged.push(t);
|
|
210
|
+
}
|
|
211
|
+
untagged.sort((a, b) => a - b);
|
|
212
|
+
let n = 0, prev = -Infinity;
|
|
213
|
+
for (const t of untagged) { if (t - prev > gapMs) n++; prev = t; }
|
|
214
|
+
return ids.size + n;
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
/**
|
|
218
|
+
* Marks the requests the role has stopped asking for, read off the log.
|
|
219
|
+
*
|
|
220
|
+
* Marked, never settled and never moved. Declining is a person's decision, and
|
|
221
|
+
* reordering would shift the numbers `grant <n>` is typed against while
|
|
222
|
+
* nobody decided anything. The mark says what is true — the role ran N times
|
|
223
|
+
* since and did not ask again — and leaves the rest to whoever reads it.
|
|
224
|
+
* `entries` are log lines (`read(logPath(root))`); `now` is for the tests.
|
|
225
|
+
*/
|
|
226
|
+
export function markStale(queue, entries, { runs = STALE_RUNS, gapMs = RUN_GAP_MS } = {}) {
|
|
227
|
+
const byRole = new Map(); // role -> its log lines
|
|
228
|
+
for (const e of entries) {
|
|
229
|
+
if (!e.role) continue;
|
|
230
|
+
(byRole.get(e.role) ?? byRole.set(e.role, []).get(e.role)).push(e);
|
|
231
|
+
}
|
|
232
|
+
for (const r of queue) {
|
|
233
|
+
const n = runsAfter(byRole.get(r.role) ?? [], Date.parse(r.last), gapMs);
|
|
234
|
+
if (n >= runs) r.stale = { runs: n, since: r.last };
|
|
235
|
+
}
|
|
236
|
+
return queue;
|
|
237
|
+
}
|
|
238
|
+
|
|
176
239
|
/**
|
|
177
240
|
* Adds a granted glob to a role in the config text, with its provenance.
|
|
178
241
|
*
|
|
@@ -195,6 +258,31 @@ export function pending(file, { includeSettled = false } = {}) {
|
|
|
195
258
|
* is the worst possible bug, and it is not exotic: a role with only `keys`
|
|
196
259
|
* declared is an ordinary config.
|
|
197
260
|
*/
|
|
261
|
+
/**
|
|
262
|
+
* Refuses a grant that `never_writes` would cancel, before anything is written.
|
|
263
|
+
*
|
|
264
|
+
* A request filed before the subtraction existed can still be in the queue.
|
|
265
|
+
* Approving it would add the path to `writes` and change nothing — denyWrite
|
|
266
|
+
* wins — so the person would believe they granted something the kernel still
|
|
267
|
+
* refuses. The two keys contradict each other, and which one should win is the
|
|
268
|
+
* decision; seisin will not make it by writing one of them silently.
|
|
269
|
+
*/
|
|
270
|
+
export function refuseIfBarred(config, request) {
|
|
271
|
+
if (request.action === "read") return;
|
|
272
|
+
const role = config.roles?.[request.role];
|
|
273
|
+
const hit = role && neverWrites(role, request.target, config);
|
|
274
|
+
if (hit)
|
|
275
|
+
throw new Error(
|
|
276
|
+
`${request.target} is under never_writes of ${request.role} ("${hit}"), which wins over ` +
|
|
277
|
+
`writes. Granting it would change nothing. If the subtraction is wrong, remove that ` +
|
|
278
|
+
`entry from [roles.${request.role}]; otherwise decline this request.`);
|
|
279
|
+
// Only a repository the role does not write: its own `.git` is its own business.
|
|
280
|
+
if (isGitMetadata(request.target) && !(role?.writes ?? []).some((g) => covers(g, request.target)))
|
|
281
|
+
throw new Error(
|
|
282
|
+
`${request.target} is git's own bookkeeping, not a territory: granting it lets ${request.role} ` +
|
|
283
|
+
`rewrite another owner's history. Decline it; a role that needs to commit gets a worktree of its own.`);
|
|
284
|
+
}
|
|
285
|
+
|
|
198
286
|
export function applyGrant(toml, request, note = "") {
|
|
199
287
|
const field = request.action === "read" ? "keys" : "writes";
|
|
200
288
|
const header = new RegExp(`^\\[roles\\.${escapeRe(request.role)}\\]\\s*$`, "m");
|
package/src/review.js
CHANGED
|
@@ -73,7 +73,7 @@ function findings(config, entries, minDenials) {
|
|
|
73
73
|
const stopped = new Map(); // role + directory -> how often
|
|
74
74
|
for (const e of entries) {
|
|
75
75
|
if (e.verdict !== "denied" || !e.target) continue;
|
|
76
|
-
const dir = e.action === "read" ? e.target : dirOf(e.target);
|
|
76
|
+
const dir = e.action === "read" || e.action === "connect" ? e.target : dirOf(e.target);
|
|
77
77
|
const key = `${e.role} ${e.action} ${dir}`;
|
|
78
78
|
const seen = stopped.get(key) ?? {
|
|
79
79
|
role: e.role, action: e.action, where: dir, times: 0,
|
|
@@ -81,7 +81,7 @@ function findings(config, entries, minDenials) {
|
|
|
81
81
|
// Carried by the log from both writers — the hook sets it from the config
|
|
82
82
|
// and so does the parent for a kernel denial. Recomputing it from the path
|
|
83
83
|
// here would be a second opinion on a settled question.
|
|
84
|
-
kind: e.kind === "key"
|
|
84
|
+
kind: e.kind === "key" || e.kind === "network" ? e.kind : "file",
|
|
85
85
|
};
|
|
86
86
|
seen.times++;
|
|
87
87
|
stopped.set(key, seen);
|
|
@@ -91,8 +91,10 @@ function findings(config, entries, minDenials) {
|
|
|
91
91
|
.filter((f) => f.times >= minDenials)
|
|
92
92
|
.sort((a, b) => b.times - a.times);
|
|
93
93
|
|
|
94
|
-
const friction = repeated.filter((f) => f.kind !== "key");
|
|
94
|
+
const friction = repeated.filter((f) => f.kind !== "key" && f.kind !== "network");
|
|
95
95
|
const guarded = repeated.filter((f) => f.kind === "key");
|
|
96
|
+
// Ports and sockets: repeated, and not a thing `grant` can answer.
|
|
97
|
+
const connects = repeated.filter((f) => f.kind === "network");
|
|
96
98
|
|
|
97
99
|
/* ── 2. what was granted and never used ─────────────────────────────── */
|
|
98
100
|
|
|
@@ -144,7 +146,9 @@ function findings(config, entries, minDenials) {
|
|
|
144
146
|
|
|
145
147
|
const unowned = new Map();
|
|
146
148
|
for (const e of entries) {
|
|
147
|
-
|
|
149
|
+
// A key directory is closed on purpose; a port or a socket is not a path
|
|
150
|
+
// anybody could own. Neither is a hole in the map.
|
|
151
|
+
if (!e.target || e.kind === "key" || e.kind === "network") continue;
|
|
148
152
|
if (ownersOf(config, e.target).length) continue;
|
|
149
153
|
const dir = dirOf(e.target);
|
|
150
154
|
unowned.set(dir, (unowned.get(dir) ?? 0) + 1);
|
|
@@ -153,6 +157,7 @@ function findings(config, entries, minDenials) {
|
|
|
153
157
|
return {
|
|
154
158
|
friction,
|
|
155
159
|
guarded,
|
|
160
|
+
connects,
|
|
156
161
|
unused,
|
|
157
162
|
unusedKnowable: knowable,
|
|
158
163
|
unowned: [...unowned.entries()]
|
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,10 +399,16 @@ 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
|
+
// Keep the code on the rejection: `seisin ui` reads it to tell "a
|
|
410
|
+
// console is already here, reopen its link" from any other failure.
|
|
411
|
+
? Object.assign(new Error(`port ${port} is busy — pass another with: seisin ui --port <n>`), { code: "EADDRINUSE" })
|
|
300
412
|
: e));
|
|
301
413
|
});
|
|
302
414
|
}
|
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
|
+
}
|