@agentguard-run/spend 0.14.0 → 0.15.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.
@@ -127,7 +127,7 @@ export class WorkflowContext {
127
127
  const cost = this.extractOutcomeCost(result);
128
128
  const parentReceiptId = this._last_receipt_id;
129
129
  const chainHash = this._receipts.length ? await computeChainHash(this._receipts[this._receipts.length - 1]!) : null;
130
- const receipt = buildReceipt({
130
+ const receipt = await buildReceipt({
131
131
  workflow_id: this.workflow_id,
132
132
  outcome_name: name,
133
133
  user_id: this.user_id,
@@ -137,6 +137,7 @@ export class WorkflowContext {
137
137
  workflow_checkpoint_idx: null,
138
138
  workflow_total_spend_usd_to_date: this._total_spend_usd + cost,
139
139
  reviewer_cascade: extractReviewerCascade(result),
140
+ signingKeys: this.config.signingKeys,
140
141
  });
141
142
 
142
143
  this._receipts.push(receipt);
@@ -196,7 +197,7 @@ export class WorkflowContext {
196
197
 
197
198
  private async writeCheckpoint(label?: string): Promise<CheckpointReceipt> {
198
199
  const chainHash = this._receipts.length ? await computeChainHash(this._receipts[this._receipts.length - 1]!) : null;
199
- const checkpoint = buildReceipt({
200
+ const checkpoint = await buildReceipt({
200
201
  workflow_id: this.workflow_id,
201
202
  outcome_name: 'CHECKPOINT',
202
203
  user_id: this.user_id,
@@ -208,6 +209,7 @@ export class WorkflowContext {
208
209
  is_checkpoint: true,
209
210
  checkpoint_label: label ?? null,
210
211
  receipt_type: 'checkpoint',
212
+ signingKeys: this.config.signingKeys,
211
213
  }) as CheckpointReceipt;
212
214
  this._receipts.push(checkpoint);
213
215
  this._last_checkpoint_id = checkpoint.receipt_id;
@@ -219,7 +221,7 @@ export class WorkflowContext {
219
221
 
220
222
  private async writeFinalReceipt(type: 'cancelled' | 'completed' | 'cap_hit', reason: string): Promise<void> {
221
223
  const chainHash = this._receipts.length ? await computeChainHash(this._receipts[this._receipts.length - 1]!) : null;
222
- const receipt = buildReceipt({
224
+ const receipt = await buildReceipt({
223
225
  workflow_id: this.workflow_id,
224
226
  outcome_name: type === 'completed' ? 'WORKFLOW_COMPLETED' : 'WORKFLOW_STOPPED',
225
227
  user_id: this.user_id,
@@ -230,6 +232,7 @@ export class WorkflowContext {
230
232
  workflow_total_spend_usd_to_date: this._total_spend_usd,
231
233
  receipt_type: type === 'cancelled' ? 'cancel' : type,
232
234
  cancelled_reason: reason,
235
+ signingKeys: this.config.signingKeys,
233
236
  });
234
237
  this._receipts.push(receipt);
235
238
  this._last_receipt_id = receipt.receipt_id;
@@ -1,7 +1,24 @@
1
- import { createHash, randomUUID } from 'crypto';
1
+ import { createHash, createPrivateKey, createPublicKey, randomUUID, sign as cryptoSign, verify as cryptoVerify, type KeyObject } from 'crypto';
2
2
  import { canonicalJson, sha256Hex } from '../decision-log';
3
3
  import type { ProvenanceBlock } from '../receipts/schema';
4
- import type { ReceiptV2, ReceiptV3, ReviewerCascadeEvidence } from './types';
4
+ import type { ReceiptV2, ReceiptV3, ReviewerCascadeEvidence, WorkflowConfig } from './types';
5
+
6
+ const ED25519_PKCS8_SEED_PREFIX = Buffer.from('302e020100300506032b657004220420', 'hex');
7
+ const ED25519_SPKI_PUBLIC_PREFIX = Buffer.from('302a300506032b6570032100', 'hex');
8
+
9
+ type WorkflowSigningKeys = NonNullable<WorkflowConfig['signingKeys']>;
10
+
11
+ interface ResolvedSigningKey {
12
+ privateKey: KeyObject;
13
+ publicKeyRaw: Buffer;
14
+ publicKeyHex: string;
15
+ publicKeyFingerprint: string;
16
+ }
17
+
18
+ export interface WorkflowReceiptVerifyOptions {
19
+ publicKeyHex?: string;
20
+ allowedSignerPublicKeysHex?: string[];
21
+ }
5
22
 
6
23
  export function canonicalJSONStringify(value: unknown): string {
7
24
  return canonicalJson(value);
@@ -15,11 +32,21 @@ export function workflowReceiptId(prefix = 'ag_r'): string {
15
32
  return `${prefix}_${randomUUID().replace(/-/g, '').slice(0, 16)}`;
16
33
  }
17
34
 
18
- export function signReceiptPayload(payload: unknown): string {
19
- return sha256Hex(canonicalJson(payload));
35
+ export async function signReceiptPayload(payload: unknown, signingKeys?: WorkflowSigningKeys): Promise<{
36
+ signature: string;
37
+ public_key_fingerprint: string;
38
+ public_key_hex: string;
39
+ }> {
40
+ const resolved = resolveWorkflowSigningKey(signingKeys);
41
+ const canonical = canonicalJson(payload);
42
+ return {
43
+ signature: cryptoSign(null, Buffer.from(canonical, 'utf8'), resolved.privateKey).toString('hex'),
44
+ public_key_fingerprint: resolved.publicKeyFingerprint,
45
+ public_key_hex: resolved.publicKeyHex,
46
+ };
20
47
  }
21
48
 
22
- export function buildReceipt(args: {
49
+ export async function buildReceipt(args: {
23
50
  workflow_id: string | null;
24
51
  outcome_name: string;
25
52
  user_id: string;
@@ -33,22 +60,20 @@ export function buildReceipt(args: {
33
60
  reviewer_cascade?: ReviewerCascadeEvidence | null;
34
61
  receipt_type?: ReceiptV2['receipt_type'];
35
62
  cancelled_reason?: string | null;
36
- }): ReceiptV2 {
63
+ signingKeys?: WorkflowSigningKeys;
64
+ receipt_id?: string;
65
+ signed_at?: string;
66
+ }): Promise<ReceiptV2> {
37
67
  if (!Number.isFinite(args.cost_usd) || args.cost_usd < 0) {
38
68
  throw new Error('Workflow receipt cost must be a finite non-negative number');
39
69
  }
40
- const publicKeyFingerprint = createHash('sha256')
41
- .update(args.workflow_id || args.user_id || 'agentguard-workflow')
42
- .digest('hex')
43
- .slice(0, 16);
44
- const unsigned: Omit<ReceiptV2, 'signature'> = {
45
- receipt_id: workflowReceiptId(args.is_checkpoint ? 'ag_cp' : 'ag_r'),
70
+ const unsigned: Omit<ReceiptV2, 'signature' | 'public_key_fingerprint' | 'public_key_hex'> = {
71
+ receipt_id: args.receipt_id ?? workflowReceiptId(args.is_checkpoint ? 'ag_cp' : 'ag_r'),
46
72
  schema_version: 2,
47
73
  outcome_name: args.outcome_name,
48
74
  user_id: args.user_id,
49
75
  cost_usd: roundUsd(args.cost_usd),
50
- signed_at: new Date().toISOString(),
51
- public_key_fingerprint: publicKeyFingerprint,
76
+ signed_at: args.signed_at ?? new Date().toISOString(),
52
77
  workflow_id: args.workflow_id,
53
78
  parent_receipt_id: args.parent_receipt_id,
54
79
  chain_validation_hash: args.chain_validation_hash,
@@ -61,12 +86,10 @@ export function buildReceipt(args: {
61
86
  receipt_type: args.receipt_type ?? (args.is_checkpoint ? 'checkpoint' : 'outcome'),
62
87
  cancelled_reason: args.cancelled_reason ?? null,
63
88
  };
64
- return { ...unsigned, signature: signReceiptPayload(unsigned) };
89
+ return { ...unsigned, ...(await signReceiptPayload(unsigned, args.signingKeys)) };
65
90
  }
66
91
 
67
-
68
-
69
- export function buildReceiptV3(args: {
92
+ export async function buildReceiptV3(args: {
70
93
  workflow_id: string | null;
71
94
  outcome_name: string;
72
95
  user_id: string;
@@ -81,27 +104,108 @@ export function buildReceiptV3(args: {
81
104
  reviewer_cascade?: ReviewerCascadeEvidence | null;
82
105
  receipt_type?: ReceiptV2['receipt_type'];
83
106
  cancelled_reason?: string | null;
84
- }): ReceiptV3 {
85
- const v2 = buildReceipt(args);
86
- const unsigned: Omit<ReceiptV3, 'signature'> = {
87
- ...v2,
107
+ signingKeys?: WorkflowSigningKeys;
108
+ receipt_id?: string;
109
+ signed_at?: string;
110
+ }): Promise<ReceiptV3> {
111
+ const v2 = await buildReceipt(args);
112
+ const unsigned: Omit<ReceiptV3, 'signature' | 'public_key_fingerprint' | 'public_key_hex'> = {
113
+ ...stripReceiptSignature(v2),
88
114
  schema_version: 3,
89
115
  provenance: args.provenance,
90
116
  };
91
- const { signature: _oldSignature, ...withoutSignature } = unsigned as Omit<ReceiptV3, 'signature'> & { signature?: string };
92
- return { ...withoutSignature, signature: signReceiptPayload(withoutSignature) };
117
+ return { ...unsigned, ...(await signReceiptPayload(unsigned, args.signingKeys)) };
93
118
  }
94
119
 
95
- export function verifyAnyReceipt(receipt: ReceiptV2 | ReceiptV3): boolean {
96
- const { signature: _signature, ...unsigned } = receipt;
97
- return signReceiptPayload(unsigned) === receipt.signature;
120
+ export function verifyAnyReceipt(receipt: ReceiptV2 | ReceiptV3, options: WorkflowReceiptVerifyOptions = {}): boolean {
121
+ return verifyReceipt(receipt, options);
98
122
  }
99
123
 
100
- export function verifyReceipt(receipt: ReceiptV2): boolean {
101
- const { signature: _signature, ...unsigned } = receipt;
102
- return signReceiptPayload(unsigned) === receipt.signature;
124
+ export function verifyReceipt(receipt: ReceiptV2 | ReceiptV3, options: WorkflowReceiptVerifyOptions = {}): boolean {
125
+ const { signature, public_key_hex: embeddedPublicKeyHex, public_key_fingerprint: fingerprint, ...unsigned } = receipt as ReceiptV2 & { public_key_hex?: string };
126
+ if (!signature || !/^[0-9a-fA-F]{128}$/.test(String(signature))) return false;
127
+ const publicKeyHex = String(options.publicKeyHex || embeddedPublicKeyHex || '').toLowerCase();
128
+ if (!/^[0-9a-f]{64}$/.test(publicKeyHex)) return false;
129
+ const allowed = normalizeAllowedSignerKeys(options.allowedSignerPublicKeysHex);
130
+ if (allowed && !allowed.has(publicKeyHex)) return false;
131
+ const publicKeyRaw = Buffer.from(publicKeyHex, 'hex');
132
+ if (fingerprint && computePublicKeyFingerprint(publicKeyRaw) !== String(fingerprint)) return false;
133
+ const publicKey = createPublicKey({ key: Buffer.concat([ED25519_SPKI_PUBLIC_PREFIX, publicKeyRaw]), format: 'der', type: 'spki' });
134
+ try {
135
+ return cryptoVerify(null, Buffer.from(canonicalJson(unsigned), 'utf8'), publicKey, Buffer.from(signature, 'hex'));
136
+ } catch {
137
+ return false;
138
+ }
103
139
  }
104
140
 
105
141
  export function roundUsd(value: number): number {
106
142
  return Math.round(value * 1_000_000) / 1_000_000;
107
143
  }
144
+
145
+ function stripReceiptSignature(receipt: ReceiptV2): Omit<ReceiptV2, 'signature' | 'public_key_fingerprint' | 'public_key_hex'> {
146
+ const { signature: _signature, public_key_fingerprint: _fingerprint, public_key_hex: _publicKeyHex, ...unsigned } = receipt;
147
+ return unsigned;
148
+ }
149
+
150
+ function resolveWorkflowSigningKey(signingKeys?: WorkflowSigningKeys): ResolvedSigningKey {
151
+ if (signingKeys) return keyFromSeed(signingKeys.privateKey, signingKeys.publicKey);
152
+ const raw = process.env.AGENTGUARD_SIGNING_KEY_ED25519_PRIVATE;
153
+ if (!raw) throw new Error('Workflow receipt signing key is required');
154
+ return keyFromEnvironment(raw);
155
+ }
156
+
157
+ function keyFromSeed(seed: Uint8Array, expectedPublicKey?: Uint8Array): ResolvedSigningKey {
158
+ if (seed.length !== 32) throw new Error(`Ed25519 private seed must be 32 bytes, got ${seed.length} bytes`);
159
+ const privateKey = createPrivateKey({ key: Buffer.concat([ED25519_PKCS8_SEED_PREFIX, Buffer.from(seed)]), format: 'der', type: 'pkcs8' });
160
+ const publicKeyRaw = rawPublicKeyFromPrivate(privateKey);
161
+ if (expectedPublicKey && Buffer.compare(publicKeyRaw, Buffer.from(expectedPublicKey)) !== 0) {
162
+ throw new Error('Workflow receipt public key does not match private seed');
163
+ }
164
+ return resolved(privateKey, publicKeyRaw);
165
+ }
166
+
167
+ function keyFromEnvironment(raw: string): ResolvedSigningKey {
168
+ const value = raw.trim();
169
+ let privateKey: KeyObject;
170
+ if (value.startsWith('-----BEGIN')) {
171
+ privateKey = createPrivateKey(value);
172
+ } else if (value.startsWith('{')) {
173
+ privateKey = createPrivateKey({ key: JSON.parse(value), format: 'jwk' });
174
+ } else {
175
+ const clean = value.replace(/^0x/, '');
176
+ let bytes: Buffer;
177
+ if (/^[0-9a-fA-F]+$/.test(clean) && clean.length % 2 === 0) bytes = Buffer.from(clean, 'hex');
178
+ else bytes = Buffer.from(value, 'base64');
179
+ privateKey = bytes.length === 32
180
+ ? createPrivateKey({ key: Buffer.concat([ED25519_PKCS8_SEED_PREFIX, bytes]), format: 'der', type: 'pkcs8' })
181
+ : createPrivateKey({ key: bytes, format: 'der', type: 'pkcs8' });
182
+ }
183
+ return resolved(privateKey, rawPublicKeyFromPrivate(privateKey));
184
+ }
185
+
186
+ function rawPublicKeyFromPrivate(privateKey: KeyObject): Buffer {
187
+ const der = createPublicKey(privateKey).export({ format: 'der', type: 'spki' });
188
+ return Buffer.from(der).subarray(-32);
189
+ }
190
+
191
+ function resolved(privateKey: KeyObject, publicKeyRaw: Buffer): ResolvedSigningKey {
192
+ return {
193
+ privateKey,
194
+ publicKeyRaw,
195
+ publicKeyHex: publicKeyRaw.toString('hex'),
196
+ publicKeyFingerprint: computePublicKeyFingerprint(publicKeyRaw),
197
+ };
198
+ }
199
+
200
+ function computePublicKeyFingerprint(publicKeyRaw: Buffer): string {
201
+ return createHash('sha256').update(publicKeyRaw).digest('hex').slice(0, 16);
202
+ }
203
+
204
+ function normalizeAllowedSignerKeys(explicit?: string[]): Set<string> | null {
205
+ const values = [...(explicit ?? [])];
206
+ const envAllowed = process.env.AGENTGUARD_ALLOWED_SIGNER_PUBKEYS_HEX;
207
+ if (envAllowed) values.push(...envAllowed.split(','));
208
+ if (values.length === 0) return null;
209
+ const clean = values.map((value) => String(value).trim().toLowerCase()).filter((value) => /^[0-9a-f]{64}$/.test(value));
210
+ return new Set(clean);
211
+ }
@@ -20,6 +20,12 @@ export interface WorkflowConfig {
20
20
  user_id?: string;
21
21
  api_base_url?: string;
22
22
  license_key?: string;
23
+ signingKeys?: {
24
+ /** 32-byte Ed25519 secret seed. */
25
+ privateKey: Uint8Array;
26
+ /** 32-byte Ed25519 public key. */
27
+ publicKey: Uint8Array;
28
+ };
23
29
  post_json?: (url: string, body: unknown, headers: Record<string, string>) => Promise<unknown>;
24
30
  get_json?: (url: string, headers: Record<string, string>) => Promise<unknown>;
25
31
  }
@@ -44,6 +50,7 @@ export interface ReceiptV2 {
44
50
  signed_at: string;
45
51
  signature: string;
46
52
  public_key_fingerprint: string;
53
+ public_key_hex: string;
47
54
  workflow_id: string | null;
48
55
  parent_receipt_id: string | null;
49
56
  chain_validation_hash: string | null;