@verax-ai/proxy 0.1.3 → 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.d.ts CHANGED
@@ -14,3 +14,5 @@ export type { ApprovalRow, ApproveResult } from "./approvals.ts";
14
14
  export type { CardCsvOpts, ChannelRow, ReconcileReport } from "./reconcile.ts";
15
15
  export type { FileLedgerOpts, LedgerCounts } from "./ledger.ts";
16
16
  export type { EffectSigner, ExplainChain, ExplainPair, ExplainFinding, ExplainOpts, ExplainResult, ExplainTrustRoot, ExplainWarning, ExtractWindow, Ledger, LedgerEffect, Policy, PolicyDecision, Principal, ProxyDeps, RecordSigner, ToolCall, ToolResult, WitnessClass, } from "./types.ts";
17
+ export { verifyLedger } from "./verify-ledger.ts";
18
+ export type { VerifyResult, VerifyOptions, VerifyTrust } from "./verify-ledger.ts";
package/dist/index.js CHANGED
@@ -16,3 +16,4 @@ export { diskProbe } from "./disk.js";
16
16
  export { checkpointsPath } from "./checkpoints.js";
17
17
  export { signEffectAttestation } from "./ledger.js";
18
18
  export { spokenReason } from "./spoken-reason.js";
19
+ export { verifyLedger } from "./verify-ledger.js";
package/dist/proxy.js CHANGED
@@ -65,6 +65,21 @@ function allowedReplay(ref) {
65
65
  isError: false,
66
66
  };
67
67
  }
68
+ function threwReplay(ref) {
69
+ // The tool ran and exploded. That is not a deny — the gate allowed it.
70
+ // `allowed:` would teach a retry library the opposite of the ledger.
71
+ return {
72
+ content: [{ type: "text", text: `threw:${ref}` }],
73
+ isError: true,
74
+ };
75
+ }
76
+ async function replayAfterEffect(ledger, ref) {
77
+ const primary = (await ledger.effects()).find((e) => e.row.ref === ref && e.row.effectClass !== "duplicate-effect");
78
+ if (primary?.row.effectClass.endsWith(":threw")) {
79
+ return threwReplay(ref);
80
+ }
81
+ return allowedReplay(ref);
82
+ }
68
83
  function deepFreeze(value) {
69
84
  if (value === null || typeof value !== "object")
70
85
  return value;
@@ -461,7 +476,7 @@ export function createProxy(deps) {
461
476
  await approvals.updateStatus(scopedRef, "approved", { allowRef: allow.ref });
462
477
  }
463
478
  if (await hasPrimaryEffect(deps.ledger, allow.ref)) {
464
- return { kind: "done", result: allowedReplay(allow.ref) };
479
+ return { kind: "done", result: await replayAfterEffect(deps.ledger, allow.ref) };
465
480
  }
466
481
  if (allow.subject === "spend") {
467
482
  return {
@@ -505,7 +520,7 @@ export function createProxy(deps) {
505
520
  }
506
521
  if (existing.decision === "allow" && existing.ref) {
507
522
  if (await hasPrimaryEffect(deps.ledger, existing.ref)) {
508
- return { kind: "done", result: allowedReplay(existing.ref) };
523
+ return { kind: "done", result: await replayAfterEffect(deps.ledger, existing.ref) };
509
524
  }
510
525
  if (existing.subject === "spend") {
511
526
  return {
@@ -612,11 +627,13 @@ export function createProxy(deps) {
612
627
  if (plan.kind === "wait") {
613
628
  try {
614
629
  await plan.work;
630
+ return allowedReplay(plan.replayRef);
615
631
  }
616
632
  catch {
617
- // The first call recorded the throw on its effect row.
633
+ // The first call recorded the throw on its effect row. The
634
+ // waiter must not call the tool again, and must not say allowed.
635
+ return threwReplay(plan.replayRef);
618
636
  }
619
- return allowedReplay(plan.replayRef);
620
637
  }
621
638
  try {
622
639
  return await plan.work;
@@ -0,0 +1,25 @@
1
+ export type VerifyTrust = {
2
+ /** `pinned`: the caller supplied the key. `in-ledger`: read from the records. */
3
+ source: "pinned" | "in-ledger" | "none";
4
+ publicKeyPem: string | null;
5
+ note: string;
6
+ };
7
+ export type VerifyResult = {
8
+ ok: boolean;
9
+ directory: string;
10
+ decisions: number;
11
+ effects: number;
12
+ signaturesValid: number;
13
+ signaturesInvalid: number;
14
+ /** Index of the first record whose `prevRecordHash` does not match, else null. */
15
+ chainBreakAt: number | null;
16
+ effectsBound: number;
17
+ effectsOrphaned: number;
18
+ trust: VerifyTrust;
19
+ problems: string[];
20
+ };
21
+ export type VerifyOptions = {
22
+ /** Verify against this key instead of the one the records carry. */
23
+ publicKeyPem?: string;
24
+ };
25
+ export declare function verifyLedger(dir: string, opts?: VerifyOptions): Promise<VerifyResult>;
@@ -0,0 +1,171 @@
1
+ /**
2
+ * Reads a ledger directory and says whether it still holds together, with no
3
+ * body running and nothing on the network.
4
+ *
5
+ * This is the answer to a question a customer is right to ask: if we stopped
6
+ * using Verax tomorrow, would these files still mean anything? Evidence that
7
+ * can only be read by the vendor who sold it is a weak kind of evidence.
8
+ *
9
+ * Three things fail separately, so three things are stated separately:
10
+ *
11
+ * signatures each record verifies against a public key
12
+ * chain each record names the hash of the one before it
13
+ * effects each effect row is bound to a decision by `effectHash`
14
+ *
15
+ * And a fourth, which matters most and is the easiest to fudge: **which key**.
16
+ * A ledger checked against the key sitting next to it is internally
17
+ * consistent and nothing more — whatever could write the file could write
18
+ * that key too (the downstream audit showed exactly this: a stdio child runs
19
+ * as the same user and can read `keys/*.pem`). So the trust source is part of
20
+ * the answer rather than a footnote, and `publicKeyPem` lets a reader pin a
21
+ * copy they hold themselves.
22
+ */
23
+ import { existsSync, readFileSync } from "node:fs";
24
+ import { join } from "node:path";
25
+ import { findDecisionRecordChainBreak, verifyDecisionRecord } from "@cedulon/core";
26
+ import { readLedgerManifest } from "./ledger-manifest.js";
27
+ /** Every decisions file this directory holds, oldest piece first. */
28
+ function decisionFiles(dir) {
29
+ const manifest = readLedgerManifest(dir);
30
+ if (manifest && Array.isArray(manifest.pieces) && manifest.pieces.length > 0) {
31
+ return manifest.pieces.map((p) => join(dir, p.decisions)).filter((p) => existsSync(p));
32
+ }
33
+ const tek = join(dir, "decisions.jsonl");
34
+ return existsSync(tek) ? [tek] : [];
35
+ }
36
+ function effectFiles(dir) {
37
+ const manifest = readLedgerManifest(dir);
38
+ if (manifest && Array.isArray(manifest.pieces) && manifest.pieces.length > 0) {
39
+ return manifest.pieces.map((p) => join(dir, p.effects)).filter((p) => existsSync(p));
40
+ }
41
+ const tek = join(dir, "effects.jsonl");
42
+ return existsSync(tek) ? [tek] : [];
43
+ }
44
+ /** Parses JSONL, reporting the line a bad row sits on rather than throwing. */
45
+ function readJsonl(path, problems) {
46
+ const out = [];
47
+ const text = readFileSync(path, "utf8");
48
+ const lines = text.split("\n");
49
+ for (let i = 0; i < lines.length; i += 1) {
50
+ const line = lines[i].trim();
51
+ if (line === "")
52
+ continue;
53
+ try {
54
+ out.push(JSON.parse(line));
55
+ }
56
+ catch {
57
+ problems.push(`unreadable row: ${path}:${i + 1} is not JSON`);
58
+ }
59
+ }
60
+ return out;
61
+ }
62
+ export async function verifyLedger(dir, opts = {}) {
63
+ const problems = [];
64
+ const kararDosyalari = decisionFiles(dir);
65
+ const records = [];
66
+ for (const path of kararDosyalari) {
67
+ for (const row of readJsonl(path, problems)) {
68
+ records.push(row);
69
+ }
70
+ }
71
+ // An empty directory is not a clean ledger. Saying "valid" over no records
72
+ // would let a deleted ledger pass as a verified one.
73
+ if (records.length === 0) {
74
+ problems.push("no decisions found: this directory holds no ledger to verify");
75
+ return {
76
+ ok: false,
77
+ directory: dir,
78
+ decisions: 0,
79
+ effects: 0,
80
+ signaturesValid: 0,
81
+ signaturesInvalid: 0,
82
+ chainBreakAt: null,
83
+ effectsBound: 0,
84
+ effectsOrphaned: 0,
85
+ trust: { source: "none", publicKeyPem: null, note: "no records, so no key was used" },
86
+ problems,
87
+ };
88
+ }
89
+ const pinned = opts.publicKeyPem?.trim();
90
+ const icerden = typeof records[0]?.publicKeyPem === "string" ? records[0].publicKeyPem : null;
91
+ const anahtar = pinned && pinned !== "" ? pinned : icerden;
92
+ const trust = pinned && pinned !== ""
93
+ ? {
94
+ source: "pinned",
95
+ publicKeyPem: pinned,
96
+ note: "verified against a key the reader supplied, not one taken from these files",
97
+ }
98
+ : {
99
+ source: "in-ledger",
100
+ publicKeyPem: icerden,
101
+ note: "verified against the key carried in the records themselves: this shows the files are " +
102
+ "internally consistent, not that the key was ever trusted. Pin a key you hold to check that.",
103
+ };
104
+ let signaturesValid = 0;
105
+ let signaturesInvalid = 0;
106
+ for (let i = 0; i < records.length; i += 1) {
107
+ const rec = records[i];
108
+ let gecerli = false;
109
+ try {
110
+ gecerli = verifyDecisionRecord(rec, anahtar ?? undefined);
111
+ }
112
+ catch {
113
+ gecerli = false;
114
+ }
115
+ if (gecerli) {
116
+ signaturesValid += 1;
117
+ }
118
+ else {
119
+ signaturesInvalid += 1;
120
+ const ref = typeof rec.claims?.ref === "string" ? rec.claims.ref : "?";
121
+ problems.push(`signature does not verify: record ${i} (ref ${ref})`);
122
+ }
123
+ }
124
+ // Returns `{ index, reason }` or null. The reason is carried through: a
125
+ // broken link and a bad signature are different accidents, and a reader
126
+ // chasing one should not be told the other.
127
+ const brk = findDecisionRecordChainBreak(records, anahtar ? [anahtar] : undefined);
128
+ const chainBreakAt = brk ? brk.index : null;
129
+ if (brk) {
130
+ problems.push(`chain breaks at record ${brk.index} (${brk.reason}): a row was changed, removed or inserted`);
131
+ }
132
+ // Each effect names the decision it belongs to. An effect whose ref is on no
133
+ // record is an action with no decision behind it — the loudest thing this
134
+ // file can find, so it is counted rather than summarised away.
135
+ const refler = new Set();
136
+ for (const r of records) {
137
+ if (typeof r.claims?.ref === "string")
138
+ refler.add(r.claims.ref);
139
+ }
140
+ let effects = 0;
141
+ let effectsBound = 0;
142
+ let effectsOrphaned = 0;
143
+ for (const path of effectFiles(dir)) {
144
+ for (const row of readJsonl(path, problems)) {
145
+ effects += 1;
146
+ const e = row;
147
+ const ref = typeof e.row?.ref === "string" ? e.row.ref : null;
148
+ if (ref !== null && refler.has(ref)) {
149
+ effectsBound += 1;
150
+ }
151
+ else {
152
+ effectsOrphaned += 1;
153
+ problems.push(`effect with no decision: ref ${ref ?? "(missing)"}`);
154
+ }
155
+ }
156
+ }
157
+ const ok = problems.length === 0 && signaturesInvalid === 0 && chainBreakAt === null && effectsOrphaned === 0;
158
+ return {
159
+ ok,
160
+ directory: dir,
161
+ decisions: records.length,
162
+ effects,
163
+ signaturesValid,
164
+ signaturesInvalid,
165
+ chainBreakAt,
166
+ effectsBound,
167
+ effectsOrphaned,
168
+ trust,
169
+ problems,
170
+ };
171
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@verax-ai/proxy",
3
- "version": "0.1.3",
3
+ "version": "0.2.0",
4
4
  "license": "Apache-2.0",
5
5
  "type": "module",
6
6
  "engines": {