attesso 0.4.1 → 0.6.1

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/store.mjs CHANGED
@@ -5,11 +5,19 @@
5
5
  // config.json the recorded head (count + hash), the only thing that makes a
6
6
  // truncated log visible
7
7
  // lock held while appending, so two agents cannot interleave writes
8
- import { closeSync, existsSync, mkdirSync, openSync, readFileSync, renameSync, unlinkSync, writeFileSync, writeSync } from 'node:fs';
9
- import { join } from 'node:path';
10
- import { buildEntry, entryHash, newSigningKey, signEntry } from './ledger.mjs';
11
- import { evaluate, policyDigest, validatePolicy } from './policy.mjs';
12
- import { evaluatePath, rulesDigest, validateRules } from './rules.mjs';
8
+ import { randomBytes } from 'node:crypto';
9
+ import { channel } from 'node:diagnostics_channel';
10
+ import { closeSync, existsSync, fsyncSync, ftruncateSync, linkSync, mkdirSync, openSync, readFileSync, readSync, renameSync, statSync, unlinkSync, writeFileSync } from 'node:fs';
11
+ import { dirname, join } from 'node:path';
12
+ import { buildEntry, entryHash, newSigningKey, signEntry, verifyEntry, verifyHead, verifyStep } from './ledger.mjs';
13
+ import { DEFAULT_TOOLS, loadTools } from './classify.mjs';
14
+ import { decide } from './decide.mjs';
15
+ import { parseLine, parseTail } from './jsonl.mjs';
16
+ import { policyDigest, validatePolicy } from './policy.mjs';
17
+ import { rulesDigest, rulesForProfile, validateRules } from './rules.mjs';
18
+ import { toolGate, manifestDigest, toolsDigest } from './toolpin.mjs';
19
+ import { evaluatePathScope, validatePathsConfig } from './fs-paths.mjs';
20
+ import { forEachEntry, forEachEntrySync } from './stream.mjs';
13
21
 
14
22
  export function paths(dir) {
15
23
  return {
@@ -17,48 +25,247 @@ export function paths(dir) {
17
25
  key: join(dir, 'key.json'),
18
26
  policy: join(dir, 'policy.json'),
19
27
  rules: join(dir, 'rules.json'),
28
+ fsPaths: join(dir, 'paths.json'),
29
+ uiIndex: join(dir, 'ui-index.json'),
20
30
  ledger: join(dir, 'ledger.jsonl'),
21
31
  config: join(dir, 'config.json'),
22
32
  lock: join(dir, 'lock'),
33
+ policies: join(dir, 'policies'),
34
+ tools: join(dir, 'tools.json'),
35
+ toolLock: join(dir, 'tools.lock.json'),
36
+ toolsSnapshots: join(dir, 'tools'),
37
+ mcpSnapshots: join(dir, 'mcp'),
23
38
  };
24
39
  }
25
40
 
26
41
  const readJson = (path) => JSON.parse(readFileSync(path, 'utf8'));
27
42
 
43
+ // In-process monitoring: no dependency, no output, no cost when nobody listens.
44
+ const decisionChannel = channel('bounds.decision');
45
+
46
+ function publishDecision(entry) {
47
+ if (!decisionChannel.hasSubscribers) return;
48
+ try {
49
+ decisionChannel.publish({
50
+ seq: entry.seq,
51
+ at: entry.at,
52
+ action: entry.action,
53
+ decision: entry.decision,
54
+ code: entry.code,
55
+ rule: entry.rule ?? null,
56
+ capability: entry.capability ?? null,
57
+ trust: entry.trust ?? null,
58
+ args_hash: entry.args_hash ?? null,
59
+ policy_digest: entry.policy_digest,
60
+ rules_digest: entry.rules_digest ?? null,
61
+ tools_digest: entry.tools_digest ?? null,
62
+ tool_pin: entry.tool_pin ?? null,
63
+ mcp_digest: entry.mcp_digest ?? null,
64
+ entry_hash: entryHash(entry),
65
+ kid: entry.kid,
66
+ });
67
+ } catch {
68
+ // publish runs subscribers synchronously; a subscriber that throws is a bug
69
+ // in the subscriber, not a reason to fail the decision that was recorded.
70
+ }
71
+ }
72
+
28
73
  function writeJson(path, value) {
29
- const temporary = `${path}.tmp`;
30
- writeFileSync(temporary, `${JSON.stringify(value, null, 2)}\n`, { mode: 0o600 });
74
+ const temporary = `${path}.${process.pid}.tmp`;
75
+ const text = `${JSON.stringify(value, null, 2)}\n`;
76
+ const handle = openSync(temporary, 'w', 0o600);
77
+ try {
78
+ writeFileSync(handle, text);
79
+ fsyncSync(handle);
80
+ } finally {
81
+ closeSync(handle);
82
+ }
31
83
  renameSync(temporary, path);
84
+ fsyncDir(dirname(path));
85
+ }
86
+
87
+ function writeEntry(file, entry) {
88
+ const handle = openSync(file.ledger, 'a', 0o600);
89
+ try {
90
+ writeFileSync(handle, `${JSON.stringify(entry)}\n`);
91
+ fsyncSync(handle);
92
+ } finally {
93
+ closeSync(handle);
94
+ }
95
+ }
96
+
97
+ function readAt(fd, buffer, position) {
98
+ let offset = 0;
99
+ while (offset < buffer.length) {
100
+ const bytes = readSync(fd, buffer, offset, buffer.length - offset, position + offset);
101
+ if (bytes === 0) break;
102
+ offset += bytes;
103
+ }
104
+ return offset;
105
+ }
106
+
107
+ // A crash can leave a line without its newline. A parseable line is kept (its
108
+ // signature still decides whether it counts); an unparseable tail was never an
109
+ // entry and is dropped. The head count is untouched, so a real truncation still
110
+ // fails the append instead of being papered over. This reads backwards so an
111
+ // old ledger is not loaded into memory just to repair its last line.
112
+ function repairTornTail(file) {
113
+ const size = statSync(file.ledger).size;
114
+ if (size === 0) return;
115
+ const fd = openSync(file.ledger, 'r+');
116
+ try {
117
+ const last = Buffer.alloc(1);
118
+ readAt(fd, last, size - 1);
119
+ if (last[0] === 0x0a) return;
120
+
121
+ let newline = -1;
122
+ let end = size - 1;
123
+ while (end >= 0 && newline === -1) {
124
+ const start = Math.max(0, end - 64 * 1024 + 1);
125
+ const chunk = Buffer.alloc(end - start + 1);
126
+ readAt(fd, chunk, start);
127
+ const at = chunk.lastIndexOf(0x0a);
128
+ if (at !== -1) newline = start + at;
129
+ end = start - 1;
130
+ }
131
+ const cut = newline + 1;
132
+ const tail = Buffer.alloc(size - cut);
133
+ readAt(fd, tail, cut);
134
+ let complete = false;
135
+ try {
136
+ JSON.parse(tail.toString('utf8'));
137
+ complete = true;
138
+ } catch {
139
+ // The tail is a partial write, not an entry.
140
+ }
141
+ if (complete) {
142
+ const handle = openSync(file.ledger, 'a', 0o600);
143
+ try {
144
+ writeFileSync(handle, '\n');
145
+ fsyncSync(handle);
146
+ } finally {
147
+ closeSync(handle);
148
+ }
149
+ return;
150
+ }
151
+ ftruncateSync(fd, cut);
152
+ fsyncSync(fd);
153
+ } finally {
154
+ closeSync(fd);
155
+ }
156
+ }
157
+
158
+ // A rename is durable once the directory entry is flushed; Windows cannot open a
159
+ // directory for fsync, so this is best-effort there.
160
+ function fsyncDir(path) {
161
+ let handle;
162
+ try {
163
+ handle = openSync(path, 'r');
164
+ fsyncSync(handle);
165
+ } catch {
166
+ // Directory fsync is unavailable on this platform; the file fsync still ran.
167
+ } finally {
168
+ if (handle !== undefined) closeSync(handle);
169
+ }
170
+ }
171
+
172
+ // A decision names the exact policy and rules bytes that judged it, so a receipt
173
+ // can carry them later. Content addressed, written once, never rewritten.
174
+ const snapshotBase = (dir, kind) => (kind === 'tools' ? paths(dir).toolsSnapshots : paths(dir).policies);
175
+ const snapshotFile = (dir, digest, kind) => join(snapshotBase(dir, kind), `${digest}.json`);
176
+
177
+ function writeSnapshot(dir, digest, value, kind = 'policy') {
178
+ const file = snapshotFile(dir, digest, kind);
179
+ if (existsSync(file)) return;
180
+ mkdirSync(snapshotBase(dir, kind), { recursive: true, mode: 0o700 });
181
+ writeJson(file, value);
182
+ }
183
+
184
+ export function readToolLock(dir) {
185
+ const file = paths(dir).toolLock;
186
+ return existsSync(file) ? JSON.parse(readFileSync(file, 'utf8')) : null;
187
+ }
188
+
189
+ export function writeToolLock(dir, lock) {
190
+ return withLock(dir, () => {
191
+ writeJson(paths(dir).toolLock, lock);
192
+ return lock;
193
+ });
194
+ }
195
+
196
+ export function writeMcpManifest(dir, manifest) {
197
+ const digest = manifestDigest(manifest);
198
+ writeSnapshot(dir, digest, manifest, 'mcp');
199
+ return digest;
32
200
  }
33
201
 
34
202
  export function isInitialised(dir) {
35
203
  return existsSync(paths(dir).key);
36
204
  }
37
205
 
38
- export function init(dir) {
206
+ export function readKey(dir) {
207
+ return readJson(paths(dir).key);
208
+ }
209
+
210
+ export function readConfig(dir) {
211
+ return readJson(paths(dir).config);
212
+ }
213
+
214
+ export function readSnapshot(dir, digest, kind = 'policy') {
215
+ const file = snapshotFile(dir, digest, kind);
216
+ if (!existsSync(file)) throw new Error(`SNAPSHOT_MISSING: ${kind} ${digest} was not archived with this entry`);
217
+ return readJson(file);
218
+ }
219
+
220
+ export function init(dir, { profile = 'balanced' } = {}) {
39
221
  if (isInitialised(dir)) {
40
222
  throw new Error(`${dir} already holds a ledger; refusing to replace its key`);
41
223
  }
224
+ const rules = rulesForProfile(profile);
42
225
  mkdirSync(dir, { recursive: true, mode: 0o700 });
43
- const file = paths(dir);
44
- const key = newSigningKey();
45
- writeJson(file.key, { version: 1, kid: key.kid, public: key.publicPem, private: key.privatePem });
46
- // Record everything by default and let rules.json decide: a policy that names
47
- // one action refuses every other action, so a fresh install could not log the
48
- // very commands its own README shows.
49
- writeJson(file.policy, {
50
- version: 1,
51
- action: '*',
226
+ return withLock(dir, () => {
227
+ // Two processes that saw an empty directory must not both create a key.
228
+ // The first one wins; the second sees the key under the same lock.
229
+ if (isInitialised(dir)) {
230
+ throw new Error(`${dir} already holds a ledger; refusing to replace its key`);
231
+ }
232
+ const file = paths(dir);
233
+ const key = newSigningKey();
234
+ // Record everything by default and let rules.json decide: a policy that names
235
+ // one action refuses every other action, so a fresh install could not log the
236
+ // very commands its own README shows.
237
+ writeJson(file.policy, {
238
+ version: 1,
239
+ action: '*',
240
+ });
241
+ writeJson(file.config, { version: 1, head: { count: 0, hash: 'genesis' } });
242
+ writeJson(file.tools, DEFAULT_TOOLS);
243
+ writeJson(file.rules, rules);
244
+ writeFileSync(file.ledger, '', { mode: 0o600 });
245
+ // The key is the readiness marker; writing it last means a crash during init
246
+ // leaves a directory that is still uninitialised, not one that half works.
247
+ writeJson(file.key, { version: 1, kid: key.kid, public: key.publicPem, private: key.privatePem });
248
+ return { dir, kid: key.kid, profile };
52
249
  });
53
- writeJson(file.config, { version: 1, head: { count: 0, hash: 'genesis' } });
54
- writeJson(file.rules, {
55
- version: 1,
56
- rules: [
57
- { id: 'no-send-after-untrusted-read', deny: { capabilities: ['send', 'execute'] }, if_path: { trust: 'untrusted', capability: 'read' } },
58
- ],
250
+ }
251
+
252
+ // An existing ledger can adopt a shipped profile without touching its key,
253
+ // head or entries. The previous rules stay beside the ledger for rollback.
254
+ export function setRulesProfile(dir, profile = 'balanced') {
255
+ const next = rulesForProfile(profile);
256
+ validateRules(next);
257
+ return withLock(dir, () => {
258
+ const file = paths(dir);
259
+ const previous = existsSync(file.rules) ? readFileSync(file.rules, 'utf8') : null;
260
+ const previousDigest = previous === null ? undefined : rulesDigest(JSON.parse(previous));
261
+ const nextDigest = rulesDigest(next);
262
+ if (previousDigest === nextDigest) {
263
+ return { profile, changed: false, previous: previousDigest, digest: nextDigest };
264
+ }
265
+ if (previous !== null) renameSync(file.rules, `${file.rules}.bak`);
266
+ writeJson(file.rules, next);
267
+ return { profile, changed: true, previous: previousDigest, digest: nextDigest };
59
268
  });
60
- writeFileSync(file.ledger, '', { mode: 0o600 });
61
- return { dir, kid: key.kid };
62
269
  }
63
270
 
64
271
  export function readState(dir) {
@@ -75,16 +282,43 @@ export function readState(dir) {
75
282
  export function readEntries(dir) {
76
283
  const text = readFileSync(paths(dir).ledger, 'utf8');
77
284
  if (text === '') return [];
78
- return text
79
- .split('\n')
80
- .filter((line) => line !== '')
81
- .map((line, index) => {
82
- try {
83
- return JSON.parse(line);
84
- } catch {
85
- throw new Error(`ledger line ${index + 1} is not JSON; the file was edited or truncated mid-line`);
86
- }
285
+ const lines = text.split('\n');
286
+ const trailing = lines.pop();
287
+ const entries = lines.map((line, index) => parseLine(line, index + 1));
288
+ // A reader can catch an append mid-line. A partial tail is not an entry and
289
+ // is ignored; the recorded head still makes a real truncation visible.
290
+ if (trailing !== '') {
291
+ const entry = parseTail(trailing, lines.length + 1);
292
+ if (entry !== undefined) entries.push(entry);
293
+ }
294
+ return entries;
295
+ }
296
+
297
+ // Stream the same checks verifyChain performs over an array. This is the
298
+ // path that keeps a year-old ledger from being loaded into memory at once.
299
+ export async function verifyLedger(dir) {
300
+ const file = paths(dir);
301
+ let key;
302
+ let head;
303
+ try {
304
+ key = readKey(dir);
305
+ head = readConfig(dir).head;
306
+ } catch (error) {
307
+ return { ok: false, reason: error.message };
308
+ }
309
+ let previous = 'genesis';
310
+ let count = 0;
311
+ try {
312
+ await forEachEntry(file.ledger, (entry, index) => {
313
+ const step = verifyStep(entry, index - 1, previous, key.public);
314
+ if (!step.ok) throw new Error(step.reason);
315
+ previous = step.hash;
316
+ count = index;
87
317
  });
318
+ } catch (error) {
319
+ return { ok: false, reason: error.message, kid: key.kid };
320
+ }
321
+ return { ...verifyHead(count, previous, head), kid: key.kid, count };
88
322
  }
89
323
 
90
324
  function processAlive(pid) {
@@ -96,51 +330,170 @@ function processAlive(pid) {
96
330
  }
97
331
  }
98
332
 
333
+ // A writer waits for a live holder instead of failing the decision. The process
334
+ // is short-lived, so a synchronous sleep is the simplest correct backoff.
335
+ function sleepSync(ms) {
336
+ Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, ms);
337
+ }
338
+
339
+ const LOCK_WAIT_MS = () => Number(process.env.ATTESSO_LOCK_WAIT_MS ?? 5000);
340
+ const LOCK_RETRY_MS = 25;
341
+
342
+ // Create the lock with its owner already inside, then link it into place. A
343
+ // plain O_EXCL create leaves an empty file for a moment, and a waiter that reads
344
+ // it in that window would reclaim a live lock. The token lets the holder remove
345
+ // only its own lock, never one a later process created after reclaiming it.
346
+ function acquireLock(lock) {
347
+ const token = `${process.pid}.${randomBytes(6).toString('hex')}`;
348
+ const candidate = `${lock}.${token}`;
349
+ const handle = openSync(candidate, 'wx', 0o600);
350
+ try {
351
+ writeFileSync(handle, token);
352
+ fsyncSync(handle);
353
+ } finally {
354
+ closeSync(handle);
355
+ }
356
+ try {
357
+ linkSync(candidate, lock);
358
+ return token;
359
+ } catch (error) {
360
+ if (error.code === 'EEXIST') return null;
361
+ throw error;
362
+ } finally {
363
+ try {
364
+ unlinkSync(candidate);
365
+ } catch {
366
+ // The candidate was already removed; the lock name is what matters.
367
+ }
368
+ }
369
+ }
370
+
99
371
  function withLock(dir, run) {
100
372
  const { lock } = paths(dir);
101
- for (let attempt = 0; attempt < 2; attempt += 1) {
102
- try {
103
- const handle = openSync(lock, 'wx', 0o600);
104
- writeSync(handle, String(process.pid));
105
- closeSync(handle);
373
+ const deadline = Date.now() + LOCK_WAIT_MS();
374
+ for (;;) {
375
+ const token = acquireLock(lock);
376
+ if (token) {
106
377
  try {
107
378
  return run();
108
379
  } finally {
109
- unlinkSync(lock);
380
+ try {
381
+ if (readFileSync(lock, 'utf8').trim() === token) unlinkSync(lock);
382
+ } catch {
383
+ // The lock is already gone; nothing to undo.
384
+ }
110
385
  }
111
- } catch (error) {
112
- if (error.code !== 'EEXIST') throw error;
113
- const holder = Number(readFileSync(lock, 'utf8'));
114
- if (!processAlive(holder)) {
115
- unlinkSync(lock);
386
+ }
387
+ let raw = '';
388
+ try {
389
+ raw = readFileSync(lock, 'utf8').trim();
390
+ } catch {
391
+ // The holder released it between link and read; retry immediately.
392
+ continue;
393
+ }
394
+ const holder = Number(raw.split('.')[0]);
395
+ if (raw === '' || !Number.isInteger(holder) || holder <= 0) {
396
+ throw new Error(`the ledger lock at ${lock} has no owner; remove it if no attesso process is running`);
397
+ }
398
+ if (!processAlive(holder)) {
399
+ // Only unlink the lock we judged stale, not one a new holder just linked.
400
+ let current = '';
401
+ try {
402
+ current = readFileSync(lock, 'utf8').trim();
403
+ } catch {
116
404
  continue;
117
405
  }
406
+ if (current === raw) {
407
+ try {
408
+ unlinkSync(lock);
409
+ } catch {
410
+ // Someone else reclaimed it first; the next iteration takes it.
411
+ }
412
+ }
413
+ continue;
414
+ }
415
+ if (Date.now() >= deadline) {
118
416
  throw new Error(`another attesso process (pid ${holder}) holds the ledger; retry when it finishes`);
119
417
  }
418
+ sleepSync(LOCK_RETRY_MS + Math.floor(Math.random() * LOCK_RETRY_MS));
419
+ }
420
+ }
421
+
422
+ export function withLedgerLock(dir, run) {
423
+ return withLock(dir, run);
424
+ }
425
+
426
+ // A crash can leave signed lines whose head update never landed. Verify the
427
+ // tail while collecting only the task history the rules need, so append does
428
+ // not hold the whole ledger in memory.
429
+ function reconcileHead(dir, head, task, publicPem) {
430
+ let count = 0;
431
+ let lastHash = head.hash;
432
+ const history = [];
433
+ forEachEntrySync(paths(dir).ledger, (entry, index) => {
434
+ count = index;
435
+ if (index > head.count) {
436
+ if (entry.seq !== index) throw new Error(`entry ${index} carries seq ${entry.seq}`);
437
+ if (entry.prev !== lastHash) throw new Error(`entry ${entry.seq} does not chain to the entry before it`);
438
+ if (!verifyEntry(entry, publicPem)) throw new Error(`entry ${entry.seq} signature does not verify`);
439
+ lastHash = entryHash(entry);
440
+ }
441
+ if ((entry.task ?? null) === (task ?? null)) history.push(entry);
442
+ });
443
+ if (count < head.count) {
444
+ throw new Error(`config records ${head.count} entries, the log holds ${count}; the ledger was truncated`);
120
445
  }
121
- throw new Error('could not take the ledger lock');
446
+ if (count === head.count) return { head, history };
447
+ const repaired = { count, hash: lastHash };
448
+ writeJson(paths(dir).config, { version: 1, head: repaired });
449
+ return { head: repaired, history };
122
450
  }
123
451
 
124
- export function append(dir, proposal, { now = new Date() } = {}) {
452
+ export function append(dir, proposal, { now = new Date(), control } = {}) {
125
453
  return withLock(dir, () => {
126
454
  const file = paths(dir);
455
+ repairTornTail(file);
127
456
  const key = readJson(file.key);
128
457
  const config = readJson(file.config);
129
458
  const policy = validatePolicy(readJson(file.policy));
130
459
  const rules = existsSync(file.rules) ? validateRules(readJson(file.rules)) : null;
131
- const history = readEntries(dir).filter((entry) => (entry.task ?? null) === (proposal.task ?? null));
132
- const policyVerdict = evaluate(policy, proposal, now);
133
- const pathVerdict = policyVerdict.decision === 'ALLOW'
134
- ? evaluatePath(rules, history, proposal, now)
460
+ const pathConfig = existsSync(file.fsPaths) ? validatePathsConfig(readJson(file.fsPaths)) : null;
461
+ const tools = loadTools(dir);
462
+ const toolsHash = toolsDigest(tools);
463
+ const toolLock = readToolLock(dir);
464
+ const toolHash =
465
+ typeof proposal.tool_hash === 'string' && proposal.tool_hash !== ''
466
+ ? proposal.tool_hash
467
+ : typeof proposal.tool_definition_hash === 'string' && proposal.tool_definition_hash !== ''
468
+ ? proposal.tool_definition_hash
469
+ : undefined;
470
+ const mcpDigest =
471
+ typeof proposal.mcp_digest === 'string' && proposal.mcp_digest !== '' ? proposal.mcp_digest : undefined;
472
+ const toolVerdict = toolGate(toolLock, toolsHash, { ...proposal, tool_hash: toolHash });
473
+ const policyHash = policyDigest(policy);
474
+ const rulesHash = rules ? rulesDigest(rules) : undefined;
475
+ const reconciled = reconcileHead(dir, config.head, proposal.task ?? null, key.public);
476
+ const head = reconciled.head;
477
+ const history = reconciled.history;
478
+ const controlVerdict = control
479
+ ? {
480
+ decision: 'DENY',
481
+ code: control.mode === 'stop' ? 'TASK_STOPPED' : 'TASK_PAUSED',
482
+ rule: `control.${control.mode}`,
483
+ }
135
484
  : null;
136
- const verdict = pathVerdict ?? policyVerdict;
137
- const meta = pathVerdict?.tainted_by === undefined
138
- ? proposal.meta
139
- : { ...(proposal.meta ?? {}), rule: pathVerdict.rule, tainted_by: pathVerdict.tainted_by };
140
- const seq = config.head.count + 1;
485
+ const pathVerdict = pathConfig ? evaluatePathScope(pathConfig, proposal) : null;
486
+ const verdict = controlVerdict ?? toolVerdict ?? pathVerdict ?? decide({ policy, rules, history, proposal, now });
487
+ const toolPin = toolLock
488
+ ? (toolVerdict?.tool_pin ?? (toolHash ? 'verified' : 'unverified'))
489
+ : undefined;
490
+ const meta = { ...(proposal.meta ?? {}) };
491
+ const argsHash = proposal.args_hash ?? meta.args_hash;
492
+ delete meta.args_hash;
493
+ const seq = head.count + 1;
141
494
  const entry = buildEntry({
142
495
  seq,
143
- prev: config.head.hash,
496
+ prev: head.hash,
144
497
  action: proposal.action,
145
498
  subject: proposal.subject,
146
499
  amount: proposal.amount,
@@ -150,18 +503,26 @@ export function append(dir, proposal, { now = new Date() } = {}) {
150
503
  capability: proposal.capability,
151
504
  decision: verdict.decision,
152
505
  code: verdict.code,
153
- policyDigest: policyDigest(policy),
506
+ rule: verdict.rule,
507
+ taintedBy: verdict.tainted_by,
508
+ argsHash,
509
+ policyDigest: policyHash,
510
+ rulesDigest: rulesHash,
511
+ toolsDigest: toolsHash,
512
+ toolHash,
513
+ toolPin,
514
+ mcpDigest,
515
+ control,
154
516
  kid: key.kid,
155
517
  at: now.toISOString(),
156
518
  });
157
519
  entry.sig = signEntry(entry, key.private);
158
- const handle = openSync(file.ledger, 'a', 0o600);
159
- try {
160
- writeSync(handle, `${JSON.stringify(entry)}\n`);
161
- } finally {
162
- closeSync(handle);
163
- }
520
+ writeSnapshot(dir, policyHash, policy);
521
+ if (rules) writeSnapshot(dir, rulesHash, rules);
522
+ writeSnapshot(dir, toolsHash, tools, 'tools');
523
+ writeEntry(file, entry);
164
524
  writeJson(file.config, { version: 1, head: { count: seq, hash: entryHash(entry) } });
525
+ publishDecision(entry);
165
526
  return entry;
166
527
  });
167
528
  }
package/src/stream.mjs ADDED
@@ -0,0 +1,72 @@
1
+ // Read a JSONL ledger one line at a time. The rules match readEntries: a
2
+ // complete malformed line is corruption, a malformed final fragment is a writer
3
+ // caught mid-line and is ignored, and a parseable fragment without a newline is
4
+ // still an entry.
5
+ import { closeSync, createReadStream, openSync, readSync } from 'node:fs';
6
+ import { StringDecoder } from 'node:string_decoder';
7
+ import { parseLine, parseTail } from './jsonl.mjs';
8
+
9
+ // The async reader feeds report and verify; append needs the same byte-exact
10
+ // framing without turning every caller into an async function.
11
+ export function forEachEntrySync(file, onEntry, { startOffset = 0 } = {}) {
12
+ const fd = openSync(file, 'r');
13
+ const decoder = new StringDecoder('utf8');
14
+ const chunk = Buffer.alloc(64 * 1024);
15
+ let buffer = '';
16
+ let index = 0;
17
+ let offset = startOffset;
18
+ let readOffset = startOffset;
19
+ try {
20
+ let bytes;
21
+ while ((bytes = readSync(fd, chunk, 0, chunk.length, readOffset)) > 0) {
22
+ readOffset += bytes;
23
+ buffer += decoder.write(chunk.subarray(0, bytes));
24
+ let newline;
25
+ while ((newline = buffer.indexOf('\n')) !== -1) {
26
+ const line = buffer.slice(0, newline);
27
+ buffer = buffer.slice(newline + 1);
28
+ const start = offset;
29
+ offset += Buffer.byteLength(line, 'utf8') + 1;
30
+ index += 1;
31
+ const keep = onEntry(parseLine(line, index), index, start, offset);
32
+ if (keep === false) return;
33
+ }
34
+ }
35
+ buffer += decoder.end();
36
+ if (buffer !== '') {
37
+ const start = offset;
38
+ offset += Buffer.byteLength(buffer, 'utf8');
39
+ const entry = parseTail(buffer, index + 1);
40
+ if (entry !== undefined) onEntry(entry, index + 1, start, offset);
41
+ }
42
+ } finally {
43
+ closeSync(fd);
44
+ }
45
+ }
46
+
47
+ export async function forEachEntry(file, onEntry) {
48
+ const stream = createReadStream(file);
49
+ const decoder = new StringDecoder('utf8');
50
+ let buffer = '';
51
+ let index = 0;
52
+
53
+ const handle = async (line, tail) => {
54
+ index += 1;
55
+ const entry = tail ? parseTail(line, index) : parseLine(line, index);
56
+ if (entry === undefined) return;
57
+ await onEntry(entry, index);
58
+ };
59
+
60
+ for await (const chunk of stream) {
61
+ buffer += decoder.write(chunk);
62
+ let at = buffer.indexOf('\n');
63
+ while (at !== -1) {
64
+ const line = buffer.slice(0, at);
65
+ buffer = buffer.slice(at + 1);
66
+ await handle(line, false);
67
+ at = buffer.indexOf('\n');
68
+ }
69
+ }
70
+ buffer += decoder.end();
71
+ if (buffer !== '') await handle(buffer, true);
72
+ }