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/src/intake.js ADDED
@@ -0,0 +1,243 @@
1
+ /**
2
+ * The parent's intake: what arrives during a run, checked, and written down.
3
+ *
4
+ * Two sources, and they are not equally trustworthy. The hook runs inside the
5
+ * box, so everything it sends is a claim by the process being recorded. The
6
+ * kernel is the thing that actually refused. Both end up as log lines and,
7
+ * sometimes, as requests in front of a person; this module is where each is
8
+ * recomputed from the policy rather than believed.
9
+ *
10
+ * Every line written here carries two fields that make it evidence rather
11
+ * than a sentence:
12
+ *
13
+ * - `run`, the id of the run that produced it — the same id as the nonce in
14
+ * the kernel's command tag and the run's private directory (rundir.js).
15
+ * Before, the log had no run marker and "how many runs since" was guessed
16
+ * from 10-minute gaps.
17
+ * - `policy`, a hash of the policy file the run STARTED with. The kernel
18
+ * enforces that one for the whole run, whatever happens to the file
19
+ * afterwards, and `review` reading today's policy over last week's lines
20
+ * could not tell a grant made since from one that was always there.
21
+ *
22
+ * Pulled out of run.js, which held both handlers inline between the spawn and
23
+ * the signal handling — 180 lines of policy arithmetic inside the launcher.
24
+ */
25
+ import { createHash } from "node:crypto";
26
+ import { readFileSync } from "node:fs";
27
+ import { join } from "node:path";
28
+ import { toRepoRelative } from "./paths.js";
29
+ import { explain, ownersOf } from "./owners.js";
30
+ import { append, logPath } from "./log.js";
31
+ import { record, requestsPath } from "./requests.js";
32
+ import { inScope, scopeOf, reachedForContent } from "./violations.js";
33
+ import { mcpServer } from "./hook.js";
34
+
35
+ /** A hash of the policy file as it is now, short: it names a version, it proves nothing. */
36
+ export function policyId(config) {
37
+ try {
38
+ return createHash("sha256").update(readFileSync(config.path)).digest("hex").slice(0, 12);
39
+ } catch {
40
+ return null;
41
+ }
42
+ }
43
+
44
+ /**
45
+ * The handlers for one run.
46
+ *
47
+ * `fromHook(to, entry)` is the spool's sink; `fromKernel(denial)` is
48
+ * watchDenials' callback; `stats` counts what the kernel said that was left
49
+ * out on purpose, for the line at the end of the run.
50
+ */
51
+ export function intake({ config, role, runId, observe = false, settings, notify }) {
52
+ const stamp = { run: runId.slice(0, 8), policy: policyId(config) };
53
+ const log = (entry) => append(logPath(config.root), { ...entry, ...stamp });
54
+ const ask = (asked) => { record(requestsPath(config.root), asked); notify?.maybe(asked); };
55
+
56
+ const scope = scopeOf(settings, config.root);
57
+ const keyDirs = (config.keyDirs ?? []).map((d) => (d.startsWith("/") ? d : join(config.root, d)));
58
+ const connects = new Set(); // one line per refused target per run
59
+ const stats = { offPolicy: 0, walks: 0 };
60
+
61
+ /**
62
+ * Nothing from inside the box is taken at its word.
63
+ *
64
+ * The sender is the process being recorded, so every field it supplies is
65
+ * a claim. Two of them matter. `role` decides whose request this is — left
66
+ * alone, a frontend agent could file one as backend and wait for a human to
67
+ * approve it. `owners` decides who the queue says it belongs to, and the
68
+ * parent can work that out itself from the policy.
69
+ *
70
+ * So the role is overwritten with the role of this run, the owners are
71
+ * recomputed, and anything shaped wrong is dropped. Forging a *log* line is
72
+ * noise. Forging a *request* is a sentence placed in front of a person for
73
+ * approval, and that is a different thing entirely.
74
+ */
75
+ function fromHook(to, entry) {
76
+ if (!entry || typeof entry !== "object") return;
77
+
78
+ /**
79
+ * An MCP tool call. It names a resource, not a path, so it is recorded and
80
+ * never queued — which servers a role may load is a change to its `mcp`
81
+ * list, not a grant a person approves, the same shape as never_writes. The
82
+ * verdict is recomputed here from the same list rather than believed from
83
+ * inside the box: the server is parsed from the tool name and asked of the
84
+ * policy, so a line claiming "allowed" for a server the role may not load
85
+ * comes back marked `disputed`.
86
+ */
87
+ if (to === "log" && entry.action === "use") {
88
+ if (typeof entry.target !== "string" || !entry.target.trim()) return;
89
+ if (!["allowed", "denied", "observed"].includes(entry.verdict)) return;
90
+ const server = mcpServer(entry.target);
91
+ const v = server ? explain(config, role, "mcp", server) : { allowed: false };
92
+ const expected = observe ? "observed" : v.allowed ? "allowed" : "denied";
93
+ return void log({
94
+ at: entry.at, role,
95
+ tool: String(entry.tool ?? "").slice(0, 40),
96
+ action: "use", kind: "tool",
97
+ target: entry.target.slice(0, 1000),
98
+ verdict: entry.verdict, owners: [],
99
+ reason: String(entry.reason ?? "").slice(0, 500),
100
+ ...(expected !== entry.verdict ? { disputed: expected } : {}),
101
+ });
102
+ }
103
+
104
+ if (entry.action !== "read" && entry.action !== "write") return;
105
+ if (typeof entry.target !== "string" || !entry.target.trim()) return;
106
+
107
+ if (to === "log") {
108
+ // The same treatment the queue already got. The role was overwritten but
109
+ // everything else was passed through, so a process inside the box could
110
+ // write "allowed" lines for paths it never touched — which would not move
111
+ // the boundary, but would move `review`, and `init --from-observations`
112
+ // builds a policy out of exactly this.
113
+ if (!["allowed", "denied", "observed"].includes(entry.verdict)) return;
114
+ /**
115
+ * The verdict is the hook's account of what it did, so it stays — but it
116
+ * is checked. The parent works out what the policy this run started with
117
+ * says, and when the two differ the line carries `disputed` with the
118
+ * parent's answer. Two causes, both worth seeing: the policy file changed
119
+ * since the run started (the hook reads it fresh, the kernel does not), or
120
+ * the line was not written by the hook at all.
121
+ */
122
+ const expected = observe
123
+ ? "observed"
124
+ : explain(config, role, entry.action, entry.target).allowed ? "allowed" : "denied";
125
+ return void log({
126
+ at: entry.at,
127
+ role,
128
+ tool: String(entry.tool ?? "").slice(0, 40),
129
+ action: entry.action,
130
+ kind: entry.kind === "key" ? "key" : "file",
131
+ target: entry.target.slice(0, 1000),
132
+ verdict: entry.verdict,
133
+ owners: ownersOf(config, entry.target), // recomputed, never taken
134
+ reason: String(entry.reason ?? "").slice(0, 500),
135
+ ...(expected !== entry.verdict ? { disputed: expected } : {}),
136
+ });
137
+ }
138
+
139
+ /**
140
+ * The policy has to actually refuse it, or it does not belong in a queue.
141
+ *
142
+ * Nothing checked. A process inside the box could file a request for a path
143
+ * its own role already owns, and the queue would print *first refused on
144
+ * …* about something that was never refused — a sentence placed in front of
145
+ * a person for approval, describing an event that did not happen.
146
+ *
147
+ * Same treatment as `owners` above: the claim is recomputed here rather
148
+ * than believed. It is the policy answering a question about itself, which
149
+ * is arithmetic, and it is the parent asking — the one process the confined
150
+ * side cannot reach.
151
+ *
152
+ * This does not require the agent to have *tried*. Asking for a permission
153
+ * before reaching for it is a reasonable thing to do, and the wording says
154
+ * "asked" rather than "refused" for exactly that case. What it rules out is
155
+ * a request for something that is not refused at all — nor for a
156
+ * `never_writes` path, git's metadata, or a protected one, none of which a
157
+ * grant can open.
158
+ */
159
+ const v = explain(config, role, entry.action, entry.target);
160
+ if (v.allowed || v.neverWrites || v.gitMetadata || v.protected) return;
161
+ // The owners explain found: for a key that is who declares it, not who
162
+ // writes the directory it sits in — the queue said "owned by dev" about
163
+ // backend's key because dev writes `**`.
164
+ ask({ role, action: entry.action, target: entry.target, owners: v.owners });
165
+ }
166
+
167
+ function fromKernel(d) {
168
+ /**
169
+ * A refused connection: logged, never queued.
170
+ *
171
+ * Once per target per run, because the thing that dials a closed port is
172
+ * usually a readiness loop, and a loop polling a database every half second
173
+ * would otherwise write the log the size of the wait. And no request: a port
174
+ * is not a territory anyone owns, so there is nobody to hand it to — the
175
+ * sentence says what the policy would have to change instead.
176
+ */
177
+ if (d.action === "connect") {
178
+ if (connects.has(d.path)) return;
179
+ connects.add(d.path);
180
+ const v = explain(config, role, "connect", d.path);
181
+ log({
182
+ at: new Date().toISOString(), role, tool: "kernel", source: "kernel",
183
+ action: "connect", kind: "network", target: d.path, verdict: "denied",
184
+ owners: [], reason: d.operation, ...(v.listed ? { listed: true } : {}),
185
+ });
186
+ return;
187
+ }
188
+ if (!inScope(d.path, scope)) { stats.offPolicy++; return; }
189
+ if (!reachedForContent(d)) { stats.walks++; return; }
190
+ // Relative inside the repo, absolute outside it. A role can be refused at
191
+ // ~/.ssh under `isolate`, and "../../../.ssh/id_rsa" would be a worse
192
+ // answer to "what was refused" than the path itself.
193
+ const rel = toRepoRelative(config, d.path);
194
+
195
+ /**
196
+ * A refusal the hook never saw still leaves a request behind.
197
+ *
198
+ * Without this the two halves of the record disagree in the worst
199
+ * direction: `review` would show a role stopped repeatedly on a directory
200
+ * while the queue held nothing to approve, so the one refusal a person most
201
+ * needed to see — the one that escaped the hook — would be the one with no
202
+ * way to act on it.
203
+ *
204
+ * There is no double counting to avoid. When the hook catches something it
205
+ * denies the tool call outright and the command never runs, so the kernel
206
+ * never sees it. These are the ones that got past it, which is the whole
207
+ * reason this exists. Inside the repo only: `~/.ssh` is refused on purpose
208
+ * under `isolate` and is not a territory anyone is meant to ask for.
209
+ */
210
+ const verdict = rel !== d.path && d.action === "write"
211
+ ? explain(config, role, "write", rel)
212
+ : null;
213
+ const barred = verdict?.neverWrites ?? null;
214
+ const unaskable = verdict?.gitMetadata === true || verdict?.protected != null;
215
+ // A request for something the policy already grants cannot be approved into
216
+ // anything: it is misattribution or a kernel/policy mismatch, and either way
217
+ // the line in the log is the evidence, not a question for a person. The hook
218
+ // path has always had this check; the kernel path did not, which is how a
219
+ // role ended up asking for its own territory.
220
+ const granted = verdict?.allowed === true;
221
+ if (rel !== d.path && !barred && !unaskable && !granted)
222
+ ask({ role, action: d.action, target: rel, owners: ownersOf(config, rel) });
223
+
224
+ log({
225
+ at: new Date().toISOString(),
226
+ role,
227
+ tool: "kernel",
228
+ source: "kernel",
229
+ action: d.action,
230
+ kind: keyDirs.some((k) => d.path === k || d.path.startsWith(k + "/")) ? "key" : "file",
231
+ target: rel,
232
+ verdict: "denied",
233
+ owners: ownersOf(config, rel),
234
+ reason: d.operation,
235
+ // So the log can tell a subtraction doing its job from a missing permission,
236
+ // and a protected file from a territory somebody could be asked for.
237
+ ...(barred ? { neverWrites: barred } : {}),
238
+ ...(verdict?.protected ? { protected: verdict.protected } : {}),
239
+ });
240
+ }
241
+
242
+ return { fromHook, fromKernel, stats };
243
+ }
package/src/keys.js CHANGED
@@ -41,9 +41,21 @@
41
41
  */
42
42
  import { spawnSync } from "node:child_process";
43
43
  import { readFileSync } from "node:fs";
44
- import { resolve, isAbsolute } from "node:path";
44
+ import { resolve, isAbsolute, delimiter } from "node:path";
45
45
  import { BASE, DEFAULTS } from "./env.js";
46
46
  import { SOCK_ENV } from "./spool.js";
47
+ import { resolveExecutable, trustedPath } from "./surface.js";
48
+
49
+ /**
50
+ * How long a provider may take. A minute, because the slow case is a person:
51
+ * `op` and the keychain can put a prompt in front of someone who is not
52
+ * looking at the screen yet. Before this there was no limit, and a provider
53
+ * stuck on a prompt nobody could see held the run with no message at all.
54
+ */
55
+ export const PROVIDER_TIMEOUT_MS = 60_000;
56
+
57
+ /** A credential is bytes, not megabytes. Past this it is not a key. */
58
+ const PROVIDER_MAX_BYTES = 1024 * 1024;
47
59
 
48
60
  /**
49
61
  * `scheme://rest`, and nothing cleverer.
@@ -202,15 +214,78 @@ function fromJson(obj, key, ref) {
202
214
  return String(v);
203
215
  }
204
216
 
217
+ /**
218
+ * One `NAME=value` line's value, the way dotenv reads it — or a refusal.
219
+ *
220
+ * Compared against dotenv 16 on the same file (2026-09-23), three lines came
221
+ * back different, and a different value is a wrong credential handed over
222
+ * without a word:
223
+ *
224
+ * - `A=abc # comment` — dotenv drops the comment; this kept it.
225
+ * - a name defined twice — dotenv takes the last; this took the first.
226
+ * - a quoted value spanning lines — dotenv reads it whole; this returned
227
+ * the first line with its quote.
228
+ *
229
+ * The comment is read like dotenv, because there is one reading of it. The
230
+ * other two are refused, because loaders disagree about them and picking one
231
+ * is guessing which credential somebody meant.
232
+ */
233
+ function dotEnvValue(raw, ref, key) {
234
+ const v = raw.trim();
235
+ const q = v[0];
236
+ if (q === '"' || q === "'" || q === "`") {
237
+ // The closing quote is the first one not escaped with a backslash, as in
238
+ // dotenv's own pattern: `"a\"b"` is one value, `a\"b`, not `a\`.
239
+ let end = -1;
240
+ for (let i = 1; i < v.length; i++) {
241
+ if (v[i] === "\\") { i++; continue; }
242
+ if (v[i] === q) { end = i; break; }
243
+ }
244
+ if (end === -1)
245
+ throw new Error(
246
+ `key "${ref}": ${key}= opens a ${q} quote that does not close on its line.\n` +
247
+ ` A value across several lines is read differently by different loaders, so seisin ` +
248
+ `does not read it. Put the credential on one line, or in a file of its own.`);
249
+ // Only a comment may follow the closing quote. `A="a" b` is read by dotenv
250
+ // as the whole line, quotes included, and by others as `a`.
251
+ const rest = v.slice(end + 1).trim();
252
+ if (rest && !rest.startsWith("#"))
253
+ throw new Error(
254
+ `key "${ref}": ${key}= has text after its closing quote (${rest.slice(0, 20)}…).\n` +
255
+ ` Loaders disagree on what that value is. Quote the whole value.`);
256
+ const inner = v.slice(1, end);
257
+ // Inside double quotes dotenv turns \n and \r into the characters — the
258
+ // shape a PEM key takes in a .env. Handed over literally, the key has a
259
+ // backslash and an n where its line breaks go, and it does not parse.
260
+ return q === '"' ? inner.replace(/\\n/g, "\n").replace(/\\r/g, "\r") : inner;
261
+ }
262
+ const bare = v.replace(/\s+#.*$/, "");
263
+ // A `#` inside an unquoted value: dotenv 16 cuts the value there
264
+ // (`tok_#_123` → `tok_`), python-dotenv and docker compose keep it.
265
+ if (bare.includes("#"))
266
+ throw new Error(
267
+ `key "${ref}": the value of ${key}= contains a # without quotes.\n` +
268
+ ` Some loaders read everything after it as a comment and some do not, so the ` +
269
+ `credential depends on who reads the file. Quote it: ${key}="…".`);
270
+ return bare;
271
+ }
272
+
205
273
  function fromDotEnv(text, key, ref) {
206
274
  let sawAny = false;
275
+ let found = null;
207
276
  for (const line of text.split(/\r?\n/)) {
208
277
  const m = /^\s*(?:export\s+)?([A-Za-z_][A-Za-z0-9_]*)\s*=\s*(.*)$/.exec(line);
209
278
  if (!m) continue;
210
279
  sawAny = true;
211
280
  if (m[1] !== key) continue;
212
- return m[2].trim().replace(/^(['"])(.*)\1$/, "$2");
281
+ if (found !== null)
282
+ throw new Error(
283
+ `key "${ref}": ${key}= is defined more than once in that file.\n` +
284
+ ` dotenv takes the last one and other loaders the first, so which credential is meant ` +
285
+ `is not written down. Keep one.`);
286
+ found = dotEnvValue(m[2], ref, key);
213
287
  }
288
+ if (found !== null) return found;
214
289
  /**
215
290
  * Two different failures, and telling them apart is the whole message.
216
291
  *
@@ -437,16 +512,43 @@ export function modeOf(config, role, entry) {
437
512
  * is — a provider that prints the secret and then exits non-zero would
438
513
  * otherwise put it in the terminal and the log.
439
514
  */
440
- export function resolveRef(entry, provider, { run = spawnSync, root = "." } = {}) {
515
+ export function resolveRef(entry, provider, { run = spawnSync, root = ".", config = null, env = process.env } = {}) {
441
516
  if (provider?.builtin === "file" || (provider === undefined && entry.scheme === "file"))
442
517
  return readFileRef(entry, root);
443
518
  const argv = provider.command.map((part) => part.replaceAll("{ref}", entry.ref));
444
- const r = run(argv[0], argv.slice(1), { encoding: "utf8" });
519
+ /**
520
+ * Found where no role can write, and handed the same PATH.
521
+ *
522
+ * `spawnSync("fakeprov")` searched the parent's PATH, and a PATH directory
523
+ * inside a territory made the next run execute whatever that role had put
524
+ * there — outside the box, as you. Measured on 2026-09-23 with a marker file.
525
+ * The provider's own PATH is narrowed the same way, because a provider that
526
+ * is a script starts with `#!/usr/bin/env bash` and looks its interpreter up
527
+ * again. Without a config (a caller embedding seisin), or with a `run` of the
528
+ * caller's own, the argv is handed over as given: finding the program is part
529
+ * of spawning it, and whoever replaced the spawn owns that too.
530
+ */
531
+ const opts = { encoding: "utf8", timeout: PROVIDER_TIMEOUT_MS, maxBuffer: PROVIDER_MAX_BYTES };
532
+ if (config && run === spawnSync) {
533
+ argv[0] = resolveExecutable(argv[0], config, env);
534
+ opts.env = { ...env, PATH: trustedPath(config, env).join(delimiter) };
535
+ }
536
+ const r = run(argv[0], argv.slice(1), opts);
537
+ if (r.error?.code === "ETIMEDOUT")
538
+ throw new Error(
539
+ `key "${entry.raw}": the ${entry.scheme} provider (${argv[0]}) did not answer in ` +
540
+ `${PROVIDER_TIMEOUT_MS / 1000} s and was stopped.\n` +
541
+ ` A provider that waits on a prompt nobody sees would otherwise hold the run forever.`);
445
542
  if (r.error)
446
543
  throw new Error(
447
544
  `key "${entry.raw}": could not run the ${entry.scheme} provider (${argv[0]}): ${r.error.message}`);
448
545
  if (r.status !== 0) {
449
- const said = (r.stderr ?? "").trim().split("\n").slice(0, 3).join("\n ");
546
+ // Whatever it printed on stdout is taken out of stderr before stderr is
547
+ // shown: a provider that echoes the secret to both and then fails would
548
+ // otherwise put it in the terminal through the error meant to explain it.
549
+ const leaked = (r.stdout ?? "").trim();
550
+ const clean = leaked.length >= 4 ? (r.stderr ?? "").split(leaked).join("‹redacted›") : (r.stderr ?? "");
551
+ const said = clean.trim().split("\n").slice(0, 3).map((l) => l.slice(0, 200)).join("\n ");
450
552
  throw new Error(
451
553
  `key "${entry.raw}": the ${entry.scheme} provider exited ${r.status ?? "on a signal"}.` +
452
554
  (said ? `\n ${said}` : "") +
@@ -473,7 +575,7 @@ export function resolveRef(entry, provider, { run = spawnSync, root = "." } = {}
473
575
  * should say so without asking anyone's keychain for a password first.
474
576
  */
475
577
  export function resolveKeys(config, role, opts = {}) {
476
- opts = { root: config.root ?? ".", ...opts };
578
+ opts = { root: config.root ?? ".", config, ...opts };
477
579
  const refs = role.keyEntries.filter((e) => e.kind === "ref");
478
580
  const plan = refs.map((entry) => ({ entry, mode: modeOf(config, role, entry) }));
479
581
  return plan.map((p) => ({
package/src/log.js CHANGED
@@ -13,8 +13,9 @@
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, existsSync, mkdirSync, readFileSync, statSync } from "node:fs";
17
- import { dirname, join } from "node:path";
16
+ import { appendFileSync, closeSync, existsSync, mkdirSync, openSync, readdirSync, readFileSync, readSync, renameSync, statSync, unlinkSync } from "node:fs";
17
+ import { createHash } from "node:crypto";
18
+ import { basename, dirname, join } from "node:path";
18
19
  import { STATE_DIR, LOG_NAME } from "./layout.js";
19
20
  import { send } from "./spool.js";
20
21
 
@@ -37,13 +38,211 @@ export function append(file, entry) {
37
38
  if (send("log", entry)) return true;
38
39
  try {
39
40
  mkdirSync(dirname(file), { recursive: true });
40
- appendFileSync(file, JSON.stringify({ at: new Date().toISOString(), ...entry }) + "\n");
41
+ withLock(file, () => {
42
+ // Rotate first, under the same lock: a full segment is renamed away and a
43
+ // fresh one seeded so the chain continues, then this line lands in the new
44
+ // segment. Done here so the size check and the append never race.
45
+ maybeRotate(file);
46
+ // `prev` goes last and is computed from the previous line exactly as it
47
+ // sits on disk, so verifying needs nothing but the file.
48
+ const line = JSON.stringify({ at: new Date().toISOString(), ...entry, prev: hashOf(lastLine(file)) });
49
+ appendFileSync(file, line + "\n");
50
+ });
41
51
  return true;
42
52
  } catch {
43
53
  return false;
44
54
  }
45
55
  }
46
56
 
57
+ /**
58
+ * When the current segment fills, retire it and start a fresh one that still
59
+ * chains.
60
+ *
61
+ * An append-only file that only grows is the right default and the wrong
62
+ * forever: a busy team writes megabytes of it, and `verify` walks all of them
63
+ * on every run. Rotation caps the size of the file three tools tail while
64
+ * keeping the property that made the chain worth having — that a deletion shows.
65
+ *
66
+ * The retired segment keeps its own chain intact; the new segment's first line
67
+ * is a `rotated` marker whose `prev` is the retired segment's last hash, so the
68
+ * two verify as one chain across the cut (`verifyLog`). The comment on GENESIS
69
+ * asked for exactly this. Segments are numbered upward, `.1` the oldest, and
70
+ * the oldest beyond the keep count are dropped — the only place seisin removes
71
+ * a record, and it removes whole retired segments, never a line.
72
+ */
73
+ const DEFAULT_MAX_BYTES = 8 * 1024 * 1024; // 8 MiB per segment
74
+ const DEFAULT_KEEP = 5; // retired segments kept beside the current one
75
+
76
+ function envInt(name, fallback) {
77
+ const n = Number(process.env[name]);
78
+ return Number.isFinite(n) && n > 0 ? n : fallback;
79
+ }
80
+
81
+ /** Every segment of the log, oldest → newest, current last. */
82
+ export function logSegments(file) {
83
+ const dir = dirname(file);
84
+ const base = basename(file);
85
+ let names = [];
86
+ try { names = readdirSync(dir); } catch { return existsSync(file) ? [file] : []; }
87
+ const archives = names
88
+ .map((n) => {
89
+ if (!n.startsWith(base + ".")) return null;
90
+ const seq = Number(n.slice(base.length + 1));
91
+ return Number.isInteger(seq) && seq > 0 ? { seq, path: join(dir, n) } : null;
92
+ })
93
+ .filter(Boolean)
94
+ .sort((a, b) => a.seq - b.seq)
95
+ .map((a) => a.path);
96
+ if (existsSync(file)) archives.push(file);
97
+ return archives;
98
+ }
99
+
100
+ function maybeRotate(file) {
101
+ let sz = 0;
102
+ try { sz = statSync(file).size; } catch { return; }
103
+ if (sz < envInt("SEISIN_LOG_MAX_BYTES", DEFAULT_MAX_BYTES)) return;
104
+
105
+ const tail = hashOf(lastLine(file));
106
+ const archives = logSegments(file).filter((p) => p !== file);
107
+ const seqs = archives.map((p) => Number(p.slice((file + ".").length))).filter(Number.isInteger);
108
+ const next = (seqs.length ? Math.max(...seqs) : 0) + 1;
109
+ renameSync(file, `${file}.${next}`);
110
+ // Seed the new current so the first real append chains from the retired tail.
111
+ const marker = JSON.stringify({ at: new Date().toISOString(), event: "rotated", from: `${basename(file)}.${next}`, prev: tail });
112
+ appendFileSync(file, marker + "\n");
113
+
114
+ const keep = envInt("SEISIN_LOG_KEEP", DEFAULT_KEEP);
115
+ const retired = logSegments(file).filter((p) => p !== file); // oldest → newest, includes the new archive
116
+ for (let i = 0; i < retired.length - keep; i++) { try { unlinkSync(retired[i]); } catch {} }
117
+ }
118
+
119
+ /**
120
+ * The log is chained: every line carries `prev`, the hash of the line before it.
121
+ *
122
+ * The log is the record of who touched what and who was refused what, and it
123
+ * could be edited without a trace. A chain does not stop anyone with write
124
+ * access from rewriting it — nothing inside a file can — but it makes an edit,
125
+ * a deletion or a reordering show, and `seisin log verify` says where. Taken
126
+ * from nono's audit trail; the Merkle root and the signature it adds on top
127
+ * are left for when someone asks for them.
128
+ *
129
+ * The first chained line of a file points at GENESIS. A file started by
130
+ * rotation should point at the last line of the one before it; there is no
131
+ * rotation yet, and when there is, that is what it has to do.
132
+ */
133
+ export const GENESIS = "0".repeat(32);
134
+
135
+ export function hashOf(line) {
136
+ return line == null ? GENESIS : createHash("sha256").update(line).digest("hex").slice(0, 32);
137
+ }
138
+
139
+ /** The last complete line of the file, or null when there is none. */
140
+ function lastLine(file) {
141
+ if (!existsSync(file)) return null;
142
+ const { size } = statSync(file);
143
+ if (size === 0) return null;
144
+ const from = Math.max(0, size - 64 * 1024);
145
+ const fd = openSync(file, "r");
146
+ try {
147
+ const buf = Buffer.alloc(size - from);
148
+ readSync(fd, buf, 0, buf.length, from);
149
+ const lines = buf.toString("utf8").split("\n").filter((l) => l.trim());
150
+ return lines.length ? lines[lines.length - 1] : null;
151
+ } finally {
152
+ closeSync(fd);
153
+ }
154
+ }
155
+
156
+ /**
157
+ * One writer at a time. Several roles of a round run at once and write this
158
+ * same file; two of them reading the same last line would fork the chain.
159
+ * A lock left by a killed process is taken over after a few seconds.
160
+ */
161
+ function withLock(file, fn) {
162
+ const lock = file + ".lock";
163
+ const deadline = Date.now() + 2000;
164
+ for (;;) {
165
+ try {
166
+ closeSync(openSync(lock, "wx"));
167
+ break;
168
+ } catch (e) {
169
+ if (e.code !== "EEXIST") throw e;
170
+ try { if (Date.now() - statSync(lock).mtimeMs > 5000) { unlinkSync(lock); continue; } } catch {}
171
+ if (Date.now() > deadline) break; // never block the run over bookkeeping
172
+ Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, 5);
173
+ }
174
+ }
175
+ try { return fn(); } finally { try { unlinkSync(lock); } catch {} }
176
+ }
177
+
178
+ /**
179
+ * Walk the file and say where the chain breaks.
180
+ *
181
+ * Lines written before the chain existed have no `prev`; they are counted as an
182
+ * unchained prefix, not reported as tampering. After the first chained line,
183
+ * a line without `prev` is itself a break.
184
+ */
185
+ function walk(file, { seed = GENESIS, startLine = 0 } = {}) {
186
+ const out = { lines: 0, unchained: 0, chained: 0, breaks: [], tail: null };
187
+ if (!existsSync(file)) return out;
188
+ let previous = null;
189
+ let started = false;
190
+ for (const line of readFileSync(file, "utf8").split("\n")) {
191
+ if (!line.trim()) continue;
192
+ out.lines++;
193
+ let e = null;
194
+ try { e = JSON.parse(line); } catch {}
195
+ const prev = e?.prev;
196
+ if (!started && prev === undefined) { out.unchained++; previous = line; continue; }
197
+ started = true;
198
+ const first = out.chained === 0 && out.unchained === 0;
199
+ // A rotation marker leading a segment we have nothing to check it against —
200
+ // a file verified on its own, or the oldest segment left after the prefix
201
+ // was pruned for retention (seed still GENESIS) — is accepted rather than
202
+ // called a break. For any later segment the seed is the real previous tail,
203
+ // so the marker's `prev` IS checked, and a whole segment deleted or
204
+ // reordered in the middle shows.
205
+ const rotationStart = first && e?.event === "rotated" && seed === GENESIS;
206
+ const expected = first ? seed : hashOf(previous);
207
+ const ok = rotationStart || prev === expected || (first && prev === hashOf(previous));
208
+ if (!ok) out.breaks.push({ line: startLine + out.lines, expected, found: prev ?? null });
209
+ out.chained++;
210
+ previous = line;
211
+ }
212
+ out.tail = previous;
213
+ return out;
214
+ }
215
+
216
+ export function verifyChain(file, seed = GENESIS) {
217
+ const r = walk(file, { seed });
218
+ return { lines: r.lines, unchained: r.unchained, chained: r.chained, breaks: r.breaks };
219
+ }
220
+
221
+ /**
222
+ * Verify the whole log, across every rotated segment, as one chain.
223
+ *
224
+ * Each retired segment is walked in order, and its last hash becomes the seed
225
+ * the next segment's `rotated` marker must match — so deleting or reordering a
226
+ * whole segment shows, not only an edit inside one. With no rotation there is a
227
+ * single segment and this is exactly `verifyChain`. What it cannot see is a
228
+ * segment dropped off the *oldest* end by the keep limit: that is retention, not
229
+ * tampering, and the count of segments is reported so the drop is not silent.
230
+ */
231
+ export function verifyLog(file) {
232
+ const segs = logSegments(file);
233
+ const agg = { lines: 0, unchained: 0, chained: 0, breaks: [], segments: segs.length };
234
+ let seed = GENESIS;
235
+ for (const seg of segs) {
236
+ const r = walk(seg, { seed, startLine: agg.lines });
237
+ agg.lines += r.lines;
238
+ agg.unchained += r.unchained;
239
+ agg.chained += r.chained;
240
+ agg.breaks.push(...r.breaks);
241
+ seed = hashOf(r.tail);
242
+ }
243
+ return agg;
244
+ }
245
+
47
246
  /** Reads entries, newest last. A malformed line is skipped, never fatal. */
48
247
  export function read(file, { role, verdict, since, limit = 0 } = {}) {
49
248
  if (!existsSync(file)) return [];
@@ -56,6 +255,7 @@ export function read(file, { role, verdict, since, limit = 0 } = {}) {
56
255
  } catch {
57
256
  continue; // a half-written line from a killed process is not an error
58
257
  }
258
+ if (e.event === "rotated") continue; // a structural marker, not a decision
59
259
  if (role && e.role !== role) continue;
60
260
  if (verdict && e.verdict !== verdict) continue;
61
261
  if (since && e.at < since) continue;