@agentguard-run/spend 0.13.3 → 0.14.1
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/CHANGELOG.md +11 -0
- package/dist/cli/hermes-kanban.d.ts +2 -0
- package/dist/cli/hermes-kanban.d.ts.map +1 -0
- package/dist/cli/hermes-kanban.js +86 -0
- package/dist/cli/hermes-kanban.js.map +1 -0
- package/dist/cli/main.d.ts.map +1 -1
- package/dist/cli/main.js +5 -0
- package/dist/cli/main.js.map +1 -1
- package/dist/cli/serve.d.ts +1 -0
- package/dist/cli/serve.d.ts.map +1 -1
- package/dist/cli/serve.js +8 -4
- package/dist/cli/serve.js.map +1 -1
- package/dist/frameworks/hermes-kanban.d.ts +131 -0
- package/dist/frameworks/hermes-kanban.d.ts.map +1 -0
- package/dist/frameworks/hermes-kanban.js +486 -0
- package/dist/frameworks/hermes-kanban.js.map +1 -0
- package/dist/frameworks/index.d.ts +1 -0
- package/dist/frameworks/index.d.ts.map +1 -1
- package/dist/frameworks/index.js +1 -0
- package/dist/frameworks/index.js.map +1 -1
- package/dist/index.d.ts +1 -1
- package/dist/index.js +1 -1
- package/dist/telemetry.js +1 -1
- package/dist/workflow/context.d.ts.map +1 -1
- package/dist/workflow/context.js +6 -3
- package/dist/workflow/context.js.map +1 -1
- package/dist/workflow/receipt.d.ts +22 -6
- package/dist/workflow/receipt.d.ts.map +1 -1
- package/dist/workflow/receipt.js +109 -22
- package/dist/workflow/receipt.js.map +1 -1
- package/dist/workflow/types.d.ts +7 -0
- package/dist/workflow/types.d.ts.map +1 -1
- package/package.json +6 -1
- package/src/frameworks/hermes-kanban.ts +622 -0
- package/src/frameworks/index.ts +1 -0
- package/src/workflow/context.ts +6 -3
- package/src/workflow/receipt.ts +134 -30
- package/src/workflow/types.ts +7 -0
|
@@ -0,0 +1,622 @@
|
|
|
1
|
+
import { randomUUID } from 'crypto';
|
|
2
|
+
import { inferProvider } from '../cost-table';
|
|
3
|
+
import { canonicalJson, computeSignerFingerprint, sha256Hex } from '../decision-log';
|
|
4
|
+
import { signDagNode, verifyReceiptDag, type ReceiptCapabilityLevel, type ReceiptDagEnvelope, type ReceiptDagNode } from '../receipts/dag';
|
|
5
|
+
import { AgentGuardBlockedError, SpendGuard, type SpendGuardConfig } from '../spend-guard';
|
|
6
|
+
import type { CallContext, CapabilityTier, Provider, SignedDecisionLogEntry, SpendDecision, SpendPolicy, SpendScope } from '../types';
|
|
7
|
+
import { safeRequestShape } from './common';
|
|
8
|
+
|
|
9
|
+
export type HermesKanbanProfile = 'researcher' | 'writer' | 'reviewer' | 'director' | 'orchestrator' | string;
|
|
10
|
+
export type HermesKanbanTaskState = 'governed' | 'running' | 'completed' | 'revoked';
|
|
11
|
+
|
|
12
|
+
export interface HermesKanbanProfilePolicy {
|
|
13
|
+
capability: ReceiptCapabilityLevel;
|
|
14
|
+
toolAllowlist?: string[];
|
|
15
|
+
capCents?: number;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export interface HermesKanbanAdapterOptions {
|
|
19
|
+
policy: SpendPolicy;
|
|
20
|
+
scope: SpendScope;
|
|
21
|
+
config?: Omit<SpendGuardConfig, 'policy'>;
|
|
22
|
+
licenseKey?: string;
|
|
23
|
+
defaultModel?: string;
|
|
24
|
+
defaultCapCents?: number;
|
|
25
|
+
runtimeBudgetCentsPerMinute?: number;
|
|
26
|
+
profileMap?: Record<string, HermesKanbanProfilePolicy>;
|
|
27
|
+
reviewerCascade?: {
|
|
28
|
+
enabled?: boolean;
|
|
29
|
+
highRiskThreshold?: number;
|
|
30
|
+
drafterModel?: string;
|
|
31
|
+
reviewerModel?: string;
|
|
32
|
+
drafterMaxCostCents?: number;
|
|
33
|
+
reviewerMaxCostCents?: number;
|
|
34
|
+
};
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export interface HermesKanbanTaskInput {
|
|
38
|
+
taskId: string;
|
|
39
|
+
boardSlug: string;
|
|
40
|
+
profile: HermesKanbanProfile;
|
|
41
|
+
parentTaskIds?: string[];
|
|
42
|
+
maxRuntimeMinutes?: number;
|
|
43
|
+
maxRuntimeMs?: number;
|
|
44
|
+
metadata?: Record<string, unknown>;
|
|
45
|
+
builderCode?: string;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
export interface HermesKanbanEnvelope {
|
|
49
|
+
taskId: string;
|
|
50
|
+
taskHash: string;
|
|
51
|
+
boardHash: string;
|
|
52
|
+
profile: string;
|
|
53
|
+
state: HermesKanbanTaskState;
|
|
54
|
+
capabilityCeiling: ReceiptCapabilityLevel;
|
|
55
|
+
toolAllowlist: string[];
|
|
56
|
+
capCents: number;
|
|
57
|
+
parentTaskIds: string[];
|
|
58
|
+
parentEntryHashes: string[];
|
|
59
|
+
scope: SpendScope;
|
|
60
|
+
builderCode?: string;
|
|
61
|
+
createdAt: string;
|
|
62
|
+
revokedAt?: string;
|
|
63
|
+
completedAt?: string;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
export interface HermesKanbanDispatchInput {
|
|
67
|
+
taskId: string;
|
|
68
|
+
model?: string;
|
|
69
|
+
provider?: Provider;
|
|
70
|
+
inputTokens: number;
|
|
71
|
+
outputTokens: number;
|
|
72
|
+
metadata?: Record<string, unknown>;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
export interface HermesKanbanDispatchResult {
|
|
76
|
+
allow: boolean;
|
|
77
|
+
decision: SpendDecision;
|
|
78
|
+
signed: SignedDecisionLogEntry | null;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
export interface HermesKanbanCapabilityInput {
|
|
82
|
+
taskId: string;
|
|
83
|
+
requestedCapability: ReceiptCapabilityLevel;
|
|
84
|
+
toolName?: string;
|
|
85
|
+
toolArgs?: Record<string, unknown>;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
export interface HermesKanbanCapabilityResult {
|
|
89
|
+
approved: boolean;
|
|
90
|
+
reason?: string;
|
|
91
|
+
signed?: SignedDecisionLogEntry | null;
|
|
92
|
+
decision?: SpendDecision;
|
|
93
|
+
editedArgs?: Record<string, unknown>;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
export interface HermesKanbanReceiptInput {
|
|
97
|
+
taskId: string;
|
|
98
|
+
parentTaskIds?: string[];
|
|
99
|
+
capability?: ReceiptCapabilityLevel;
|
|
100
|
+
modelIds?: string[];
|
|
101
|
+
tokenCounts?: { inputTokens?: number; outputTokens?: number };
|
|
102
|
+
totalCostCents?: number;
|
|
103
|
+
metadata?: Record<string, unknown>;
|
|
104
|
+
highRiskClassifierScore?: number;
|
|
105
|
+
keywordRisk?: boolean;
|
|
106
|
+
reviewerVerdict?: 'drafter_only' | 'review_required' | 'approved' | 'rejected';
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
export interface HermesKanbanReceiptResult {
|
|
110
|
+
node: ReceiptDagNode;
|
|
111
|
+
envelope: HermesKanbanEnvelope;
|
|
112
|
+
dag: ReceiptDagEnvelope;
|
|
113
|
+
verification?: Awaited<ReturnType<typeof verifyReceiptDag>>;
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
const DEFAULT_PROFILE_MAP: Record<string, HermesKanbanProfilePolicy> = {
|
|
117
|
+
researcher: { capability: 'READ_ONLY', toolAllowlist: ['web.fetch', 'file.read', 'notes.read', 'search.query'] },
|
|
118
|
+
writer: { capability: 'TRANSACT', toolAllowlist: ['draft.create', 'file.write', 'notes.write', 'email.draft'] },
|
|
119
|
+
reviewer: { capability: 'READ_ONLY', toolAllowlist: ['review.read', 'notes.read', 'decision.record'] },
|
|
120
|
+
director: { capability: 'ORCHESTRATE', toolAllowlist: ['task.assign', 'board.plan', 'decision.record'] },
|
|
121
|
+
orchestrator: { capability: 'ORCHESTRATE', toolAllowlist: ['task.assign', 'board.plan', 'decision.record'] },
|
|
122
|
+
};
|
|
123
|
+
|
|
124
|
+
const CAPABILITY_RANK: Record<ReceiptCapabilityLevel, number> = {
|
|
125
|
+
READ_ONLY: 0,
|
|
126
|
+
TRANSACT: 1,
|
|
127
|
+
ADMIN: 2,
|
|
128
|
+
ORCHESTRATE: 3,
|
|
129
|
+
};
|
|
130
|
+
|
|
131
|
+
const DATA_PLANE_KEYS = /^(title|summary|prompt|completion|content|messages|input|output|text|raw|source|notes|draft|body|transcript)$/i;
|
|
132
|
+
const SAFE_METADATA_KEYS = new Set([
|
|
133
|
+
'assignee',
|
|
134
|
+
'board_hash',
|
|
135
|
+
'duration_ms',
|
|
136
|
+
'event',
|
|
137
|
+
'model',
|
|
138
|
+
'model_id',
|
|
139
|
+
'outcome',
|
|
140
|
+
'priority',
|
|
141
|
+
'profile',
|
|
142
|
+
'provider',
|
|
143
|
+
'risk_score',
|
|
144
|
+
'state',
|
|
145
|
+
'task_id',
|
|
146
|
+
'ticket_id',
|
|
147
|
+
'token_count',
|
|
148
|
+
'workflow_id',
|
|
149
|
+
]);
|
|
150
|
+
|
|
151
|
+
export function createHermesKanbanAdapter(opts: HermesKanbanAdapterOptions): HermesKanbanAdapter {
|
|
152
|
+
return new HermesKanbanAdapter(opts);
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
export class HermesKanbanAdapter {
|
|
156
|
+
private readonly taskGuards = new Map<string, SpendGuard>();
|
|
157
|
+
private readonly envelopes = new Map<string, HermesKanbanEnvelope>();
|
|
158
|
+
private readonly nodesByTaskId = new Map<string, ReceiptDagNode>();
|
|
159
|
+
private readonly nodes: ReceiptDagNode[] = [];
|
|
160
|
+
private readonly profileMap: Record<string, HermesKanbanProfilePolicy>;
|
|
161
|
+
private readonly config: Omit<SpendGuardConfig, 'policy'>;
|
|
162
|
+
|
|
163
|
+
constructor(private readonly opts: HermesKanbanAdapterOptions) {
|
|
164
|
+
this.profileMap = { ...DEFAULT_PROFILE_MAP, ...(opts.profileMap ?? {}) };
|
|
165
|
+
this.config = opts.config ?? {};
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
governTask(input: HermesKanbanTaskInput): HermesKanbanEnvelope {
|
|
169
|
+
assertTaskInput(input);
|
|
170
|
+
const profilePolicy = this.profilePolicy(input.profile);
|
|
171
|
+
const capCents = capForTask(input, profilePolicy, this.opts);
|
|
172
|
+
const boardHash = hashValue(input.boardSlug);
|
|
173
|
+
const taskHash = hashValue(input.taskId);
|
|
174
|
+
const scope: SpendScope = { ...this.opts.scope, taskId: input.taskId };
|
|
175
|
+
const envelope: HermesKanbanEnvelope = {
|
|
176
|
+
taskId: input.taskId,
|
|
177
|
+
taskHash,
|
|
178
|
+
boardHash,
|
|
179
|
+
profile: normalizeProfile(input.profile),
|
|
180
|
+
state: 'governed',
|
|
181
|
+
capabilityCeiling: profilePolicy.capability,
|
|
182
|
+
toolAllowlist: [...(profilePolicy.toolAllowlist ?? [])],
|
|
183
|
+
capCents,
|
|
184
|
+
parentTaskIds: [...new Set(input.parentTaskIds ?? [])],
|
|
185
|
+
parentEntryHashes: [],
|
|
186
|
+
scope,
|
|
187
|
+
builderCode: safeBuilderCode(input.builderCode),
|
|
188
|
+
createdAt: new Date().toISOString(),
|
|
189
|
+
};
|
|
190
|
+
const taskPolicy: SpendPolicy = {
|
|
191
|
+
...this.opts.policy,
|
|
192
|
+
id: `${this.opts.policy.id}:hermes-kanban:${taskHash.slice(0, 12)}`,
|
|
193
|
+
name: `${this.opts.policy.name} Hermes Kanban task`,
|
|
194
|
+
scope,
|
|
195
|
+
caps: [{ window: 'per_day', amountCents: capCents, action: 'block', reason: 'Hermes Kanban task cap' }],
|
|
196
|
+
requiredCapability: spendCapabilityForReceiptCapability(profilePolicy.capability),
|
|
197
|
+
};
|
|
198
|
+
this.envelopes.set(input.taskId, envelope);
|
|
199
|
+
this.taskGuards.set(input.taskId, new SpendGuard({
|
|
200
|
+
policy: taskPolicy,
|
|
201
|
+
...this.config,
|
|
202
|
+
licenseKey: this.opts.licenseKey ?? this.config.licenseKey,
|
|
203
|
+
}));
|
|
204
|
+
return { ...envelope };
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
async enforceProviderCall(input: HermesKanbanDispatchInput): Promise<HermesKanbanDispatchResult> {
|
|
208
|
+
const envelope = this.requireEnvelope(input.taskId);
|
|
209
|
+
assertActive(envelope);
|
|
210
|
+
assertMetadataOnly(input.metadata ?? {});
|
|
211
|
+
validateTokens(input.inputTokens, input.outputTokens);
|
|
212
|
+
const guard = this.requireGuard(input.taskId);
|
|
213
|
+
const model = safeLabel(input.model) ?? this.opts.defaultModel ?? 'openai/gpt-4o-mini';
|
|
214
|
+
const call: CallContext = {
|
|
215
|
+
provider: input.provider ?? inferProvider(model),
|
|
216
|
+
model,
|
|
217
|
+
inputTokens: input.inputTokens,
|
|
218
|
+
outputTokens: input.outputTokens,
|
|
219
|
+
scope: envelope.scope,
|
|
220
|
+
workflowId: envelope.boardHash,
|
|
221
|
+
subagentId: envelope.taskId,
|
|
222
|
+
label: 'hermes-kanban',
|
|
223
|
+
capabilityClaim: spendCapabilityForReceiptCapability(envelope.capabilityCeiling),
|
|
224
|
+
requestShape: safeRequestShape({
|
|
225
|
+
framework: 'hermes-kanban',
|
|
226
|
+
event: 'provider-call',
|
|
227
|
+
taskId: envelope.taskId,
|
|
228
|
+
taskHash: envelope.taskHash,
|
|
229
|
+
boardHash: envelope.boardHash,
|
|
230
|
+
profile: envelope.profile,
|
|
231
|
+
metadata: filterReceiptMetadata(input.metadata ?? {}),
|
|
232
|
+
}),
|
|
233
|
+
};
|
|
234
|
+
const { decision, signed } = await guard.decide(call);
|
|
235
|
+
return { allow: decision.action !== 'block', decision, signed };
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
async guardCapability(input: HermesKanbanCapabilityInput): Promise<HermesKanbanCapabilityResult> {
|
|
239
|
+
const envelope = this.requireEnvelope(input.taskId);
|
|
240
|
+
assertActive(envelope);
|
|
241
|
+
assertMetadataOnly(input.toolArgs ?? {});
|
|
242
|
+
if (CAPABILITY_RANK[input.requestedCapability] > CAPABILITY_RANK[envelope.capabilityCeiling]) {
|
|
243
|
+
return {
|
|
244
|
+
approved: false,
|
|
245
|
+
reason: `Hermes Kanban profile ${envelope.profile} is capped at ${envelope.capabilityCeiling}`,
|
|
246
|
+
};
|
|
247
|
+
}
|
|
248
|
+
if (input.toolName && envelope.toolAllowlist.length > 0 && !toolAllowed(input.toolName, envelope.toolAllowlist)) {
|
|
249
|
+
return { approved: false, reason: `Tool ${input.toolName} is outside the Hermes Kanban profile allowlist` };
|
|
250
|
+
}
|
|
251
|
+
const gate = await this.requireGuard(input.taskId).guardToolCall({
|
|
252
|
+
toolName: input.toolName ?? 'hermes-kanban.tool',
|
|
253
|
+
toolArgs: safeRequestShape(input.toolArgs ?? {}),
|
|
254
|
+
workflowId: envelope.boardHash,
|
|
255
|
+
scope: envelope.scope,
|
|
256
|
+
});
|
|
257
|
+
return {
|
|
258
|
+
approved: gate.approved,
|
|
259
|
+
reason: gate.decision.reasons.join('; '),
|
|
260
|
+
signed: gate.signed,
|
|
261
|
+
decision: gate.decision,
|
|
262
|
+
editedArgs: gate.editedArgs,
|
|
263
|
+
};
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
async revokeTask(taskId: string): Promise<HermesKanbanReceiptResult> {
|
|
267
|
+
const envelope = this.requireEnvelope(taskId);
|
|
268
|
+
if (envelope.state === 'revoked') return this.receiptResult(envelope, this.requireNode(taskId));
|
|
269
|
+
envelope.state = 'revoked';
|
|
270
|
+
envelope.revokedAt = new Date().toISOString();
|
|
271
|
+
const node = await this.signTaskNode(envelope, {
|
|
272
|
+
event: 'revoked',
|
|
273
|
+
action: 'block',
|
|
274
|
+
blocked: true,
|
|
275
|
+
capability: envelope.capabilityCeiling,
|
|
276
|
+
trustScore: 0.7,
|
|
277
|
+
});
|
|
278
|
+
this.nodesByTaskId.set(taskId, node);
|
|
279
|
+
this.nodes.push(node);
|
|
280
|
+
return this.receiptResult(envelope, node);
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
async emitTaskReceipt(input: HermesKanbanReceiptInput): Promise<HermesKanbanReceiptResult> {
|
|
284
|
+
const envelope = this.requireEnvelope(input.taskId);
|
|
285
|
+
assertActive(envelope);
|
|
286
|
+
assertMetadataOnly(input.metadata ?? {});
|
|
287
|
+
envelope.state = 'completed';
|
|
288
|
+
envelope.completedAt = new Date().toISOString();
|
|
289
|
+
const parentTaskIds = [...new Set(input.parentTaskIds ?? envelope.parentTaskIds)];
|
|
290
|
+
const parentNodes = parentTaskIds.map((id) => this.nodesByTaskId.get(id)).filter(Boolean) as ReceiptDagNode[];
|
|
291
|
+
envelope.parentEntryHashes = parentNodes.map((node) => node.entryHash);
|
|
292
|
+
const capability = input.capability ?? envelope.capabilityCeiling;
|
|
293
|
+
const triggers = reviewerCascadeTriggers(envelope, input, this.opts);
|
|
294
|
+
const node = await this.signTaskNode(envelope, {
|
|
295
|
+
event: 'completed',
|
|
296
|
+
action: 'allow',
|
|
297
|
+
blocked: false,
|
|
298
|
+
capability,
|
|
299
|
+
trustScore: triggers.length > 0 ? 0.9 : 0.94,
|
|
300
|
+
parentNodes,
|
|
301
|
+
metadata: input.metadata,
|
|
302
|
+
modelIds: input.modelIds,
|
|
303
|
+
tokenCounts: input.tokenCounts,
|
|
304
|
+
totalCostCents: input.totalCostCents,
|
|
305
|
+
reviewerCascade: triggers.length > 0 ? buildReviewerCascadeReceipt(input, this.opts, triggers) : undefined,
|
|
306
|
+
});
|
|
307
|
+
this.nodesByTaskId.set(input.taskId, node);
|
|
308
|
+
this.nodes.push(node);
|
|
309
|
+
return this.receiptResult(envelope, node);
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
getEnvelope(taskId: string): HermesKanbanEnvelope | null {
|
|
313
|
+
const envelope = this.envelopes.get(taskId);
|
|
314
|
+
return envelope ? { ...envelope, parentTaskIds: [...envelope.parentTaskIds], parentEntryHashes: [...envelope.parentEntryHashes], toolAllowlist: [...envelope.toolAllowlist] } : null;
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
getReceiptDag(rootTaskId?: string): ReceiptDagEnvelope {
|
|
318
|
+
const root = rootTaskId ? this.nodesByTaskId.get(rootTaskId) : this.nodes[this.nodes.length - 1];
|
|
319
|
+
return {
|
|
320
|
+
nodes: [...this.nodes],
|
|
321
|
+
rootId: root?.entryHash ?? '0'.repeat(64),
|
|
322
|
+
workflowId: this.nodes[0]?.workflowId ?? null,
|
|
323
|
+
};
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
outboundPayloads(): Record<string, unknown>[] {
|
|
327
|
+
return [];
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
async verifyDag(publicKeyHex?: string): Promise<Awaited<ReturnType<typeof verifyReceiptDag>>> {
|
|
331
|
+
const key = publicKeyHex ?? this.publicKeyHex();
|
|
332
|
+
return verifyReceiptDag(this.getReceiptDag(), key);
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
private async signTaskNode(
|
|
336
|
+
envelope: HermesKanbanEnvelope,
|
|
337
|
+
args: {
|
|
338
|
+
event: 'completed' | 'revoked';
|
|
339
|
+
action: 'allow' | 'block';
|
|
340
|
+
blocked: boolean;
|
|
341
|
+
capability: ReceiptCapabilityLevel;
|
|
342
|
+
trustScore: number;
|
|
343
|
+
parentNodes?: ReceiptDagNode[];
|
|
344
|
+
metadata?: Record<string, unknown>;
|
|
345
|
+
modelIds?: string[];
|
|
346
|
+
tokenCounts?: { inputTokens?: number; outputTokens?: number };
|
|
347
|
+
totalCostCents?: number;
|
|
348
|
+
reviewerCascade?: Record<string, unknown>;
|
|
349
|
+
},
|
|
350
|
+
): Promise<ReceiptDagNode> {
|
|
351
|
+
if (!this.config.signingKeys) throw new Error('Hermes Kanban receipts require signingKeys');
|
|
352
|
+
const parentNodes = args.parentNodes ?? [];
|
|
353
|
+
const decision = taskDecision(envelope, args, this.opts.policy);
|
|
354
|
+
return signDagNode({
|
|
355
|
+
sequence: this.nodes.length,
|
|
356
|
+
decision,
|
|
357
|
+
privateKey: this.config.signingKeys.privateKey,
|
|
358
|
+
publicKey: this.config.signingKeys.publicKey,
|
|
359
|
+
parents: parentNodes,
|
|
360
|
+
workflowId: envelope.boardHash,
|
|
361
|
+
trustScore: args.trustScore,
|
|
362
|
+
blocked: args.blocked,
|
|
363
|
+
capability: args.capability,
|
|
364
|
+
builderCode: envelope.builderCode ?? null,
|
|
365
|
+
});
|
|
366
|
+
}
|
|
367
|
+
|
|
368
|
+
private receiptResult(envelope: HermesKanbanEnvelope, node: ReceiptDagNode): HermesKanbanReceiptResult {
|
|
369
|
+
return {
|
|
370
|
+
node,
|
|
371
|
+
envelope: { ...envelope, parentTaskIds: [...envelope.parentTaskIds], parentEntryHashes: [...envelope.parentEntryHashes], toolAllowlist: [...envelope.toolAllowlist] },
|
|
372
|
+
dag: this.getReceiptDag(envelope.taskId),
|
|
373
|
+
};
|
|
374
|
+
}
|
|
375
|
+
|
|
376
|
+
private requireEnvelope(taskId: string): HermesKanbanEnvelope {
|
|
377
|
+
const envelope = this.envelopes.get(taskId);
|
|
378
|
+
if (!envelope) throw new Error(`Unknown Hermes Kanban task ${taskId}`);
|
|
379
|
+
return envelope;
|
|
380
|
+
}
|
|
381
|
+
|
|
382
|
+
private requireGuard(taskId: string): SpendGuard {
|
|
383
|
+
const guard = this.taskGuards.get(taskId);
|
|
384
|
+
if (!guard) throw new Error(`Unknown Hermes Kanban task ${taskId}`);
|
|
385
|
+
return guard;
|
|
386
|
+
}
|
|
387
|
+
|
|
388
|
+
private requireNode(taskId: string): ReceiptDagNode {
|
|
389
|
+
const node = this.nodesByTaskId.get(taskId);
|
|
390
|
+
if (!node) throw new Error(`Hermes Kanban task ${taskId} has no receipt`);
|
|
391
|
+
return node;
|
|
392
|
+
}
|
|
393
|
+
|
|
394
|
+
private profilePolicy(profile: string): HermesKanbanProfilePolicy {
|
|
395
|
+
return this.profileMap[normalizeProfile(profile)] ?? { capability: 'READ_ONLY', toolAllowlist: [] };
|
|
396
|
+
}
|
|
397
|
+
|
|
398
|
+
private publicKeyHex(): string {
|
|
399
|
+
if (!this.config.signingKeys) throw new Error('Hermes Kanban receipts require signingKeys');
|
|
400
|
+
return Buffer.from(this.config.signingKeys.publicKey).toString('hex');
|
|
401
|
+
}
|
|
402
|
+
}
|
|
403
|
+
|
|
404
|
+
function taskDecision(
|
|
405
|
+
envelope: HermesKanbanEnvelope,
|
|
406
|
+
args: {
|
|
407
|
+
event: 'completed' | 'revoked';
|
|
408
|
+
action: 'allow' | 'block';
|
|
409
|
+
capability: ReceiptCapabilityLevel;
|
|
410
|
+
parentNodes?: ReceiptDagNode[];
|
|
411
|
+
metadata?: Record<string, unknown>;
|
|
412
|
+
modelIds?: string[];
|
|
413
|
+
tokenCounts?: { inputTokens?: number; outputTokens?: number };
|
|
414
|
+
totalCostCents?: number;
|
|
415
|
+
reviewerCascade?: Record<string, unknown>;
|
|
416
|
+
},
|
|
417
|
+
policy: SpendPolicy,
|
|
418
|
+
): SpendDecision {
|
|
419
|
+
const parentNodes = args.parentNodes ?? [];
|
|
420
|
+
const inputTokens = safeNonNegativeInteger(args.tokenCounts?.inputTokens);
|
|
421
|
+
const outputTokens = safeNonNegativeInteger(args.tokenCounts?.outputTokens);
|
|
422
|
+
const totalCostCents = safeNonNegativeInteger(args.totalCostCents);
|
|
423
|
+
const receipt: Record<string, unknown> = {
|
|
424
|
+
type: 'hermes_kanban_task',
|
|
425
|
+
event: args.event,
|
|
426
|
+
taskId: envelope.taskId,
|
|
427
|
+
taskHash: envelope.taskHash,
|
|
428
|
+
boardHash: envelope.boardHash,
|
|
429
|
+
profile: envelope.profile,
|
|
430
|
+
capability: args.capability,
|
|
431
|
+
capabilityCeiling: envelope.capabilityCeiling,
|
|
432
|
+
state: envelope.state,
|
|
433
|
+
parentTaskIds: [...envelope.parentTaskIds],
|
|
434
|
+
parentEntryHashes: parentNodes.map((node) => node.entryHash),
|
|
435
|
+
parentMsgHashes: parentNodes.map((node) => node.msgHash),
|
|
436
|
+
modelIds: safeModelIds(args.modelIds ?? []),
|
|
437
|
+
inputTokens,
|
|
438
|
+
outputTokens,
|
|
439
|
+
totalCostCents,
|
|
440
|
+
metadata: filterReceiptMetadata(args.metadata ?? {}),
|
|
441
|
+
};
|
|
442
|
+
if (args.reviewerCascade) receipt.reviewerCascade = args.reviewerCascade;
|
|
443
|
+
const decision: SpendDecision = {
|
|
444
|
+
decisionId: randomUUID(),
|
|
445
|
+
timestamp: new Date().toISOString(),
|
|
446
|
+
action: args.action,
|
|
447
|
+
triggeredCap: null,
|
|
448
|
+
triggeredScopeKey: null,
|
|
449
|
+
projectedCents: totalCostCents,
|
|
450
|
+
windowSpendBefore: 0,
|
|
451
|
+
windowSpendAfter: totalCostCents,
|
|
452
|
+
provider: 'unknown',
|
|
453
|
+
modelRequested: 'hermes-kanban',
|
|
454
|
+
modelResolved: 'hermes-kanban',
|
|
455
|
+
policyId: policy.id,
|
|
456
|
+
policyVersion: policy.version,
|
|
457
|
+
enforcementMode: policy.mode,
|
|
458
|
+
reasons: [`Hermes Kanban task ${args.event}: ${envelope.taskId}`],
|
|
459
|
+
entryType: 'outcome',
|
|
460
|
+
outcomeReceipt: receipt,
|
|
461
|
+
};
|
|
462
|
+
if (envelope.builderCode) decision.builderCode = envelope.builderCode;
|
|
463
|
+
return decision;
|
|
464
|
+
}
|
|
465
|
+
|
|
466
|
+
function buildReviewerCascadeReceipt(input: HermesKanbanReceiptInput, opts: HermesKanbanAdapterOptions, triggers: string[]): Record<string, unknown> {
|
|
467
|
+
const cascade = opts.reviewerCascade ?? {};
|
|
468
|
+
return {
|
|
469
|
+
outcome: safeLabel(input.metadata?.outcome) ?? 'hermes_kanban_task',
|
|
470
|
+
drafter: {
|
|
471
|
+
model: cascade.drafterModel ?? opts.defaultModel ?? 'openai/gpt-4o-mini',
|
|
472
|
+
maxCostCents: safeNonNegativeInteger(cascade.drafterMaxCostCents ?? 25),
|
|
473
|
+
},
|
|
474
|
+
reviewer: {
|
|
475
|
+
model: cascade.reviewerModel ?? 'anthropic/claude-sonnet-4-6',
|
|
476
|
+
maxCostCents: safeNonNegativeInteger(cascade.reviewerMaxCostCents ?? 75),
|
|
477
|
+
},
|
|
478
|
+
triggerFired: triggers,
|
|
479
|
+
reviewerVerdict: input.reviewerVerdict ?? 'review_required',
|
|
480
|
+
};
|
|
481
|
+
}
|
|
482
|
+
|
|
483
|
+
function reviewerCascadeTriggers(envelope: HermesKanbanEnvelope, input: HermesKanbanReceiptInput, opts: HermesKanbanAdapterOptions): string[] {
|
|
484
|
+
if (opts.reviewerCascade?.enabled === false) return [];
|
|
485
|
+
const threshold = opts.reviewerCascade?.highRiskThreshold ?? 0.55;
|
|
486
|
+
const triggers: string[] = [];
|
|
487
|
+
if (envelope.profile === 'reviewer') triggers.push('reviewer_profile');
|
|
488
|
+
if (typeof input.highRiskClassifierScore === 'number' && input.highRiskClassifierScore >= threshold) triggers.push(`risk_score_above:${threshold}`);
|
|
489
|
+
if (input.keywordRisk === true) triggers.push('keyword_prefilter');
|
|
490
|
+
return [...new Set(triggers)];
|
|
491
|
+
}
|
|
492
|
+
|
|
493
|
+
function capForTask(input: HermesKanbanTaskInput, profilePolicy: HermesKanbanProfilePolicy, opts: HermesKanbanAdapterOptions): number {
|
|
494
|
+
if (Number.isSafeInteger(profilePolicy.capCents) && profilePolicy.capCents! >= 0) return profilePolicy.capCents!;
|
|
495
|
+
const runtimeMinutes = runtimeMinutesFor(input);
|
|
496
|
+
const runtimeBudget = opts.runtimeBudgetCentsPerMinute ?? 10;
|
|
497
|
+
const cap = Math.ceil(runtimeMinutes * runtimeBudget);
|
|
498
|
+
return Math.max(1, Math.min(cap, opts.defaultCapCents ?? cap));
|
|
499
|
+
}
|
|
500
|
+
|
|
501
|
+
function runtimeMinutesFor(input: HermesKanbanTaskInput): number {
|
|
502
|
+
if (Number.isFinite(input.maxRuntimeMinutes) && input.maxRuntimeMinutes! > 0) return input.maxRuntimeMinutes!;
|
|
503
|
+
if (Number.isFinite(input.maxRuntimeMs) && input.maxRuntimeMs! > 0) return input.maxRuntimeMs! / 60_000;
|
|
504
|
+
return 5;
|
|
505
|
+
}
|
|
506
|
+
|
|
507
|
+
function spendCapabilityForReceiptCapability(capability: ReceiptCapabilityLevel): CapabilityTier {
|
|
508
|
+
if (capability === 'ORCHESTRATE') return 'payment_execute';
|
|
509
|
+
if (capability === 'ADMIN') return 'payment_initiate';
|
|
510
|
+
if (capability === 'TRANSACT') return 'data_write';
|
|
511
|
+
return 'read_only';
|
|
512
|
+
}
|
|
513
|
+
|
|
514
|
+
function assertTaskInput(input: HermesKanbanTaskInput): void {
|
|
515
|
+
assertPlainRecord(input, 'task');
|
|
516
|
+
const allowed = new Set(['taskId', 'boardSlug', 'profile', 'parentTaskIds', 'maxRuntimeMinutes', 'maxRuntimeMs', 'metadata', 'builderCode']);
|
|
517
|
+
for (const key of Object.keys(input as unknown as Record<string, unknown>)) {
|
|
518
|
+
if (!allowed.has(key)) throw new Error(`Hermes Kanban task input cannot include data-plane or unknown field: ${key}`);
|
|
519
|
+
}
|
|
520
|
+
if (!safeLabel(input.taskId)) throw new Error('Hermes Kanban taskId is required');
|
|
521
|
+
if (!safeLabel(input.boardSlug)) throw new Error('Hermes Kanban boardSlug is required');
|
|
522
|
+
if (!safeLabel(input.profile)) throw new Error('Hermes Kanban profile is required');
|
|
523
|
+
assertMetadataOnly(input.metadata ?? {});
|
|
524
|
+
for (const parent of input.parentTaskIds ?? []) {
|
|
525
|
+
if (!safeLabel(parent)) throw new Error('Hermes Kanban parent task id must be a metadata identifier');
|
|
526
|
+
}
|
|
527
|
+
}
|
|
528
|
+
|
|
529
|
+
function assertMetadataOnly(value: unknown, path = 'metadata'): void {
|
|
530
|
+
if (value == null) return;
|
|
531
|
+
if (Array.isArray(value)) {
|
|
532
|
+
for (let i = 0; i < value.length; i++) assertMetadataOnly(value[i], `${path}[${i}]`);
|
|
533
|
+
return;
|
|
534
|
+
}
|
|
535
|
+
if (typeof value !== 'object') return;
|
|
536
|
+
for (const [key, child] of Object.entries(value as Record<string, unknown>)) {
|
|
537
|
+
if (DATA_PLANE_KEYS.test(key)) throw new Error(`Hermes Kanban metadata cannot include data-plane field: ${path}.${key}`);
|
|
538
|
+
assertMetadataOnly(child, `${path}.${key}`);
|
|
539
|
+
}
|
|
540
|
+
}
|
|
541
|
+
|
|
542
|
+
function assertPlainRecord(value: unknown, name: string): void {
|
|
543
|
+
if (!value || typeof value !== 'object' || Array.isArray(value)) throw new Error(`Hermes Kanban ${name} must be an object`);
|
|
544
|
+
}
|
|
545
|
+
|
|
546
|
+
function assertActive(envelope: HermesKanbanEnvelope): void {
|
|
547
|
+
if (envelope.state === 'revoked') throw new AgentGuardBlockedError(blockedDecision(envelope, 'revoked'), envelope.scope);
|
|
548
|
+
if (envelope.state === 'completed') throw new AgentGuardBlockedError(blockedDecision(envelope, 'completed'), envelope.scope);
|
|
549
|
+
}
|
|
550
|
+
|
|
551
|
+
function blockedDecision(envelope: HermesKanbanEnvelope, state: string): SpendDecision {
|
|
552
|
+
return {
|
|
553
|
+
decisionId: randomUUID(),
|
|
554
|
+
timestamp: new Date().toISOString(),
|
|
555
|
+
action: 'block',
|
|
556
|
+
triggeredCap: null,
|
|
557
|
+
triggeredScopeKey: null,
|
|
558
|
+
projectedCents: 0,
|
|
559
|
+
windowSpendBefore: 0,
|
|
560
|
+
windowSpendAfter: 0,
|
|
561
|
+
provider: 'unknown',
|
|
562
|
+
modelRequested: 'hermes-kanban',
|
|
563
|
+
modelResolved: 'hermes-kanban',
|
|
564
|
+
policyId: 'hermes-kanban',
|
|
565
|
+
policyVersion: 1,
|
|
566
|
+
enforcementMode: 'enforce',
|
|
567
|
+
reasons: [`Hermes Kanban task ${envelope.taskId} is ${state}`],
|
|
568
|
+
};
|
|
569
|
+
}
|
|
570
|
+
|
|
571
|
+
function filterReceiptMetadata(metadata: Record<string, unknown>): Record<string, unknown> {
|
|
572
|
+
assertMetadataOnly(metadata);
|
|
573
|
+
const out: Record<string, unknown> = {};
|
|
574
|
+
for (const [key, value] of Object.entries(metadata)) {
|
|
575
|
+
if (!SAFE_METADATA_KEYS.has(key)) continue;
|
|
576
|
+
if (value == null || typeof value === 'string' || typeof value === 'number' || typeof value === 'boolean') {
|
|
577
|
+
out[key] = value;
|
|
578
|
+
}
|
|
579
|
+
}
|
|
580
|
+
return out;
|
|
581
|
+
}
|
|
582
|
+
|
|
583
|
+
function validateTokens(inputTokens: number, outputTokens: number): void {
|
|
584
|
+
if (!Number.isSafeInteger(inputTokens) || inputTokens < 0) throw new Error('inputTokens must be a non-negative safe integer');
|
|
585
|
+
if (!Number.isSafeInteger(outputTokens) || outputTokens < 0) throw new Error('outputTokens must be a non-negative safe integer');
|
|
586
|
+
}
|
|
587
|
+
|
|
588
|
+
function hashValue(value: string): string {
|
|
589
|
+
return sha256Hex(canonicalJson({ value }));
|
|
590
|
+
}
|
|
591
|
+
|
|
592
|
+
function normalizeProfile(profile: string): string {
|
|
593
|
+
return profile.trim().toLowerCase().replace(/[^a-z0-9_.:-]/g, '_');
|
|
594
|
+
}
|
|
595
|
+
|
|
596
|
+
function safeLabel(value: unknown): string | undefined {
|
|
597
|
+
if (typeof value !== 'string') return undefined;
|
|
598
|
+
const trimmed = value.trim();
|
|
599
|
+
if (!trimmed || !/^[A-Za-z0-9_.:/-]{1,160}$/.test(trimmed)) return undefined;
|
|
600
|
+
return trimmed;
|
|
601
|
+
}
|
|
602
|
+
|
|
603
|
+
function safeBuilderCode(value: unknown): string | undefined {
|
|
604
|
+
const label = safeLabel(value);
|
|
605
|
+
return label ? label.slice(0, 64) : undefined;
|
|
606
|
+
}
|
|
607
|
+
|
|
608
|
+
function safeModelIds(models: string[]): string[] {
|
|
609
|
+
return models.map((model) => safeLabel(model)).filter(Boolean) as string[];
|
|
610
|
+
}
|
|
611
|
+
|
|
612
|
+
function safeNonNegativeInteger(value: unknown): number {
|
|
613
|
+
return Number.isSafeInteger(value) && (value as number) >= 0 ? value as number : 0;
|
|
614
|
+
}
|
|
615
|
+
|
|
616
|
+
function toolAllowed(toolName: string, allowlist: string[]): boolean {
|
|
617
|
+
return allowlist.some((allowed) => toolName === allowed || toolName.startsWith(`${allowed}.`));
|
|
618
|
+
}
|
|
619
|
+
|
|
620
|
+
export function hermesKanbanSignerFingerprint(opts: HermesKanbanAdapterOptions): string | null {
|
|
621
|
+
return opts.config?.signingKeys ? computeSignerFingerprint(opts.config.signingKeys.publicKey) : null;
|
|
622
|
+
}
|
package/src/frameworks/index.ts
CHANGED
package/src/workflow/context.ts
CHANGED
|
@@ -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;
|