@axtary/ledger 0.1.0 → 0.2.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/dist/index.js CHANGED
@@ -1,11 +1,20 @@
1
- import { mkdir, readFile, rename, writeFile } from "node:fs/promises";
1
+ import { createHash, randomUUID } from "node:crypto";
2
+ import { mkdir, open, readFile, rename, rm } from "node:fs/promises";
2
3
  import { dirname } from "node:path";
3
- import { AxtaryDecisionSchema, hashJson, LedgerExecutionOutcomeSchema, LedgerRecordSchema, recordDecision, } from "@axtary/actionpass";
4
+ import { AxtaryDecisionSchema, hashJson, LedgerExecutionOutcomeSchema, LedgerRecordSchema, recordActionPassRevocationEvent, recordDecision, } from "@axtary/actionpass";
5
+ import { decodeProtectedHeader, importJWK, jwtVerify, SignJWT, } from "jose";
6
+ import { lock } from "proper-lockfile";
4
7
  import { z } from "zod";
8
+ import { merkleConsistencyProof, merkleInclusionProof, merkleLeafHash, merkleNodeHash, merkleTreeRoot, verifyMerkleConsistency, verifyMerkleInclusion, } from "./merkle.js";
9
+ export { merkleConsistencyProof, merkleInclusionProof, merkleLeafHash, merkleNodeHash, merkleTreeRoot, verifyMerkleConsistency, verifyMerkleInclusion, };
10
+ export { BEHAVIOR_STORE_VERSION, BehaviorStoreSchema, InMemoryBehaviorStore, FileBehaviorStore, deriveBehaviorSignals, recordBehaviorObservation, } from "./behavior.js";
11
+ export { LEDGER_FORENSICS_VERSION, analyzeForensics, reconstructIncident, } from "./forensics.js";
5
12
  export const LOCAL_LEDGER_SCHEMA_VERSION = "axtary.local-ledger.v0";
13
+ export const LOCAL_LEDGER_HEAD_SCHEMA_VERSION = "axtary.local-ledger-head.v0";
6
14
  export const LEDGER_EXPORT_SCHEMA_VERSION = "axtary.ledger_export.v0";
7
15
  export const LEDGER_SYNC_SCHEMA_VERSION = "axtary.ledger_sync.v0";
8
16
  export const LEDGER_SIEM_EVENT_SCHEMA_VERSION = "axtary.siem_event.v0";
17
+ export const LEDGER_OTEL_EXPORT_SCHEMA_VERSION = "axtary.otel_trace_export.v0";
9
18
  export const LedgerAppendResultSchema = z.object({
10
19
  record: LedgerRecordSchema,
11
20
  lineNumber: z.number().int().positive(),
@@ -60,7 +69,18 @@ export const LedgerSiemEventSchema = z.object({
60
69
  }),
61
70
  provider: z
62
71
  .object({
63
- name: z.enum(["github", "slack", "linear", "docs", "mcp", "aws", "gcp", "jira"]),
72
+ name: z.enum([
73
+ "github",
74
+ "slack",
75
+ "linear",
76
+ "docs",
77
+ "drive",
78
+ "mcp",
79
+ "aws",
80
+ "gcp",
81
+ "jira",
82
+ "postgres",
83
+ ]),
64
84
  tool: z.string().min(1),
65
85
  operation: z.string().min(1),
66
86
  resourceKind: z.string().min(1),
@@ -78,58 +98,81 @@ export const LedgerSyncResultSchema = z.object({
78
98
  lastLedgerHash: z.string().regex(/^sha256:[a-f0-9]{64}$/).nullable(),
79
99
  response: z.unknown().nullable(),
80
100
  });
101
+ const LedgerHeadStateSchema = z.object({
102
+ schemaVersion: z.literal(LOCAL_LEDGER_HEAD_SCHEMA_VERSION),
103
+ fileSize: z.number().int().nonnegative(),
104
+ lineNumber: z.number().int().nonnegative(),
105
+ lastLedgerHash: z.string().regex(/^sha256:[a-f0-9]{64}$/).nullable(),
106
+ });
81
107
  export class LocalJsonlLedger {
82
108
  filePath;
83
- constructor(filePath) {
109
+ headPath;
110
+ options;
111
+ constructor(filePath, options = {}) {
84
112
  this.filePath = filePath;
113
+ this.headPath = `${filePath}.head.json`;
114
+ this.options = options;
85
115
  }
86
116
  async appendDecision(input) {
87
- const records = await this.readRecords();
88
- const previousLedgerHash = records.at(-1)?.ledgerHash ?? null;
89
- const record = recordDecision({
90
- action: input.action,
91
- decision: input.decision,
92
- actionPassId: input.actionPassId ?? null,
93
- previousLedgerHash,
94
- occurredAt: input.occurredAt,
117
+ const result = await this.withAppendLock(async (head) => {
118
+ const record = recordDecision({
119
+ action: input.action,
120
+ decision: input.decision,
121
+ actionPassId: input.actionPassId ?? null,
122
+ delegation: input.delegation,
123
+ budget: input.budget,
124
+ previousLedgerHash: head.lastLedgerHash,
125
+ occurredAt: input.occurredAt,
126
+ });
127
+ return this.appendLocked(record, head);
95
128
  });
96
- return this.appendRecord(record);
129
+ await this.exportTelemetry(result.record);
130
+ return result;
97
131
  }
98
132
  async appendExecutionOutcome(input) {
99
- const records = await this.readRecords();
100
- const previousLedgerHash = records.at(-1)?.ledgerHash ?? null;
101
- const executionOutcome = LedgerExecutionOutcomeSchema.parse(input.executionOutcome);
102
- const record = recordDecision({
103
- action: input.action,
104
- decision: input.decision,
105
- actionPassId: input.actionPassId ?? null,
106
- previousLedgerHash,
107
- occurredAt: input.occurredAt,
108
- traceId: input.traceId,
109
- executionOutcome,
110
- correlationId: input.correlationId,
133
+ const result = await this.withAppendLock(async (head) => {
134
+ const executionOutcome = LedgerExecutionOutcomeSchema.parse(input.executionOutcome);
135
+ const record = recordDecision({
136
+ action: input.action,
137
+ decision: input.decision,
138
+ actionPassId: input.actionPassId ?? null,
139
+ delegation: input.delegation,
140
+ budget: input.budget,
141
+ previousLedgerHash: head.lastLedgerHash,
142
+ occurredAt: input.occurredAt,
143
+ traceId: input.traceId,
144
+ executionOutcome,
145
+ correlationId: input.correlationId,
146
+ });
147
+ return this.appendLocked(record, head);
111
148
  });
112
- return this.appendRecord(record);
149
+ await this.exportTelemetry(result.record);
150
+ return result;
151
+ }
152
+ async appendRevocation(input) {
153
+ const result = await this.withAppendLock(async (head) => {
154
+ const record = recordActionPassRevocationEvent({
155
+ revokedRecord: input.revokedRecord,
156
+ revokedBy: input.revokedBy,
157
+ reason: input.reason,
158
+ occurredAt: input.occurredAt,
159
+ previousLedgerHash: head.lastLedgerHash,
160
+ });
161
+ return this.appendLocked(record, head);
162
+ });
163
+ await this.exportTelemetry(result.record);
164
+ return result;
113
165
  }
114
166
  async appendRecord(recordInput) {
115
- const records = await this.readRecords();
116
- const previousRecord = records.at(-1) ?? null;
117
167
  const record = LedgerRecordSchema.parse(recordInput);
118
- if (record.previousLedgerHash !== (previousRecord?.ledgerHash ?? null)) {
119
- throw new Error("ledger_previous_hash_mismatch");
120
- }
121
- assertRecordHash(record);
122
- await mkdir(dirname(this.filePath), { recursive: true });
123
- const nextContent = [...records, record]
124
- .map((entry) => JSON.stringify(entry))
125
- .join("\n");
126
- const tmpPath = `${this.filePath}.${process.pid}.${Date.now()}.tmp`;
127
- await writeFile(tmpPath, `${nextContent}\n`, { encoding: "utf8", flag: "wx" });
128
- await rename(tmpPath, this.filePath);
129
- return LedgerAppendResultSchema.parse({
130
- record,
131
- lineNumber: records.length + 1,
168
+ const result = await this.withAppendLock(async (head) => {
169
+ if (record.previousLedgerHash !== head.lastLedgerHash) {
170
+ throw new Error("ledger_previous_hash_mismatch");
171
+ }
172
+ return this.appendLocked(record, head);
132
173
  });
174
+ await this.exportTelemetry(result.record);
175
+ return result;
133
176
  }
134
177
  async readRecords() {
135
178
  const text = await readLedgerFile(this.filePath);
@@ -187,10 +230,133 @@ export class LocalJsonlLedger {
187
230
  lastLedgerHash: records.at(-1)?.ledgerHash ?? null,
188
231
  };
189
232
  }
233
+ async withAppendLock(operation) {
234
+ await mkdir(dirname(this.filePath), { recursive: true });
235
+ let compromisedReason = null;
236
+ const lockOptions = this.options.lock ?? {};
237
+ const release = await lock(this.filePath, {
238
+ realpath: false,
239
+ stale: lockOptions.staleMs ?? 10_000,
240
+ update: lockOptions.updateMs ?? 5_000,
241
+ retries: {
242
+ retries: lockOptions.retries ?? 100,
243
+ factor: 1.2,
244
+ minTimeout: lockOptions.minTimeoutMs ?? 10,
245
+ maxTimeout: lockOptions.maxTimeoutMs ?? 100,
246
+ },
247
+ onCompromised: (error) => {
248
+ compromisedReason = error.message;
249
+ },
250
+ });
251
+ try {
252
+ if (compromisedReason !== null) {
253
+ throw new Error(`ledger_lock_compromised:${compromisedReason}`);
254
+ }
255
+ const head = await loadLedgerHead(this.filePath, this.headPath);
256
+ const result = await operation(head);
257
+ if (compromisedReason !== null) {
258
+ throw new Error(`ledger_lock_compromised:${compromisedReason}`);
259
+ }
260
+ return result;
261
+ }
262
+ finally {
263
+ await release();
264
+ }
265
+ }
266
+ async appendLocked(recordInput, head) {
267
+ const record = LedgerRecordSchema.parse(recordInput);
268
+ if (record.previousLedgerHash !== head.lastLedgerHash) {
269
+ throw new Error("ledger_previous_hash_mismatch");
270
+ }
271
+ assertRecordHash(record);
272
+ const line = Buffer.from(`${JSON.stringify(record)}\n`, "utf8");
273
+ const handle = await open(this.filePath, "a+", 0o600);
274
+ let nextSize;
275
+ try {
276
+ await handle.chmod(0o600);
277
+ const before = await handle.stat();
278
+ if (before.size !== head.fileSize) {
279
+ throw new Error("ledger_concurrent_write_detected");
280
+ }
281
+ await writeAll(handle, line);
282
+ await handle.sync();
283
+ const after = await handle.stat();
284
+ nextSize = after.size;
285
+ if (nextSize !== before.size + line.byteLength) {
286
+ throw new Error("ledger_concurrent_write_detected");
287
+ }
288
+ }
289
+ finally {
290
+ await handle.close();
291
+ }
292
+ const nextHead = LedgerHeadStateSchema.parse({
293
+ schemaVersion: LOCAL_LEDGER_HEAD_SCHEMA_VERSION,
294
+ fileSize: nextSize,
295
+ lineNumber: head.lineNumber + 1,
296
+ lastLedgerHash: record.ledgerHash,
297
+ });
298
+ await persistLedgerHead(this.headPath, nextHead);
299
+ await syncDirectory(dirname(this.filePath));
300
+ return LedgerAppendResultSchema.parse({
301
+ record,
302
+ lineNumber: nextHead.lineNumber,
303
+ });
304
+ }
305
+ async exportTelemetry(record) {
306
+ await this.options.telemetry?.exportRecord(record);
307
+ }
308
+ }
309
+ export class OtlpHttpTraceExporter {
310
+ options;
311
+ constructor(options) {
312
+ this.options = options;
313
+ }
314
+ async exportRecord(record) {
315
+ await exportLedgerRecordsToOtlp({
316
+ ...this.options,
317
+ records: [record],
318
+ });
319
+ }
190
320
  }
191
321
  export async function readLedgerRecords(filePath) {
192
322
  return new LocalJsonlLedger(filePath).readRecords();
193
323
  }
324
+ /**
325
+ * Revoke a pass through the single honest path used by both the CLI and the
326
+ * dashboard: first write the durable trust-store revocation (the enforcement
327
+ * authority the proxy/adapters/MCP check), then append a tamper-evident
328
+ * revocation event to the hash-chained ledger bound to the most recent record
329
+ * naming that pass. Enforcement is written first so a later audit-append
330
+ * failure still leaves the pass actually revoked. When no ledger record names
331
+ * the pass (e.g. it never ran locally), the revocation is still authoritative
332
+ * and `ledgerEvent` is null — never faked.
333
+ */
334
+ export async function revokeActionPassWithLedgerEvent(input) {
335
+ const revocation = await input.trustStore.revokeActionPass({
336
+ passId: input.passId,
337
+ revokedBy: input.revokedBy,
338
+ reason: input.reason,
339
+ revokedAt: input.occurredAt,
340
+ });
341
+ const records = await input.ledger.readRecords();
342
+ const revokedRecord = [...records]
343
+ .reverse()
344
+ .find((record) => record.actionPassId === input.passId);
345
+ let ledgerEvent = null;
346
+ if (revokedRecord) {
347
+ ledgerEvent = await input.ledger.appendRevocation({
348
+ revokedRecord,
349
+ revokedBy: input.revokedBy,
350
+ reason: input.reason,
351
+ occurredAt: input.occurredAt,
352
+ });
353
+ }
354
+ return {
355
+ revocation,
356
+ ledgerEvent,
357
+ revokedRecordId: revokedRecord?.id ?? null,
358
+ };
359
+ }
194
360
  export async function verifyLedgerFile(filePath) {
195
361
  return new LocalJsonlLedger(filePath).verify();
196
362
  }
@@ -226,6 +392,42 @@ export async function exportLedgerRecords(input) {
226
392
  records,
227
393
  });
228
394
  }
395
+ export async function exportLedgerRecordsToOtlp(input) {
396
+ if (input.records.length === 0) {
397
+ throw new Error("otel_export_requires_records");
398
+ }
399
+ const fetchImpl = input.fetch ?? globalThis.fetch;
400
+ if (!fetchImpl) {
401
+ throw new Error("otel_export_fetch_required");
402
+ }
403
+ const records = input.records.map((record) => LedgerRecordSchema.parse(record));
404
+ const body = buildOtlpTraceRequest({
405
+ records,
406
+ serviceName: input.serviceName,
407
+ serviceVersion: input.serviceVersion,
408
+ });
409
+ const response = await fetchWithTimeout(fetchImpl, input.endpoint, {
410
+ method: "POST",
411
+ headers: {
412
+ "content-type": "application/json",
413
+ accept: "application/json",
414
+ ...(input.headers ?? {}),
415
+ },
416
+ body: JSON.stringify(body),
417
+ }, input.timeoutMs);
418
+ await response.text();
419
+ if (!response.ok) {
420
+ throw new Error(`otel_export_failed:${response.status}`);
421
+ }
422
+ return {
423
+ schemaVersion: LEDGER_OTEL_EXPORT_SCHEMA_VERSION,
424
+ endpoint: input.endpoint,
425
+ exportedAt: new Date().toISOString(),
426
+ records: records.length,
427
+ spans: records.length,
428
+ status: response.status,
429
+ };
430
+ }
229
431
  export async function syncLedgerExport(input) {
230
432
  const fetchImpl = input.fetch ?? globalThis.fetch;
231
433
  if (!fetchImpl) {
@@ -274,6 +476,518 @@ export function formatLedgerExport(input) {
274
476
  export function computeLedgerHash(record) {
275
477
  return hashJson(record);
276
478
  }
479
+ /** Recompute a record's `ledgerHash` from its own content (spec §9.1 step 2). */
480
+ function recomputeRecordLedgerHash(record) {
481
+ const { ledgerHash, ...recordWithoutHash } = record;
482
+ void ledgerHash;
483
+ return computeLedgerHash(recordWithoutHash);
484
+ }
485
+ // ---------------------------------------------------------------------------
486
+ // Ledger attestation (axtary.ledger_attestation.v0)
487
+ //
488
+ // A detached, signed attestation over a ledger export: a JWS whose claims pin
489
+ // the SHA-256 digest of the canonical (JCS) export plus the chain head/tail and
490
+ // record count. An independent verifier needs only this artifact, the export,
491
+ // and the issuer's public key (plus a JOSE library) to confirm the export was
492
+ // signed by the issuer and has not been altered by a single byte — with no
493
+ // Axtary internals. See spec/actionpass-v0.md §9.
494
+ // ---------------------------------------------------------------------------
495
+ export const LEDGER_ATTESTATION_VERSION = "axtary.ledger_attestation.v0";
496
+ const LEDGER_ATTESTATION_TYP = "axtary-ledger-attestation+jwt";
497
+ const LEDGER_ATTESTATION_ALGORITHMS = ["ES256", "EdDSA"];
498
+ const SHA256_HASH = z.string().regex(/^sha256:[a-f0-9]{64}$/);
499
+ export const LedgerAttestationClaimsSchema = z.object({
500
+ lav: z.literal(LEDGER_ATTESTATION_VERSION),
501
+ iss: z.string().min(1),
502
+ iat: z.number().int().positive(),
503
+ tenant: z.string().min(1).optional(),
504
+ exportDigest: SHA256_HASH,
505
+ recordCount: z.number().int().nonnegative(),
506
+ firstLedgerHash: SHA256_HASH.nullable(),
507
+ segmentHead: SHA256_HASH.nullable(),
508
+ sourceLastLedgerHash: SHA256_HASH.nullable(),
509
+ range: LedgerExportFilterSchema,
510
+ // RFC 6962 signed tree head (optional; older v0 attestations omit these and
511
+ // older verifiers ignore them — the exportDigest still binds the full export).
512
+ // `treeSize` equals `recordCount`; `merkleRoot` is the MTH over the per-record
513
+ // `ledgerHash` leaves, enabling inclusion/consistency proofs (spec §9.4).
514
+ merkleRoot: SHA256_HASH.optional(),
515
+ treeSize: z.number().int().nonnegative().optional(),
516
+ });
517
+ /** SHA-256 digest of the canonical (JCS) ledger export — the content binding. */
518
+ export function computeLedgerExportDigest(exportInput) {
519
+ return hashJson(LedgerExportSchema.parse(exportInput));
520
+ }
521
+ /**
522
+ * RFC 6962 leaves for an export: one leaf per record, committing to that
523
+ * record's `ledgerHash`. Because a verifier recomputes each `ledgerHash` from
524
+ * record content (spec §9.1), binding leaves to `ledgerHash` transitively binds
525
+ * the full record content — a one-byte rewrite changes the leaf and the root.
526
+ */
527
+ export function ledgerExportLeafHashes(exportInput) {
528
+ const exported = LedgerExportSchema.parse(exportInput);
529
+ return exported.records.map((record) => merkleLeafHash(Buffer.from(record.ledgerHash, "utf8")));
530
+ }
531
+ /** Merkle Tree Hash (RFC 6962) over the export's record leaves, as `sha256:hex`. */
532
+ export function computeLedgerExportMerkleRoot(exportInput) {
533
+ const root = merkleTreeRoot(ledgerExportLeafHashes(exportInput));
534
+ return `sha256:${root.toString("hex")}`;
535
+ }
536
+ function hexFromSha256(value) {
537
+ return Buffer.from(value.replace(/^sha256:/, ""), "hex");
538
+ }
539
+ export async function attestLedgerExport(input) {
540
+ const exported = LedgerExportSchema.parse(input.export);
541
+ const now = input.now ?? new Date();
542
+ const claims = LedgerAttestationClaimsSchema.parse({
543
+ lav: LEDGER_ATTESTATION_VERSION,
544
+ iss: input.issuer,
545
+ iat: Math.floor(now.getTime() / 1000),
546
+ tenant: input.tenant,
547
+ exportDigest: hashJson(exported),
548
+ recordCount: exported.records.length,
549
+ firstLedgerHash: exported.records[0]?.ledgerHash ?? null,
550
+ segmentHead: exported.records.at(-1)?.ledgerHash ?? null,
551
+ sourceLastLedgerHash: exported.source.lastLedgerHash,
552
+ range: exported.filters,
553
+ merkleRoot: computeLedgerExportMerkleRoot(exported),
554
+ treeSize: exported.records.length,
555
+ });
556
+ const token = await new SignJWT(claims)
557
+ .setProtectedHeader({
558
+ alg: input.algorithm ?? "ES256",
559
+ typ: LEDGER_ATTESTATION_TYP,
560
+ kid: input.keyId,
561
+ })
562
+ .sign(input.signingKey);
563
+ return { schemaVersion: LEDGER_ATTESTATION_VERSION, token, claims };
564
+ }
565
+ /**
566
+ * Standalone verifier: confirm a ledger export carries a valid issuer signature
567
+ * and has not been altered. Needs only the export, the attestation token, and
568
+ * the issuer public key — re-derivable from the spec alone.
569
+ */
570
+ export async function verifyLedgerAttestation(input) {
571
+ try {
572
+ const exported = LedgerExportSchema.parse(input.export);
573
+ const token = typeof input.attestation === "string"
574
+ ? input.attestation
575
+ : input.attestation.token;
576
+ const algorithms = input.algorithms ?? LEDGER_ATTESTATION_ALGORITHMS;
577
+ const header = decodeProtectedHeader(token);
578
+ if (header.typ !== LEDGER_ATTESTATION_TYP) {
579
+ return { valid: false, reason: "attestation_type_mismatch" };
580
+ }
581
+ const verificationKey = await resolveAttestationKey(input, header.kid, algorithms);
582
+ const { payload } = await jwtVerify(token, verificationKey, {
583
+ issuer: input.issuer,
584
+ algorithms,
585
+ typ: LEDGER_ATTESTATION_TYP,
586
+ currentDate: input.currentDate,
587
+ });
588
+ const claims = LedgerAttestationClaimsSchema.parse(payload);
589
+ const chainError = verifyExportRecordChain(exported);
590
+ if (chainError) {
591
+ return { valid: false, reason: chainError };
592
+ }
593
+ if (hashJson(exported) !== claims.exportDigest) {
594
+ return { valid: false, reason: "export_digest_mismatch" };
595
+ }
596
+ if (claims.recordCount !== exported.records.length) {
597
+ return { valid: false, reason: "export_record_count_mismatch" };
598
+ }
599
+ return { valid: true, claims, recomputedDigest: claims.exportDigest };
600
+ }
601
+ catch (error) {
602
+ return {
603
+ valid: false,
604
+ reason: error instanceof Error ? error.message : "attestation_verification_failed",
605
+ };
606
+ }
607
+ }
608
+ export function buildOtlpTraceRequest(input) {
609
+ return {
610
+ resourceSpans: [
611
+ {
612
+ resource: {
613
+ attributes: otlpAttributes({
614
+ "service.name": input.serviceName ?? "axtary-local",
615
+ "service.version": input.serviceVersion ?? "0.2.0",
616
+ "axtary.exporter": "ledger-otlp",
617
+ }),
618
+ },
619
+ scopeSpans: [
620
+ {
621
+ scope: {
622
+ name: "@axtary/ledger",
623
+ version: "0.2.0",
624
+ },
625
+ spans: input.records.map(ledgerRecordToOtlpSpan),
626
+ },
627
+ ],
628
+ },
629
+ ],
630
+ };
631
+ }
632
+ function ledgerRecordToOtlpSpan(record) {
633
+ const startedAt = dateToUnixNanos(record.occurredAt);
634
+ const endedAt = startedAt + BigInt(1_000_000);
635
+ const toolName = record.auditContext?.tool ?? record.providerEvidence?.tool ?? "unknown";
636
+ const status = otlpSpanStatus(record);
637
+ return {
638
+ traceId: traceIdForRecord(record),
639
+ spanId: spanIdForRecord(record),
640
+ name: `execute_tool ${toolName}`,
641
+ kind: "SPAN_KIND_INTERNAL",
642
+ startTimeUnixNano: startedAt.toString(),
643
+ endTimeUnixNano: endedAt.toString(),
644
+ attributes: otlpAttributes({
645
+ "gen_ai.operation.name": "execute_tool",
646
+ "gen_ai.tool.name": toolName,
647
+ "gen_ai.tool.type": "function",
648
+ "axtary.schema_version": record.schemaVersion,
649
+ "axtary.ledger.record_id": record.id,
650
+ "axtary.ledger.hash": record.ledgerHash,
651
+ "axtary.ledger.previous_hash": record.previousLedgerHash,
652
+ "axtary.action.hash": record.actionHash,
653
+ "axtary.action.payload_hash": record.payloadHash,
654
+ "axtary.action.pass_id": record.actionPassId,
655
+ "axtary.trace.id": record.traceId ?? null,
656
+ "axtary.correlation_id": record.correlationId ?? null,
657
+ "axtary.decision": record.decision,
658
+ "axtary.decision.reasons": record.reasons,
659
+ "axtary.policy.native_rule": record.policy.nativeRule,
660
+ "axtary.policy.version": record.policy.version,
661
+ "axtary.audit.tenant": record.auditContext?.tenant ?? null,
662
+ "axtary.audit.agent_id": record.auditContext?.agentId ?? null,
663
+ "axtary.audit.human_owner": record.auditContext?.humanOwner ?? null,
664
+ "axtary.audit.task_id": record.auditContext?.taskId ?? null,
665
+ "axtary.audit.resource": record.auditContext?.resource ?? null,
666
+ "axtary.execution.status": record.executionOutcome?.status ?? null,
667
+ "axtary.execution.result_id": record.executionOutcome?.resultId ?? null,
668
+ "axtary.execution.failure_reason": record.executionOutcome?.failureReason ?? null,
669
+ }),
670
+ ...(status ? { status } : {}),
671
+ };
672
+ }
673
+ function otlpSpanStatus(record) {
674
+ if (record.decision === "deny" ||
675
+ record.decision === "step_up" ||
676
+ record.executionOutcome?.status === "failed") {
677
+ return {
678
+ code: "STATUS_CODE_ERROR",
679
+ message: record.executionOutcome?.failureReason ??
680
+ record.reasons[0] ??
681
+ record.decision,
682
+ };
683
+ }
684
+ if (record.executionOutcome?.status === "succeeded") {
685
+ return { code: "STATUS_CODE_OK" };
686
+ }
687
+ return null;
688
+ }
689
+ function otlpAttributes(values) {
690
+ const attributes = [];
691
+ for (const [key, value] of Object.entries(values)) {
692
+ const otlpValue = toOtlpAnyValue(value);
693
+ if (otlpValue) {
694
+ attributes.push({ key, value: otlpValue });
695
+ }
696
+ }
697
+ return attributes;
698
+ }
699
+ function toOtlpAnyValue(value) {
700
+ if (value === null || value === undefined)
701
+ return null;
702
+ if (Array.isArray(value)) {
703
+ return { arrayValue: { values: value.map((entry) => ({ stringValue: entry })) } };
704
+ }
705
+ if (typeof value === "boolean")
706
+ return { boolValue: value };
707
+ if (typeof value === "number")
708
+ return { intValue: Math.trunc(value).toString() };
709
+ return { stringValue: value };
710
+ }
711
+ function traceIdForRecord(record) {
712
+ const source = record.traceId ?? record.correlationId ?? record.id;
713
+ return hexBytes(`trace:${source}`, 16);
714
+ }
715
+ function spanIdForRecord(record) {
716
+ return hexBytes(`span:${record.id}`, 8);
717
+ }
718
+ function hexBytes(input, bytes) {
719
+ const hex = createHash("sha256").update(input).digest("hex").slice(0, bytes * 2);
720
+ return /^0+$/.test(hex) ? `1${hex.slice(1)}` : hex;
721
+ }
722
+ function dateToUnixNanos(isoDate) {
723
+ return BigInt(new Date(isoDate).getTime()) * BigInt(1_000_000);
724
+ }
725
+ /**
726
+ * Re-verify the exported records from the records alone: every record's own hash
727
+ * must recompute, and — for a full (unfiltered) export — `previousLedgerHash` must
728
+ * chain. Filtered exports are not contiguous, so only the per-record hash is
729
+ * asserted; reorder/truncation is still caught by the signed export digest.
730
+ */
731
+ function verifyExportRecordChain(exported) {
732
+ const isFull = exported.filters.from === null &&
733
+ exported.filters.to === null &&
734
+ exported.filters.decisions === null;
735
+ let previous = null;
736
+ for (let index = 0; index < exported.records.length; index += 1) {
737
+ const record = exported.records[index];
738
+ try {
739
+ assertRecordHash(record);
740
+ }
741
+ catch {
742
+ return `ledger_record_hash_mismatch:${index + 1}`;
743
+ }
744
+ if (isFull) {
745
+ if (record.previousLedgerHash !== previous) {
746
+ return `ledger_previous_hash_mismatch:${index + 1}`;
747
+ }
748
+ previous = record.ledgerHash;
749
+ }
750
+ }
751
+ return null;
752
+ }
753
+ async function resolveAttestationKey(input, keyId, algorithms) {
754
+ let key;
755
+ if (input.verificationKeys) {
756
+ key =
757
+ typeof input.verificationKeys === "function"
758
+ ? await input.verificationKeys(keyId)
759
+ : keyId
760
+ ? input.verificationKeys[keyId]
761
+ : undefined;
762
+ if (!key) {
763
+ throw new Error(keyId ? `verification_key_not_found:${keyId}` : "verification_key_id_required");
764
+ }
765
+ }
766
+ else {
767
+ if (!input.verificationKey) {
768
+ throw new Error("verification_key_required");
769
+ }
770
+ key = input.verificationKey;
771
+ }
772
+ return normalizeVerificationKey(key, algorithms);
773
+ }
774
+ async function normalizeVerificationKey(key, algorithms) {
775
+ if (isJwk(key)) {
776
+ const imported = await importJWK(key, key.alg ?? algorithms[0] ?? "ES256");
777
+ return imported;
778
+ }
779
+ return key;
780
+ }
781
+ function isJwk(key) {
782
+ return (typeof key === "object" &&
783
+ key !== null &&
784
+ !(key instanceof Uint8Array) &&
785
+ "kty" in key);
786
+ }
787
+ // ---------------------------------------------------------------------------
788
+ // Attestation bundle: a self-contained artifact (export + token + public key)
789
+ // that a third party can verify with nothing but a JOSE library and §3/§9 of
790
+ // the spec. This is what `axtary attest-ledger` emits and `verify-export` reads.
791
+ // ---------------------------------------------------------------------------
792
+ export const LEDGER_ATTESTATION_BUNDLE_VERSION = "axtary.ledger_attestation_bundle.v0";
793
+ export const LedgerAttestationBundleSchema = z.object({
794
+ schemaVersion: z.literal(LEDGER_ATTESTATION_BUNDLE_VERSION),
795
+ export: LedgerExportSchema,
796
+ attestation: z.object({
797
+ token: z.string().min(1),
798
+ claims: LedgerAttestationClaimsSchema,
799
+ }),
800
+ publicJwk: z.record(z.string(), z.unknown()),
801
+ keyId: z.string().min(1).nullable(),
802
+ });
803
+ /** Bundle an export, its attestation, and the public key into one artifact. */
804
+ export function buildLedgerAttestationBundle(input) {
805
+ return LedgerAttestationBundleSchema.parse({
806
+ schemaVersion: LEDGER_ATTESTATION_BUNDLE_VERSION,
807
+ export: input.export,
808
+ attestation: {
809
+ token: input.attestation.token,
810
+ claims: input.attestation.claims,
811
+ },
812
+ publicJwk: input.publicJwk,
813
+ keyId: input.keyId ?? null,
814
+ });
815
+ }
816
+ /** Verify a self-contained attestation bundle (export + token + embedded public key). */
817
+ export async function verifyLedgerAttestationBundle(bundleInput, options = {}) {
818
+ let bundle;
819
+ try {
820
+ bundle = LedgerAttestationBundleSchema.parse(bundleInput);
821
+ }
822
+ catch (error) {
823
+ return {
824
+ valid: false,
825
+ reason: error instanceof Error ? `invalid_bundle:${error.message}` : "invalid_bundle",
826
+ };
827
+ }
828
+ return verifyLedgerAttestation({
829
+ export: bundle.export,
830
+ attestation: bundle.attestation.token,
831
+ verificationKey: bundle.publicJwk,
832
+ issuer: options.issuer,
833
+ algorithms: options.algorithms,
834
+ currentDate: options.currentDate,
835
+ });
836
+ }
837
+ // ---------------------------------------------------------------------------
838
+ // RFC 6962 transparency-log proofs (spec/actionpass-v0.md §9.4)
839
+ //
840
+ // Inclusion and consistency proof artifacts over the export's Merkle tree. They
841
+ // are not signed themselves; trust flows from the signed tree head (the
842
+ // attestation's `merkleRoot`/`treeSize`). A verifier binds a proof to a signed
843
+ // head by passing the attestation's `merkleRoot` as the expected root.
844
+ // ---------------------------------------------------------------------------
845
+ const HEX_NODE = z.string().regex(/^[a-f0-9]{64}$/);
846
+ export const LEDGER_INCLUSION_PROOF_VERSION = "axtary.ledger_inclusion_proof.v0";
847
+ export const LedgerInclusionProofSchema = z.object({
848
+ schemaVersion: z.literal(LEDGER_INCLUSION_PROOF_VERSION),
849
+ recordId: z.string().min(1),
850
+ ledgerHash: SHA256_HASH,
851
+ leafIndex: z.number().int().nonnegative(),
852
+ treeSize: z.number().int().positive(),
853
+ merkleRoot: SHA256_HASH,
854
+ auditPath: z.array(HEX_NODE),
855
+ });
856
+ export const LEDGER_CONSISTENCY_PROOF_VERSION = "axtary.ledger_consistency_proof.v0";
857
+ export const LedgerConsistencyProofSchema = z.object({
858
+ schemaVersion: z.literal(LEDGER_CONSISTENCY_PROOF_VERSION),
859
+ firstSize: z.number().int().positive(),
860
+ secondSize: z.number().int().positive(),
861
+ firstRoot: SHA256_HASH,
862
+ secondRoot: SHA256_HASH,
863
+ proof: z.array(HEX_NODE),
864
+ });
865
+ /** Build an inclusion proof for one record (by id or 0-based index). */
866
+ export function proveLedgerInclusion(input) {
867
+ const exported = LedgerExportSchema.parse(input.export);
868
+ if (exported.records.length === 0) {
869
+ throw new Error("ledger_inclusion_empty_export");
870
+ }
871
+ const index = input.index ??
872
+ exported.records.findIndex((record) => record.id === input.recordId);
873
+ if (index < 0 || index >= exported.records.length) {
874
+ throw new Error("ledger_inclusion_record_not_found");
875
+ }
876
+ const record = exported.records[index];
877
+ const leaves = ledgerExportLeafHashes(exported);
878
+ const auditPath = merkleInclusionProof(leaves, index);
879
+ const root = merkleTreeRoot(leaves);
880
+ return LedgerInclusionProofSchema.parse({
881
+ schemaVersion: LEDGER_INCLUSION_PROOF_VERSION,
882
+ recordId: record.id,
883
+ ledgerHash: record.ledgerHash,
884
+ leafIndex: index,
885
+ treeSize: exported.records.length,
886
+ merkleRoot: `sha256:${root.toString("hex")}`,
887
+ auditPath: auditPath.map((node) => node.toString("hex")),
888
+ });
889
+ }
890
+ /**
891
+ * Verify an inclusion proof against a Merkle root. Pass `expectedRoot` (e.g. a
892
+ * signed attestation's `merkleRoot`) to bind the proof to a signed tree head.
893
+ * Pass `record` to additionally recompute the leaf from record content so a
894
+ * proof cannot smuggle a `ledgerHash` that does not match the record.
895
+ */
896
+ export function verifyLedgerInclusionProof(input) {
897
+ let proof;
898
+ try {
899
+ proof = LedgerInclusionProofSchema.parse(input.proof);
900
+ }
901
+ catch (error) {
902
+ return {
903
+ valid: false,
904
+ reason: error instanceof Error ? `invalid_proof:${error.message}` : "invalid_proof",
905
+ };
906
+ }
907
+ if (proof.leafIndex >= proof.treeSize) {
908
+ return { valid: false, reason: "leaf_index_out_of_range" };
909
+ }
910
+ if (input.expectedRoot && input.expectedRoot !== proof.merkleRoot) {
911
+ return { valid: false, reason: "merkle_root_mismatch" };
912
+ }
913
+ if (input.record) {
914
+ if (recomputeRecordLedgerHash(input.record) !== proof.ledgerHash) {
915
+ return { valid: false, reason: "ledger_record_hash_mismatch" };
916
+ }
917
+ if (input.record.id !== proof.recordId) {
918
+ return { valid: false, reason: "record_id_mismatch" };
919
+ }
920
+ }
921
+ const ok = verifyMerkleInclusion({
922
+ leafHash: merkleLeafHash(Buffer.from(proof.ledgerHash, "utf8")),
923
+ index: proof.leafIndex,
924
+ treeSize: proof.treeSize,
925
+ proof: proof.auditPath.map((node) => Buffer.from(node, "hex")),
926
+ root: hexFromSha256(proof.merkleRoot),
927
+ });
928
+ return ok ? { valid: true } : { valid: false, reason: "merkle_inclusion_invalid" };
929
+ }
930
+ /**
931
+ * Build a consistency proof showing the `second` export append-only-extends the
932
+ * `first`. Roots are computed from each export independently; if `first` is not
933
+ * a genuine prefix, verification fails (the honest, fail-closed outcome).
934
+ */
935
+ export function proveLedgerConsistency(input) {
936
+ const first = LedgerExportSchema.parse(input.first);
937
+ const second = LedgerExportSchema.parse(input.second);
938
+ const firstSize = first.records.length;
939
+ const secondSize = second.records.length;
940
+ if (firstSize === 0) {
941
+ throw new Error("ledger_consistency_empty_first");
942
+ }
943
+ if (firstSize > secondSize) {
944
+ throw new Error("ledger_consistency_first_larger");
945
+ }
946
+ const firstLeaves = ledgerExportLeafHashes(first);
947
+ const secondLeaves = ledgerExportLeafHashes(second);
948
+ const proof = merkleConsistencyProof(secondLeaves, firstSize);
949
+ return LedgerConsistencyProofSchema.parse({
950
+ schemaVersion: LEDGER_CONSISTENCY_PROOF_VERSION,
951
+ firstSize,
952
+ secondSize,
953
+ firstRoot: `sha256:${merkleTreeRoot(firstLeaves).toString("hex")}`,
954
+ secondRoot: `sha256:${merkleTreeRoot(secondLeaves).toString("hex")}`,
955
+ proof: proof.map((node) => node.toString("hex")),
956
+ });
957
+ }
958
+ /**
959
+ * Verify a consistency proof. Pass `firstExpectedRoot`/`secondExpectedRoot`
960
+ * (the two signed heads' `merkleRoot`s) to bind the proof to signed tree heads.
961
+ */
962
+ export function verifyLedgerConsistencyProof(input) {
963
+ let proof;
964
+ try {
965
+ proof = LedgerConsistencyProofSchema.parse(input.proof);
966
+ }
967
+ catch (error) {
968
+ return {
969
+ valid: false,
970
+ reason: error instanceof Error ? `invalid_proof:${error.message}` : "invalid_proof",
971
+ };
972
+ }
973
+ if (proof.firstSize > proof.secondSize) {
974
+ return { valid: false, reason: "first_size_exceeds_second" };
975
+ }
976
+ if (input.firstExpectedRoot && input.firstExpectedRoot !== proof.firstRoot) {
977
+ return { valid: false, reason: "first_root_mismatch" };
978
+ }
979
+ if (input.secondExpectedRoot && input.secondExpectedRoot !== proof.secondRoot) {
980
+ return { valid: false, reason: "second_root_mismatch" };
981
+ }
982
+ const ok = verifyMerkleConsistency({
983
+ firstSize: proof.firstSize,
984
+ secondSize: proof.secondSize,
985
+ firstRoot: hexFromSha256(proof.firstRoot),
986
+ secondRoot: hexFromSha256(proof.secondRoot),
987
+ proof: proof.proof.map((node) => Buffer.from(node, "hex")),
988
+ });
989
+ return ok ? { valid: true } : { valid: false, reason: "merkle_consistency_invalid" };
990
+ }
277
991
  function toSiemEvent(record, exported) {
278
992
  return {
279
993
  "@timestamp": record.occurredAt,
@@ -368,6 +1082,175 @@ function assertRecordHash(record) {
368
1082
  throw new Error("ledger_hash_mismatch");
369
1083
  }
370
1084
  }
1085
+ async function loadLedgerHead(filePath, headPath) {
1086
+ const handle = await open(filePath, "a+", 0o600);
1087
+ try {
1088
+ const stats = await handle.stat();
1089
+ const persisted = await readLedgerHead(headPath);
1090
+ if (stats.size === 0) {
1091
+ if (persisted && persisted.fileSize > 0) {
1092
+ throw new Error("ledger_truncated");
1093
+ }
1094
+ return LedgerHeadStateSchema.parse({
1095
+ schemaVersion: LOCAL_LEDGER_HEAD_SCHEMA_VERSION,
1096
+ fileSize: 0,
1097
+ lineNumber: 0,
1098
+ lastLedgerHash: null,
1099
+ });
1100
+ }
1101
+ if (persisted && stats.size < persisted.fileSize) {
1102
+ throw new Error("ledger_truncated");
1103
+ }
1104
+ if (persisted && persisted.fileSize === stats.size) {
1105
+ const tail = await readLastLedgerRecord(handle, stats.size, persisted.lineNumber);
1106
+ assertRecordHash(tail);
1107
+ if (tail.ledgerHash !== persisted.lastLedgerHash) {
1108
+ throw new Error("ledger_head_hash_mismatch");
1109
+ }
1110
+ return persisted;
1111
+ }
1112
+ }
1113
+ finally {
1114
+ await handle.close();
1115
+ }
1116
+ return recoverLedgerHead(filePath);
1117
+ }
1118
+ async function readLedgerHead(headPath) {
1119
+ try {
1120
+ return LedgerHeadStateSchema.parse(JSON.parse(await readFile(headPath, "utf8")));
1121
+ }
1122
+ catch (error) {
1123
+ if (isNodeError(error) && error.code === "ENOENT") {
1124
+ return null;
1125
+ }
1126
+ throw error;
1127
+ }
1128
+ }
1129
+ async function recoverLedgerHead(filePath) {
1130
+ const text = await readLedgerFile(filePath);
1131
+ const lines = text.split("\n").filter((line) => line.trim().length > 0);
1132
+ let previousLedgerHash = null;
1133
+ for (let index = 0; index < lines.length; index += 1) {
1134
+ const record = parseLedgerLine(lines[index], index + 1);
1135
+ if (record.previousLedgerHash !== previousLedgerHash) {
1136
+ throw new Error(`ledger_previous_hash_mismatch:${index + 1}`);
1137
+ }
1138
+ assertRecordHash(record);
1139
+ previousLedgerHash = record.ledgerHash;
1140
+ }
1141
+ const stats = await open(filePath, "r").then(async (handle) => {
1142
+ try {
1143
+ return await handle.stat();
1144
+ }
1145
+ finally {
1146
+ await handle.close();
1147
+ }
1148
+ });
1149
+ return LedgerHeadStateSchema.parse({
1150
+ schemaVersion: LOCAL_LEDGER_HEAD_SCHEMA_VERSION,
1151
+ fileSize: stats.size,
1152
+ lineNumber: lines.length,
1153
+ lastLedgerHash: previousLedgerHash,
1154
+ });
1155
+ }
1156
+ async function readLastLedgerRecord(handle, fileSize, lineNumber) {
1157
+ const chunkSize = 64 * 1024;
1158
+ let cursor = fileSize;
1159
+ let suffix = Buffer.alloc(0);
1160
+ let trimmedEnd = fileSize;
1161
+ while (cursor > 0) {
1162
+ const length = Math.min(chunkSize, cursor);
1163
+ const position = cursor - length;
1164
+ const chunk = Buffer.allocUnsafe(length);
1165
+ const { bytesRead } = await handle.read(chunk, 0, length, position);
1166
+ const bytes = chunk.subarray(0, bytesRead);
1167
+ if (trimmedEnd === fileSize) {
1168
+ let index = bytes.length - 1;
1169
+ while (index >= 0 && (bytes[index] === 0x0a || bytes[index] === 0x0d)) {
1170
+ index -= 1;
1171
+ trimmedEnd -= 1;
1172
+ }
1173
+ suffix = Buffer.concat([bytes.subarray(index + 1, bytes.length), suffix]);
1174
+ if (index < 0) {
1175
+ cursor = position;
1176
+ continue;
1177
+ }
1178
+ const content = bytes.subarray(0, index + 1);
1179
+ const newline = content.lastIndexOf(0x0a);
1180
+ if (newline >= 0) {
1181
+ const line = Buffer.concat([content.subarray(newline + 1), suffix])
1182
+ .toString("utf8")
1183
+ .trim();
1184
+ return parseLedgerLine(line, lineNumber);
1185
+ }
1186
+ suffix = Buffer.concat([content, suffix]);
1187
+ }
1188
+ else {
1189
+ const newline = bytes.lastIndexOf(0x0a);
1190
+ if (newline >= 0) {
1191
+ const line = Buffer.concat([bytes.subarray(newline + 1), suffix])
1192
+ .toString("utf8")
1193
+ .trim();
1194
+ return parseLedgerLine(line, lineNumber);
1195
+ }
1196
+ suffix = Buffer.concat([bytes, suffix]);
1197
+ }
1198
+ cursor = position;
1199
+ }
1200
+ const line = suffix.toString("utf8").trim();
1201
+ if (!line) {
1202
+ throw new Error("ledger_tail_missing");
1203
+ }
1204
+ return parseLedgerLine(line, lineNumber);
1205
+ }
1206
+ async function writeAll(handle, data) {
1207
+ let offset = 0;
1208
+ while (offset < data.byteLength) {
1209
+ const { bytesWritten } = await handle.write(data, offset, data.byteLength - offset);
1210
+ if (bytesWritten < 1) {
1211
+ throw new Error("ledger_append_incomplete");
1212
+ }
1213
+ offset += bytesWritten;
1214
+ }
1215
+ }
1216
+ async function persistLedgerHead(headPath, head) {
1217
+ const tmpPath = `${headPath}.${process.pid}.${randomUUID()}.tmp`;
1218
+ let renamed = false;
1219
+ try {
1220
+ const handle = await open(tmpPath, "wx", 0o600);
1221
+ try {
1222
+ await writeAll(handle, Buffer.from(`${JSON.stringify(head)}\n`, "utf8"));
1223
+ await handle.sync();
1224
+ }
1225
+ finally {
1226
+ await handle.close();
1227
+ }
1228
+ await rename(tmpPath, headPath);
1229
+ renamed = true;
1230
+ }
1231
+ finally {
1232
+ if (!renamed) {
1233
+ await rm(tmpPath, { force: true });
1234
+ }
1235
+ }
1236
+ }
1237
+ async function syncDirectory(directory) {
1238
+ const handle = await open(directory, "r");
1239
+ try {
1240
+ await handle.sync();
1241
+ }
1242
+ catch (error) {
1243
+ if (process.platform === "win32" &&
1244
+ isNodeError(error) &&
1245
+ ["EISDIR", "EINVAL", "ENOTSUP", "EPERM"].includes(error.code ?? "")) {
1246
+ return;
1247
+ }
1248
+ throw error;
1249
+ }
1250
+ finally {
1251
+ await handle.close();
1252
+ }
1253
+ }
371
1254
  async function readLedgerFile(filePath) {
372
1255
  try {
373
1256
  return await readFile(filePath, "utf8");