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