@verax-ai/proxy 0.1.2 → 0.1.4

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.
@@ -0,0 +1,12 @@
1
+ /**
2
+ * Called before `inner` runs. A failure here must not block the call.
3
+ *
4
+ * The stamp is wall-clock time and not the proxy's injected `now`: a mark is
5
+ * not a ledger row, and taking a tick from a deterministic clock would shift
6
+ * every timestamp recorded after it — the golden ledger caught exactly that.
7
+ */
8
+ export declare function markStarted(stateDir: string, ref: string, subject: string): void;
9
+ /** Called once the effect row is written, on success and on failure alike. */
10
+ export declare function markEnded(stateDir: string, ref: string): void;
11
+ /** True when a run started and no end was recorded: the outcome is unknown. */
12
+ export declare function startedWithoutEnd(stateDir: string, ref: string): boolean;
@@ -0,0 +1,65 @@
1
+ /**
2
+ * Durable in-flight marks: "this ref started running".
3
+ *
4
+ * The in-memory registry that stops a second run dies with the process. On the
5
+ * approved path an `allow` does not mean the tool ran — it means the operator
6
+ * said yes — so a restarted body cannot tell "approved, never started" from
7
+ * "approved, started, crashed before the effect". Without that distinction it
8
+ * has to either run everything twice or run nothing at all.
9
+ *
10
+ * A mark is one empty file per ref, created before `inner` and removed after
11
+ * the effect is written. One file per ref rather than a shared log because two
12
+ * refs may start and finish at the same moment: file create and unlink need no
13
+ * lock, a shared append-only log would need a compaction pass and would race.
14
+ *
15
+ * A mark left behind by a crash is exactly the signal wanted: it outlives the
16
+ * process, and the retry that finds it answers `outcome-unknown` instead of
17
+ * running the tool a second time.
18
+ */
19
+ import { createHash } from "node:crypto";
20
+ import { existsSync, mkdirSync, rmSync, writeFileSync } from "node:fs";
21
+ import { join } from "node:path";
22
+ const KLASOR = "in-flight";
23
+ /** Ref may carry a tenant prefix and any path character; the name never does. */
24
+ function markPath(stateDir, ref) {
25
+ const ad = createHash("sha256").update(ref, "utf8").digest("hex").slice(0, 32);
26
+ return join(stateDir, KLASOR, `${ad}.start`);
27
+ }
28
+ /**
29
+ * Called before `inner` runs. A failure here must not block the call.
30
+ *
31
+ * The stamp is wall-clock time and not the proxy's injected `now`: a mark is
32
+ * not a ledger row, and taking a tick from a deterministic clock would shift
33
+ * every timestamp recorded after it — the golden ledger caught exactly that.
34
+ */
35
+ export function markStarted(stateDir, ref, subject) {
36
+ try {
37
+ mkdirSync(join(stateDir, KLASOR), { recursive: true, mode: 0o700 });
38
+ // The body is for a human reading the directory after a crash; nothing parses it.
39
+ writeFileSync(markPath(stateDir, ref), `${JSON.stringify({ ref, subject, atMs: Date.now() })}\n`, {
40
+ encoding: "utf8",
41
+ mode: 0o600,
42
+ });
43
+ }
44
+ catch {
45
+ // A missing mark degrades to today's behaviour, it does not deny the call.
46
+ }
47
+ }
48
+ /** Called once the effect row is written, on success and on failure alike. */
49
+ export function markEnded(stateDir, ref) {
50
+ try {
51
+ rmSync(markPath(stateDir, ref), { force: true });
52
+ }
53
+ catch {
54
+ // Leaving a stale mark makes the next retry cautious, never wrong.
55
+ }
56
+ }
57
+ /** True when a run started and no end was recorded: the outcome is unknown. */
58
+ export function startedWithoutEnd(stateDir, ref) {
59
+ try {
60
+ return existsSync(markPath(stateDir, ref));
61
+ }
62
+ catch {
63
+ return false;
64
+ }
65
+ }
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
@@ -4,6 +4,7 @@ import { signDecisionRecord } from "@cedulon/core";
4
4
  import { approvePending, approvalsLogFor, createApprovalBudgetGuard, drainApprovalCommands, spentTodayMinorOf, } from "./approvals.js";
5
5
  import { SerialQueue } from "./serial-queue.js";
6
6
  import { diskProbe } from "./disk.js";
7
+ import { markEnded, markStarted, startedWithoutEnd } from "./in-flight-log.js";
7
8
  import { effectDescriptor, sha256Canonical } from "./hash.js";
8
9
  import { inputsLogFor } from "./inputs.js";
9
10
  import { countedWork, hasPrimaryEffect, lookupDecisionByRef, lookupReauthByHash, lookupResolvedBy, noteResolution, noteTenantRef, } from "./ledger.js";
@@ -64,6 +65,21 @@ function allowedReplay(ref) {
64
65
  isError: false,
65
66
  };
66
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
+ }
67
83
  function deepFreeze(value) {
68
84
  if (value === null || typeof value !== "object")
69
85
  return value;
@@ -294,6 +310,11 @@ export function createProxy(deps) {
294
310
  };
295
311
  }
296
312
  function launchInner(dispatchedCall, principal, allowRef, flightKey) {
313
+ // Written before the tool runs, removed after the effect is recorded. A
314
+ // mark that survives the process is what tells a restarted body that this
315
+ // ref was already started (in-flight-log.ts).
316
+ if (stateDir !== null)
317
+ markStarted(stateDir, flightKey, dispatchedCall.name);
297
318
  const work = runInner(dispatchedCall, principal, allowRef);
298
319
  inFlight.set(flightKey, work);
299
320
  return { kind: "run", work, key: flightKey, replayRef: allowRef };
@@ -455,7 +476,7 @@ export function createProxy(deps) {
455
476
  await approvals.updateStatus(scopedRef, "approved", { allowRef: allow.ref });
456
477
  }
457
478
  if (await hasPrimaryEffect(deps.ledger, allow.ref)) {
458
- return { kind: "done", result: allowedReplay(allow.ref) };
479
+ return { kind: "done", result: await replayAfterEffect(deps.ledger, allow.ref) };
459
480
  }
460
481
  if (allow.subject === "spend") {
461
482
  return {
@@ -469,6 +490,27 @@ export function createProxy(deps) {
469
490
  const flying = inFlight.get(scopedRef);
470
491
  if (flying)
471
492
  return { kind: "wait", work: flying, replayRef: allow.ref };
493
+ // An approved allow means "the operator said yes", not "it ran",
494
+ // so the first retry after an approval is the first run. Only a
495
+ // mark left on disk says a run was already started and never
496
+ // finished — that one cannot be run again.
497
+ if (stateDir !== null && startedWithoutEnd(stateDir, scopedRef)) {
498
+ const unknownRef = deps.nonce();
499
+ await writeRecord({
500
+ decision: "deny",
501
+ reasonCode: "outcome-unknown",
502
+ ref: unknownRef,
503
+ requestHash,
504
+ inputs: {
505
+ ...resolved.inputs,
506
+ approver: { id: "verax-proxy", via: "proxy", resolves: scopedRef },
507
+ },
508
+ effectHash: null,
509
+ subject: call.name,
510
+ timestampMs,
511
+ });
512
+ return { kind: "done", result: denied("outcome-unknown", unknownRef) };
513
+ }
472
514
  return launchInner(dispatched, principal, allow.ref, scopedRef);
473
515
  }
474
516
  return { kind: "done", result: deferred(given) };
@@ -478,7 +520,7 @@ export function createProxy(deps) {
478
520
  }
479
521
  if (existing.decision === "allow" && existing.ref) {
480
522
  if (await hasPrimaryEffect(deps.ledger, existing.ref)) {
481
- return { kind: "done", result: allowedReplay(existing.ref) };
523
+ return { kind: "done", result: await replayAfterEffect(deps.ledger, existing.ref) };
482
524
  }
483
525
  if (existing.subject === "spend") {
484
526
  return {
@@ -585,17 +627,23 @@ export function createProxy(deps) {
585
627
  if (plan.kind === "wait") {
586
628
  try {
587
629
  await plan.work;
630
+ return allowedReplay(plan.replayRef);
588
631
  }
589
632
  catch {
590
- // 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);
591
636
  }
592
- return allowedReplay(plan.replayRef);
593
637
  }
594
638
  try {
595
639
  return await plan.work;
596
640
  }
597
641
  finally {
598
642
  inFlight.delete(plan.key);
643
+ // runInner has written its effect row by now — on the throw path too,
644
+ // as `<name>:threw`. The outcome is on the ledger, so the mark goes.
645
+ if (stateDir !== null)
646
+ markEnded(stateDir, plan.key);
599
647
  }
600
648
  },
601
649
  };
@@ -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.2",
3
+ "version": "0.1.4",
4
4
  "license": "Apache-2.0",
5
5
  "type": "module",
6
6
  "engines": {