@decentrys/agent 0.1.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/src/policy.ts ADDED
@@ -0,0 +1,951 @@
1
+ /**
2
+ * The policy engine.
3
+ *
4
+ * Two things live here, and keeping them together is deliberate.
5
+ *
6
+ * **Sealing** is how "the agent cannot widen its own limits" stops being a
7
+ * convention. An operator writes an `AgentPolicy` and calls `sealPolicy()`.
8
+ * What comes back is deep-frozen, cloned away from the caller's own arrays,
9
+ * and carries a fingerprint of its contents. `AgentGuard` accepts nothing
10
+ * else, exposes no setter, and records the fingerprint on every decision. An
11
+ * agent handed a reference to that object can read it and cannot change it,
12
+ * and a policy that changed between two decisions cannot pretend it did not.
13
+ *
14
+ * The only way to derive a new policy is `narrowPolicy()`, which takes the
15
+ * stricter of every field. There is no widening path in this file at all —
16
+ * not a guarded one, not an admin one. A capability that does not exist
17
+ * cannot be reached by a sufficiently persuasive prompt.
18
+ *
19
+ * **Evaluation** is a pure function. `evaluatePolicy()` performs no I/O and
20
+ * reads no clock it was not given, so the same action, policy, assessment and
21
+ * usage always produce the same decision. That is what makes an audit record
22
+ * checkable rather than merely readable: an investigator can re-run it.
23
+ */
24
+
25
+ import {
26
+ RISK_LEVELS, applyPolicy,
27
+ type Assessment, type Policy, type PolicyAction, type RiskLevel,
28
+ } from '@decentrys/protect';
29
+
30
+ import {
31
+ AGENT_MODEL_VERSION,
32
+ type AgentAction, type AgentActionType, type AgentDecision, type AgentFailMode,
33
+ type AgentPolicy, type AgentVerdict, type RateWindow, type RuleEvaluation,
34
+ type SpendWindow,
35
+ } from './model';
36
+ import type { AgentUsage } from './ledger';
37
+
38
+ // ---------------------------------------------------------------------------
39
+ // The default risk policy for an autonomous actor
40
+ // ---------------------------------------------------------------------------
41
+
42
+ /**
43
+ * How risk levels map to behaviour when nobody is watching.
44
+ *
45
+ * Protect's consumer default warns from CAUTION upward and blocks only
46
+ * `KNOWN_MALICIOUS`, because a person is reading the warning. Neither half of
47
+ * that transfers:
48
+ *
49
+ * - **`warn` is not available to an agent.** It is mapped out entirely; see
50
+ * `sealPolicy`, which refuses a `warn` in an agent risk policy rather than
51
+ * quietly reinterpreting it.
52
+ * - **CAUTION stays `allow`, and that is not laziness.** CAUTION is reached
53
+ * by a single SIGNIFICANT *capability* — a pausable token, an upgradeable
54
+ * router. Most serious protocols have one. Escalating there would send a
55
+ * human hundreds of approvals a day, and a human who approves 500
56
+ * escalations approves the 501st unread. The Sentinel stage recorded the
57
+ * same failure for alerts; it is worse here, because the 501st is the one
58
+ * that empties the treasury. The capability is still recorded on the
59
+ * decision, so it is visible without being an interrupt.
60
+ * - **ELEVATED_RISK upward escalates**, because at that point actual threat
61
+ * signals with evidence exist and a person can weigh them.
62
+ * - **`KNOWN_MALICIOUS` denies, and a denial is not escalatable.** It is the
63
+ * one level that requires analyst-verified evidence, and there is no value
64
+ * at which signing to confirmed malicious infrastructure is the right call.
65
+ *
66
+ * `CRITICAL_THREAT` escalates rather than denies for the reason Protect draws
67
+ * the same line: severe evidence that no analyst has confirmed is exactly the
68
+ * case where a person should look at the evidence.
69
+ */
70
+ export const DEFAULT_AGENT_RISK_POLICY: Required<Policy> = {
71
+ NO_CRITICAL_RISK_DETECTED: 'allow',
72
+ INFORMATIONAL: 'allow',
73
+ CAUTION: 'allow',
74
+ ELEVATED_RISK: 'require_confirmation',
75
+ HIGH_RISK: 'require_confirmation',
76
+ CRITICAL_THREAT: 'require_confirmation',
77
+ KNOWN_MALICIOUS: 'block',
78
+ };
79
+
80
+ /**
81
+ * An allowance this large cannot be exhausted by any real token supply, so it
82
+ * is unlimited in every sense that matters to the holder. Wallets and routers
83
+ * use several different sentinels (`2^256-1`, `2^255-1`, and others); a
84
+ * threshold catches all of them without a table of magic constants.
85
+ */
86
+ const UNLIMITED_ALLOWANCE_THRESHOLD = 1n << 255n;
87
+
88
+ /** How strict each action is, for narrowing. Higher is stricter. */
89
+ const POLICY_ACTION_STRICTNESS: Record<PolicyAction, number> = {
90
+ allow: 0, inform: 1, warn: 2, warn_strong: 3, require_confirmation: 4, block: 5,
91
+ };
92
+
93
+ const FAIL_MODE_STRICTNESS: Record<AgentFailMode, number> = { open: 0, escalate: 1, closed: 2 };
94
+
95
+ // ---------------------------------------------------------------------------
96
+ // Sealing
97
+ // ---------------------------------------------------------------------------
98
+
99
+ /**
100
+ * The brand, as a real runtime symbol rather than a `declare`d type-only one.
101
+ *
102
+ * A type-only brand is erased at compile time, so an object cast to
103
+ * `SealedPolicy` would satisfy every check and carry no mark at all. This
104
+ * symbol is module-private and never exported, so the only way to obtain an
105
+ * object bearing it is to call `sealPolicy()` — which clones and freezes.
106
+ */
107
+ const SEALED_POLICY: unique symbol = Symbol('decentrys.agent.sealedPolicy');
108
+
109
+ export interface SealedPolicy {
110
+ /**
111
+ * Structural, not decorative. A plain object literal is not assignable to
112
+ * `SealedPolicy`, so there is no way to hand `AgentGuard` a policy that did
113
+ * not go through `sealPolicy()` — including from inside a tool an agent
114
+ * controls.
115
+ */
116
+ readonly [SEALED_POLICY]: true;
117
+ readonly policy: Readonly<AgentPolicy>;
118
+ /**
119
+ * A content fingerprint. Not a cryptographic commitment — it exists so two
120
+ * decisions can be shown to have run under the same rules, and so a policy
121
+ * that changed mid-incident cannot be described afterwards as unchanged.
122
+ */
123
+ readonly fingerprint: string;
124
+ /**
125
+ * Configurations that are legal but deserve to be said out loud — a policy
126
+ * that permits confirmed-malicious counterparties, or one with no limits at
127
+ * all. Surfaced rather than refused: policy belongs to the integrator.
128
+ */
129
+ readonly warnings: readonly string[];
130
+ }
131
+
132
+ export function sealPolicy(policy: AgentPolicy): SealedPolicy {
133
+ const risk = policy.risk ?? {};
134
+ for (const [level, action] of Object.entries(risk)) {
135
+ if (action === 'warn' || action === 'warn_strong') {
136
+ // Refused rather than reinterpreted. An agent has no eyes; a `warn` here
137
+ // would either be silently upgraded to an escalation (changing the
138
+ // operator's meaning without telling them) or silently treated as an
139
+ // allow (a control that does nothing). Both are worse than an error at
140
+ // wiring time.
141
+ throw new Error(
142
+ `AgentGuard: '${action}' is not a valid action for an agent policy (at ${level}). `
143
+ + 'There is nobody to warn. Use \'allow\' to permit and record it, or '
144
+ + '\'require_confirmation\' to route it to a person.',
145
+ );
146
+ }
147
+ }
148
+
149
+ const cloned = clonePolicy(policy);
150
+ const warnings: string[] = [];
151
+
152
+ const effectiveRisk = { ...DEFAULT_AGENT_RISK_POLICY, ...cloned.risk };
153
+ if (effectiveRisk.KNOWN_MALICIOUS !== 'block') {
154
+ warnings.push(
155
+ 'This policy does not deny KNOWN_MALICIOUS counterparties. That level requires '
156
+ + 'analyst-verified evidence and is the one classification Decentrys treats as an accusation.',
157
+ );
158
+ }
159
+ if (!hasAnyLimit(cloned)) {
160
+ warnings.push(
161
+ 'This policy sets no value cap, no cumulative window, no allowlist and no rate ceiling. '
162
+ + 'The agent is bounded only by risk classification.',
163
+ );
164
+ }
165
+
166
+ return deepFreeze({
167
+ [SEALED_POLICY]: true as const,
168
+ policy: cloned,
169
+ fingerprint: fingerprint(cloned),
170
+ warnings,
171
+ }) as SealedPolicy;
172
+ }
173
+
174
+ /**
175
+ * Derive a stricter policy. There is no counterpart that loosens one.
176
+ *
177
+ * Every field is combined by taking the tighter side: caps become the minimum,
178
+ * allowlists intersect, blocklists union, windows accumulate, and
179
+ * `allowUnlimitedApprovals` is true only if both sides say so. So a
180
+ * restriction supplied at call time — including one an agent composed itself —
181
+ * can only ever reduce what is permitted.
182
+ */
183
+ export function narrowPolicy(base: SealedPolicy, restriction: AgentPolicy): SealedPolicy {
184
+ const a = base.policy;
185
+ const b = clonePolicy(restriction);
186
+
187
+ if (a.agentId && b.agentId && a.agentId !== b.agentId) {
188
+ // Not a narrowing — it is a contradiction, and silently resolving it either
189
+ // way would hide which agent's limits are actually in force.
190
+ throw new Error(
191
+ `AgentGuard: cannot narrow a policy bound to agent '${a.agentId}' with one bound to '${b.agentId}'.`,
192
+ );
193
+ }
194
+
195
+ return sealPolicy({
196
+ version: [a.version, b.version].filter(Boolean).join('+') || undefined,
197
+ agentId: a.agentId ?? b.agentId,
198
+ risk: strictestRisk(a.risk, b.risk),
199
+ maxRiskLevel: lowerRiskLevel(a.maxRiskLevel, b.maxRiskLevel),
200
+ maxValuePerActionUsd: minDefined(a.maxValuePerActionUsd, b.maxValuePerActionUsd),
201
+ spendWindows: concatWindows(a.spendWindows, b.spendWindows),
202
+ rateWindows: concatWindows(a.rateWindows, b.rateWindows),
203
+ allowedCounterparties: intersectLists(a.allowedCounterparties, b.allowedCounterparties),
204
+ blockedCounterparties: unionLists(a.blockedCounterparties, b.blockedCounterparties),
205
+ allowedContracts: intersectLists(a.allowedContracts, b.allowedContracts),
206
+ allowedTokens: intersectLists(a.allowedTokens, b.allowedTokens),
207
+ allowedActions: intersectLists(a.allowedActions, b.allowedActions),
208
+ deniedActions: unionLists(a.deniedActions, b.deniedActions),
209
+ allowedChains: intersectLists(a.allowedChains, b.allowedChains),
210
+ allowUnlimitedApprovals: (a.allowUnlimitedApprovals ?? false) && (b.allowUnlimitedApprovals ?? false),
211
+ humanApprovalAboveUsd: minDefined(a.humanApprovalAboveUsd, b.humanApprovalAboveUsd),
212
+ humanApprovalAtRiskLevel: lowerRiskLevel(a.humanApprovalAtRiskLevel, b.humanApprovalAtRiskLevel),
213
+ failMode: stricterFailMode(a.failMode, b.failMode),
214
+ });
215
+ }
216
+
217
+ // ---------------------------------------------------------------------------
218
+ // Evaluation
219
+ // ---------------------------------------------------------------------------
220
+
221
+ export interface EvaluatePolicyInput {
222
+ action: AgentAction;
223
+ policy: SealedPolicy;
224
+ /** The Protect assessment, when one was obtained. */
225
+ assessment?: Assessment | null;
226
+ /** Why no assessment was obtained. Drives the `failMode` rule. */
227
+ assessmentUnavailable?: string;
228
+ /** Committed spend and admitted actions so far. Absent means none recorded. */
229
+ usage?: AgentUsage;
230
+ now?: Date;
231
+ /** Supplied by the caller so a decision and its reservation share an id. */
232
+ decisionId?: string;
233
+ }
234
+
235
+ /**
236
+ * Evaluate an action against a sealed policy.
237
+ *
238
+ * Every rule is evaluated, including after one has already failed. Stopping at
239
+ * the first failure would produce a record saying an action exceeded its value
240
+ * cap while omitting that its counterparty was also confirmed malicious — and
241
+ * an operator reading that would tune the cap and ship the same hole.
242
+ *
243
+ * The verdict is the worst outcome any rule reached: one `fail` denies, one
244
+ * `escalate` sends it to a person, and everything else allows.
245
+ */
246
+ export function evaluatePolicy(input: EvaluatePolicyInput): AgentDecision {
247
+ const now = input.now ?? new Date();
248
+ const nowMs = now.getTime();
249
+ const { action } = input;
250
+ const policy = input.policy.policy;
251
+ const evaluations: RuleEvaluation[] = [];
252
+
253
+ // --- 1. Is this policy even this agent's? --------------------------------
254
+ if (policy.agentId) {
255
+ const bound = policy.agentId === action.agentId;
256
+ evaluations.push({
257
+ rule: 'AGENT_BINDING',
258
+ outcome: bound ? 'pass' : 'fail',
259
+ statement: bound
260
+ ? `This policy is bound to agent '${policy.agentId}', which proposed the action.`
261
+ : `This policy is bound to agent '${policy.agentId}'; the action was proposed by '${action.agentId}'.`,
262
+ limit: policy.agentId,
263
+ observed: action.agentId,
264
+ });
265
+ } else {
266
+ evaluations.push({
267
+ rule: 'AGENT_BINDING',
268
+ outcome: 'not_configured',
269
+ statement: 'This policy is not bound to a specific agent.',
270
+ });
271
+ }
272
+
273
+ // --- 2. What the agent is permitted to do --------------------------------
274
+ evaluations.push(evaluateActionType(action.type, policy.allowedActions, policy.deniedActions));
275
+ evaluations.push(evaluateAllowlist(
276
+ 'CHAIN', 'chain', action.chain, policy.allowedChains,
277
+ ));
278
+
279
+ // --- 3. Who it may deal with ---------------------------------------------
280
+ evaluations.push(evaluateCounterparty(action, policy));
281
+ evaluations.push(evaluateContract(action, policy));
282
+ evaluations.push(evaluateAllowlist('TOKEN', 'token', action.token, policy.allowedTokens));
283
+
284
+ // --- 4. Allowances --------------------------------------------------------
285
+ evaluations.push(evaluateAllowance(action, policy.allowUnlimitedApprovals ?? false));
286
+
287
+ // --- 5. Value -------------------------------------------------------------
288
+ evaluations.push(evaluatePerActionValue(action, policy.maxValuePerActionUsd));
289
+ evaluations.push(evaluateCumulativeSpend(action, policy.spendWindows, input.usage, nowMs));
290
+
291
+ // --- 6. Rate --------------------------------------------------------------
292
+ evaluations.push(evaluateRate(action, policy.rateWindows, input.usage, nowMs));
293
+
294
+ // --- 7. Risk, from the Protect model — not reimplemented here -------------
295
+ const effectiveRisk: Policy = { ...DEFAULT_AGENT_RISK_POLICY, ...policy.risk };
296
+ evaluations.push(evaluateAvailability(input.assessment, input.assessmentUnavailable, policy.failMode ?? 'escalate'));
297
+ evaluations.push(evaluateRisk(input.assessment, effectiveRisk, policy.maxRiskLevel));
298
+
299
+ // --- 8. Thresholds a human owns even when everything passed ---------------
300
+ evaluations.push(evaluateHumanApproval(action, policy, input.assessment));
301
+
302
+ const verdict = verdictFrom(evaluations);
303
+ const reasons = evaluations
304
+ .filter((e) => e.outcome === 'fail' || e.outcome === 'escalate')
305
+ .map((e) => e.statement);
306
+
307
+ return {
308
+ decisionId: input.decisionId ?? newDecisionId(),
309
+ agentId: action.agentId,
310
+ verdict,
311
+ reasons,
312
+ evaluations,
313
+ ...(input.assessment ? { assessment: input.assessment } : {}),
314
+ ...(input.assessmentUnavailable ? { assessmentUnavailable: input.assessmentUnavailable } : {}),
315
+ policyFingerprint: input.policy.fingerprint,
316
+ ...(policy.version ? { policyVersion: policy.version } : {}),
317
+ actionDigest: fingerprint(canonicalAction(action)),
318
+ modelVersion: AGENT_MODEL_VERSION,
319
+ decidedAt: now.toISOString(),
320
+ };
321
+ }
322
+
323
+ function verdictFrom(evaluations: RuleEvaluation[]): AgentVerdict {
324
+ if (evaluations.some((e) => e.outcome === 'fail')) return 'deny';
325
+ if (evaluations.some((e) => e.outcome === 'escalate')) return 'require_human_approval';
326
+ return 'allow';
327
+ }
328
+
329
+ // ---------------------------------------------------------------------------
330
+ // Individual rules
331
+ // ---------------------------------------------------------------------------
332
+
333
+ function evaluateActionType(
334
+ type: AgentActionType,
335
+ allowed: readonly AgentActionType[] | undefined,
336
+ denied: readonly AgentActionType[] | undefined,
337
+ ): RuleEvaluation {
338
+ if (denied?.includes(type)) {
339
+ return {
340
+ rule: 'ACTION_TYPE', outcome: 'fail',
341
+ statement: `Actions of type '${type}' are denied by this policy.`,
342
+ limit: `denied: ${denied.join(', ')}`, observed: type,
343
+ };
344
+ }
345
+ // An empty allowlist is an allowlist that nothing is on. Treating it as
346
+ // "unconfigured" would make `narrowPolicy` able to *widen* a policy whenever
347
+ // two allowlists it intersected had nothing in common.
348
+ if (allowed) {
349
+ const ok = allowed.includes(type);
350
+ return {
351
+ rule: 'ACTION_TYPE', outcome: ok ? 'pass' : 'fail',
352
+ statement: ok
353
+ ? `'${type}' is one of the action types this agent may perform.`
354
+ // `unknown` lands here whenever the integrator could not classify the
355
+ // action. Under an allowlist that is a denial, which is the right way
356
+ // round: an agent should not be able to do something by virtue of
357
+ // nobody being able to say what it is.
358
+ : `'${type}' is not among the action types this agent may perform.`,
359
+ limit: allowed.join(', '), observed: type,
360
+ };
361
+ }
362
+ return {
363
+ rule: 'ACTION_TYPE', outcome: 'not_configured',
364
+ statement: 'This policy does not restrict which action types the agent may perform.',
365
+ observed: type,
366
+ };
367
+ }
368
+
369
+ function evaluateAllowlist(
370
+ rule: 'CHAIN' | 'TOKEN', label: string, value: string | undefined,
371
+ allowed: readonly string[] | undefined,
372
+ ): RuleEvaluation {
373
+ if (!allowed) {
374
+ return {
375
+ rule, outcome: 'not_configured',
376
+ statement: `This policy does not restrict the ${label}.`,
377
+ ...(value === undefined ? {} : { observed: value }),
378
+ };
379
+ }
380
+ if (value === undefined) {
381
+ return {
382
+ rule, outcome: 'not_applicable',
383
+ statement: `The action carries no ${label}, so the ${label} allowlist does not apply.`,
384
+ limit: allowed.join(', '),
385
+ };
386
+ }
387
+ const ok = matchesList(value, allowed);
388
+ return {
389
+ rule, outcome: ok ? 'pass' : 'fail',
390
+ statement: ok
391
+ ? `The ${label} '${value}' is on the allowlist.`
392
+ : `The ${label} '${value}' is not on the allowlist.`,
393
+ limit: allowed.join(', '), observed: value,
394
+ };
395
+ }
396
+
397
+ /** Action types that address code rather than a plain recipient. */
398
+ const CODE_ADDRESSED: AgentActionType[] = ['contract_call', 'approve', 'swap', 'bridge', 'stake', 'unstake'];
399
+
400
+ function evaluateCounterparty(action: AgentAction, policy: AgentPolicy): RuleEvaluation {
401
+ const to = action.to;
402
+ const blocked = policy.blockedCounterparties;
403
+ const allowed = policy.allowedCounterparties;
404
+
405
+ if (to && blocked && matchesList(to, blocked)) {
406
+ // Denial wins over the allowlist. A counterparty on both lists is an
407
+ // operator error, and resolving it towards permission is the wrong guess.
408
+ return {
409
+ rule: 'COUNTERPARTY', outcome: 'fail',
410
+ statement: `The counterparty ${to} is on this policy's blocklist.`,
411
+ limit: 'blocklist', observed: to,
412
+ };
413
+ }
414
+ if (!allowed) {
415
+ return {
416
+ rule: 'COUNTERPARTY',
417
+ outcome: blocked && blocked.length > 0 ? 'pass' : 'not_configured',
418
+ statement: blocked && blocked.length > 0
419
+ ? `The counterparty ${to ?? '(none)'} is not on this policy's blocklist.`
420
+ : 'This policy does not restrict counterparties.',
421
+ ...(to === undefined ? {} : { observed: to }),
422
+ };
423
+ }
424
+ if (!to) {
425
+ return {
426
+ rule: 'COUNTERPARTY', outcome: 'fail',
427
+ statement: 'This policy allowlists counterparties, and the action names none. '
428
+ + 'An action with no identifiable recipient cannot be checked against an allowlist.',
429
+ limit: `${allowed.length} allowlisted`,
430
+ };
431
+ }
432
+ const ok = matchesList(to, allowed);
433
+ return {
434
+ rule: 'COUNTERPARTY', outcome: ok ? 'pass' : 'fail',
435
+ statement: ok
436
+ ? `The counterparty ${to} is on this policy's allowlist.`
437
+ : `The counterparty ${to} is not on this policy's allowlist.`,
438
+ limit: `${allowed.length} allowlisted`, observed: to,
439
+ };
440
+ }
441
+
442
+ function evaluateContract(action: AgentAction, policy: AgentPolicy): RuleEvaluation {
443
+ const allowed = policy.allowedContracts;
444
+ if (!allowed) {
445
+ return {
446
+ rule: 'CONTRACT', outcome: 'not_configured',
447
+ statement: 'This policy does not restrict which contracts the agent may call.',
448
+ };
449
+ }
450
+ if (!CODE_ADDRESSED.includes(action.type)) {
451
+ return {
452
+ rule: 'CONTRACT', outcome: 'not_applicable',
453
+ statement: `A '${action.type}' action does not call a contract, so the contract allowlist does not apply.`,
454
+ limit: `${allowed.length} allowlisted`,
455
+ };
456
+ }
457
+ if (!action.to) {
458
+ return {
459
+ rule: 'CONTRACT', outcome: 'fail',
460
+ statement: `A '${action.type}' action must name the contract it calls for the allowlist to be applied.`,
461
+ limit: `${allowed.length} allowlisted`,
462
+ };
463
+ }
464
+ const ok = matchesList(action.to, allowed);
465
+ return {
466
+ rule: 'CONTRACT', outcome: ok ? 'pass' : 'fail',
467
+ statement: ok
468
+ ? `The contract ${action.to} is on this policy's allowlist.`
469
+ : `The contract ${action.to} is not on this policy's allowlist.`,
470
+ limit: `${allowed.length} allowlisted`, observed: action.to,
471
+ };
472
+ }
473
+
474
+ function evaluateAllowance(action: AgentAction, allowUnlimited: boolean): RuleEvaluation {
475
+ if (action.type !== 'approve') {
476
+ return {
477
+ rule: 'APPROVAL_ALLOWANCE', outcome: 'not_applicable',
478
+ statement: `A '${action.type}' action grants no allowance.`,
479
+ };
480
+ }
481
+ const amount = action.approvalAmount;
482
+ if (amount === undefined) {
483
+ return {
484
+ rule: 'APPROVAL_ALLOWANCE', outcome: 'fail',
485
+ statement: 'An approval was proposed without stating the allowance. The size of an allowance is '
486
+ + 'the whole of what is being granted, and an unstated one cannot be bounded.',
487
+ };
488
+ }
489
+ const unlimited = isUnlimitedAllowance(amount);
490
+ if (!unlimited) {
491
+ return {
492
+ rule: 'APPROVAL_ALLOWANCE', outcome: 'pass',
493
+ statement: `The approval grants a bounded allowance of ${amount} base units.`,
494
+ observed: amount,
495
+ };
496
+ }
497
+ return {
498
+ rule: 'APPROVAL_ALLOWANCE',
499
+ outcome: allowUnlimited ? 'pass' : 'fail',
500
+ statement: allowUnlimited
501
+ ? 'The approval is unlimited, which this policy explicitly permits.'
502
+ : 'The approval grants an unlimited allowance. This policy does not permit one — an unlimited '
503
+ + 'allowance turns any later compromise of the spender into an unbounded loss.',
504
+ limit: allowUnlimited ? 'unlimited permitted' : 'bounded allowances only',
505
+ observed: 'unlimited',
506
+ };
507
+ }
508
+
509
+ /** `unlimited`, or a number large enough that no real supply could exhaust it. */
510
+ export function isUnlimitedAllowance(amount: string): boolean {
511
+ const trimmed = amount.trim();
512
+ if (/^unlimited$/i.test(trimmed) || /^max$/i.test(trimmed) || /^infinite$/i.test(trimmed)) return true;
513
+ try {
514
+ const parsed = /^0x[0-9a-f]+$/i.test(trimmed) ? BigInt(trimmed) : BigInt(trimmed.replace(/^\+/, ''));
515
+ return parsed >= UNLIMITED_ALLOWANCE_THRESHOLD;
516
+ } catch {
517
+ // Not a number and not a keyword. Reported as bounded here; the value is
518
+ // still recorded on the decision, and an integrator passing prose into
519
+ // this field has a wiring problem this function should not paper over.
520
+ return false;
521
+ }
522
+ }
523
+
524
+ function evaluatePerActionValue(action: AgentAction, cap: number | undefined): RuleEvaluation {
525
+ if (cap === undefined) {
526
+ return {
527
+ rule: 'VALUE_PER_ACTION', outcome: 'not_configured',
528
+ statement: 'This policy sets no per-action value cap.',
529
+ ...(action.valueUsd === undefined ? {} : { observed: action.valueUsd }),
530
+ };
531
+ }
532
+ if (action.valueUsd === undefined) {
533
+ // The only defensible answer. A cap enforced against an unknown value is
534
+ // not a cap, and for an autonomous signer "we could not price it" must not
535
+ // resolve to "so we signed it". `valueUsd: 0` is available and explicit
536
+ // for actions that genuinely move nothing.
537
+ return {
538
+ rule: 'VALUE_PER_ACTION', outcome: 'fail',
539
+ statement: `This policy caps a single action at $${cap.toLocaleString('en-US')}, and the action's `
540
+ + 'value was not stated. An unpriced action cannot be shown to be under a cap. Pass `valueUsd` — '
541
+ + 'use 0 for an action that moves nothing.',
542
+ limit: cap, observed: 'unknown',
543
+ };
544
+ }
545
+ const ok = action.valueUsd <= cap;
546
+ return {
547
+ rule: 'VALUE_PER_ACTION', outcome: ok ? 'pass' : 'fail',
548
+ statement: ok
549
+ ? `$${action.valueUsd.toLocaleString('en-US')} is within the $${cap.toLocaleString('en-US')} per-action cap.`
550
+ : `$${action.valueUsd.toLocaleString('en-US')} exceeds the $${cap.toLocaleString('en-US')} per-action cap.`,
551
+ limit: cap, observed: action.valueUsd,
552
+ };
553
+ }
554
+
555
+ function evaluateCumulativeSpend(
556
+ action: AgentAction, windows: readonly SpendWindow[] | undefined,
557
+ usage: AgentUsage | undefined, nowMs: number,
558
+ ): RuleEvaluation {
559
+ if (!windows || windows.length === 0) {
560
+ return {
561
+ rule: 'CUMULATIVE_SPEND', outcome: 'not_configured',
562
+ statement: 'This policy sets no cumulative spend limit.',
563
+ };
564
+ }
565
+ if (action.valueUsd === undefined) {
566
+ return {
567
+ rule: 'CUMULATIVE_SPEND', outcome: 'fail',
568
+ statement: 'This policy limits cumulative spend, and the action\'s value was not stated. '
569
+ + 'An unpriced action cannot be added to a running total.',
570
+ limit: windows.map(describeSpendWindow).join('; '), observed: 'unknown',
571
+ };
572
+ }
573
+
574
+ const breached: string[] = [];
575
+ const lines: string[] = [];
576
+ for (const window of windows) {
577
+ const spent = usage?.spentUsdSince(action.agentId, nowMs - window.windowMs) ?? 0;
578
+ const projected = spent + action.valueUsd;
579
+ lines.push(
580
+ `${window.label}: $${round2(spent).toLocaleString('en-US')} committed, `
581
+ + `$${round2(projected).toLocaleString('en-US')} with this action, `
582
+ + `against $${window.maxValueUsd.toLocaleString('en-US')}`,
583
+ );
584
+ if (projected > window.maxValueUsd) breached.push(window.label);
585
+ }
586
+
587
+ return {
588
+ rule: 'CUMULATIVE_SPEND',
589
+ outcome: breached.length > 0 ? 'fail' : 'pass',
590
+ statement: breached.length > 0
591
+ ? `This action would take the agent past its ${breached.join(' and ')} spend limit. ${lines.join('; ')}.`
592
+ : `Within every cumulative spend limit. ${lines.join('; ')}.`,
593
+ limit: windows.map(describeSpendWindow).join('; '),
594
+ observed: action.valueUsd,
595
+ };
596
+ }
597
+
598
+ function evaluateRate(
599
+ action: AgentAction, windows: readonly RateWindow[] | undefined,
600
+ usage: AgentUsage | undefined, nowMs: number,
601
+ ): RuleEvaluation {
602
+ if (!windows || windows.length === 0) {
603
+ return {
604
+ rule: 'RATE_LIMIT', outcome: 'not_configured',
605
+ statement: 'This policy sets no rate ceiling. An agent in a loop is bounded only by its value limits.',
606
+ };
607
+ }
608
+
609
+ const breached: string[] = [];
610
+ const lines: string[] = [];
611
+ for (const window of windows) {
612
+ const admitted = usage?.actionsSince(action.agentId, nowMs - window.windowMs) ?? 0;
613
+ lines.push(`${window.label}: ${admitted} admitted, ${admitted + 1} with this action, against ${window.maxActions}`);
614
+ if (admitted + 1 > window.maxActions) breached.push(window.label);
615
+ }
616
+
617
+ return {
618
+ rule: 'RATE_LIMIT',
619
+ outcome: breached.length > 0 ? 'fail' : 'pass',
620
+ statement: breached.length > 0
621
+ ? `This action would exceed the agent's ${breached.join(' and ')} rate ceiling. ${lines.join('; ')}.`
622
+ : `Within every rate ceiling. ${lines.join('; ')}.`,
623
+ limit: windows.map((w) => `${w.maxActions} per ${describeDuration(w.windowMs)} (${w.label})`).join('; '),
624
+ };
625
+ }
626
+
627
+ function evaluateAvailability(
628
+ assessment: Assessment | null | undefined, unavailable: string | undefined, failMode: AgentFailMode,
629
+ ): RuleEvaluation {
630
+ if (assessment) {
631
+ return {
632
+ rule: 'ASSESSMENT_AVAILABILITY', outcome: 'pass',
633
+ statement: 'A Decentrys assessment was obtained for this action.',
634
+ observed: assessment.riskLevel,
635
+ };
636
+ }
637
+ const why = unavailable ?? 'no assessment was supplied';
638
+ const outcome: RuleOutcomeForFailMode = failMode === 'closed'
639
+ ? 'fail' : failMode === 'escalate' ? 'escalate' : 'pass';
640
+ return {
641
+ rule: 'ASSESSMENT_AVAILABILITY', outcome,
642
+ statement: failMode === 'closed'
643
+ ? `No risk assessment could be obtained (${why}), and this policy is configured to stop rather than `
644
+ + 'act on unscreened actions.'
645
+ : failMode === 'escalate'
646
+ ? `No risk assessment could be obtained (${why}). The operator's own limits still applied and still `
647
+ + 'bind; only the external check is missing, so this action needs a person.'
648
+ : `No risk assessment could be obtained (${why}). This policy is configured to proceed on the `
649
+ + 'operator\'s own limits alone.',
650
+ limit: `failMode: ${failMode}`, observed: 'unavailable',
651
+ };
652
+ }
653
+
654
+ type RuleOutcomeForFailMode = 'pass' | 'fail' | 'escalate';
655
+
656
+ function evaluateRisk(
657
+ assessment: Assessment | null | undefined, risk: Policy, maxRiskLevel: RiskLevel | undefined,
658
+ ): RuleEvaluation {
659
+ if (!assessment) {
660
+ return {
661
+ rule: 'RISK_LEVEL', outcome: 'not_applicable',
662
+ statement: 'No assessment was available, so no risk level was applied. See ASSESSMENT_AVAILABILITY.',
663
+ };
664
+ }
665
+
666
+ const level = assessment.riskLevel;
667
+ if (maxRiskLevel && riskRank(level) > riskRank(maxRiskLevel)) {
668
+ return {
669
+ rule: 'RISK_LEVEL', outcome: 'fail',
670
+ statement: `The subject classified as ${level}, above this policy's ceiling of ${maxRiskLevel}. `
671
+ + (assessment.explanation[0] ?? ''),
672
+ limit: maxRiskLevel, observed: level,
673
+ };
674
+ }
675
+
676
+ // Delegated to Protect. The risk model is not reimplemented here — this
677
+ // package decides what an *agent* does with a level, never what the level is.
678
+ const decision = applyPolicy(assessment, risk);
679
+ const outcome: RuleOutcomeForFailMode = decision.action === 'block'
680
+ ? 'fail' : decision.action === 'require_confirmation' ? 'escalate' : 'pass';
681
+
682
+ const statement = decision.action === 'block'
683
+ ? `The subject classified as ${level} and this policy denies at that level. ${decision.reason}`
684
+ : decision.action === 'require_confirmation'
685
+ ? `The subject classified as ${level}, which this policy routes to a person. ${decision.reason}`
686
+ : `The subject classified as ${level}, which this policy permits. ${decision.reason}`;
687
+
688
+ return { rule: 'RISK_LEVEL', outcome, statement, limit: `${level} → ${decision.action}`, observed: level };
689
+ }
690
+
691
+ function evaluateHumanApproval(
692
+ action: AgentAction, policy: AgentPolicy, assessment: Assessment | null | undefined,
693
+ ): RuleEvaluation {
694
+ const valueThreshold = policy.humanApprovalAboveUsd;
695
+ const riskThreshold = policy.humanApprovalAtRiskLevel;
696
+
697
+ if (valueThreshold === undefined && riskThreshold === undefined) {
698
+ return {
699
+ rule: 'HUMAN_APPROVAL_THRESHOLD', outcome: 'not_configured',
700
+ statement: 'This policy sets no threshold above which a person must approve.',
701
+ };
702
+ }
703
+
704
+ const triggers: string[] = [];
705
+ if (valueThreshold !== undefined) {
706
+ if (action.valueUsd === undefined) {
707
+ triggers.push(
708
+ `a person approves anything above $${valueThreshold.toLocaleString('en-US')} and this action's `
709
+ + 'value was not stated, so it cannot be shown to be below that',
710
+ );
711
+ } else if (action.valueUsd > valueThreshold) {
712
+ triggers.push(
713
+ `$${action.valueUsd.toLocaleString('en-US')} is above the $${valueThreshold.toLocaleString('en-US')} `
714
+ + 'threshold for human approval',
715
+ );
716
+ }
717
+ }
718
+ if (riskThreshold && assessment && riskRank(assessment.riskLevel) >= riskRank(riskThreshold)) {
719
+ triggers.push(`the subject classified as ${assessment.riskLevel}, at or above the ${riskThreshold} threshold`);
720
+ }
721
+
722
+ if (triggers.length === 0) {
723
+ return {
724
+ rule: 'HUMAN_APPROVAL_THRESHOLD', outcome: 'pass',
725
+ statement: 'Below every threshold that would require a person to approve.',
726
+ ...(valueThreshold === undefined ? {} : { limit: valueThreshold }),
727
+ ...(action.valueUsd === undefined ? {} : { observed: action.valueUsd }),
728
+ };
729
+ }
730
+
731
+ return {
732
+ rule: 'HUMAN_APPROVAL_THRESHOLD', outcome: 'escalate',
733
+ statement: `A person must approve this action: ${triggers.join('; ')}.`,
734
+ ...(valueThreshold === undefined ? {} : { limit: valueThreshold }),
735
+ ...(action.valueUsd === undefined ? {} : { observed: action.valueUsd }),
736
+ };
737
+ }
738
+
739
+ // ---------------------------------------------------------------------------
740
+ // Helpers
741
+ // ---------------------------------------------------------------------------
742
+
743
+ function riskRank(level: RiskLevel): number {
744
+ return RISK_LEVELS.indexOf(level);
745
+ }
746
+
747
+ /**
748
+ * Addresses are compared case-insensitively.
749
+ *
750
+ * EVM addresses arrive in mixed checksum case and must still match an
751
+ * allowlist an operator wrote in lowercase. For a case-sensitive family such
752
+ * as Solana's base58, passing a case-variant of an allowlisted address would
753
+ * require holding a private key for that variant, which is not something an
754
+ * attacker can search for. The looser comparison therefore costs nothing and
755
+ * removes a class of allowlist that silently fails to match.
756
+ */
757
+ function matchesList(value: string, list: readonly string[]): boolean {
758
+ const needle = value.trim().toLowerCase();
759
+ return list.some((entry) => entry.trim().toLowerCase() === needle);
760
+ }
761
+
762
+ function describeSpendWindow(window: SpendWindow): string {
763
+ return `$${window.maxValueUsd.toLocaleString('en-US')} per ${describeDuration(window.windowMs)} (${window.label})`;
764
+ }
765
+
766
+ export function describeDuration(ms: number): string {
767
+ if (ms % 86_400_000 === 0) return plural(ms / 86_400_000, 'day');
768
+ if (ms % 3_600_000 === 0) return plural(ms / 3_600_000, 'hour');
769
+ if (ms % 60_000 === 0) return plural(ms / 60_000, 'minute');
770
+ if (ms % 1_000 === 0) return plural(ms / 1_000, 'second');
771
+ return `${ms}ms`;
772
+ }
773
+
774
+ function plural(count: number, unit: string): string {
775
+ return count === 1 ? unit : `${count} ${unit}s`;
776
+ }
777
+
778
+ function round2(value: number): number {
779
+ return Math.round(value * 100) / 100;
780
+ }
781
+
782
+ function hasAnyLimit(policy: AgentPolicy): boolean {
783
+ return policy.maxValuePerActionUsd !== undefined
784
+ || (policy.spendWindows?.length ?? 0) > 0
785
+ || (policy.rateWindows?.length ?? 0) > 0
786
+ || policy.allowedCounterparties !== undefined
787
+ || (policy.blockedCounterparties?.length ?? 0) > 0
788
+ || policy.allowedContracts !== undefined
789
+ || policy.allowedTokens !== undefined
790
+ || policy.allowedActions !== undefined
791
+ || (policy.deniedActions?.length ?? 0) > 0
792
+ || policy.allowedChains !== undefined
793
+ || policy.humanApprovalAboveUsd !== undefined
794
+ || policy.humanApprovalAtRiskLevel !== undefined;
795
+ }
796
+
797
+ /**
798
+ * Clone every array and object out of the caller's hands.
799
+ *
800
+ * Freezing the caller's own arrays would be both rude and insufficient: an
801
+ * operator who keeps a reference to the array they passed could otherwise
802
+ * push a new counterparty onto a live allowlist, and nothing in the audit
803
+ * record would show the policy had changed.
804
+ */
805
+ function clonePolicy(policy: AgentPolicy): AgentPolicy {
806
+ const cloned: AgentPolicy = {};
807
+ if (policy.version !== undefined) cloned.version = policy.version;
808
+ if (policy.agentId !== undefined) cloned.agentId = policy.agentId;
809
+ if (policy.risk !== undefined) cloned.risk = { ...policy.risk };
810
+ if (policy.maxRiskLevel !== undefined) cloned.maxRiskLevel = policy.maxRiskLevel;
811
+ if (policy.maxValuePerActionUsd !== undefined) cloned.maxValuePerActionUsd = policy.maxValuePerActionUsd;
812
+ if (policy.spendWindows !== undefined) cloned.spendWindows = policy.spendWindows.map((w) => ({ ...w }));
813
+ if (policy.rateWindows !== undefined) cloned.rateWindows = policy.rateWindows.map((w) => ({ ...w }));
814
+ if (policy.allowedCounterparties !== undefined) cloned.allowedCounterparties = [...policy.allowedCounterparties];
815
+ if (policy.blockedCounterparties !== undefined) cloned.blockedCounterparties = [...policy.blockedCounterparties];
816
+ if (policy.allowedContracts !== undefined) cloned.allowedContracts = [...policy.allowedContracts];
817
+ if (policy.allowedTokens !== undefined) cloned.allowedTokens = [...policy.allowedTokens];
818
+ if (policy.allowedActions !== undefined) cloned.allowedActions = [...policy.allowedActions];
819
+ if (policy.deniedActions !== undefined) cloned.deniedActions = [...policy.deniedActions];
820
+ if (policy.allowedChains !== undefined) cloned.allowedChains = [...policy.allowedChains];
821
+ if (policy.allowUnlimitedApprovals !== undefined) cloned.allowUnlimitedApprovals = policy.allowUnlimitedApprovals;
822
+ if (policy.humanApprovalAboveUsd !== undefined) cloned.humanApprovalAboveUsd = policy.humanApprovalAboveUsd;
823
+ if (policy.humanApprovalAtRiskLevel !== undefined) cloned.humanApprovalAtRiskLevel = policy.humanApprovalAtRiskLevel;
824
+ if (policy.failMode !== undefined) cloned.failMode = policy.failMode;
825
+ return cloned;
826
+ }
827
+
828
+ function deepFreeze<T>(value: T): T {
829
+ if (value === null || typeof value !== 'object') return value;
830
+ for (const key of Object.getOwnPropertyNames(value)) {
831
+ deepFreeze((value as Record<string, unknown>)[key]);
832
+ }
833
+ return Object.freeze(value);
834
+ }
835
+
836
+ function minDefined(a: number | undefined, b: number | undefined): number | undefined {
837
+ if (a === undefined) return b;
838
+ if (b === undefined) return a;
839
+ return Math.min(a, b);
840
+ }
841
+
842
+ function lowerRiskLevel(a: RiskLevel | undefined, b: RiskLevel | undefined): RiskLevel | undefined {
843
+ if (a === undefined) return b;
844
+ if (b === undefined) return a;
845
+ return riskRank(a) <= riskRank(b) ? a : b;
846
+ }
847
+
848
+ function stricterFailMode(a: AgentFailMode | undefined, b: AgentFailMode | undefined): AgentFailMode | undefined {
849
+ if (a === undefined) return b;
850
+ if (b === undefined) return a;
851
+ return FAIL_MODE_STRICTNESS[a] >= FAIL_MODE_STRICTNESS[b] ? a : b;
852
+ }
853
+
854
+ function strictestRisk(a: Policy | undefined, b: Policy | undefined): Policy | undefined {
855
+ if (!a) return b;
856
+ if (!b) return a;
857
+ const merged: Policy = { ...a };
858
+ for (const level of RISK_LEVELS) {
859
+ const left = a[level];
860
+ const right = b[level];
861
+ if (left === undefined) {
862
+ if (right !== undefined) merged[level] = right;
863
+ continue;
864
+ }
865
+ if (right === undefined) continue;
866
+ merged[level] = POLICY_ACTION_STRICTNESS[left] >= POLICY_ACTION_STRICTNESS[right] ? left : right;
867
+ }
868
+ return merged;
869
+ }
870
+
871
+ function intersectLists<T extends string>(a: readonly T[] | undefined, b: readonly T[] | undefined): T[] | undefined {
872
+ if (!a) return b ? [...b] : undefined;
873
+ if (!b) return [...a];
874
+ const right = new Set(b.map((v) => v.trim().toLowerCase()));
875
+ return a.filter((v) => right.has(v.trim().toLowerCase()));
876
+ }
877
+
878
+ function unionLists<T extends string>(a: readonly T[] | undefined, b: readonly T[] | undefined): T[] | undefined {
879
+ if (!a && !b) return undefined;
880
+ const seen = new Set<string>();
881
+ const out: T[] = [];
882
+ for (const v of [...(a ?? []), ...(b ?? [])]) {
883
+ const key = v.trim().toLowerCase();
884
+ if (seen.has(key)) continue;
885
+ seen.add(key);
886
+ out.push(v);
887
+ }
888
+ return out;
889
+ }
890
+
891
+ function concatWindows<T extends { label: string }>(
892
+ a: readonly T[] | undefined, b: readonly T[] | undefined,
893
+ ): T[] | undefined {
894
+ if (!a && !b) return undefined;
895
+ // Every window applies, so more windows is strictly tighter. Labels are
896
+ // disambiguated rather than deduplicated: two windows sharing a label are
897
+ // two different limits, and collapsing them would drop one.
898
+ const out = [...(a ?? [])];
899
+ for (const window of b ?? []) {
900
+ out.push(out.some((existing) => existing.label === window.label)
901
+ ? { ...window, label: `${window.label} (narrowed)` }
902
+ : window);
903
+ }
904
+ return out;
905
+ }
906
+
907
+ /** A stable object shape for hashing, with keys in a fixed order. */
908
+ function canonicalAction(action: AgentAction): Record<string, unknown> {
909
+ return {
910
+ agentId: action.agentId, chain: action.chain, type: action.type, from: action.from,
911
+ to: action.to ?? null, token: action.token ?? null, value: action.value ?? null,
912
+ valueUsd: action.valueUsd ?? null, data: action.data ?? null, raw: action.raw ?? null,
913
+ origin: action.origin ?? null, approvalAmount: action.approvalAmount ?? null,
914
+ };
915
+ }
916
+
917
+ /**
918
+ * FNV-1a over canonical JSON.
919
+ *
920
+ * A change detector, not a commitment. It answers "were these two decisions
921
+ * made under the same rules" without a dependency and without a claim it
922
+ * cannot support; anyone needing tamper evidence should sign the record.
923
+ */
924
+ export function fingerprint(value: unknown): string {
925
+ const json = canonicalJson(value);
926
+ let hash = 0x811c9dc5;
927
+ for (let i = 0; i < json.length; i += 1) {
928
+ hash ^= json.charCodeAt(i);
929
+ hash = Math.imul(hash, 0x01000193) >>> 0;
930
+ }
931
+ return hash.toString(16).padStart(8, '0');
932
+ }
933
+
934
+ function canonicalJson(value: unknown): string {
935
+ if (value === null || typeof value !== 'object') return JSON.stringify(value) ?? 'null';
936
+ if (Array.isArray(value)) return `[${value.map(canonicalJson).join(',')}]`;
937
+ const entries = Object.entries(value as Record<string, unknown>)
938
+ .filter(([, v]) => v !== undefined)
939
+ .sort(([a], [b]) => (a < b ? -1 : a > b ? 1 : 0));
940
+ return `{${entries.map(([k, v]) => `${JSON.stringify(k)}:${canonicalJson(v)}`).join(',')}}`;
941
+ }
942
+
943
+ let decisionCounter = 0;
944
+
945
+ /** `crypto.randomUUID` where it exists; a monotonic fallback everywhere else. */
946
+ export function newDecisionId(): string {
947
+ const webCrypto = (globalThis as { crypto?: { randomUUID?: () => string } }).crypto;
948
+ if (typeof webCrypto?.randomUUID === 'function') return `ad_${webCrypto.randomUUID()}`;
949
+ decisionCounter += 1;
950
+ return `ad_${Date.now().toString(36)}_${decisionCounter.toString(36)}`;
951
+ }