thumbgate 1.31.0 → 1.34.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/.claude-plugin/plugin.json +1 -1
- package/.well-known/mcp/server-card.json +1 -1
- package/adapters/claude/.mcp.json +2 -2
- package/adapters/forge/forge.yaml +3 -3
- package/adapters/mcp/server-stdio.js +40 -3
- package/adapters/opencode/opencode.json +1 -1
- package/bin/cli.js +21 -0
- package/config/gates/claim-verifiers.example.json +42 -0
- package/config/gates/claim-verifiers.json +25 -0
- package/config/mcp-allowlists.json +21 -0
- package/hooks/hooks.json +1 -1
- package/package.json +14 -9
- package/public/index.html +2 -2
- package/public/numbers.html +2 -2
- package/scripts/agent-readiness.js +110 -0
- package/scripts/auto-wire-hooks.js +20 -8
- package/scripts/cli-schema.js +14 -0
- package/scripts/feedback-schema.js +3 -0
- package/scripts/file-ledger-lock.js +130 -0
- package/scripts/financial-control-plane.js +1514 -0
- package/scripts/gates-engine.js +185 -7
- package/scripts/gemini-embedding-policy.js +1 -0
- package/scripts/hook-runtime.js +5 -0
- package/scripts/hook-stop-anti-claim.js +63 -3
- package/scripts/human-escalation.js +353 -41
- package/scripts/provider-action-normalizer.js +11 -4
- package/scripts/tool-registry.js +95 -5
- package/scripts/universal-claim-evaluator.js +767 -0
- package/scripts/vector-store.js +60 -27
- package/scripts/workflow-sentinel.js +77 -11
- package/server.json +2 -2
- package/src/api/server.js +2 -0
|
@@ -13,8 +13,11 @@ const crypto = require('node:crypto');
|
|
|
13
13
|
const fs = require('node:fs');
|
|
14
14
|
const path = require('node:path');
|
|
15
15
|
const { getFeedbackPaths } = require('./feedback-paths');
|
|
16
|
+
const { withFileLedgerLock } = require('./file-ledger-lock');
|
|
16
17
|
|
|
17
18
|
const ESCALATIONS_FILE = 'human-escalations.jsonl';
|
|
19
|
+
const ESCALATIONS_HEAD_FILE = 'human-escalations.head.json';
|
|
20
|
+
const ESCALATIONS_JOURNAL_FILE = 'human-escalations.journal.json';
|
|
18
21
|
const MAX_TTL_MS = 7 * 24 * 60 * 60 * 1000;
|
|
19
22
|
const DEFAULT_TTL_MS = 24 * 60 * 60 * 1000;
|
|
20
23
|
const SEVERITIES = new Set(['low', 'medium', 'high', 'critical']);
|
|
@@ -24,6 +27,16 @@ function getEscalationsPath(options = {}) {
|
|
|
24
27
|
return path.join(getFeedbackPaths(options).FEEDBACK_DIR, ESCALATIONS_FILE);
|
|
25
28
|
}
|
|
26
29
|
|
|
30
|
+
function getEscalationsHeadPath(options = {}) {
|
|
31
|
+
if (options.inputPath) return `${path.resolve(options.inputPath)}.head.json`;
|
|
32
|
+
return path.join(getFeedbackPaths(options).FEEDBACK_DIR, ESCALATIONS_HEAD_FILE);
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
function getEscalationsJournalPath(options = {}) {
|
|
36
|
+
if (options.inputPath) return `${path.resolve(options.inputPath)}.journal.json`;
|
|
37
|
+
return path.join(getFeedbackPaths(options).FEEDBACK_DIR, ESCALATIONS_JOURNAL_FILE);
|
|
38
|
+
}
|
|
39
|
+
|
|
27
40
|
function requestEscalation(input = {}, options = {}) {
|
|
28
41
|
const now = options.now || new Date();
|
|
29
42
|
const taskId = requiredString(input.taskId, 'taskId');
|
|
@@ -35,8 +48,7 @@ function requestEscalation(input = {}, options = {}) {
|
|
|
35
48
|
if (!SEVERITIES.has(severity)) throw escalationError(`severity must be one of ${Array.from(SEVERITIES).join(', ')}`);
|
|
36
49
|
const ttlMs = Math.min(MAX_TTL_MS, Math.max(1, finiteNumber(input.ttlMs, DEFAULT_TTL_MS)));
|
|
37
50
|
const idempotencyKey = requiredString(input.idempotencyKey || taskId, 'idempotencyKey');
|
|
38
|
-
const
|
|
39
|
-
|
|
51
|
+
const approvalContextDigest = optionalDigest(input.approvalContextDigest, 'approvalContextDigest');
|
|
40
52
|
const request = {
|
|
41
53
|
escalationId: input.escalationId || `esc_${crypto.randomUUID()}`,
|
|
42
54
|
idempotencyKey,
|
|
@@ -50,19 +62,31 @@ function requestEscalation(input = {}, options = {}) {
|
|
|
50
62
|
status: 'pending',
|
|
51
63
|
eventType: 'requested',
|
|
52
64
|
};
|
|
53
|
-
request.
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
65
|
+
if (approvalContextDigest) request.approvalContextDigest = approvalContextDigest;
|
|
66
|
+
return withEscalationLock(options, () => {
|
|
67
|
+
const ledger = readLedger(options);
|
|
68
|
+
assertLedgerHealthy(ledger);
|
|
69
|
+
// Compare retries with the immutable request event. A later decision event
|
|
70
|
+
// deliberately carries the reviewer's reason and status, so comparing the
|
|
71
|
+
// projected row would turn an already-approved request into a false
|
|
72
|
+
// idempotency conflict during cross-ledger recovery.
|
|
73
|
+
const existingRequest = ledger.events.find((event) => (
|
|
74
|
+
event.idempotencyKey === idempotencyKey
|
|
75
|
+
&& (!event.eventType || event.eventType === 'requested')
|
|
76
|
+
));
|
|
77
|
+
if (existingRequest) {
|
|
78
|
+
if (eventComparableHash(existingRequest) !== eventComparableHash(request)) {
|
|
79
|
+
const error = escalationError(`conflicting request for idempotency key '${idempotencyKey}'`);
|
|
80
|
+
error.code = 'THUMBGATE_IDEMPOTENCY_CONFLICT';
|
|
81
|
+
throw error;
|
|
82
|
+
}
|
|
83
|
+
const current = projectEscalations(ledger.events, options)
|
|
84
|
+
.find((entry) => entry.escalationId === existingRequest.escalationId);
|
|
85
|
+
return { recorded: false, duplicate: true, escalation: current || existingRequest };
|
|
60
86
|
}
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
appendEvent(request, options);
|
|
65
|
-
return { recorded: true, duplicate: false, escalation: request };
|
|
87
|
+
const recorded = appendEventUnlocked(request, options, ledger.events);
|
|
88
|
+
return { recorded: true, duplicate: false, escalation: recorded };
|
|
89
|
+
});
|
|
66
90
|
}
|
|
67
91
|
|
|
68
92
|
function decideEscalation(input = {}, options = {}) {
|
|
@@ -75,29 +99,74 @@ function decideEscalation(input = {}, options = {}) {
|
|
|
75
99
|
const actor = requiredIdentity(options.authenticatedActor, 'authenticatedActor');
|
|
76
100
|
if (actor.kind !== 'human') throw escalationError('authenticatedActor.kind must be human');
|
|
77
101
|
const reason = requiredString(input.reason, 'reason');
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
102
|
+
return withEscalationLock(options, () => {
|
|
103
|
+
const ledger = readLedger(options);
|
|
104
|
+
assertLedgerHealthy(ledger);
|
|
105
|
+
const current = projectEscalations(ledger.events, options)
|
|
106
|
+
.find((entry) => entry.escalationId === escalationId);
|
|
107
|
+
if (!current) throw escalationError(`unknown escalation '${escalationId}'`);
|
|
108
|
+
if (current.status !== 'pending') throw escalationError(`escalation '${escalationId}' is already ${current.status}`);
|
|
109
|
+
if (sameIdentity(current.requester, actor)) throw escalationError('requester cannot decide their own escalation');
|
|
82
110
|
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
111
|
+
const now = options.now || new Date();
|
|
112
|
+
const event = {
|
|
113
|
+
escalationId,
|
|
114
|
+
taskId: current.taskId,
|
|
115
|
+
status: decision,
|
|
116
|
+
eventType: 'decided',
|
|
117
|
+
decision,
|
|
118
|
+
actor,
|
|
119
|
+
reason,
|
|
120
|
+
decidedAt: now.toISOString(),
|
|
121
|
+
};
|
|
122
|
+
if (current.approvalContextDigest) {
|
|
123
|
+
event.approvalContextDigest = current.approvalContextDigest;
|
|
124
|
+
}
|
|
125
|
+
const signingKey = optionalString(options.approvalSigningKey);
|
|
126
|
+
if (signingKey) event.approvalReceipt = signApprovalReceipt(event, signingKey);
|
|
127
|
+
const recorded = appendEventUnlocked(event, options, ledger.events);
|
|
128
|
+
return { recorded: true, escalation: { ...current, ...recorded } };
|
|
129
|
+
});
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
/**
|
|
133
|
+
* Return an approval only when both the append-only history and the reviewer
|
|
134
|
+
* receipt authenticate. Merely appending an `approved` JSON row is not proof
|
|
135
|
+
* that the independently authenticated reviewer API produced it.
|
|
136
|
+
*/
|
|
137
|
+
function getVerifiedApproval(escalationId, options = {}) {
|
|
138
|
+
return withEscalationLock(options, () => getVerifiedApprovalUnlocked(escalationId, options));
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
function getVerifiedApprovalUnlocked(escalationId, options = {}) {
|
|
142
|
+
const ledger = readLedger(options);
|
|
143
|
+
const integrity = validateEscalationLedger(ledger.events, ledger.malformedRows, ledger.head);
|
|
144
|
+
if (!integrity.ok) throw escalationError('escalation ledger integrity verification failed');
|
|
145
|
+
const events = ledger.events.filter((event) => event.escalationId === escalationId);
|
|
146
|
+
const requested = events.find((event) => event.eventType === 'requested');
|
|
147
|
+
const decided = events.findLast((event) => event.eventType === 'decided');
|
|
148
|
+
if (!requested || !decided || decided.status !== 'approved') return null;
|
|
149
|
+
if (decided.taskId !== requested.taskId) throw escalationError('approval task does not match its request');
|
|
150
|
+
if ((requested.approvalContextDigest || null) !== (decided.approvalContextDigest || null)) {
|
|
151
|
+
throw escalationError('approval context does not match its request');
|
|
152
|
+
}
|
|
153
|
+
if (decided.actor?.kind !== 'human' || sameIdentity(requested.requester, decided.actor)) {
|
|
154
|
+
throw escalationError('approval is not from an independent human actor');
|
|
155
|
+
}
|
|
156
|
+
const verificationKey = optionalString(
|
|
157
|
+
options.approvalVerificationKey || process.env.THUMBGATE_HUMAN_REVIEWER_KEY
|
|
158
|
+
);
|
|
159
|
+
if (!verificationKey || !verifyApprovalReceipt(decided, verificationKey)) {
|
|
160
|
+
throw escalationError('approval receipt is missing or unauthenticated');
|
|
161
|
+
}
|
|
162
|
+
return { ...requested, ...decided };
|
|
97
163
|
}
|
|
98
164
|
|
|
99
165
|
function listEscalations(options = {}) {
|
|
100
|
-
|
|
166
|
+
return projectEscalations(readEvents(options), options);
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
function projectEscalations(events, options = {}) {
|
|
101
170
|
const byId = new Map();
|
|
102
171
|
for (const event of events) {
|
|
103
172
|
const current = byId.get(event.escalationId) || {};
|
|
@@ -143,26 +212,254 @@ function calculateEscalationMetrics(escalations = [], now = new Date()) {
|
|
|
143
212
|
}
|
|
144
213
|
|
|
145
214
|
function readEvents(options = {}) {
|
|
215
|
+
return readLedger(options).events;
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
function readLedger(options = {}) {
|
|
146
219
|
const inputPath = options.inputPath ? path.resolve(options.inputPath) : getEscalationsPath(options);
|
|
147
220
|
let raw = '';
|
|
148
221
|
try {
|
|
149
222
|
raw = fs.readFileSync(inputPath, 'utf8');
|
|
150
|
-
} catch {
|
|
151
|
-
|
|
223
|
+
} catch (error) {
|
|
224
|
+
if (error.code !== 'ENOENT') throw error;
|
|
225
|
+
return { events: [], malformedRows: [], head: readLedgerHead(options) };
|
|
152
226
|
}
|
|
153
|
-
|
|
227
|
+
const events = [];
|
|
228
|
+
const malformedRows = [];
|
|
229
|
+
raw.split('\n').forEach((line, index) => {
|
|
230
|
+
if (!line.trim()) return;
|
|
154
231
|
try {
|
|
155
|
-
|
|
232
|
+
events.push(JSON.parse(line));
|
|
156
233
|
} catch {
|
|
157
|
-
|
|
234
|
+
malformedRows.push(index + 1);
|
|
158
235
|
}
|
|
159
236
|
});
|
|
237
|
+
return { events, malformedRows, head: readLedgerHead(options) };
|
|
160
238
|
}
|
|
161
239
|
|
|
162
|
-
function
|
|
240
|
+
function appendEventUnlocked(event, options, existingEvents) {
|
|
163
241
|
const outputPath = getEscalationsPath(options);
|
|
242
|
+
const previous = existingEvents.at(-1) || null;
|
|
243
|
+
const chained = {
|
|
244
|
+
...event,
|
|
245
|
+
schemaVersion: 'human-escalation-v2',
|
|
246
|
+
sequence: existingEvents.length + 1,
|
|
247
|
+
previousEventHash: previous?.eventHash || null,
|
|
248
|
+
};
|
|
249
|
+
chained.eventHash = eventHash(chained);
|
|
164
250
|
fs.mkdirSync(path.dirname(outputPath), { recursive: true });
|
|
165
|
-
|
|
251
|
+
const journalPath = getEscalationsJournalPath(options);
|
|
252
|
+
writeAtomicJson(journalPath, {
|
|
253
|
+
schemaVersion: 'human-escalation-journal-v1',
|
|
254
|
+
previousHead: readLedgerHead(options),
|
|
255
|
+
event: chained,
|
|
256
|
+
});
|
|
257
|
+
const ledgerFd = fs.openSync(outputPath, 'a', 0o600);
|
|
258
|
+
try {
|
|
259
|
+
fs.writeSync(ledgerFd, `${JSON.stringify(chained)}\n`, null, 'utf8');
|
|
260
|
+
fs.fsyncSync(ledgerFd);
|
|
261
|
+
} finally {
|
|
262
|
+
fs.closeSync(ledgerFd);
|
|
263
|
+
}
|
|
264
|
+
fsyncDirectoryFor(outputPath);
|
|
265
|
+
writeLedgerHead(chained, options);
|
|
266
|
+
removeDurableFile(journalPath);
|
|
267
|
+
return chained;
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
function assertLedgerHealthy(ledger) {
|
|
271
|
+
if (!validateEscalationLedger(ledger.events, ledger.malformedRows, ledger.head).ok) {
|
|
272
|
+
throw escalationError('refusing to append to a damaged escalation ledger');
|
|
273
|
+
}
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
function withEscalationLock(options, callback) {
|
|
277
|
+
return withFileLedgerLock(`${getEscalationsPath(options)}.lock`, callback, {
|
|
278
|
+
now: options.now,
|
|
279
|
+
lockStaleMs: options.lockStaleMs,
|
|
280
|
+
errorFactory: (message) => escalationError(message),
|
|
281
|
+
beforeCallback: () => recoverEscalationTransaction(options),
|
|
282
|
+
});
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
function readLedgerHead(options = {}) {
|
|
286
|
+
try {
|
|
287
|
+
return JSON.parse(fs.readFileSync(getEscalationsHeadPath(options), 'utf8'));
|
|
288
|
+
} catch (error) {
|
|
289
|
+
if (error.code === 'ENOENT') return null;
|
|
290
|
+
return { malformed: true };
|
|
291
|
+
}
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
function writeLedgerHead(event, options = {}) {
|
|
295
|
+
const headPath = getEscalationsHeadPath(options);
|
|
296
|
+
const head = {
|
|
297
|
+
schemaVersion: 'human-escalation-head-v1',
|
|
298
|
+
sequence: event.sequence,
|
|
299
|
+
eventHash: event.eventHash,
|
|
300
|
+
};
|
|
301
|
+
writeAtomicJson(headPath, head);
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
function recoverEscalationTransaction(options = {}) {
|
|
305
|
+
const journalPath = getEscalationsJournalPath(options);
|
|
306
|
+
let journal;
|
|
307
|
+
try {
|
|
308
|
+
journal = JSON.parse(fs.readFileSync(journalPath, 'utf8'));
|
|
309
|
+
} catch (error) {
|
|
310
|
+
if (error.code === 'ENOENT') return;
|
|
311
|
+
throw escalationError(`cannot recover escalation journal: ${error.message}`);
|
|
312
|
+
}
|
|
313
|
+
if (journal?.schemaVersion !== 'human-escalation-journal-v1'
|
|
314
|
+
|| !journal.event
|
|
315
|
+
|| journal.event.eventHash !== eventHash(journal.event)) {
|
|
316
|
+
throw escalationError('escalation journal integrity verification failed');
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
const ledger = readLedger(options);
|
|
320
|
+
const event = journal.event;
|
|
321
|
+
const currentLast = ledger.events.at(-1) || null;
|
|
322
|
+
const eventHead = { sequence: event.sequence, eventHash: event.eventHash };
|
|
323
|
+
const eventAlreadyAppended = sameHead(currentLast, eventHead);
|
|
324
|
+
const headAtPrevious = sameHead(ledger.head, journal.previousHead);
|
|
325
|
+
const headAtEvent = sameHead(ledger.head, eventHead);
|
|
326
|
+
|
|
327
|
+
if (eventAlreadyAppended) {
|
|
328
|
+
const preceding = ledger.events.at(-2) || null;
|
|
329
|
+
const precedingMatches = event.sequence === ledger.events.length
|
|
330
|
+
&& event.previousEventHash === (preceding?.eventHash || null);
|
|
331
|
+
const syntheticHead = {
|
|
332
|
+
schemaVersion: 'human-escalation-head-v1',
|
|
333
|
+
...eventHead,
|
|
334
|
+
};
|
|
335
|
+
const integrity = validateEscalationLedger(ledger.events, ledger.malformedRows, syntheticHead);
|
|
336
|
+
if (!precedingMatches || !integrity.ok || (!headAtPrevious && !headAtEvent)) {
|
|
337
|
+
throw escalationError('escalation journal does not match the recoverable append');
|
|
338
|
+
}
|
|
339
|
+
if (!headAtEvent) writeLedgerHead(event, options);
|
|
340
|
+
removeDurableFile(journalPath);
|
|
341
|
+
return;
|
|
342
|
+
}
|
|
343
|
+
|
|
344
|
+
const currentIntegrity = validateEscalationLedger(ledger.events, ledger.malformedRows, ledger.head);
|
|
345
|
+
if (currentIntegrity.ok && headAtPrevious && event.sequence === ledger.events.length + 1) {
|
|
346
|
+
// No append became durable, so the caller never received success. Discard
|
|
347
|
+
// the prepared transaction and let the original operation be retried.
|
|
348
|
+
removeDurableFile(journalPath);
|
|
349
|
+
return;
|
|
350
|
+
}
|
|
351
|
+
throw escalationError('escalation journal cannot be reconciled safely');
|
|
352
|
+
}
|
|
353
|
+
|
|
354
|
+
function sameHead(left, right) {
|
|
355
|
+
if (!left && !right) return true;
|
|
356
|
+
return left?.sequence === right?.sequence && left?.eventHash === right?.eventHash;
|
|
357
|
+
}
|
|
358
|
+
|
|
359
|
+
function writeAtomicJson(targetPath, value) {
|
|
360
|
+
fs.mkdirSync(path.dirname(targetPath), { recursive: true });
|
|
361
|
+
const temporaryPath = `${targetPath}.tmp-${process.pid}-${crypto.randomUUID()}`;
|
|
362
|
+
const fd = fs.openSync(temporaryPath, 'w', 0o600);
|
|
363
|
+
try {
|
|
364
|
+
fs.writeSync(fd, `${JSON.stringify(value)}\n`, null, 'utf8');
|
|
365
|
+
fs.fsyncSync(fd);
|
|
366
|
+
} finally {
|
|
367
|
+
fs.closeSync(fd);
|
|
368
|
+
}
|
|
369
|
+
fs.renameSync(temporaryPath, targetPath);
|
|
370
|
+
fsyncDirectoryFor(targetPath);
|
|
371
|
+
}
|
|
372
|
+
|
|
373
|
+
function removeDurableFile(targetPath) {
|
|
374
|
+
fs.unlinkSync(targetPath);
|
|
375
|
+
fsyncDirectoryFor(targetPath);
|
|
376
|
+
}
|
|
377
|
+
|
|
378
|
+
function fsyncDirectoryFor(targetPath) {
|
|
379
|
+
const directoryFd = fs.openSync(path.dirname(targetPath), 'r');
|
|
380
|
+
try {
|
|
381
|
+
fs.fsyncSync(directoryFd);
|
|
382
|
+
} finally {
|
|
383
|
+
fs.closeSync(directoryFd);
|
|
384
|
+
}
|
|
385
|
+
}
|
|
386
|
+
|
|
387
|
+
function validateEscalationLedger(events, malformedRows = [], head = null) {
|
|
388
|
+
const invalidEventHashes = [];
|
|
389
|
+
const invalidChainLinks = [];
|
|
390
|
+
let previousHash = null;
|
|
391
|
+
let chainedEvents = 0;
|
|
392
|
+
events.forEach((event, index) => {
|
|
393
|
+
const sequence = index + 1;
|
|
394
|
+
if (event.eventHash !== eventHash(event)) invalidEventHashes.push(sequence);
|
|
395
|
+
const isLegacy = !event.schemaVersion
|
|
396
|
+
&& event.sequence === undefined
|
|
397
|
+
&& event.previousEventHash === undefined;
|
|
398
|
+
if (!isLegacy) chainedEvents += 1;
|
|
399
|
+
// Legacy events predate the global chain. They may remain only as a
|
|
400
|
+
// contiguous prefix; the first new event seals their terminal hash into
|
|
401
|
+
// the v2 chain and creates the external head checkpoint.
|
|
402
|
+
if ((!isLegacy && (event.sequence !== sequence || event.previousEventHash !== previousHash))
|
|
403
|
+
|| (isLegacy && chainedEvents > 0)) {
|
|
404
|
+
invalidChainLinks.push(sequence);
|
|
405
|
+
}
|
|
406
|
+
previousHash = event.eventHash || null;
|
|
407
|
+
});
|
|
408
|
+
const expected = events.at(-1) || null;
|
|
409
|
+
const headValid = expected === null
|
|
410
|
+
? head === null
|
|
411
|
+
: chainedEvents === 0
|
|
412
|
+
? head === null
|
|
413
|
+
: head?.schemaVersion === 'human-escalation-head-v1'
|
|
414
|
+
&& head.sequence === expected.sequence
|
|
415
|
+
&& head.eventHash === expected.eventHash;
|
|
416
|
+
return {
|
|
417
|
+
ok: malformedRows.length === 0
|
|
418
|
+
&& invalidEventHashes.length === 0
|
|
419
|
+
&& invalidChainLinks.length === 0
|
|
420
|
+
&& headValid,
|
|
421
|
+
malformedRows,
|
|
422
|
+
invalidEventHashes,
|
|
423
|
+
invalidChainLinks,
|
|
424
|
+
headValid,
|
|
425
|
+
};
|
|
426
|
+
}
|
|
427
|
+
|
|
428
|
+
function signApprovalReceipt(event, signingKey) {
|
|
429
|
+
return {
|
|
430
|
+
algorithm: 'hmac-sha256',
|
|
431
|
+
keyId: crypto.createHash('sha256').update(signingKey).digest('hex').slice(0, 16),
|
|
432
|
+
signature: crypto.createHmac('sha256', signingKey).update(approvalPayload(event)).digest('hex'),
|
|
433
|
+
};
|
|
434
|
+
}
|
|
435
|
+
|
|
436
|
+
function verifyApprovalReceipt(event, verificationKey) {
|
|
437
|
+
const receipt = event.approvalReceipt;
|
|
438
|
+
if (!receipt || receipt.algorithm !== 'hmac-sha256') return false;
|
|
439
|
+
const expectedKeyId = crypto.createHash('sha256').update(verificationKey).digest('hex').slice(0, 16);
|
|
440
|
+
if (receipt.keyId !== expectedKeyId) return false;
|
|
441
|
+
const expected = crypto.createHmac('sha256', verificationKey).update(approvalPayload(event)).digest();
|
|
442
|
+
let actual;
|
|
443
|
+
try {
|
|
444
|
+
actual = Buffer.from(String(receipt.signature || ''), 'hex');
|
|
445
|
+
} catch {
|
|
446
|
+
return false;
|
|
447
|
+
}
|
|
448
|
+
return actual.length === expected.length && crypto.timingSafeEqual(actual, expected);
|
|
449
|
+
}
|
|
450
|
+
|
|
451
|
+
function approvalPayload(event) {
|
|
452
|
+
const payload = {
|
|
453
|
+
escalationId: event.escalationId,
|
|
454
|
+
taskId: event.taskId,
|
|
455
|
+
status: event.status,
|
|
456
|
+
decision: event.decision,
|
|
457
|
+
actor: event.actor,
|
|
458
|
+
reason: event.reason,
|
|
459
|
+
decidedAt: event.decidedAt,
|
|
460
|
+
};
|
|
461
|
+
if (event.approvalContextDigest) payload.approvalContextDigest = event.approvalContextDigest;
|
|
462
|
+
return stableStringify(payload);
|
|
166
463
|
}
|
|
167
464
|
|
|
168
465
|
function requiredIdentity(value, field) {
|
|
@@ -187,6 +484,14 @@ function optionalString(value) {
|
|
|
187
484
|
return clean || undefined;
|
|
188
485
|
}
|
|
189
486
|
|
|
487
|
+
function optionalDigest(value, field) {
|
|
488
|
+
const digest = optionalString(value);
|
|
489
|
+
if (digest && !/^[a-f0-9]{64}$/i.test(digest)) {
|
|
490
|
+
throw escalationError(`${field} must be a SHA-256 hex digest`);
|
|
491
|
+
}
|
|
492
|
+
return digest?.toLowerCase();
|
|
493
|
+
}
|
|
494
|
+
|
|
190
495
|
function stringArray(value) {
|
|
191
496
|
return Array.isArray(value) ? value.map((entry) => String(entry).trim()).filter(Boolean) : [];
|
|
192
497
|
}
|
|
@@ -201,7 +506,9 @@ function sameIdentity(a, b) {
|
|
|
201
506
|
}
|
|
202
507
|
|
|
203
508
|
function eventHash(event) {
|
|
204
|
-
|
|
509
|
+
const copy = { ...event };
|
|
510
|
+
delete copy.eventHash;
|
|
511
|
+
return crypto.createHash('sha256').update(stableStringify(copy)).digest('hex');
|
|
205
512
|
}
|
|
206
513
|
|
|
207
514
|
function eventComparableHash(event) {
|
|
@@ -213,6 +520,7 @@ function eventComparableHash(event) {
|
|
|
213
520
|
requester: event.requester,
|
|
214
521
|
evidence: event.evidence,
|
|
215
522
|
};
|
|
523
|
+
if (event.approvalContextDigest) comparable.approvalContextDigest = event.approvalContextDigest;
|
|
216
524
|
return crypto.createHash('sha256').update(stableStringify(comparable)).digest('hex');
|
|
217
525
|
}
|
|
218
526
|
|
|
@@ -259,7 +567,11 @@ module.exports = {
|
|
|
259
567
|
calculateEscalationMetrics,
|
|
260
568
|
decideEscalation,
|
|
261
569
|
getEscalation,
|
|
570
|
+
getEscalationsHeadPath,
|
|
571
|
+
getEscalationsJournalPath,
|
|
262
572
|
getEscalationsPath,
|
|
573
|
+
getVerifiedApproval,
|
|
263
574
|
listEscalations,
|
|
264
575
|
requestEscalation,
|
|
576
|
+
validateEscalationLedger,
|
|
265
577
|
};
|
|
@@ -491,12 +491,19 @@ function normalizeProviderAction(input = {}) {
|
|
|
491
491
|
|
|
492
492
|
function normalizeBudget(input = {}) {
|
|
493
493
|
const budget = asObject(input);
|
|
494
|
+
const hasNumericAlias = (...keys) => keys.some((key) => (
|
|
495
|
+
Object.hasOwn(budget, key) && Number.isFinite(Number(budget[key]))
|
|
496
|
+
));
|
|
494
497
|
return {
|
|
495
498
|
maxTokensPerAction: firstNumber(budget.maxTokensPerAction, budget.perActionTokens, budget.tokenLimit),
|
|
496
499
|
remainingTokens: firstNumber(budget.remainingTokens, budget.tokensRemaining),
|
|
497
500
|
maxCostUsdPerAction: firstNumber(budget.maxCostUsdPerAction, budget.perActionCostUsd, budget.costLimitUsd),
|
|
498
501
|
remainingCostUsd: firstNumber(budget.remainingCostUsd, budget.costUsdRemaining),
|
|
499
502
|
maxParallelBranches: firstNumber(budget.maxParallelBranches, budget.parallelBranchLimit, DEFAULT_MAX_PARALLEL_BRANCHES),
|
|
503
|
+
hasMaxTokensPerAction: hasNumericAlias('maxTokensPerAction', 'perActionTokens', 'tokenLimit'),
|
|
504
|
+
hasRemainingTokens: hasNumericAlias('remainingTokens', 'tokensRemaining'),
|
|
505
|
+
hasMaxCostUsdPerAction: hasNumericAlias('maxCostUsdPerAction', 'perActionCostUsd', 'costLimitUsd'),
|
|
506
|
+
hasRemainingCostUsd: hasNumericAlias('remainingCostUsd', 'costUsdRemaining'),
|
|
500
507
|
};
|
|
501
508
|
}
|
|
502
509
|
|
|
@@ -540,16 +547,16 @@ function buildCostControl(normalizedAction = {}, budgetInput = {}) {
|
|
|
540
547
|
const totalTokens = firstNumber(usage.totalTokens);
|
|
541
548
|
const estimatedCostUsd = firstNumber(usage.estimatedCostUsd);
|
|
542
549
|
|
|
543
|
-
if (budget.
|
|
550
|
+
if (budget.hasMaxTokensPerAction && totalTokens > budget.maxTokensPerAction) {
|
|
544
551
|
reasons.push(`Token estimate ${totalTokens} exceeds per-action limit ${budget.maxTokensPerAction}.`);
|
|
545
552
|
}
|
|
546
|
-
if (budget.
|
|
553
|
+
if (budget.hasRemainingTokens && totalTokens > budget.remainingTokens) {
|
|
547
554
|
reasons.push(`Token estimate ${totalTokens} exceeds remaining budget ${budget.remainingTokens}.`);
|
|
548
555
|
}
|
|
549
|
-
if (budget.
|
|
556
|
+
if (budget.hasMaxCostUsdPerAction && estimatedCostUsd > budget.maxCostUsdPerAction) {
|
|
550
557
|
reasons.push(`Estimated cost $${estimatedCostUsd.toFixed(4)} exceeds per-action limit $${budget.maxCostUsdPerAction.toFixed(4)}.`);
|
|
551
558
|
}
|
|
552
|
-
if (budget.
|
|
559
|
+
if (budget.hasRemainingCostUsd && estimatedCostUsd > budget.remainingCostUsd) {
|
|
553
560
|
reasons.push(`Estimated cost $${estimatedCostUsd.toFixed(4)} exceeds remaining budget $${budget.remainingCostUsd.toFixed(4)}.`);
|
|
554
561
|
}
|
|
555
562
|
if (budget.maxParallelBranches > 0 && normalizedAction.workflow?.branchCount > budget.maxParallelBranches) {
|
package/scripts/tool-registry.js
CHANGED
|
@@ -1185,14 +1185,91 @@ const TOOLS = [
|
|
|
1185
1185
|
},
|
|
1186
1186
|
},
|
|
1187
1187
|
}),
|
|
1188
|
+
destructiveTool({
|
|
1189
|
+
name: 'create_purchase_requisition',
|
|
1190
|
+
description: 'Create an append-only purchase requisition and independent human-escalation request. This does not authorize spending.',
|
|
1191
|
+
inputSchema: {
|
|
1192
|
+
type: 'object',
|
|
1193
|
+
additionalProperties: false,
|
|
1194
|
+
required: ['taskId', 'vendor', 'amountUsd', 'purpose', 'sourceMessageId', 'evidence', 'toolName', 'toolInput'],
|
|
1195
|
+
properties: {
|
|
1196
|
+
taskId: { type: 'string', minLength: 1 },
|
|
1197
|
+
vendor: { type: 'string', minLength: 1 },
|
|
1198
|
+
amountUsd: { type: 'number', exclusiveMinimum: 0 },
|
|
1199
|
+
purpose: { type: 'string', minLength: 1 },
|
|
1200
|
+
sourceMessageId: { type: 'string', minLength: 1, description: 'Stable identifier for the exact user message authorizing the request.' },
|
|
1201
|
+
toolName: { type: 'string', minLength: 1, description: 'Exact future economic tool name. The human-approved requisition is cryptographically bound to it.' },
|
|
1202
|
+
toolInput: { type: 'object', minProperties: 1, description: 'Exact future economic tool input before financialControl metadata is attached. Stored only as a fingerprint.' },
|
|
1203
|
+
evidence: { type: 'array', minItems: 1, items: { type: 'string', minLength: 1 } },
|
|
1204
|
+
ttlMs: { type: 'number', minimum: 1 },
|
|
1205
|
+
idempotencyKey: { type: 'string', minLength: 1 },
|
|
1206
|
+
},
|
|
1207
|
+
},
|
|
1208
|
+
}),
|
|
1209
|
+
readOnlyTool({
|
|
1210
|
+
name: 'list_purchase_requisitions',
|
|
1211
|
+
description: 'List projected purchase-requisition states from the append-only financial ledger and human-review queue.',
|
|
1212
|
+
inputSchema: {
|
|
1213
|
+
type: 'object',
|
|
1214
|
+
additionalProperties: false,
|
|
1215
|
+
properties: {
|
|
1216
|
+
status: { type: 'string' },
|
|
1217
|
+
limit: { type: 'integer', minimum: 1, maximum: 100 },
|
|
1218
|
+
},
|
|
1219
|
+
},
|
|
1220
|
+
}),
|
|
1221
|
+
destructiveTool({
|
|
1222
|
+
name: 'reserve_purchase_requisition',
|
|
1223
|
+
description: 'Reserve a single-use amount from an independently approved purchase requisition. Approval is unavailable from the agent tool surface.',
|
|
1224
|
+
inputSchema: {
|
|
1225
|
+
type: 'object',
|
|
1226
|
+
additionalProperties: false,
|
|
1227
|
+
required: ['requisitionId', 'amountUsd', 'vendor', 'purpose', 'sourceMessageId'],
|
|
1228
|
+
properties: {
|
|
1229
|
+
requisitionId: { type: 'string', minLength: 1 },
|
|
1230
|
+
amountUsd: { type: 'number', exclusiveMinimum: 0 },
|
|
1231
|
+
vendor: { type: 'string', minLength: 1 },
|
|
1232
|
+
purpose: { type: 'string', minLength: 1 },
|
|
1233
|
+
sourceMessageId: { type: 'string', minLength: 1 },
|
|
1234
|
+
ttlMs: { type: 'number', minimum: 1 },
|
|
1235
|
+
idempotencyKey: { type: 'string', minLength: 1 },
|
|
1236
|
+
},
|
|
1237
|
+
},
|
|
1238
|
+
}),
|
|
1239
|
+
destructiveTool({
|
|
1240
|
+
name: 'settle_purchase_requisition',
|
|
1241
|
+
description: 'Commit actual spend with receipt evidence or release an unused reservation. Events are append-only.',
|
|
1242
|
+
inputSchema: {
|
|
1243
|
+
type: 'object',
|
|
1244
|
+
additionalProperties: false,
|
|
1245
|
+
required: ['requisitionId', 'reservationId', 'status'],
|
|
1246
|
+
properties: {
|
|
1247
|
+
requisitionId: { type: 'string', minLength: 1 },
|
|
1248
|
+
reservationId: { type: 'string', minLength: 1 },
|
|
1249
|
+
status: { type: 'string', enum: ['committed', 'released'] },
|
|
1250
|
+
actualAmountUsd: { type: 'number', minimum: 0 },
|
|
1251
|
+
evidence: { type: 'array', items: { type: 'string', minLength: 1 } },
|
|
1252
|
+
reason: { type: 'string' },
|
|
1253
|
+
},
|
|
1254
|
+
},
|
|
1255
|
+
}),
|
|
1256
|
+
readOnlyTool({
|
|
1257
|
+
name: 'reconcile_purchase_ledger',
|
|
1258
|
+
description: 'Reconcile the append-only financial ledger, including totals, stale reservations, status counts, and tamper-evident event hashes.',
|
|
1259
|
+
inputSchema: {
|
|
1260
|
+
type: 'object',
|
|
1261
|
+
additionalProperties: false,
|
|
1262
|
+
properties: {},
|
|
1263
|
+
},
|
|
1264
|
+
}),
|
|
1188
1265
|
readOnlyTool({
|
|
1189
1266
|
name: 'verify_claim',
|
|
1190
|
-
description: 'Check whether a claim has enough tracked evidence before the agent asserts it.',
|
|
1267
|
+
description: 'Check whether a claim has enough tracked evidence and, for parseable factual claims (row counts, file lines/bytes/existence, versions), recheck configured SQLite/filesystem/JSON verifiers before the agent asserts it.',
|
|
1191
1268
|
inputSchema: {
|
|
1192
1269
|
type: 'object',
|
|
1193
1270
|
required: ['claim'],
|
|
1194
1271
|
properties: {
|
|
1195
|
-
claim: { type: 'string', description: 'The claim text to verify' },
|
|
1272
|
+
claim: { type: 'string', description: 'The claim text to verify (e.g. "the row count is 1,284" or "all tests pass")' },
|
|
1196
1273
|
goalContract: GOAL_CONTRACT_SCHEMA,
|
|
1197
1274
|
},
|
|
1198
1275
|
},
|
|
@@ -1248,6 +1325,19 @@ const TOOLS = [
|
|
|
1248
1325
|
additionalProperties: true,
|
|
1249
1326
|
description: 'Optional per-action budget controls: maxTokensPerAction, remainingTokens, maxCostUsdPerAction, remainingCostUsd, maxParallelBranches',
|
|
1250
1327
|
},
|
|
1328
|
+
financialControl: {
|
|
1329
|
+
type: 'object',
|
|
1330
|
+
additionalProperties: false,
|
|
1331
|
+
description: 'Single-use purchase authorization scope from the append-only financial ledger.',
|
|
1332
|
+
properties: {
|
|
1333
|
+
requisitionId: { type: 'string' },
|
|
1334
|
+
reservationId: { type: 'string' },
|
|
1335
|
+
actionId: { type: 'string' },
|
|
1336
|
+
vendor: { type: 'string' },
|
|
1337
|
+
purpose: { type: 'string' },
|
|
1338
|
+
sourceMessageId: { type: 'string' },
|
|
1339
|
+
},
|
|
1340
|
+
},
|
|
1251
1341
|
workflowPattern: {
|
|
1252
1342
|
type: 'string',
|
|
1253
1343
|
enum: ['single_action', 'chaining', 'routing', 'parallelization', 'evaluator-optimizer', 'agent'],
|
|
@@ -1655,13 +1745,13 @@ const TOOLS = [
|
|
|
1655
1745
|
}),
|
|
1656
1746
|
readOnlyTool({
|
|
1657
1747
|
name: 'require_evidence_for_claim',
|
|
1658
|
-
description: 'Leader-Agent completion gate. Before any agent declares done/fixed/shipped/resolved, require tracked evidence. Blocking response when evidence missing; callers honor the blocking flag to stop completion claims.',
|
|
1748
|
+
description: 'Leader-Agent completion gate. Before any agent declares done/fixed/shipped/resolved, require tracked session evidence AND recheck parseable factual claims (row counts, file metrics, versions) against configured verifiers. Blocking response when evidence is missing or a factual claim mismatches; callers honor the blocking flag to stop completion claims.',
|
|
1659
1749
|
inputSchema: {
|
|
1660
1750
|
type: 'object',
|
|
1661
1751
|
required: ['claim'],
|
|
1662
1752
|
properties: {
|
|
1663
|
-
claim: { type: 'string', description: 'The completion claim text to verify (e.g. "Fix shipped", "
|
|
1664
|
-
mode: { type: 'string', enum: ['blocking', 'advisory'], description: 'blocking (default) returns blocking=true when evidence missing; advisory returns blocking=false' },
|
|
1753
|
+
claim: { type: 'string', description: 'The completion claim text to verify (e.g. "Fix shipped", "the row count is 1,284")' },
|
|
1754
|
+
mode: { type: 'string', enum: ['blocking', 'advisory'], description: 'blocking (default) returns blocking=true when evidence missing or factual claim mismatches; advisory returns blocking=false' },
|
|
1665
1755
|
sessionId: { type: 'string', description: 'Optional session id to associate with the gate decision' },
|
|
1666
1756
|
goalContract: GOAL_CONTRACT_SCHEMA,
|
|
1667
1757
|
},
|