@agentguard-run/spend 0.13.2 → 0.14.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/CHANGELOG.md +10 -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/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/receipts/dag.d.ts.map +1 -1
- package/dist/receipts/dag.js +2 -0
- package/dist/receipts/dag.js.map +1 -1
- package/dist/telemetry.js +1 -1
- package/package.json +6 -1
- package/src/frameworks/hermes-kanban.ts +622 -0
- package/src/frameworks/index.ts +1 -0
- package/src/receipts/dag.ts +2 -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/receipts/dag.ts
CHANGED
|
@@ -192,6 +192,7 @@ export async function verifyReceiptDag(
|
|
|
192
192
|
entryHash: node.entryHash,
|
|
193
193
|
signature: node.signature,
|
|
194
194
|
signerFingerprint: node.signerFingerprint,
|
|
195
|
+
builderCode: node.builderCode ?? undefined,
|
|
195
196
|
},
|
|
196
197
|
publicKey,
|
|
197
198
|
);
|
|
@@ -348,6 +349,7 @@ function normalizeDagNode(input: ReceiptDagInput): NormalizedDagNode {
|
|
|
348
349
|
entryHash: candidate.entryHash,
|
|
349
350
|
signature: candidate.signature,
|
|
350
351
|
signerFingerprint: candidate.signerFingerprint,
|
|
352
|
+
builderCode: candidate.builderCode ?? null,
|
|
351
353
|
timestamp: stringOrNull(decision.timestamp) ?? new Date(0).toISOString(),
|
|
352
354
|
previousHash,
|
|
353
355
|
legacyPreviousHash: previousHash,
|