@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/LICENSE +21 -0
- package/README.md +62 -0
- package/dist/browser/decentrys-agent.js +2051 -0
- package/dist/browser/decentrys-agent.mjs +2026 -0
- package/dist/client.d.ts +194 -0
- package/dist/client.d.ts.map +1 -0
- package/dist/client.js +303 -0
- package/dist/client.js.map +1 -0
- package/dist/explain.d.ts +63 -0
- package/dist/explain.d.ts.map +1 -0
- package/dist/explain.js +144 -0
- package/dist/explain.js.map +1 -0
- package/dist/index.d.ts +6 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +22 -0
- package/dist/index.js.map +1 -0
- package/dist/ledger.d.ts +166 -0
- package/dist/ledger.d.ts.map +1 -0
- package/dist/ledger.js +218 -0
- package/dist/ledger.js.map +1 -0
- package/dist/model.d.ts +256 -0
- package/dist/model.d.ts.map +1 -0
- package/dist/model.js +74 -0
- package/dist/model.js.map +1 -0
- package/dist/policy.d.ts +137 -0
- package/dist/policy.d.ts.map +1 -0
- package/dist/policy.js +845 -0
- package/dist/policy.js.map +1 -0
- package/package.json +64 -0
- package/src/client.test.ts +351 -0
- package/src/client.ts +392 -0
- package/src/explain.test.ts +148 -0
- package/src/explain.ts +204 -0
- package/src/index.ts +5 -0
- package/src/ledger.test.ts +192 -0
- package/src/ledger.ts +303 -0
- package/src/model.ts +324 -0
- package/src/policy.test.ts +762 -0
- package/src/policy.ts +951 -0
|
@@ -0,0 +1,762 @@
|
|
|
1
|
+
import { describe, expect, it } from 'vitest';
|
|
2
|
+
import { classify, type Assessment, type Evidence, type ThreatSignal } from '@decentrys/protect';
|
|
3
|
+
|
|
4
|
+
import { DEFAULT_AGENT_RISK_POLICY, evaluatePolicy, narrowPolicy, sealPolicy } from './policy';
|
|
5
|
+
import { SpendLedger } from './ledger';
|
|
6
|
+
import type { AgentAction } from './model';
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* What AgentGuard is for.
|
|
10
|
+
*
|
|
11
|
+
* Protect informs a person who then decides; these tests exist because there
|
|
12
|
+
* is no person here. Every case below is a way an autonomous actor gets a
|
|
13
|
+
* treasury emptied — talking its limits upward, looping faster than a cap can
|
|
14
|
+
* notice, signing to a known drainer because the amount looked small, or
|
|
15
|
+
* proceeding through an outage because nothing said stop. None of them are
|
|
16
|
+
* hypothetical failure modes for a human; all of them are routine for a
|
|
17
|
+
* process in a `while` loop.
|
|
18
|
+
*/
|
|
19
|
+
|
|
20
|
+
const NOW = new Date('2026-09-06T12:00:00Z');
|
|
21
|
+
const AGENT = 'agent-treasury-1';
|
|
22
|
+
|
|
23
|
+
function action(overrides: Partial<AgentAction> = {}): AgentAction {
|
|
24
|
+
return {
|
|
25
|
+
agentId: AGENT,
|
|
26
|
+
chain: 'ethereum',
|
|
27
|
+
type: 'transfer',
|
|
28
|
+
from: '0x1111111111111111111111111111111111111111',
|
|
29
|
+
to: '0x2222222222222222222222222222222222222222',
|
|
30
|
+
valueUsd: 100,
|
|
31
|
+
...overrides,
|
|
32
|
+
};
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
function evidence(overrides: Partial<Evidence> = {}): Evidence {
|
|
36
|
+
return {
|
|
37
|
+
id: 'ev_1',
|
|
38
|
+
type: 'BYTECODE_MATCH',
|
|
39
|
+
source: 'Decentrys',
|
|
40
|
+
observedAt: NOW.toISOString(),
|
|
41
|
+
confidence: 0.99,
|
|
42
|
+
analystVerified: false,
|
|
43
|
+
...overrides,
|
|
44
|
+
};
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
function signal(overrides: Partial<ThreatSignal> = {}): ThreatSignal {
|
|
48
|
+
return {
|
|
49
|
+
type: 'KNOWN_DRAINER_BYTECODE',
|
|
50
|
+
severity: 'CRITICAL',
|
|
51
|
+
confidence: 0.99,
|
|
52
|
+
explanation: 'Bytecode matches a confirmed drainer family.',
|
|
53
|
+
hops: 0,
|
|
54
|
+
evidence: [evidence()],
|
|
55
|
+
status: 'ACTIVE',
|
|
56
|
+
createdAt: NOW.toISOString(),
|
|
57
|
+
lastSeen: NOW.toISOString(),
|
|
58
|
+
...overrides,
|
|
59
|
+
};
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/** Classified by Protect's own model, not asserted here. */
|
|
63
|
+
const CLEAN: Assessment = classify({ now: NOW });
|
|
64
|
+
const KNOWN_MALICIOUS: Assessment = classify({
|
|
65
|
+
now: NOW,
|
|
66
|
+
threatSignals: [signal({ evidence: [evidence({ analystVerified: true })] })],
|
|
67
|
+
});
|
|
68
|
+
const ELEVATED: Assessment = classify({
|
|
69
|
+
now: NOW,
|
|
70
|
+
threatSignals: [signal({
|
|
71
|
+
type: 'STOLEN_FUNDS_DIRECT', severity: 'HIGH', confidence: 0.9,
|
|
72
|
+
explanation: 'Received assets directly from a wallet attributed to incident INC-2026-0012.',
|
|
73
|
+
})],
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
// ---------------------------------------------------------------------------
|
|
77
|
+
// The operator is not the agent
|
|
78
|
+
// ---------------------------------------------------------------------------
|
|
79
|
+
|
|
80
|
+
describe('an agent cannot exceed the policy it was given', () => {
|
|
81
|
+
/**
|
|
82
|
+
* The central claim of the package. An agent handed a reference to its own
|
|
83
|
+
* policy — which any in-process tool wrapper effectively is — must not be
|
|
84
|
+
* able to edit the number that bounds it. If this can be done at all, every
|
|
85
|
+
* other control in this file is decoration.
|
|
86
|
+
*/
|
|
87
|
+
it('cannot mutate a sealed policy', () => {
|
|
88
|
+
const sealed = sealPolicy({ maxValuePerActionUsd: 1_000 });
|
|
89
|
+
|
|
90
|
+
expect(() => {
|
|
91
|
+
(sealed.policy as { maxValuePerActionUsd?: number }).maxValuePerActionUsd = 10_000_000;
|
|
92
|
+
}).toThrow(TypeError);
|
|
93
|
+
expect(sealed.policy.maxValuePerActionUsd).toBe(1_000);
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
it('cannot push a counterparty onto a sealed allowlist', () => {
|
|
97
|
+
const sealed = sealPolicy({ allowedCounterparties: ['0xaaa'] });
|
|
98
|
+
expect(() => (sealed.policy.allowedCounterparties as string[]).push('0xbbb')).toThrow(TypeError);
|
|
99
|
+
expect(sealed.policy.allowedCounterparties).toEqual(['0xaaa']);
|
|
100
|
+
});
|
|
101
|
+
|
|
102
|
+
/**
|
|
103
|
+
* Freezing what we were handed is not enough. An operator — or an agent
|
|
104
|
+
* that talked one into it — who keeps a reference to the array they passed
|
|
105
|
+
* could otherwise grow a live allowlist with nothing in the record showing
|
|
106
|
+
* the policy had changed.
|
|
107
|
+
*/
|
|
108
|
+
it('takes a copy, so the caller\'s own array is not the live allowlist', () => {
|
|
109
|
+
const counterparties = ['0xaaa'];
|
|
110
|
+
const sealed = sealPolicy({ allowedCounterparties: counterparties });
|
|
111
|
+
|
|
112
|
+
counterparties.push('0xbbb');
|
|
113
|
+
|
|
114
|
+
expect(sealed.policy.allowedCounterparties).toEqual(['0xaaa']);
|
|
115
|
+
const decision = evaluatePolicy({
|
|
116
|
+
action: action({ to: '0xbbb' }), policy: sealed, assessment: CLEAN, now: NOW,
|
|
117
|
+
});
|
|
118
|
+
expect(decision.verdict).toBe('deny');
|
|
119
|
+
});
|
|
120
|
+
|
|
121
|
+
/** A per-call restriction is the only derivation there is, and it tightens. */
|
|
122
|
+
it('narrows a value cap and never raises one', () => {
|
|
123
|
+
const base = sealPolicy({ maxValuePerActionUsd: 1_000 });
|
|
124
|
+
|
|
125
|
+
expect(narrowPolicy(base, { maxValuePerActionUsd: 10_000 }).policy.maxValuePerActionUsd).toBe(1_000);
|
|
126
|
+
expect(narrowPolicy(base, { maxValuePerActionUsd: 100 }).policy.maxValuePerActionUsd).toBe(100);
|
|
127
|
+
});
|
|
128
|
+
|
|
129
|
+
it('intersects allowlists and unions blocklists', () => {
|
|
130
|
+
const base = sealPolicy({
|
|
131
|
+
allowedCounterparties: ['0xaaa', '0xbbb'],
|
|
132
|
+
blockedCounterparties: ['0xdead'],
|
|
133
|
+
});
|
|
134
|
+
const narrowed = narrowPolicy(base, {
|
|
135
|
+
allowedCounterparties: ['0xbbb', '0xccc'],
|
|
136
|
+
blockedCounterparties: ['0xbeef'],
|
|
137
|
+
});
|
|
138
|
+
|
|
139
|
+
// '0xccc' was not on the operator's list, so adding it to a restriction
|
|
140
|
+
// does not put it on the agent's.
|
|
141
|
+
expect(narrowed.policy.allowedCounterparties).toEqual(['0xbbb']);
|
|
142
|
+
expect(narrowed.policy.blockedCounterparties).toEqual(['0xdead', '0xbeef']);
|
|
143
|
+
});
|
|
144
|
+
|
|
145
|
+
/**
|
|
146
|
+
* Two allowlists with nothing in common intersect to the empty list, and an
|
|
147
|
+
* empty allowlist must mean "nothing is permitted". Reading it as "no
|
|
148
|
+
* allowlist configured" would turn the strictest possible narrowing into a
|
|
149
|
+
* complete removal of the control.
|
|
150
|
+
*/
|
|
151
|
+
it('treats an allowlist narrowed to nothing as permitting nothing', () => {
|
|
152
|
+
const base = sealPolicy({ allowedCounterparties: ['0xaaa'] });
|
|
153
|
+
const narrowed = narrowPolicy(base, { allowedCounterparties: ['0xbbb'] });
|
|
154
|
+
|
|
155
|
+
expect(narrowed.policy.allowedCounterparties).toEqual([]);
|
|
156
|
+
expect(evaluatePolicy({
|
|
157
|
+
action: action({ to: '0xaaa' }), policy: narrowed, assessment: CLEAN, now: NOW,
|
|
158
|
+
}).verdict).toBe('deny');
|
|
159
|
+
});
|
|
160
|
+
|
|
161
|
+
it('cannot loosen an unlimited-approval refusal', () => {
|
|
162
|
+
const base = sealPolicy({ allowUnlimitedApprovals: false });
|
|
163
|
+
expect(narrowPolicy(base, { allowUnlimitedApprovals: true }).policy.allowUnlimitedApprovals).toBe(false);
|
|
164
|
+
});
|
|
165
|
+
|
|
166
|
+
it('cannot loosen failMode or a risk ceiling', () => {
|
|
167
|
+
const base = sealPolicy({ failMode: 'closed', maxRiskLevel: 'CAUTION' });
|
|
168
|
+
const narrowed = narrowPolicy(base, { failMode: 'open', maxRiskLevel: 'HIGH_RISK' });
|
|
169
|
+
|
|
170
|
+
expect(narrowed.policy.failMode).toBe('closed');
|
|
171
|
+
expect(narrowed.policy.maxRiskLevel).toBe('CAUTION');
|
|
172
|
+
});
|
|
173
|
+
|
|
174
|
+
it('takes the stricter action at each risk level', () => {
|
|
175
|
+
const base = sealPolicy({ risk: { ELEVATED_RISK: 'block' } });
|
|
176
|
+
expect(narrowPolicy(base, { risk: { ELEVATED_RISK: 'allow' } }).policy.risk?.ELEVATED_RISK).toBe('block');
|
|
177
|
+
});
|
|
178
|
+
|
|
179
|
+
/**
|
|
180
|
+
* A policy bound to one agent cannot be re-pointed at another. Silently
|
|
181
|
+
* resolving the contradiction either way would hide whose limits are in
|
|
182
|
+
* force, which is the one thing an incident review needs to establish.
|
|
183
|
+
*/
|
|
184
|
+
it('refuses to re-bind a policy to a different agent', () => {
|
|
185
|
+
const base = sealPolicy({ agentId: AGENT });
|
|
186
|
+
expect(() => narrowPolicy(base, { agentId: 'agent-other' })).toThrow(/different agent|cannot narrow/i);
|
|
187
|
+
});
|
|
188
|
+
|
|
189
|
+
it('denies an action proposed by an agent this policy is not bound to', () => {
|
|
190
|
+
const sealed = sealPolicy({ agentId: AGENT });
|
|
191
|
+
const decision = evaluatePolicy({
|
|
192
|
+
action: action({ agentId: 'agent-impostor' }), policy: sealed, assessment: CLEAN, now: NOW,
|
|
193
|
+
});
|
|
194
|
+
|
|
195
|
+
expect(decision.verdict).toBe('deny');
|
|
196
|
+
expect(decision.evaluations.find((e) => e.rule === 'AGENT_BINDING')?.outcome).toBe('fail');
|
|
197
|
+
});
|
|
198
|
+
|
|
199
|
+
/**
|
|
200
|
+
* A decision has to name the rules it ran under. Two decisions with the same
|
|
201
|
+
* fingerprint were made under the same policy; two with different ones were
|
|
202
|
+
* not, whatever anyone remembers afterwards.
|
|
203
|
+
*/
|
|
204
|
+
it('fingerprints the policy, and the fingerprint changes when the policy does', () => {
|
|
205
|
+
const a = sealPolicy({ maxValuePerActionUsd: 1_000 });
|
|
206
|
+
const b = sealPolicy({ maxValuePerActionUsd: 1_001 });
|
|
207
|
+
const same = sealPolicy({ maxValuePerActionUsd: 1_000 });
|
|
208
|
+
|
|
209
|
+
expect(a.fingerprint).toBe(same.fingerprint);
|
|
210
|
+
expect(a.fingerprint).not.toBe(b.fingerprint);
|
|
211
|
+
expect(evaluatePolicy({ action: action(), policy: a, assessment: CLEAN, now: NOW }).policyFingerprint)
|
|
212
|
+
.toBe(a.fingerprint);
|
|
213
|
+
});
|
|
214
|
+
|
|
215
|
+
/**
|
|
216
|
+
* `warn` is Protect's most useful action and is meaningless here. Silently
|
|
217
|
+
* upgrading it to an escalation would change the operator's meaning without
|
|
218
|
+
* telling them; silently treating it as an allow would ship a control that
|
|
219
|
+
* does nothing.
|
|
220
|
+
*/
|
|
221
|
+
it('refuses a warn action in an agent risk policy rather than reinterpreting it', () => {
|
|
222
|
+
expect(() => sealPolicy({ risk: { CAUTION: 'warn' } })).toThrow(/nobody to warn/i);
|
|
223
|
+
});
|
|
224
|
+
|
|
225
|
+
it('says so when a policy permits confirmed-malicious counterparties', () => {
|
|
226
|
+
const permissive = sealPolicy({ risk: { KNOWN_MALICIOUS: 'allow' } });
|
|
227
|
+
expect(permissive.warnings.join(' ')).toMatch(/KNOWN_MALICIOUS/);
|
|
228
|
+
expect(sealPolicy({ maxValuePerActionUsd: 10 }).warnings.join(' ')).not.toMatch(/KNOWN_MALICIOUS/);
|
|
229
|
+
});
|
|
230
|
+
});
|
|
231
|
+
|
|
232
|
+
// ---------------------------------------------------------------------------
|
|
233
|
+
// Value
|
|
234
|
+
// ---------------------------------------------------------------------------
|
|
235
|
+
|
|
236
|
+
describe('per-action value cap', () => {
|
|
237
|
+
it('denies an action above the cap and permits one at it', () => {
|
|
238
|
+
const sealed = sealPolicy({ maxValuePerActionUsd: 1_000 });
|
|
239
|
+
|
|
240
|
+
expect(evaluatePolicy({
|
|
241
|
+
action: action({ valueUsd: 1_000.01 }), policy: sealed, assessment: CLEAN, now: NOW,
|
|
242
|
+
}).verdict).toBe('deny');
|
|
243
|
+
expect(evaluatePolicy({
|
|
244
|
+
action: action({ valueUsd: 1_000 }), policy: sealed, assessment: CLEAN, now: NOW,
|
|
245
|
+
}).verdict).toBe('allow');
|
|
246
|
+
});
|
|
247
|
+
|
|
248
|
+
/**
|
|
249
|
+
* A cap enforced against an unknown is not a cap. For a person, "we could
|
|
250
|
+
* not price this" is information they weigh; for an agent it must not
|
|
251
|
+
* resolve to "so we signed it", because that is the shape every unpriced
|
|
252
|
+
* exotic asset arrives in.
|
|
253
|
+
*/
|
|
254
|
+
it('denies an action whose value was never stated', () => {
|
|
255
|
+
const sealed = sealPolicy({ maxValuePerActionUsd: 1_000 });
|
|
256
|
+
const decision = evaluatePolicy({
|
|
257
|
+
action: { agentId: AGENT, chain: 'ethereum', type: 'transfer', from: '0x11', to: '0x22' },
|
|
258
|
+
policy: sealed, assessment: CLEAN, now: NOW,
|
|
259
|
+
});
|
|
260
|
+
|
|
261
|
+
expect(decision.verdict).toBe('deny');
|
|
262
|
+
expect(decision.reasons.join(' ')).toMatch(/value was not stated/i);
|
|
263
|
+
});
|
|
264
|
+
|
|
265
|
+
it('accepts an explicit zero for an action that moves nothing', () => {
|
|
266
|
+
const sealed = sealPolicy({ maxValuePerActionUsd: 1_000 });
|
|
267
|
+
expect(evaluatePolicy({
|
|
268
|
+
action: action({ type: 'sign_message', valueUsd: 0, to: undefined }),
|
|
269
|
+
policy: sealed, assessment: CLEAN, now: NOW,
|
|
270
|
+
}).verdict).toBe('allow');
|
|
271
|
+
});
|
|
272
|
+
});
|
|
273
|
+
|
|
274
|
+
// ---------------------------------------------------------------------------
|
|
275
|
+
// Cumulative spend
|
|
276
|
+
// ---------------------------------------------------------------------------
|
|
277
|
+
|
|
278
|
+
describe('a cumulative cap actually accumulates', () => {
|
|
279
|
+
/**
|
|
280
|
+
* The failure a per-transaction cap cannot catch: a hundred individually
|
|
281
|
+
* reasonable transfers. No single one of these trips a $1,000 per-action
|
|
282
|
+
* limit, and together they are the treasury.
|
|
283
|
+
*/
|
|
284
|
+
it('denies the action that would take the running total past the window', () => {
|
|
285
|
+
const clock = () => NOW.getTime();
|
|
286
|
+
const ledger = new SpendLedger({ clock });
|
|
287
|
+
const sealed = sealPolicy({
|
|
288
|
+
spendWindows: [{ label: 'daily', windowMs: 86_400_000, maxValueUsd: 1_000 }],
|
|
289
|
+
});
|
|
290
|
+
|
|
291
|
+
const admitted: number[] = [];
|
|
292
|
+
for (let i = 0; i < 5; i += 1) {
|
|
293
|
+
const decision = evaluatePolicy({
|
|
294
|
+
action: action({ valueUsd: 400 }), policy: sealed, usage: ledger, assessment: CLEAN, now: NOW,
|
|
295
|
+
});
|
|
296
|
+
if (decision.verdict !== 'deny') {
|
|
297
|
+
admitted.push(400);
|
|
298
|
+
ledger.reserve({ decisionId: decision.decisionId, agentId: AGENT, valueUsd: 400, now: NOW });
|
|
299
|
+
}
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
// $400 + $400 fits under $1,000; the third would reach $1,200.
|
|
303
|
+
expect(admitted).toEqual([400, 400]);
|
|
304
|
+
expect(ledger.spentUsdSince(AGENT, NOW.getTime() - 86_400_000)).toBe(800);
|
|
305
|
+
});
|
|
306
|
+
|
|
307
|
+
it('reports the running total and the limit on the decision, not just a refusal', () => {
|
|
308
|
+
const clock = () => NOW.getTime();
|
|
309
|
+
const ledger = new SpendLedger({ clock });
|
|
310
|
+
const sealed = sealPolicy({
|
|
311
|
+
spendWindows: [{ label: 'daily', windowMs: 86_400_000, maxValueUsd: 1_000 }],
|
|
312
|
+
});
|
|
313
|
+
ledger.reserve({ decisionId: 'd1', agentId: AGENT, valueUsd: 900, now: NOW });
|
|
314
|
+
|
|
315
|
+
const decision = evaluatePolicy({
|
|
316
|
+
action: action({ valueUsd: 200 }), policy: sealed, usage: ledger, assessment: CLEAN, now: NOW,
|
|
317
|
+
});
|
|
318
|
+
|
|
319
|
+
expect(decision.verdict).toBe('deny');
|
|
320
|
+
const rule = decision.evaluations.find((e) => e.rule === 'CUMULATIVE_SPEND');
|
|
321
|
+
expect(rule?.statement).toMatch(/\$900 committed/);
|
|
322
|
+
expect(rule?.statement).toMatch(/\$1,100 with this action/);
|
|
323
|
+
expect(rule?.statement).toMatch(/against \$1,000/);
|
|
324
|
+
});
|
|
325
|
+
|
|
326
|
+
/** The window rolls. A cap that never releases is a one-off budget. */
|
|
327
|
+
it('forgets spend that has aged out of the window', () => {
|
|
328
|
+
const ledger = new SpendLedger({ clock: () => NOW.getTime() });
|
|
329
|
+
const twoHoursAgo = new Date(NOW.getTime() - 7_200_000);
|
|
330
|
+
ledger.reserve({ decisionId: 'd1', agentId: AGENT, valueUsd: 900, now: twoHoursAgo });
|
|
331
|
+
ledger.confirm('d1', { txHash: '0xabc', now: twoHoursAgo });
|
|
332
|
+
|
|
333
|
+
expect(ledger.spentUsdSince(AGENT, NOW.getTime() - 86_400_000)).toBe(900);
|
|
334
|
+
expect(ledger.spentUsdSince(AGENT, NOW.getTime() - 3_600_000)).toBe(0);
|
|
335
|
+
});
|
|
336
|
+
|
|
337
|
+
it('keeps one agent\'s spend off another agent\'s budget', () => {
|
|
338
|
+
const ledger = new SpendLedger({ clock: () => NOW.getTime() });
|
|
339
|
+
ledger.reserve({ decisionId: 'd1', agentId: 'agent-other', valueUsd: 5_000, now: NOW });
|
|
340
|
+
|
|
341
|
+
expect(ledger.spentUsdSince(AGENT, 0)).toBe(0);
|
|
342
|
+
});
|
|
343
|
+
|
|
344
|
+
it('denies an unpriced action when a cumulative cap exists', () => {
|
|
345
|
+
const sealed = sealPolicy({
|
|
346
|
+
spendWindows: [{ label: 'daily', windowMs: 86_400_000, maxValueUsd: 1_000 }],
|
|
347
|
+
});
|
|
348
|
+
const decision = evaluatePolicy({
|
|
349
|
+
action: { agentId: AGENT, chain: 'ethereum', type: 'swap', from: '0x11', to: '0x22' },
|
|
350
|
+
policy: sealed, assessment: CLEAN, now: NOW,
|
|
351
|
+
});
|
|
352
|
+
|
|
353
|
+
expect(decision.verdict).toBe('deny');
|
|
354
|
+
expect(decision.reasons.join(' ')).toMatch(/cannot be added to a running total/i);
|
|
355
|
+
});
|
|
356
|
+
});
|
|
357
|
+
|
|
358
|
+
// ---------------------------------------------------------------------------
|
|
359
|
+
// Rate
|
|
360
|
+
// ---------------------------------------------------------------------------
|
|
361
|
+
|
|
362
|
+
describe('rate ceiling', () => {
|
|
363
|
+
/**
|
|
364
|
+
* An agent in a loop is the failure mode with no human equivalent. A person
|
|
365
|
+
* making the same mistake makes it once a minute; a process makes it a
|
|
366
|
+
* thousand times before anyone reads the first alert.
|
|
367
|
+
*/
|
|
368
|
+
it('stops an agent that keeps going', () => {
|
|
369
|
+
const ledger = new SpendLedger({ clock: () => NOW.getTime() });
|
|
370
|
+
const sealed = sealPolicy({
|
|
371
|
+
rateWindows: [{ label: 'per-minute', windowMs: 60_000, maxActions: 3 }],
|
|
372
|
+
});
|
|
373
|
+
|
|
374
|
+
let admitted = 0;
|
|
375
|
+
for (let i = 0; i < 10; i += 1) {
|
|
376
|
+
const decision = evaluatePolicy({
|
|
377
|
+
action: action({ valueUsd: 1 }), policy: sealed, usage: ledger, assessment: CLEAN, now: NOW,
|
|
378
|
+
});
|
|
379
|
+
if (decision.verdict === 'deny') continue;
|
|
380
|
+
admitted += 1;
|
|
381
|
+
ledger.reserve({ decisionId: decision.decisionId, agentId: AGENT, valueUsd: 1, now: NOW });
|
|
382
|
+
}
|
|
383
|
+
|
|
384
|
+
expect(admitted).toBe(3);
|
|
385
|
+
});
|
|
386
|
+
|
|
387
|
+
/**
|
|
388
|
+
* The ceiling counts decisions the guard admitted, not transactions that
|
|
389
|
+
* landed. An agent that assesses and abandons in a tight loop is exactly
|
|
390
|
+
* what the ceiling is for, and a counter watching only confirmed
|
|
391
|
+
* transactions would never see it.
|
|
392
|
+
*/
|
|
393
|
+
it('counts an admitted action that was later released', () => {
|
|
394
|
+
const ledger = new SpendLedger({ clock: () => NOW.getTime() });
|
|
395
|
+
ledger.reserve({ decisionId: 'd1', agentId: AGENT, valueUsd: 100, now: NOW });
|
|
396
|
+
ledger.release('d1', { now: NOW });
|
|
397
|
+
|
|
398
|
+
expect(ledger.spentUsdSince(AGENT, 0)).toBe(0);
|
|
399
|
+
expect(ledger.actionsSince(AGENT, 0)).toBe(1);
|
|
400
|
+
});
|
|
401
|
+
});
|
|
402
|
+
|
|
403
|
+
// ---------------------------------------------------------------------------
|
|
404
|
+
// Risk — delegated to Protect, applied for an agent
|
|
405
|
+
// ---------------------------------------------------------------------------
|
|
406
|
+
|
|
407
|
+
describe('a KNOWN_MALICIOUS counterparty', () => {
|
|
408
|
+
/**
|
|
409
|
+
* The requirement stated without qualification: no amount is small enough.
|
|
410
|
+
* A per-action cap is a limit on loss; this is a limit on who the agent
|
|
411
|
+
* deals with at all, and the two must not be able to trade against each
|
|
412
|
+
* other.
|
|
413
|
+
*/
|
|
414
|
+
it.each([0, 1, 0.01, 5_000_000])('is denied at a value of $%s', (valueUsd) => {
|
|
415
|
+
const sealed = sealPolicy({ maxValuePerActionUsd: 10_000_000 });
|
|
416
|
+
const decision = evaluatePolicy({
|
|
417
|
+
action: action({ valueUsd }), policy: sealed, assessment: KNOWN_MALICIOUS, now: NOW,
|
|
418
|
+
});
|
|
419
|
+
|
|
420
|
+
expect(decision.verdict).toBe('deny');
|
|
421
|
+
expect(decision.reasons.join(' ')).toMatch(/KNOWN_MALICIOUS/);
|
|
422
|
+
});
|
|
423
|
+
|
|
424
|
+
/**
|
|
425
|
+
* Denied, not escalated. A denial that a human can wave through is a
|
|
426
|
+
* denial an agent's operator can be socially engineered past, and
|
|
427
|
+
* KNOWN_MALICIOUS is the one level that already required an analyst to
|
|
428
|
+
* confirm the evidence.
|
|
429
|
+
*/
|
|
430
|
+
it('is denied rather than routed to a person, even under a low approval threshold', () => {
|
|
431
|
+
const sealed = sealPolicy({ humanApprovalAboveUsd: 1 });
|
|
432
|
+
const decision = evaluatePolicy({
|
|
433
|
+
action: action({ valueUsd: 5_000 }), policy: sealed, assessment: KNOWN_MALICIOUS, now: NOW,
|
|
434
|
+
});
|
|
435
|
+
|
|
436
|
+
expect(decision.verdict).toBe('deny');
|
|
437
|
+
});
|
|
438
|
+
|
|
439
|
+
it('is denied by the default policy without the operator writing a rule', () => {
|
|
440
|
+
expect(DEFAULT_AGENT_RISK_POLICY.KNOWN_MALICIOUS).toBe('block');
|
|
441
|
+
expect(evaluatePolicy({
|
|
442
|
+
action: action(), policy: sealPolicy({}), assessment: KNOWN_MALICIOUS, now: NOW,
|
|
443
|
+
}).verdict).toBe('deny');
|
|
444
|
+
});
|
|
445
|
+
});
|
|
446
|
+
|
|
447
|
+
describe('risk levels below confirmed malice', () => {
|
|
448
|
+
/**
|
|
449
|
+
* Evidence-backed threat signals are where a person adds value: they can
|
|
450
|
+
* read the evidence. So ELEVATED_RISK goes to a human rather than being
|
|
451
|
+
* denied outright or waved through.
|
|
452
|
+
*/
|
|
453
|
+
it('routes an evidence-backed elevated risk to a person', () => {
|
|
454
|
+
const decision = evaluatePolicy({
|
|
455
|
+
action: action(), policy: sealPolicy({}), assessment: ELEVATED, now: NOW,
|
|
456
|
+
});
|
|
457
|
+
expect(decision.verdict).toBe('require_human_approval');
|
|
458
|
+
});
|
|
459
|
+
|
|
460
|
+
/**
|
|
461
|
+
* CAUTION is reached by a single SIGNIFICANT capability — a pausable token,
|
|
462
|
+
* an upgradeable router. Most serious protocols have one. Escalating there
|
|
463
|
+
* would hand a human hundreds of approvals a day, and a human approving 500
|
|
464
|
+
* of those approves the 501st unread.
|
|
465
|
+
*/
|
|
466
|
+
it('permits a mere capability rather than paging a human about a pausable token', () => {
|
|
467
|
+
const caution = classify({
|
|
468
|
+
now: NOW,
|
|
469
|
+
capabilities: [{ type: 'PAUSABLE', severity: 'SIGNIFICANT', statement: 'Transfers can be paused.' }],
|
|
470
|
+
});
|
|
471
|
+
|
|
472
|
+
expect(caution.riskLevel).toBe('CAUTION');
|
|
473
|
+
const decision = evaluatePolicy({ action: action(), policy: sealPolicy({}), assessment: caution, now: NOW });
|
|
474
|
+
expect(decision.verdict).toBe('allow');
|
|
475
|
+
// Permitted, but not invisible: the capability is on the record.
|
|
476
|
+
expect(decision.evaluations.find((e) => e.rule === 'RISK_LEVEL')?.observed).toBe('CAUTION');
|
|
477
|
+
});
|
|
478
|
+
|
|
479
|
+
it('lets an operator be stricter than the default without touching the classifier', () => {
|
|
480
|
+
const strict = sealPolicy({ maxRiskLevel: 'INFORMATIONAL' });
|
|
481
|
+
const caution = classify({
|
|
482
|
+
now: NOW,
|
|
483
|
+
capabilities: [{ type: 'PAUSABLE', severity: 'SIGNIFICANT', statement: 'Transfers can be paused.' }],
|
|
484
|
+
});
|
|
485
|
+
|
|
486
|
+
expect(evaluatePolicy({ action: action(), policy: strict, assessment: caution, now: NOW }).verdict)
|
|
487
|
+
.toBe('deny');
|
|
488
|
+
// The assessment itself is untouched — policy changes behaviour, not facts.
|
|
489
|
+
expect(caution.riskLevel).toBe('CAUTION');
|
|
490
|
+
});
|
|
491
|
+
});
|
|
492
|
+
|
|
493
|
+
// ---------------------------------------------------------------------------
|
|
494
|
+
// Availability
|
|
495
|
+
// ---------------------------------------------------------------------------
|
|
496
|
+
|
|
497
|
+
describe('when Decentrys could not be reached', () => {
|
|
498
|
+
const unreachable = { assessment: null, assessmentUnavailable: 'timeout after 4000ms' };
|
|
499
|
+
|
|
500
|
+
/**
|
|
501
|
+
* Protect's consumer default is `warn`, and it does not transfer: there is
|
|
502
|
+
* nobody to warn, so a warning is an allow that logs. Of the three
|
|
503
|
+
* remaining options, `escalate` is the only one that loses nothing —
|
|
504
|
+
* `open` signs unscreened during precisely the window an attacker would
|
|
505
|
+
* choose, and `closed` halts the agent completely, which pushes operators
|
|
506
|
+
* to configure `open` to avoid it.
|
|
507
|
+
*/
|
|
508
|
+
it('escalates to a person by default', () => {
|
|
509
|
+
const decision = evaluatePolicy({
|
|
510
|
+
action: action(), policy: sealPolicy({}), ...unreachable, now: NOW,
|
|
511
|
+
});
|
|
512
|
+
|
|
513
|
+
expect(decision.verdict).toBe('require_human_approval');
|
|
514
|
+
expect(decision.reasons.join(' ')).toMatch(/needs a person/i);
|
|
515
|
+
});
|
|
516
|
+
|
|
517
|
+
it('stops under failMode closed', () => {
|
|
518
|
+
expect(evaluatePolicy({
|
|
519
|
+
action: action(), policy: sealPolicy({ failMode: 'closed' }), ...unreachable, now: NOW,
|
|
520
|
+
}).verdict).toBe('deny');
|
|
521
|
+
});
|
|
522
|
+
|
|
523
|
+
it('proceeds on local limits alone under failMode open', () => {
|
|
524
|
+
expect(evaluatePolicy({
|
|
525
|
+
action: action({ valueUsd: 10 }),
|
|
526
|
+
policy: sealPolicy({ failMode: 'open', maxValuePerActionUsd: 100 }),
|
|
527
|
+
...unreachable, now: NOW,
|
|
528
|
+
}).verdict).toBe('allow');
|
|
529
|
+
});
|
|
530
|
+
|
|
531
|
+
/**
|
|
532
|
+
* An outage removes one input; it does not suspend the operator's limits.
|
|
533
|
+
* A guard that fell open on everything the moment a service blipped would
|
|
534
|
+
* be worth less than no guard, because it would be trusted.
|
|
535
|
+
*/
|
|
536
|
+
it('still applies the operator\'s own limits under failMode open', () => {
|
|
537
|
+
expect(evaluatePolicy({
|
|
538
|
+
action: action({ valueUsd: 5_000 }),
|
|
539
|
+
policy: sealPolicy({ failMode: 'open', maxValuePerActionUsd: 100 }),
|
|
540
|
+
...unreachable, now: NOW,
|
|
541
|
+
}).verdict).toBe('deny');
|
|
542
|
+
});
|
|
543
|
+
|
|
544
|
+
it('records why no risk level was applied rather than leaving the rule silent', () => {
|
|
545
|
+
const decision = evaluatePolicy({
|
|
546
|
+
action: action(), policy: sealPolicy({}), ...unreachable, now: NOW,
|
|
547
|
+
});
|
|
548
|
+
|
|
549
|
+
expect(decision.evaluations.find((e) => e.rule === 'RISK_LEVEL')?.outcome).toBe('not_applicable');
|
|
550
|
+
expect(decision.assessmentUnavailable).toBe('timeout after 4000ms');
|
|
551
|
+
});
|
|
552
|
+
});
|
|
553
|
+
|
|
554
|
+
// ---------------------------------------------------------------------------
|
|
555
|
+
// What the agent may do, and with whom
|
|
556
|
+
// ---------------------------------------------------------------------------
|
|
557
|
+
|
|
558
|
+
describe('action-type restrictions', () => {
|
|
559
|
+
it('lets an operator say "may swap, may not approve"', () => {
|
|
560
|
+
const sealed = sealPolicy({ allowedActions: ['swap'], deniedActions: ['approve'] });
|
|
561
|
+
|
|
562
|
+
expect(evaluatePolicy({
|
|
563
|
+
action: action({ type: 'swap' }), policy: sealed, assessment: CLEAN, now: NOW,
|
|
564
|
+
}).verdict).toBe('allow');
|
|
565
|
+
expect(evaluatePolicy({
|
|
566
|
+
action: action({ type: 'approve', approvalAmount: '1000' }), policy: sealed, assessment: CLEAN, now: NOW,
|
|
567
|
+
}).verdict).toBe('deny');
|
|
568
|
+
});
|
|
569
|
+
|
|
570
|
+
/**
|
|
571
|
+
* An agent should not be able to do something by virtue of nobody being
|
|
572
|
+
* able to say what it is. Under an allowlist, an unclassifiable action is
|
|
573
|
+
* simply not on the list.
|
|
574
|
+
*/
|
|
575
|
+
it('denies an action nobody could classify, under an allowlist', () => {
|
|
576
|
+
const sealed = sealPolicy({ allowedActions: ['swap', 'transfer'] });
|
|
577
|
+
expect(evaluatePolicy({
|
|
578
|
+
action: action({ type: 'unknown' }), policy: sealed, assessment: CLEAN, now: NOW,
|
|
579
|
+
}).verdict).toBe('deny');
|
|
580
|
+
});
|
|
581
|
+
});
|
|
582
|
+
|
|
583
|
+
describe('unlimited approvals', () => {
|
|
584
|
+
/**
|
|
585
|
+
* The one action that converts a bounded mistake into an unbounded one.
|
|
586
|
+
* Defaulting it open would make the safe configuration the one an operator
|
|
587
|
+
* has to remember to write.
|
|
588
|
+
*/
|
|
589
|
+
it('is refused unless the operator explicitly permitted it', () => {
|
|
590
|
+
const maxUint256 = (2n ** 256n - 1n).toString();
|
|
591
|
+
const sealed = sealPolicy({});
|
|
592
|
+
|
|
593
|
+
for (const amount of ['unlimited', maxUint256, '0x' + 'f'.repeat(64)]) {
|
|
594
|
+
const decision = evaluatePolicy({
|
|
595
|
+
action: action({ type: 'approve', token: '0xtoken', approvalAmount: amount, valueUsd: 0 }),
|
|
596
|
+
policy: sealed, assessment: CLEAN, now: NOW,
|
|
597
|
+
});
|
|
598
|
+
expect(decision.verdict, amount).toBe('deny');
|
|
599
|
+
expect(decision.reasons.join(' ')).toMatch(/unbounded loss/i);
|
|
600
|
+
}
|
|
601
|
+
});
|
|
602
|
+
|
|
603
|
+
it('permits a bounded allowance', () => {
|
|
604
|
+
expect(evaluatePolicy({
|
|
605
|
+
action: action({ type: 'approve', token: '0xtoken', approvalAmount: '1000000', valueUsd: 0 }),
|
|
606
|
+
policy: sealPolicy({}), assessment: CLEAN, now: NOW,
|
|
607
|
+
}).verdict).toBe('allow');
|
|
608
|
+
});
|
|
609
|
+
|
|
610
|
+
it('permits an unlimited allowance when the operator asked for it', () => {
|
|
611
|
+
expect(evaluatePolicy({
|
|
612
|
+
action: action({ type: 'approve', token: '0xtoken', approvalAmount: 'unlimited', valueUsd: 0 }),
|
|
613
|
+
policy: sealPolicy({ allowUnlimitedApprovals: true }), assessment: CLEAN, now: NOW,
|
|
614
|
+
}).verdict).toBe('allow');
|
|
615
|
+
});
|
|
616
|
+
|
|
617
|
+
/** An unstated allowance is the whole of what is being granted, unbounded. */
|
|
618
|
+
it('denies an approval that does not say how much it grants', () => {
|
|
619
|
+
expect(evaluatePolicy({
|
|
620
|
+
action: action({ type: 'approve', token: '0xtoken', valueUsd: 0 }),
|
|
621
|
+
policy: sealPolicy({}), assessment: CLEAN, now: NOW,
|
|
622
|
+
}).verdict).toBe('deny');
|
|
623
|
+
});
|
|
624
|
+
});
|
|
625
|
+
|
|
626
|
+
describe('counterparty and contract lists', () => {
|
|
627
|
+
it('denies a counterparty that is not on the allowlist', () => {
|
|
628
|
+
const sealed = sealPolicy({ allowedCounterparties: ['0xaaa'] });
|
|
629
|
+
expect(evaluatePolicy({
|
|
630
|
+
action: action({ to: '0xbbb' }), policy: sealed, assessment: CLEAN, now: NOW,
|
|
631
|
+
}).verdict).toBe('deny');
|
|
632
|
+
});
|
|
633
|
+
|
|
634
|
+
/**
|
|
635
|
+
* EVM addresses arrive in mixed checksum case, and an allowlist an operator
|
|
636
|
+
* typed in lowercase must still match. Passing a case-variant of an
|
|
637
|
+
* allowlisted address on a case-sensitive chain would require holding a
|
|
638
|
+
* private key for that variant, which is not something an attacker searches
|
|
639
|
+
* for — so the looser comparison costs nothing.
|
|
640
|
+
*/
|
|
641
|
+
it('matches an allowlist across address casing', () => {
|
|
642
|
+
const sealed = sealPolicy({ allowedCounterparties: ['0xAbCdEf0000000000000000000000000000000001'] });
|
|
643
|
+
expect(evaluatePolicy({
|
|
644
|
+
action: action({ to: '0xabcdef0000000000000000000000000000000001' }),
|
|
645
|
+
policy: sealed, assessment: CLEAN, now: NOW,
|
|
646
|
+
}).verdict).toBe('allow');
|
|
647
|
+
});
|
|
648
|
+
|
|
649
|
+
/** An address on both lists is an operator error; guessing towards permission is the wrong guess. */
|
|
650
|
+
it('lets the blocklist win over the allowlist', () => {
|
|
651
|
+
const sealed = sealPolicy({ allowedCounterparties: ['0xaaa'], blockedCounterparties: ['0xaaa'] });
|
|
652
|
+
expect(evaluatePolicy({
|
|
653
|
+
action: action({ to: '0xaaa' }), policy: sealed, assessment: CLEAN, now: NOW,
|
|
654
|
+
}).verdict).toBe('deny');
|
|
655
|
+
});
|
|
656
|
+
|
|
657
|
+
it('applies a contract allowlist only to actions that call code', () => {
|
|
658
|
+
const sealed = sealPolicy({ allowedContracts: ['0xrouter'] });
|
|
659
|
+
|
|
660
|
+
expect(evaluatePolicy({
|
|
661
|
+
action: action({ type: 'swap', to: '0xrouter' }), policy: sealed, assessment: CLEAN, now: NOW,
|
|
662
|
+
}).evaluations.find((e) => e.rule === 'CONTRACT')?.outcome).toBe('pass');
|
|
663
|
+
expect(evaluatePolicy({
|
|
664
|
+
action: action({ type: 'transfer', to: '0xfriend' }), policy: sealed, assessment: CLEAN, now: NOW,
|
|
665
|
+
}).evaluations.find((e) => e.rule === 'CONTRACT')?.outcome).toBe('not_applicable');
|
|
666
|
+
});
|
|
667
|
+
});
|
|
668
|
+
|
|
669
|
+
describe('human approval thresholds', () => {
|
|
670
|
+
it('routes a large but otherwise clean action to a person', () => {
|
|
671
|
+
const sealed = sealPolicy({ maxValuePerActionUsd: 100_000, humanApprovalAboveUsd: 10_000 });
|
|
672
|
+
expect(evaluatePolicy({
|
|
673
|
+
action: action({ valueUsd: 25_000 }), policy: sealed, assessment: CLEAN, now: NOW,
|
|
674
|
+
}).verdict).toBe('require_human_approval');
|
|
675
|
+
});
|
|
676
|
+
|
|
677
|
+
/** A deny is not negotiable by a threshold that would otherwise escalate. */
|
|
678
|
+
it('does not let an escalation soften a denial', () => {
|
|
679
|
+
const sealed = sealPolicy({ maxValuePerActionUsd: 1_000, humanApprovalAboveUsd: 100 });
|
|
680
|
+
expect(evaluatePolicy({
|
|
681
|
+
action: action({ valueUsd: 5_000 }), policy: sealed, assessment: CLEAN, now: NOW,
|
|
682
|
+
}).verdict).toBe('deny');
|
|
683
|
+
});
|
|
684
|
+
});
|
|
685
|
+
|
|
686
|
+
// ---------------------------------------------------------------------------
|
|
687
|
+
// The record
|
|
688
|
+
// ---------------------------------------------------------------------------
|
|
689
|
+
|
|
690
|
+
describe('the audit trail', () => {
|
|
691
|
+
/**
|
|
692
|
+
* "The agent spent the treasury" is answered by the record of the allow, not
|
|
693
|
+
* of the denials. A decision that lists only what went wrong cannot answer
|
|
694
|
+
* why anything was permitted.
|
|
695
|
+
*/
|
|
696
|
+
it('records every rule it applied, including the ones that passed', () => {
|
|
697
|
+
const sealed = sealPolicy({
|
|
698
|
+
maxValuePerActionUsd: 1_000,
|
|
699
|
+
allowedCounterparties: ['0x2222222222222222222222222222222222222222'],
|
|
700
|
+
rateWindows: [{ label: 'per-minute', windowMs: 60_000, maxActions: 10 }],
|
|
701
|
+
});
|
|
702
|
+
const decision = evaluatePolicy({ action: action(), policy: sealed, assessment: CLEAN, now: NOW });
|
|
703
|
+
|
|
704
|
+
expect(decision.verdict).toBe('allow');
|
|
705
|
+
expect(decision.reasons).toEqual([]);
|
|
706
|
+
|
|
707
|
+
const value = decision.evaluations.find((e) => e.rule === 'VALUE_PER_ACTION');
|
|
708
|
+
expect(value?.outcome).toBe('pass');
|
|
709
|
+
expect(value?.limit).toBe(1_000);
|
|
710
|
+
expect(value?.observed).toBe(100);
|
|
711
|
+
|
|
712
|
+
const counterparty = decision.evaluations.find((e) => e.rule === 'COUNTERPARTY');
|
|
713
|
+
expect(counterparty?.outcome).toBe('pass');
|
|
714
|
+
expect(counterparty?.observed).toBe('0x2222222222222222222222222222222222222222');
|
|
715
|
+
});
|
|
716
|
+
|
|
717
|
+
/** Every rule is evaluated, so a record cannot hide a second reason. */
|
|
718
|
+
it('reports all the reasons an action failed, not only the first', () => {
|
|
719
|
+
const sealed = sealPolicy({
|
|
720
|
+
maxValuePerActionUsd: 100,
|
|
721
|
+
allowedCounterparties: ['0xaaa'],
|
|
722
|
+
deniedActions: ['approve'],
|
|
723
|
+
});
|
|
724
|
+
const decision = evaluatePolicy({
|
|
725
|
+
action: action({ type: 'approve', valueUsd: 5_000, to: '0xbbb', approvalAmount: 'unlimited' }),
|
|
726
|
+
policy: sealed, assessment: KNOWN_MALICIOUS, now: NOW,
|
|
727
|
+
});
|
|
728
|
+
|
|
729
|
+
const failed = decision.evaluations.filter((e) => e.outcome === 'fail').map((e) => e.rule);
|
|
730
|
+
expect(failed).toContain('ACTION_TYPE');
|
|
731
|
+
expect(failed).toContain('COUNTERPARTY');
|
|
732
|
+
expect(failed).toContain('VALUE_PER_ACTION');
|
|
733
|
+
expect(failed).toContain('APPROVAL_ALLOWANCE');
|
|
734
|
+
expect(failed).toContain('RISK_LEVEL');
|
|
735
|
+
});
|
|
736
|
+
|
|
737
|
+
/**
|
|
738
|
+
* The evaluator reads no clock and touches no network, so an investigator
|
|
739
|
+
* can replay a recorded decision and get the same answer.
|
|
740
|
+
*/
|
|
741
|
+
it('is reproducible from the same inputs', () => {
|
|
742
|
+
const sealed = sealPolicy({ maxValuePerActionUsd: 1_000 });
|
|
743
|
+
const first = evaluatePolicy({ action: action(), policy: sealed, assessment: CLEAN, now: NOW });
|
|
744
|
+
const second = evaluatePolicy({ action: action(), policy: sealed, assessment: CLEAN, now: NOW });
|
|
745
|
+
|
|
746
|
+
expect(second.verdict).toBe(first.verdict);
|
|
747
|
+
expect(second.actionDigest).toBe(first.actionDigest);
|
|
748
|
+
expect(second.policyFingerprint).toBe(first.policyFingerprint);
|
|
749
|
+
expect(second.evaluations).toEqual(first.evaluations);
|
|
750
|
+
// The id is the one thing that differs — two decisions are two events.
|
|
751
|
+
expect(second.decisionId).not.toBe(first.decisionId);
|
|
752
|
+
});
|
|
753
|
+
|
|
754
|
+
it('distinguishes an unbounded axis from one that was checked and passed', () => {
|
|
755
|
+
const decision = evaluatePolicy({
|
|
756
|
+
action: action(), policy: sealPolicy({ maxValuePerActionUsd: 1_000 }), assessment: CLEAN, now: NOW,
|
|
757
|
+
});
|
|
758
|
+
|
|
759
|
+
expect(decision.evaluations.find((e) => e.rule === 'VALUE_PER_ACTION')?.outcome).toBe('pass');
|
|
760
|
+
expect(decision.evaluations.find((e) => e.rule === 'RATE_LIMIT')?.outcome).toBe('not_configured');
|
|
761
|
+
});
|
|
762
|
+
});
|