@metamynd/agentsafe-guard 0.3.0 → 0.4.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/LICENSE +21 -21
- package/README.md +319 -303
- package/agentsafe-guard.mjs +691 -606
- package/cli.mjs +29 -0
- package/demo.mjs +161 -0
- package/example-openclaw-agent.mjs +47 -47
- package/magp-did.mjs +157 -157
- package/package.json +60 -59
- package/x402.mjs +43 -43
package/agentsafe-guard.mjs
CHANGED
|
@@ -1,606 +1,691 @@
|
|
|
1
|
-
// agentsafe-guard.mjs — drop-in runtime governance for any Node agent (OpenClaw, LangChain, custom).
|
|
2
|
-
//
|
|
3
|
-
// ZERO external dependencies: uses Node's built-in Ed25519 (node:crypto) + fetch (Node 18+),
|
|
4
|
-
// plus policy-core.mjs (the deterministic evaluator, itself dependency-free, generated from
|
|
5
|
-
// backend/src/policy-core). Before an agent performs a governed action the guard can either
|
|
6
|
-
// call the AgentSafe authorize gate (trustless fallback) OR evaluate a signed policy bundle
|
|
7
|
-
// LOCALLY (spec §9.2 cooperative mode) — both compute the identical allow/block/escalate
|
|
8
|
-
// verdict from the identical inputs, because they run the same policy-core.
|
|
9
|
-
//
|
|
10
|
-
// The agent's private key is a Hedera Ed25519 DER key (the AGENT_KEY the seed prints).
|
|
11
|
-
import crypto from 'node:crypto';
|
|
12
|
-
import { readFileSync } from 'node:fs';
|
|
13
|
-
import { evaluate, buildAuthMessage, applySignedLast, operatingModeGate } from './policy-core.mjs';
|
|
14
|
-
import { verifyDidSignature } from './magp-did.mjs';
|
|
15
|
-
import { checkSettlementBinding } from './x402.mjs';
|
|
16
|
-
|
|
17
|
-
/**
|
|
18
|
-
*
|
|
19
|
-
*
|
|
20
|
-
*
|
|
21
|
-
*
|
|
22
|
-
*
|
|
23
|
-
*
|
|
24
|
-
*
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
*
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
*/
|
|
53
|
-
export
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
if (
|
|
76
|
-
return
|
|
77
|
-
}
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
}
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
const
|
|
109
|
-
const
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
*
|
|
192
|
-
*
|
|
193
|
-
*
|
|
194
|
-
*
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
//
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
}
|
|
238
|
-
//
|
|
239
|
-
//
|
|
240
|
-
//
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
return _anchor;
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
(
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
}
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
|
|
436
|
-
|
|
437
|
-
|
|
438
|
-
|
|
439
|
-
|
|
440
|
-
|
|
441
|
-
|
|
442
|
-
|
|
443
|
-
|
|
444
|
-
|
|
445
|
-
//
|
|
446
|
-
|
|
447
|
-
|
|
448
|
-
|
|
449
|
-
|
|
450
|
-
|
|
451
|
-
|
|
452
|
-
|
|
453
|
-
|
|
454
|
-
|
|
455
|
-
|
|
456
|
-
|
|
457
|
-
|
|
458
|
-
|
|
459
|
-
|
|
460
|
-
|
|
461
|
-
|
|
462
|
-
|
|
463
|
-
|
|
464
|
-
|
|
465
|
-
|
|
466
|
-
|
|
467
|
-
|
|
468
|
-
|
|
469
|
-
|
|
470
|
-
|
|
471
|
-
|
|
472
|
-
|
|
473
|
-
|
|
474
|
-
|
|
475
|
-
}
|
|
476
|
-
|
|
477
|
-
|
|
478
|
-
|
|
479
|
-
|
|
480
|
-
|
|
481
|
-
|
|
482
|
-
|
|
483
|
-
|
|
484
|
-
|
|
485
|
-
|
|
486
|
-
|
|
487
|
-
|
|
488
|
-
|
|
489
|
-
|
|
490
|
-
|
|
491
|
-
|
|
492
|
-
|
|
493
|
-
|
|
494
|
-
|
|
495
|
-
|
|
496
|
-
|
|
497
|
-
|
|
498
|
-
|
|
499
|
-
|
|
500
|
-
|
|
501
|
-
|
|
502
|
-
|
|
503
|
-
|
|
504
|
-
|
|
505
|
-
|
|
506
|
-
}
|
|
507
|
-
|
|
508
|
-
|
|
509
|
-
|
|
510
|
-
|
|
511
|
-
|
|
512
|
-
|
|
513
|
-
|
|
514
|
-
|
|
515
|
-
|
|
516
|
-
|
|
517
|
-
|
|
518
|
-
|
|
519
|
-
|
|
520
|
-
|
|
521
|
-
|
|
522
|
-
|
|
523
|
-
|
|
524
|
-
|
|
525
|
-
|
|
526
|
-
|
|
527
|
-
|
|
528
|
-
|
|
529
|
-
|
|
530
|
-
|
|
531
|
-
|
|
532
|
-
|
|
533
|
-
|
|
534
|
-
|
|
535
|
-
|
|
536
|
-
|
|
537
|
-
|
|
538
|
-
|
|
539
|
-
|
|
540
|
-
|
|
541
|
-
|
|
542
|
-
|
|
543
|
-
|
|
544
|
-
|
|
545
|
-
*
|
|
546
|
-
*
|
|
547
|
-
|
|
548
|
-
|
|
549
|
-
|
|
550
|
-
|
|
551
|
-
|
|
552
|
-
|
|
553
|
-
const
|
|
554
|
-
|
|
555
|
-
|
|
556
|
-
|
|
557
|
-
|
|
558
|
-
|
|
559
|
-
|
|
560
|
-
|
|
561
|
-
|
|
562
|
-
|
|
563
|
-
|
|
564
|
-
|
|
565
|
-
|
|
566
|
-
|
|
567
|
-
|
|
568
|
-
|
|
569
|
-
|
|
570
|
-
|
|
571
|
-
|
|
572
|
-
|
|
573
|
-
*
|
|
574
|
-
*
|
|
575
|
-
*
|
|
576
|
-
*
|
|
577
|
-
*
|
|
578
|
-
*
|
|
579
|
-
*
|
|
580
|
-
*
|
|
581
|
-
*
|
|
582
|
-
|
|
583
|
-
|
|
584
|
-
|
|
585
|
-
|
|
586
|
-
|
|
587
|
-
|
|
588
|
-
|
|
589
|
-
|
|
590
|
-
|
|
591
|
-
|
|
592
|
-
|
|
593
|
-
|
|
594
|
-
|
|
595
|
-
|
|
596
|
-
|
|
597
|
-
|
|
598
|
-
|
|
599
|
-
|
|
600
|
-
|
|
601
|
-
|
|
602
|
-
|
|
603
|
-
|
|
604
|
-
|
|
605
|
-
|
|
606
|
-
|
|
1
|
+
// agentsafe-guard.mjs — drop-in runtime governance for any Node agent (OpenClaw, LangChain, custom).
|
|
2
|
+
//
|
|
3
|
+
// ZERO external dependencies: uses Node's built-in Ed25519 (node:crypto) + fetch (Node 18+),
|
|
4
|
+
// plus policy-core.mjs (the deterministic evaluator, itself dependency-free, generated from
|
|
5
|
+
// backend/src/policy-core). Before an agent performs a governed action the guard can either
|
|
6
|
+
// call the AgentSafe authorize gate (trustless fallback) OR evaluate a signed policy bundle
|
|
7
|
+
// LOCALLY (spec §9.2 cooperative mode) — both compute the identical allow/block/escalate
|
|
8
|
+
// verdict from the identical inputs, because they run the same policy-core.
|
|
9
|
+
//
|
|
10
|
+
// The agent's private key is a Hedera Ed25519 DER key (the AGENT_KEY the seed prints).
|
|
11
|
+
import crypto from 'node:crypto';
|
|
12
|
+
import { readFileSync } from 'node:fs';
|
|
13
|
+
import { evaluate, buildAuthMessage, applySignedLast, operatingModeGate } from './policy-core.mjs';
|
|
14
|
+
import { verifyDidSignature } from './magp-did.mjs';
|
|
15
|
+
import { checkSettlementBinding } from './x402.mjs';
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* Replay a Merkle sibling chain and report whether it reconstructs `root`.
|
|
19
|
+
*
|
|
20
|
+
* Byte-identical to backend/src/features/magp/merkle.ts: leaves and siblings are hex
|
|
21
|
+
* sha256 digests, and an internal node is sha256 over the CONCATENATED RAW BYTES of its
|
|
22
|
+
* children (not the hex text), in left-then-right order. Hashing the hex strings instead
|
|
23
|
+
* would produce a self-consistent but incompatible tree — one that verified nothing the
|
|
24
|
+
* backend ever anchored, while appearing to work.
|
|
25
|
+
*/
|
|
26
|
+
function verifyMerkleInclusion(leaf, proof, root) {
|
|
27
|
+
const sha = (buf) => crypto.createHash('sha256').update(buf).digest('hex');
|
|
28
|
+
const hashNodes = (a, b) => sha(Buffer.concat([Buffer.from(a, 'hex'), Buffer.from(b, 'hex')]));
|
|
29
|
+
let computed = leaf;
|
|
30
|
+
for (const step of proof) {
|
|
31
|
+
if (!step || typeof step.sibling !== 'string') return false;
|
|
32
|
+
computed = step.position === 'left' ? hashNodes(step.sibling, computed) : hashNodes(computed, step.sibling);
|
|
33
|
+
}
|
|
34
|
+
return computed === root;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* ExecutionAdapter (SAFR §19, Phase-4 PR-4) — the seam between a PERMITTING verdict
|
|
39
|
+
* (allow / observe) and the real side-effect. Before this, a guarded tool called its
|
|
40
|
+
* handler directly, so the only outcomes were "execute for real" or "throw". An adapter
|
|
41
|
+
* interposes so the SAME governed decision can be run live, SIMULATED (dry-run), or routed
|
|
42
|
+
* to a sandbox — without touching the tool handler or the gate.
|
|
43
|
+
*
|
|
44
|
+
* Contract: `async (execCtx) => result`, where
|
|
45
|
+
* execCtx = { action, args, decision, proceed }
|
|
46
|
+
* proceed() runs the real handler (handler(args, decision)) and returns its result.
|
|
47
|
+
* An adapter that calls `proceed()` executes for real; one that returns WITHOUT calling it
|
|
48
|
+
* substitutes the side-effect. Adapters run ONLY after the guard has permitted the action —
|
|
49
|
+
* a block/escalate still throws GovernanceBlocked before any adapter is consulted.
|
|
50
|
+
*/
|
|
51
|
+
|
|
52
|
+
/** The default: execute the real handler unchanged. */
|
|
53
|
+
export const liveExecutionAdapter = (ctx) => ctx.proceed();
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* Simulate the side-effect: do NOT call the handler, return a describe-only result. Lets an
|
|
57
|
+
* agent exercise a fully-governed flow (identity → mandate → controls → verdict) with no real
|
|
58
|
+
* booking/payment/write — for staging, canaries, and OBSERVE-mode dry-runs.
|
|
59
|
+
*/
|
|
60
|
+
export const dryRunExecutionAdapter = (ctx) => ({
|
|
61
|
+
dryRun: true,
|
|
62
|
+
action: ctx.action,
|
|
63
|
+
decision: ctx.decision?.decision ?? null,
|
|
64
|
+
reasonCode: ctx.decision?.reasonCode ?? null,
|
|
65
|
+
authorizationId: ctx.decision?.authorizationId ?? null,
|
|
66
|
+
args: ctx.args,
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
/**
|
|
70
|
+
* Process-default adapter from `AGENTSAFE_EXECUTION_MODE` ('live' | 'dry-run'). Returns null
|
|
71
|
+
* when unset/live so the caller's own default (live) applies — behavior-neutral by default.
|
|
72
|
+
*/
|
|
73
|
+
export function executionAdapterFromEnv(env = (typeof process !== 'undefined' ? process.env : {})) {
|
|
74
|
+
const mode = String(env.AGENTSAFE_EXECUTION_MODE ?? '').toLowerCase().trim();
|
|
75
|
+
if (mode === 'dry-run' || mode === 'dryrun') return dryRunExecutionAdapter;
|
|
76
|
+
return null;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/**
|
|
80
|
+
* @param {{ api: string, agentDid: string, agentKey: string }} cfg
|
|
81
|
+
* api e.g. "http://localhost:9926/api/v1" or "https://metamynd.ai/api/v1"
|
|
82
|
+
* agentDid the agent's did:hedera
|
|
83
|
+
* agentKey the agent's Ed25519 private key (Hedera DER hex, held only by the agent)
|
|
84
|
+
*/
|
|
85
|
+
/**
|
|
86
|
+
* Async loader — build a guard from the portable config the one-call `POST /onboarding/agent`
|
|
87
|
+
* endpoint returns: a URL, a file path, or the config object itself. Overrides win over the config.
|
|
88
|
+
* const guard = await createGuardFromConfig('./agent.metamynd.json');
|
|
89
|
+
*/
|
|
90
|
+
export async function createGuardFromConfig(source, overrides = {}) {
|
|
91
|
+
let cfg = source;
|
|
92
|
+
if (typeof source === 'string') {
|
|
93
|
+
cfg = /^https?:\/\//.test(source) ? await (await fetch(source)).json() : JSON.parse(readFileSync(source, 'utf8'));
|
|
94
|
+
}
|
|
95
|
+
if (cfg && cfg.data && !cfg.agentDid) cfg = cfg.data; // unwrap a { success, data } API response
|
|
96
|
+
return createGuard({ config: cfg, ...overrides });
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
export function createGuard(opts = {}) {
|
|
100
|
+
// Accept a portable agent config (from /onboarding/agent) via `config` or `configPath`, in
|
|
101
|
+
// addition to explicit { api, agentDid, agentKey }. Explicit fields win over the config.
|
|
102
|
+
let cfg = opts.config ?? null;
|
|
103
|
+
if (!cfg && opts.configPath) {
|
|
104
|
+
try { cfg = JSON.parse(readFileSync(opts.configPath, 'utf8')); }
|
|
105
|
+
catch (e) { throw new Error(`createGuard: cannot read configPath "${opts.configPath}": ${e.message}`); }
|
|
106
|
+
}
|
|
107
|
+
if (cfg && cfg.data && !cfg.agentDid) cfg = cfg.data; // unwrap a { success, data } API response
|
|
108
|
+
const api = opts.api ?? cfg?.apiBase ?? cfg?.api;
|
|
109
|
+
const agentDid = opts.agentDid ?? cfg?.agentDid;
|
|
110
|
+
const agentKey = opts.agentKey ?? cfg?.agentKey;
|
|
111
|
+
if (!api || !agentDid || !agentKey) throw new Error('createGuard requires { api, agentDid, agentKey } — directly, or via { config } / { configPath } / createGuardFromConfig()');
|
|
112
|
+
const base = api.replace(/\/$/, '');
|
|
113
|
+
// ExecutionAdapter seam (SAFR §19): an explicit opt wins, else the AGENTSAFE_EXECUTION_MODE env,
|
|
114
|
+
// else live. Applies to every guarded tool unless a tool passes its own adapter.
|
|
115
|
+
const defaultExecutionAdapter = opts.executionAdapter ?? executionAdapterFromEnv() ?? liveExecutionAdapter;
|
|
116
|
+
const privateKey = crypto.createPrivateKey({ key: Buffer.from(agentKey, 'hex'), format: 'der', type: 'pkcs8' });
|
|
117
|
+
|
|
118
|
+
// Ed25519 over the exact canonical message the backend verifies.
|
|
119
|
+
function sign(message) {
|
|
120
|
+
return crypto.sign(null, Buffer.from(message, 'utf8'), privateKey).toString('hex');
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
// --- Enforcement mode (spec §9.2 + local-first plan) --------------------------------------
|
|
124
|
+
// 'local' (DEFAULT): decide the rule layer LOCALLY against a cached signed bundle — a
|
|
125
|
+
// block/escalate needs no network; an allowed VALUE action is still sealed by the remote
|
|
126
|
+
// gate (two-phase hold + cumulative cap + evidence). 'remote': every call hits the gate.
|
|
127
|
+
const mode = opts.mode ?? cfg?.mode ?? 'local';
|
|
128
|
+
const bundleUrl = opts.bundleUrl ?? cfg?.bundleUrl ?? `${base}/policy/bundle/${encodeURIComponent(agentDid)}`;
|
|
129
|
+
const sealValueActions = opts.sealValueActions !== false; // default true
|
|
130
|
+
// Build B — trustless currency check. When on, the guard trusts its local bundle ONLY if that
|
|
131
|
+
// bundle is the LATEST one anchored on the agent's Hedera topic (read from a public mirror);
|
|
132
|
+
// otherwise it defers to the authoritative remote gate. Opt-in for now.
|
|
133
|
+
const verifyOnChain = opts.verifyOnChain ?? cfg?.verifyOnChain ?? false;
|
|
134
|
+
const _anchorTtlMs = opts.anchorTtlMs ?? 60_000;
|
|
135
|
+
let _bundle = null;
|
|
136
|
+
let _bundleAt = 0;
|
|
137
|
+
let _bundleMaxAgeMs = 10 * 60 * 1000; // overwritten by the bundle's maxStaleness
|
|
138
|
+
let _anchor = null;
|
|
139
|
+
let _anchorAt = 0;
|
|
140
|
+
let _highestSeq = 0; // monotonic: never accept a mirror response with fewer policy ops than seen
|
|
141
|
+
|
|
142
|
+
/** Parse an ISO-8601 duration like "PT10M" / "PT30S" / "PT1H" → ms (or null). */
|
|
143
|
+
function _durationMs(s) {
|
|
144
|
+
const m = /^PT(?:(\d+)H)?(?:(\d+)M)?(?:(\d+)S)?$/.exec(String(s ?? ''));
|
|
145
|
+
if (!m) return null;
|
|
146
|
+
return ((+m[1] || 0) * 3600 + (+m[2] || 0) * 60 + (+m[3] || 0)) * 1000 || null;
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
/**
|
|
150
|
+
* Build a signed authorize request (spec §7.2/§7.3) WITHOUT sending it — the object an
|
|
151
|
+
* agent presents to a counterparty (e.g. an MCP) so the counterparty can re-verify the
|
|
152
|
+
* agent's authorization trustlessly against the agent's policy bundle (§9.3). Same shape
|
|
153
|
+
* `authorize()` posts to the gate; a fresh nonce each call.
|
|
154
|
+
*/
|
|
155
|
+
function buildSignedRequest({ action, amount = 0, currency = 'USD', merchant = '', context = {}, trace, materiality }) {
|
|
156
|
+
const nonce = crypto.randomUUID();
|
|
157
|
+
const issuedAt = new Date().toISOString();
|
|
158
|
+
const message = buildAuthMessage({ agentDid, action, amount, currency, merchant, nonce, issuedAt });
|
|
159
|
+
// trace/materiality are GovernanceEnvelope fields (SAFR §5) — unsigned metadata; the
|
|
160
|
+
// signed message stays the action subset, so verification is unchanged.
|
|
161
|
+
return { agentDid, action, amount, currency, merchant, itinerary: context, trace, materiality, nonce, issuedAt, signature: sign(message) };
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
/**
|
|
165
|
+
* Ask the gate whether an action is authorized. Never throws on a policy decision —
|
|
166
|
+
* returns { decision:'allow'|'block'|'escalate', reasonCode, authorizationId, remaining }.
|
|
167
|
+
* A network/gate failure returns a fail-CLOSED block so the agent can't proceed blind.
|
|
168
|
+
*/
|
|
169
|
+
async function authorize({ action, amount = 0, currency = 'USD', merchant = '', context = {}, trace, materiality }) {
|
|
170
|
+
const nonce = crypto.randomUUID();
|
|
171
|
+
const issuedAt = new Date().toISOString();
|
|
172
|
+
// Build the canonical signed message with policy-core so the guard and the
|
|
173
|
+
// backend gate produce byte-identical input to Ed25519 (spec §7.3).
|
|
174
|
+
const message = buildAuthMessage({ agentDid, action, amount, currency, merchant, nonce, issuedAt });
|
|
175
|
+
try {
|
|
176
|
+
const res = await fetch(`${base}/policy/mandate/authorize`, {
|
|
177
|
+
method: 'POST',
|
|
178
|
+
headers: { 'Content-Type': 'application/json' },
|
|
179
|
+
// trace/materiality (SAFR §5 envelope) ride as unsigned metadata; JSON.stringify
|
|
180
|
+
// drops them when undefined, so an agent that omits them sends the legacy body.
|
|
181
|
+
body: JSON.stringify({ agentDid, action, amount, currency, merchant, itinerary: context, trace, materiality, nonce, issuedAt, signature: sign(message) }),
|
|
182
|
+
});
|
|
183
|
+
const body = await res.json().catch(() => null);
|
|
184
|
+
return body?.data ?? { decision: 'block', reasonCode: `GATE_HTTP_${res.status}` };
|
|
185
|
+
} catch (err) {
|
|
186
|
+
return { decision: 'block', reasonCode: 'GATE_UNREACHABLE', error: String(err?.message ?? err) };
|
|
187
|
+
}
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
/**
|
|
191
|
+
* Settle an approved hold (two-phase). Call after the real action succeeds with the
|
|
192
|
+
* amount actually charged (≤ the authorized amount). Pass the x402 `settlementTxHash`
|
|
193
|
+
* to record the on-chain payment proof against the capture (§7a.3.2). Optional —
|
|
194
|
+
* skip for non-payment tools.
|
|
195
|
+
*/
|
|
196
|
+
async function capture(authorizationId, amountCharged, bookingRef, settlementTxHash) {
|
|
197
|
+
const res = await fetch(`${base}/policy/mandate/authorize/${authorizationId}/capture`, {
|
|
198
|
+
method: 'POST', headers: { 'Content-Type': 'application/json' },
|
|
199
|
+
body: JSON.stringify({ amountCharged, bookingRef, settlementTxHash }),
|
|
200
|
+
});
|
|
201
|
+
return res.json().catch(() => ({}));
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
/**
|
|
205
|
+
* Evaluate a signed policy bundle LOCALLY — no network — using the same
|
|
206
|
+
* deterministic policy-core the gate runs (spec §9.2 cooperative mode). Given the
|
|
207
|
+
* same (rule packs, mandate, request), this returns the identical verdict the
|
|
208
|
+
* gate would. The stateful parts the gate owns (nonce/replay, atomic spend-cap
|
|
209
|
+
* reservation, evidence anchoring) are NOT done here — this is the local
|
|
210
|
+
* allow/block/escalate pre-check, so `authorizationId`/`remaining` are null.
|
|
211
|
+
*
|
|
212
|
+
* @param {object} p
|
|
213
|
+
* @param {Array<{standardKey:string,document:object}>} [p.standards] enforced Standards bound to the agent
|
|
214
|
+
* @param {Array<{standardKey:string,document:object}>} [p.sops] active SOPs assigned to the agent
|
|
215
|
+
* @param {object} [p.mandate] the ODRL mandate document (omit to skip the mandate layer)
|
|
216
|
+
* @param {{action:string,amount?:number,merchant?:string,context?:object,cumulativeSpend?:number,now?:string}} p.request
|
|
217
|
+
* @returns {{decision:'allow'|'block'|'escalate',reasonCode:string|null,authorizationId:null,remaining:null,proofRef:null}}
|
|
218
|
+
*/
|
|
219
|
+
function evaluateLocally({ contained = null, operatingMode = null, standards = [], sops = [], mandate, request }) {
|
|
220
|
+
// Push containment (Phase 2.3): a server-CONTAINED agent is denied at the EDGE,
|
|
221
|
+
// before any rule eval. `contained` rides alongside the signed bundle as a SIBLING
|
|
222
|
+
// response field (never inside the signed payload, so the bundle signature stays
|
|
223
|
+
// valid) and is refreshed on the `policy:changed` push, reaching the guard in ~1s.
|
|
224
|
+
if (contained && contained.status) {
|
|
225
|
+
const decision = contained.status === 'quarantined' ? 'quarantine' : 'suspend';
|
|
226
|
+
const reasonCode = contained.status === 'quarantined' ? 'AGENT_QUARANTINED' : 'AGENT_SUSPENDED';
|
|
227
|
+
return { decision, reasonCode, authorizationId: null, remaining: null, proofRef: null };
|
|
228
|
+
}
|
|
229
|
+
const { action, amount = 0, merchant = '', context = {}, cumulativeSpend = amount, now } = request;
|
|
230
|
+
// Operating-mode autonomy ladder (Phase 2.5b): the trust-driven posture rides as a
|
|
231
|
+
// SIBLING (like `contained`) and biases the edge verdict identically to the gate.
|
|
232
|
+
// READ_ONLY denies a value-bearing action up-front; SUPERVISED/RESTRICTED only
|
|
233
|
+
// ESCALATE, applied to the verdict below so a rule block/escalate still outranks it.
|
|
234
|
+
const modeGate = operatingModeGate(operatingMode?.mode, { amount, riskLevel: context?.riskLevel });
|
|
235
|
+
if (modeGate.decision === 'block') {
|
|
236
|
+
return { decision: 'block', reasonCode: modeGate.reasonCode, authorizationId: null, remaining: null, proofRef: null };
|
|
237
|
+
}
|
|
238
|
+
// Signed fields (action/agentDid/amount, mm:* operands) are applied LAST so an
|
|
239
|
+
// unsigned context key can never shadow them (spec §6.4.2) — the same invariant
|
|
240
|
+
// the gate enforces, via the same policy-core helper.
|
|
241
|
+
const verdict = evaluate({
|
|
242
|
+
standards,
|
|
243
|
+
sops,
|
|
244
|
+
mandate,
|
|
245
|
+
context: applySignedLast(context, { action, agentDid, amount }),
|
|
246
|
+
mandateRequest: mandate
|
|
247
|
+
? {
|
|
248
|
+
target: action,
|
|
249
|
+
now: now ?? new Date().toISOString(),
|
|
250
|
+
values: applySignedLast(context, {
|
|
251
|
+
'mm:payAmount': amount,
|
|
252
|
+
'mm:cumulativeSpend': cumulativeSpend,
|
|
253
|
+
'mm:merchant': merchant,
|
|
254
|
+
}),
|
|
255
|
+
}
|
|
256
|
+
: undefined,
|
|
257
|
+
});
|
|
258
|
+
// Mode ESCALATE floor: only lifts an otherwise-PERMIT (allow or observe) to human
|
|
259
|
+
// review (never softens a stricter verdict) — most-restrictive-wins, mirroring the
|
|
260
|
+
// backend gate exactly (escalate outranks observe, so a flag never masks it).
|
|
261
|
+
if ((verdict.decision === 'allow' || verdict.decision === 'observe') && modeGate.decision === 'escalate') {
|
|
262
|
+
return { ...verdict, decision: 'escalate', reasonCode: modeGate.reasonCode };
|
|
263
|
+
}
|
|
264
|
+
return verdict;
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
/** Fetch + cache the agent's signed policy bundle (refreshed per its maxStaleness). */
|
|
268
|
+
async function loadBundle(force = false) {
|
|
269
|
+
const now = Date.now();
|
|
270
|
+
if (!force && _bundle && now - _bundleAt < _bundleMaxAgeMs) return _bundle;
|
|
271
|
+
const res = await fetch(bundleUrl);
|
|
272
|
+
const body = await res.json().catch(() => null);
|
|
273
|
+
const b = body?.data ?? body;
|
|
274
|
+
if (!b || (!b.mandates && !b.sops && !b.standards)) throw new Error(`invalid policy bundle from ${bundleUrl}`);
|
|
275
|
+
// Live containment + operating mode ride as SIBLINGS of the signed bundle (never
|
|
276
|
+
// inside it, so the signature stays valid); stash them on the in-memory copy.
|
|
277
|
+
b.contained = body?.contained ?? null;
|
|
278
|
+
b.operatingMode = body?.operatingMode ?? null;
|
|
279
|
+
_bundle = b;
|
|
280
|
+
_bundleAt = now;
|
|
281
|
+
_bundleMaxAgeMs = _durationMs(b.maxStaleness) ?? _bundleMaxAgeMs;
|
|
282
|
+
return b;
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
/** Map a fetched bundle into the shape evaluateLocally expects, for one action. */
|
|
286
|
+
function _bundleFor(b, action) {
|
|
287
|
+
return {
|
|
288
|
+
contained: b.contained ?? null,
|
|
289
|
+
operatingMode: b.operatingMode ?? null,
|
|
290
|
+
standards: (b.standards ?? []).map((s) => ({ standardKey: s.id ?? s.standardKey ?? 'standard', document: s.document })).filter((s) => s.document),
|
|
291
|
+
sops: (b.sops ?? []).map((s) => ({ standardKey: s.id ?? s.sopId ?? 'sop', document: s.document })).filter((s) => s.document),
|
|
292
|
+
mandate: ((b.mandates ?? []).find((m) => m.action === action) ?? (b.mandates ?? [])[0])?.document,
|
|
293
|
+
};
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
const _sha256 = (s) => 'sha256:' + crypto.createHash('sha256').update(String(s)).digest('hex');
|
|
297
|
+
|
|
298
|
+
/**
|
|
299
|
+
* Read the CURRENT anchored policy for this agent from its OWN Hedera topic via a public
|
|
300
|
+
* mirror node — no MetaMynd call (Build B / spec §5.3.1). Returns { sigDigest, seq } of the
|
|
301
|
+
* latest `policy-update` op, or null. Cached for `_anchorTtlMs`; monotonic on `seq`.
|
|
302
|
+
*/
|
|
303
|
+
async function _currentAnchor() {
|
|
304
|
+
const now = Date.now();
|
|
305
|
+
if (_anchor && now - _anchorAt < _anchorTtlMs) return _anchor;
|
|
306
|
+
const m = /^did:hedera:([^:]+):[^_]+_(.+)$/.exec(agentDid);
|
|
307
|
+
if (!m) return _anchor;
|
|
308
|
+
const network = m[1];
|
|
309
|
+
const topicId = m[2];
|
|
310
|
+
const mbase = network === 'mainnet' ? 'https://mainnet.mirrornode.hedera.com' : 'https://testnet.mirrornode.hedera.com';
|
|
311
|
+
try {
|
|
312
|
+
// Newest-first: the latest `policy-update` op for this DID is the current policy. Its topic
|
|
313
|
+
// sequence_number is the monotonic marker (globally increasing under Hedera consensus), so a
|
|
314
|
+
// rollback / a mirror hiding recent updates shows a LOWER seq and is rejected. (A very busy
|
|
315
|
+
// topic could bury the op past one page; a per-agent topic won't — pagination is a refinement.)
|
|
316
|
+
const body = await fetch(`${mbase}/api/v1/topics/${topicId}/messages?limit=100&order=desc`).then((r) => (r.ok ? r.json() : null));
|
|
317
|
+
const hit = (body?.messages ?? [])
|
|
318
|
+
.map((x) => { try { return { seq: Number(x.sequence_number), op: JSON.parse(Buffer.from(x.message, 'base64').toString('utf8')) }; } catch { return null; } })
|
|
319
|
+
.filter((e) => e && e.op?.op === 'policy-update' && e.op.did === agentDid)
|
|
320
|
+
.sort((a, b) => b.seq - a.seq)[0];
|
|
321
|
+
if (!hit) return _anchor;
|
|
322
|
+
const a = { sigDigest: hit.op.sigDigest ?? null, seq: hit.seq };
|
|
323
|
+
if (a.seq >= _highestSeq) { _anchor = a; _anchorAt = now; _highestSeq = a.seq; }
|
|
324
|
+
} catch { /* mirror unreachable — keep the last known anchor */ }
|
|
325
|
+
return _anchor;
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
/**
|
|
329
|
+
* LOCAL-FIRST decision (the default). Evaluates the rule layer against the cached
|
|
330
|
+
* bundle with the same policy-core the gate runs — so a block/escalate is decided
|
|
331
|
+
* with NO network. An allowed VALUE action (amount > 0) is then sealed by the remote
|
|
332
|
+
* gate (two-phase hold + cumulative-spend cap + anchored evidence — the parts that
|
|
333
|
+
* MUST be server-side); set `sealValueActions:false` for pure offline. If the bundle
|
|
334
|
+
* can't be loaded, defers to the authoritative remote gate rather than blind-allow.
|
|
335
|
+
*/
|
|
336
|
+
async function authorizeLocal(input) {
|
|
337
|
+
const { action, amount = 0 } = input;
|
|
338
|
+
let b;
|
|
339
|
+
try {
|
|
340
|
+
b = await loadBundle();
|
|
341
|
+
} catch {
|
|
342
|
+
return authorize(input); // no local rules → authoritative remote gate
|
|
343
|
+
}
|
|
344
|
+
// Trustless currency check (Build B): trust the local bundle only if it is the LATEST one
|
|
345
|
+
// anchored on Hedera; otherwise defer to the authoritative remote gate (never evaluate against
|
|
346
|
+
// a bundle we can't prove is current — this defeats a stale/rolled-back or forged bundle).
|
|
347
|
+
if (verifyOnChain) {
|
|
348
|
+
const anchor = await _currentAnchor();
|
|
349
|
+
const sig = b?.proof?.signature;
|
|
350
|
+
if (!anchor?.sigDigest || !sig || _sha256(sig) !== anchor.sigDigest) return authorize(input);
|
|
351
|
+
}
|
|
352
|
+
const local = evaluateLocally({ ..._bundleFor(b, action), request: input });
|
|
353
|
+
// allow/observe both PERMIT; block/escalate/contain are decided locally with no network.
|
|
354
|
+
const permits = local.decision === 'allow' || local.decision === 'observe';
|
|
355
|
+
if (!permits) return local; // denied/escalated locally, no network
|
|
356
|
+
if (amount > 0 && sealValueActions) return authorize(input); // seal value action remotely (allow or observe)
|
|
357
|
+
return local; // non-value permit — local is sufficient
|
|
358
|
+
}
|
|
359
|
+
|
|
360
|
+
/** Mode-aware decision used by guardTool: 'local' (default) or 'remote'. */
|
|
361
|
+
async function check(input) {
|
|
362
|
+
return mode === 'remote' ? authorize(input) : authorizeLocal(input);
|
|
363
|
+
}
|
|
364
|
+
|
|
365
|
+
/**
|
|
366
|
+
* Watch for policy changes over Server-Sent Events (Build C) — ZERO-dependency (plain fetch,
|
|
367
|
+
* no socket client). On a `policy:changed` push the guard invalidates its bundle + on-chain
|
|
368
|
+
* anchor cache, so the NEXT call re-fetches (and re-verifies) the new rules — reaching the edge
|
|
369
|
+
* in ~1s instead of within maxStaleness. Push is an optimization: a dropped stream still leaves
|
|
370
|
+
* staleness (A) + the on-chain check (B) as the floor. Auto-reconnects with a short backoff.
|
|
371
|
+
* Returns a handle with `.close()`. Optional `onChange(payload)` callback.
|
|
372
|
+
*/
|
|
373
|
+
function watchPolicy(onChange) {
|
|
374
|
+
let stopped = false;
|
|
375
|
+
let controller = null;
|
|
376
|
+
(async () => {
|
|
377
|
+
while (!stopped) {
|
|
378
|
+
try {
|
|
379
|
+
controller = new AbortController();
|
|
380
|
+
const res = await fetch(`${base}/policy/events/${encodeURIComponent(agentDid)}`, {
|
|
381
|
+
headers: { Accept: 'text/event-stream' },
|
|
382
|
+
signal: controller.signal,
|
|
383
|
+
});
|
|
384
|
+
if (!res.ok || !res.body) throw new Error(`policy events ${res.status}`);
|
|
385
|
+
const reader = res.body.getReader();
|
|
386
|
+
const dec = new TextDecoder();
|
|
387
|
+
let buf = '';
|
|
388
|
+
while (!stopped) {
|
|
389
|
+
const { value, done } = await reader.read();
|
|
390
|
+
if (done) break;
|
|
391
|
+
buf += dec.decode(value, { stream: true });
|
|
392
|
+
let i;
|
|
393
|
+
while ((i = buf.indexOf('\n\n')) >= 0) {
|
|
394
|
+
const frame = buf.slice(0, i);
|
|
395
|
+
buf = buf.slice(i + 2);
|
|
396
|
+
if (!/^event:\s*policy:changed/m.test(frame)) continue; // ignore comments/heartbeats
|
|
397
|
+
_bundle = null; _bundleAt = 0; _anchor = null; _anchorAt = 0; // invalidate → next call re-fetches
|
|
398
|
+
if (onChange) {
|
|
399
|
+
const dline = frame.split('\n').find((l) => l.startsWith('data:'));
|
|
400
|
+
try { onChange(dline ? JSON.parse(dline.slice(5).trim()) : {}); } catch { /* ignore */ }
|
|
401
|
+
}
|
|
402
|
+
}
|
|
403
|
+
}
|
|
404
|
+
} catch {
|
|
405
|
+
/* stream dropped — reconnect */
|
|
406
|
+
}
|
|
407
|
+
if (!stopped) await new Promise((r) => setTimeout(r, 2000));
|
|
408
|
+
}
|
|
409
|
+
})();
|
|
410
|
+
return { close() { stopped = true; try { controller?.abort(); } catch { /* ignore */ } } };
|
|
411
|
+
}
|
|
412
|
+
|
|
413
|
+
/**
|
|
414
|
+
* Like guardTool, but evaluates LOCALLY against a policy bundle instead of calling
|
|
415
|
+
* the gate — cooperative-mode, low-latency governance (spec §9.2). Fails CLOSED:
|
|
416
|
+
* any error during local evaluation throws GovernanceBlocked, never allows.
|
|
417
|
+
*
|
|
418
|
+
* @param {string} action
|
|
419
|
+
* @param {(args:any, decision:any)=>any} handler
|
|
420
|
+
* @param {(args:any)=>{amount?:number,merchant?:string,context?:object}} mapArgs
|
|
421
|
+
* @param {object|((args:any)=>object|Promise<object>)} getBundle { standards, sops, mandate } (or a resolver)
|
|
422
|
+
*/
|
|
423
|
+
function guardToolLocal(action, handler, mapArgs = (a) => a, getBundle = {}, toolOpts = {}) {
|
|
424
|
+
const adapter = toolOpts.executionAdapter ?? defaultExecutionAdapter;
|
|
425
|
+
return async (args) => {
|
|
426
|
+
let decision;
|
|
427
|
+
try {
|
|
428
|
+
const { amount, merchant, context } = mapArgs(args);
|
|
429
|
+
const bundle = typeof getBundle === 'function' ? await getBundle(args) : getBundle;
|
|
430
|
+
decision = evaluateLocally({ ...bundle, request: { action, amount, merchant, context } });
|
|
431
|
+
} catch (err) {
|
|
432
|
+
decision = { decision: 'block', reasonCode: 'LOCAL_EVAL_ERROR', error: String(err?.message ?? err) };
|
|
433
|
+
}
|
|
434
|
+
// allow/observe both PERMIT execution; observe is permit-but-flag (SAFR §11) — the
|
|
435
|
+
// handler receives the `decision` so a caller can surface/log the observation.
|
|
436
|
+
if (decision.decision !== 'allow' && decision.decision !== 'observe') {
|
|
437
|
+
const err = new Error(`AgentSafe ${decision.decision.toUpperCase()} "${action}": ${decision.reasonCode}`);
|
|
438
|
+
err.name = 'GovernanceBlocked';
|
|
439
|
+
err.governance = decision;
|
|
440
|
+
throw err;
|
|
441
|
+
}
|
|
442
|
+
if (decision.decision === 'observe') {
|
|
443
|
+
console.warn(`[agentsafe] OBSERVE "${action}": ${decision.reasonCode} — permitted under monitoring`);
|
|
444
|
+
}
|
|
445
|
+
// ExecutionAdapter seam (§19): the adapter runs the real handler (proceed) or substitutes it.
|
|
446
|
+
return adapter({ action, args, decision, proceed: () => handler(args, decision) });
|
|
447
|
+
};
|
|
448
|
+
}
|
|
449
|
+
|
|
450
|
+
/**
|
|
451
|
+
* Wrap a tool handler so it is gated. Returns a function you register with your agent
|
|
452
|
+
* framework in place of the raw handler. On a non-allow decision it THROWS a
|
|
453
|
+
* GovernanceBlocked error (with `.governance`) so the agent surfaces the reason and
|
|
454
|
+
* does NOT perform the action.
|
|
455
|
+
*
|
|
456
|
+
* @param {string} action the governed action (must match a mandate scope, e.g. 'flight-purchase')
|
|
457
|
+
* @param {(args:any, decision:any)=>any} handler the real tool implementation
|
|
458
|
+
* @param {(args:any)=>{amount?:number,currency?:string,merchant?:string,context?:object}} mapArgs
|
|
459
|
+
* maps the tool's call args to the gate inputs (amount/merchant + the context the rules need)
|
|
460
|
+
*/
|
|
461
|
+
function guardTool(action, handler, mapArgs = (a) => a, toolOpts = {}) {
|
|
462
|
+
const adapter = toolOpts.executionAdapter ?? defaultExecutionAdapter;
|
|
463
|
+
return async (args) => {
|
|
464
|
+
const decision = await check({ action, ...mapArgs(args) });
|
|
465
|
+
// allow/observe both PERMIT execution; observe is permit-but-flag (SAFR §11) — the
|
|
466
|
+
// handler receives the `decision` so a caller can surface/log the observation.
|
|
467
|
+
if (decision.decision !== 'allow' && decision.decision !== 'observe') {
|
|
468
|
+
const err = new Error(`AgentSafe ${decision.decision.toUpperCase()} "${action}": ${decision.reasonCode}`);
|
|
469
|
+
err.name = 'GovernanceBlocked';
|
|
470
|
+
err.governance = decision;
|
|
471
|
+
throw err;
|
|
472
|
+
}
|
|
473
|
+
if (decision.decision === 'observe') {
|
|
474
|
+
console.warn(`[agentsafe] OBSERVE "${action}": ${decision.reasonCode} — permitted under monitoring`);
|
|
475
|
+
}
|
|
476
|
+
// ExecutionAdapter seam (§19): the adapter runs the real handler (proceed) or substitutes it.
|
|
477
|
+
return adapter({ action, args, decision, proceed: () => handler(args, decision) });
|
|
478
|
+
};
|
|
479
|
+
}
|
|
480
|
+
|
|
481
|
+
/**
|
|
482
|
+
* Mutual-handshake INITIATOR (spec §8.2). Prove control of this agent's DID to a
|
|
483
|
+
* Service and verify the Service controls its DID — no issuer calls (keys are in
|
|
484
|
+
* the DIDs, §4.1.2). Returns { hello, prove } to drive the exchange:
|
|
485
|
+
* const hs = guard.handshake();
|
|
486
|
+
* const { nonceA, message } = hs.hello(); // → send HELLO to the Service
|
|
487
|
+
* const { sigA, handshakeId } = hs.prove({ nonceA, challenge }); // verifies the Service, → send PROVE
|
|
488
|
+
* `prove` throws HandshakeFailed if the Service's CHALLENGE does not verify.
|
|
489
|
+
*/
|
|
490
|
+
function handshake() {
|
|
491
|
+
return {
|
|
492
|
+
hello() {
|
|
493
|
+
const nonceA = crypto.randomUUID();
|
|
494
|
+
return { nonceA, message: { fromDid: agentDid, nonceA, protoVersion: '0.4' } };
|
|
495
|
+
},
|
|
496
|
+
prove({ nonceA, challenge } = {}) {
|
|
497
|
+
const { toDid, nonceB, sigB, handshakeId } = challenge ?? {};
|
|
498
|
+
if (!toDid || !nonceB || !sigB) throw new Error('malformed CHALLENGE');
|
|
499
|
+
if (!verifyDidSignature(toDid, nonceA, sigB)) {
|
|
500
|
+
const e = new Error('Service failed to prove control of its DID');
|
|
501
|
+
e.name = 'HandshakeFailed';
|
|
502
|
+
throw e;
|
|
503
|
+
}
|
|
504
|
+
return { handshakeId, sigA: sign(nonceB), remoteDid: toDid };
|
|
505
|
+
},
|
|
506
|
+
};
|
|
507
|
+
}
|
|
508
|
+
|
|
509
|
+
/**
|
|
510
|
+
* Read a Service's 402 PaymentRequirements and prepare to pay (spec §7a.1 step 5).
|
|
511
|
+
* Refuses a 402 that is NOT bound to a MAGP authorization (§7a.2.1) — the agent
|
|
512
|
+
* must never pay for an ungoverned request — and refuses one whose authorization
|
|
513
|
+
* does not match the `authorizationId` the agent holds from its own authorize
|
|
514
|
+
* (allow) step, so a swapped 402 can't redirect the payment.
|
|
515
|
+
*
|
|
516
|
+
* @param {object} requirements the x402 PaymentRequirements from the 402 response
|
|
517
|
+
* @param {string} [expectedAuthorizationId] the authorizationId from guard.authorize()
|
|
518
|
+
* @returns {{authorizationId:string, amountMinor:string, payTo:string, asset:string, network:string, resource:string}}
|
|
519
|
+
*/
|
|
520
|
+
function preparePayment(requirements, expectedAuthorizationId) {
|
|
521
|
+
const a = requirements?.accepts?.[0];
|
|
522
|
+
if (!a?.extra?.magpAuthorizationId) {
|
|
523
|
+
const e = new Error('402 is not bound to a MAGP authorization — refusing to pay');
|
|
524
|
+
e.name = 'UnboundPayment';
|
|
525
|
+
throw e;
|
|
526
|
+
}
|
|
527
|
+
if (expectedAuthorizationId && a.extra.magpAuthorizationId !== expectedAuthorizationId) {
|
|
528
|
+
const e = new Error('402 authorization does not match the agent authorization');
|
|
529
|
+
e.name = 'AuthorizationMismatch';
|
|
530
|
+
throw e;
|
|
531
|
+
}
|
|
532
|
+
// Pay exactly the authorized amount; the binding check guards against overpay.
|
|
533
|
+
checkSettlementBinding(requirements, { authorizationId: a.extra.magpAuthorizationId, paidAmountMinor: a.maxAmountRequired });
|
|
534
|
+
return {
|
|
535
|
+
authorizationId: a.extra.magpAuthorizationId,
|
|
536
|
+
amountMinor: a.maxAmountRequired,
|
|
537
|
+
payTo: a.payTo,
|
|
538
|
+
asset: a.asset,
|
|
539
|
+
network: a.network,
|
|
540
|
+
resource: a.resource,
|
|
541
|
+
};
|
|
542
|
+
}
|
|
543
|
+
|
|
544
|
+
/**
|
|
545
|
+
* Poll the outcome of an escalated action (spec §9a). When authorize() returns
|
|
546
|
+
* `escalate`, its `escalationId` parks the action for the Owner to approve/deny.
|
|
547
|
+
* The agent polls this until the status is terminal; on `approved` the returned
|
|
548
|
+
* `authorizationId` carries into the §7a capture/pay flow. Fails soft (never throws).
|
|
549
|
+
* @returns {Promise<{status:string,reasonCode:string,authorizationId:string|null,expiresAt:string|null}>}
|
|
550
|
+
*/
|
|
551
|
+
async function escalationStatus(escalationId) {
|
|
552
|
+
try {
|
|
553
|
+
const res = await fetch(`${base}/policy/escalations/${encodeURIComponent(escalationId)}/status`);
|
|
554
|
+
const body = await res.json().catch(() => null);
|
|
555
|
+
return body?.data ?? { status: 'unknown', reasonCode: `GATE_HTTP_${res.status}`, authorizationId: null, expiresAt: null };
|
|
556
|
+
} catch (err) {
|
|
557
|
+
return { status: 'unreachable', reasonCode: 'GATE_UNREACHABLE', authorizationId: null, expiresAt: null, error: String(err?.message ?? err) };
|
|
558
|
+
}
|
|
559
|
+
}
|
|
560
|
+
|
|
561
|
+
/**
|
|
562
|
+
* Merkle inclusion proof for a decision's evidence record — fetched, then VERIFIED
|
|
563
|
+
* HERE rather than taken on trust.
|
|
564
|
+
*
|
|
565
|
+
* The point of an inclusion proof is that its holder can check it WITHOUT trusting the
|
|
566
|
+
* party that issued it. A helper that returned the server's payload as-is would look
|
|
567
|
+
* like proof and function as assertion: the caller would be believing MetaMynd's claim
|
|
568
|
+
* that the record is in the anchored batch, which is exactly the thing the proof exists
|
|
569
|
+
* to make unnecessary. So the sibling chain is replayed locally and the recomputed root
|
|
570
|
+
* is compared to the anchored one; `verified` is this SDK's own conclusion.
|
|
571
|
+
*
|
|
572
|
+
* Absence and falsification are reported as DIFFERENT outcomes, because they mean
|
|
573
|
+
* opposite things to whoever is asking:
|
|
574
|
+
*
|
|
575
|
+
* status 'verified' the record is provably in the batch anchored at anchorTxId
|
|
576
|
+
* status 'pending' no anchored batch contains it YET — anchoring is asynchronous
|
|
577
|
+
* (§10.2), so a recent decision is normally pending, not missing
|
|
578
|
+
* status 'failed' a proof was returned and it does NOT reconstruct the root.
|
|
579
|
+
* This is the alarming one and must never be conflated with
|
|
580
|
+
* 'pending'
|
|
581
|
+
* status 'unreachable' the gate could not be asked; nothing is implied either way
|
|
582
|
+
*
|
|
583
|
+
* Note the trust boundary this does NOT cross: it proves the record belongs to the
|
|
584
|
+
* batch that claims `root`. Proving that root was published on Hedera is a separate,
|
|
585
|
+
* stronger check against the mirror node — see integrations/magp-evidence/, the offline
|
|
586
|
+
* auditor, which does it with MetaMynd entirely absent.
|
|
587
|
+
*/
|
|
588
|
+
async function proof(eventId) {
|
|
589
|
+
if (!eventId) throw new Error('proof requires the evidence eventId');
|
|
590
|
+
let body;
|
|
591
|
+
let httpStatus;
|
|
592
|
+
try {
|
|
593
|
+
const res = await fetch(`${base}/magp/evidence/${encodeURIComponent(eventId)}/proof`);
|
|
594
|
+
httpStatus = res.status;
|
|
595
|
+
body = await res.json().catch(() => null);
|
|
596
|
+
} catch (err) {
|
|
597
|
+
return { status: 'unreachable', verified: false, eventId, reason: 'GATE_UNREACHABLE', error: String(err?.message ?? err) };
|
|
598
|
+
}
|
|
599
|
+
|
|
600
|
+
if (httpStatus === 404) {
|
|
601
|
+
// Not an error: batching is asynchronous, so a decision made seconds ago has
|
|
602
|
+
// genuinely not been anchored yet. Saying "unverified" here would read as doubt
|
|
603
|
+
// about a record that is simply young.
|
|
604
|
+
return { status: 'pending', verified: false, eventId, reason: 'NOT_YET_ANCHORED' };
|
|
605
|
+
}
|
|
606
|
+
const data = body?.data;
|
|
607
|
+
if (!data?.leaf || !data?.root || !Array.isArray(data?.proof)) {
|
|
608
|
+
return { status: 'unreachable', verified: false, eventId, reason: `GATE_HTTP_${httpStatus}` };
|
|
609
|
+
}
|
|
610
|
+
|
|
611
|
+
const verified = verifyMerkleInclusion(data.leaf, data.proof, data.root);
|
|
612
|
+
return {
|
|
613
|
+
status: verified ? 'verified' : 'failed',
|
|
614
|
+
verified,
|
|
615
|
+
eventId,
|
|
616
|
+
leaf: data.leaf,
|
|
617
|
+
root: data.root,
|
|
618
|
+
proof: data.proof,
|
|
619
|
+
anchorTxId: data.anchorTxId ?? null,
|
|
620
|
+
anchorRef: data.anchorRef ?? null,
|
|
621
|
+
anchoredAt: data.anchoredAt ?? null,
|
|
622
|
+
...(verified ? {} : { reason: 'MERKLE_ROOT_MISMATCH' }),
|
|
623
|
+
};
|
|
624
|
+
}
|
|
625
|
+
|
|
626
|
+
/**
|
|
627
|
+
* Effect-safety runtime (E2): report the external-effect lifecycle so an AMBIGUOUS
|
|
628
|
+
* connector outcome never becomes a blind capture/void. Call effectDispatching() just
|
|
629
|
+
* before the side-effecting call, effectDispatched() when the connector accepts, and —
|
|
630
|
+
* critically — effectUnknown() when the response is lost/timed out (instead of guessing).
|
|
631
|
+
* Once UNKNOWN, capture/void are refused by the gate until the effect is reconciled.
|
|
632
|
+
*/
|
|
633
|
+
async function _effectPost(authorizationId, kind, payload = {}) {
|
|
634
|
+
try {
|
|
635
|
+
const res = await fetch(`${base}/policy/mandate/authorize/${encodeURIComponent(authorizationId)}/effect/${kind}`, {
|
|
636
|
+
method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(payload),
|
|
637
|
+
});
|
|
638
|
+
const body = await res.json().catch(() => null);
|
|
639
|
+
return body?.data ?? { ok: false, reasonCode: `GATE_HTTP_${res.status}` };
|
|
640
|
+
} catch (err) {
|
|
641
|
+
return { ok: false, reasonCode: 'GATE_UNREACHABLE', error: String(err?.message ?? err) };
|
|
642
|
+
}
|
|
643
|
+
}
|
|
644
|
+
const effectDispatching = (authorizationId) => _effectPost(authorizationId, 'dispatching');
|
|
645
|
+
const effectDispatched = (authorizationId, remoteRef) => _effectPost(authorizationId, 'dispatched', { remoteRef });
|
|
646
|
+
const effectUnknown = (authorizationId, reason) => _effectPost(authorizationId, 'unknown', { reason });
|
|
647
|
+
async function effectStatus(authorizationId) {
|
|
648
|
+
try {
|
|
649
|
+
const res = await fetch(`${base}/policy/mandate/authorize/${encodeURIComponent(authorizationId)}/effect`);
|
|
650
|
+
const body = await res.json().catch(() => null);
|
|
651
|
+
return body?.data ?? { effectState: null, reasonCode: `GATE_HTTP_${res.status}` };
|
|
652
|
+
} catch (err) {
|
|
653
|
+
return { effectState: 'unreachable', reasonCode: 'GATE_UNREACHABLE', error: String(err?.message ?? err) };
|
|
654
|
+
}
|
|
655
|
+
}
|
|
656
|
+
|
|
657
|
+
/**
|
|
658
|
+
* BYOK proof-of-possession (onboarding proposal #4). For an agent that brought its OWN key,
|
|
659
|
+
* MetaMynd issued the identity with a one-time `challenge` and left the key UNVERIFIED — the gate
|
|
660
|
+
* blocks it with AGENT_KEY_UNVERIFIED until control is proven. This signs the challenge with the
|
|
661
|
+
* agent's private key (the same Ed25519 the gate checks) and submits it to verify-key, flipping
|
|
662
|
+
* the key to verified. A one-time SETUP step: verify-key is owner-authenticated, so pass the owner
|
|
663
|
+
* `token` you onboarded with. `ref` defaults to the identityId; `challenge` comes from the config.
|
|
664
|
+
*
|
|
665
|
+
* @param {{ ref: string, challenge: string, token?: string }} p
|
|
666
|
+
* @returns {Promise<{ verified: boolean, did?: string }>}
|
|
667
|
+
*/
|
|
668
|
+
async function verifyKey({ ref, challenge, token } = {}) {
|
|
669
|
+
if (!ref || !challenge) throw new Error('verifyKey requires { ref, challenge } (from the BYOK onboarding config)');
|
|
670
|
+
const res = await fetch(`${base}/agent-identity/${encodeURIComponent(ref)}/verify-key`, {
|
|
671
|
+
method: 'POST',
|
|
672
|
+
headers: { 'Content-Type': 'application/json', ...(token ? { Authorization: `Bearer ${token}` } : {}) },
|
|
673
|
+
body: JSON.stringify({ signature: sign(challenge) }),
|
|
674
|
+
});
|
|
675
|
+
const body = await res.json().catch(() => null);
|
|
676
|
+
if (!res.ok) {
|
|
677
|
+
const e = new Error(body?.message || `verify-key HTTP ${res.status}`);
|
|
678
|
+
e.name = 'KeyVerificationFailed';
|
|
679
|
+
throw e;
|
|
680
|
+
}
|
|
681
|
+
return body?.data ?? { verified: true };
|
|
682
|
+
}
|
|
683
|
+
|
|
684
|
+
/** Sign a BYOK challenge with the agent's key (hex) — for integrators who submit verify-key themselves. */
|
|
685
|
+
function signChallenge(challenge) {
|
|
686
|
+
if (!challenge) throw new Error('signChallenge requires the challenge nonce');
|
|
687
|
+
return sign(challenge);
|
|
688
|
+
}
|
|
689
|
+
|
|
690
|
+
return { authorize, authorizeLocal, check, loadBundle, policyAnchor: _currentAnchor, watchPolicy, mode, verifyOnChain, buildSignedRequest, capture, guardTool, evaluateLocally, guardToolLocal, handshake, preparePayment, escalationStatus, proof, effectDispatching, effectDispatched, effectUnknown, effectStatus, verifyKey, signChallenge, agentDid, executionAdapter: defaultExecutionAdapter };
|
|
691
|
+
}
|