@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
package/src/client.ts
ADDED
|
@@ -0,0 +1,392 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* AgentGuard — the client an operator wires in front of an agent.
|
|
3
|
+
*
|
|
4
|
+
* The contract differs from both of its neighbours, and the difference is the
|
|
5
|
+
* product.
|
|
6
|
+
*
|
|
7
|
+
* - **Protect never throws and never blocks.** It sits between a person and a
|
|
8
|
+
* signing screen; a security service having a bad minute must not cost
|
|
9
|
+
* someone their transaction, and the person decides.
|
|
10
|
+
* - **Sentinel throws.** It is management tooling, and an operator who
|
|
11
|
+
* believes they are monitored when they are not is worse off than one who
|
|
12
|
+
* saw an error.
|
|
13
|
+
* - **AgentGuard returns a verdict, and the verdict can be `deny`.** Nothing
|
|
14
|
+
* here throws on a call path either — an exception thrown at an agent is a
|
|
15
|
+
* condition it will handle by retrying, or by taking the branch that skips
|
|
16
|
+
* the guard. A decision object it must read is harder to route around.
|
|
17
|
+
*
|
|
18
|
+
* The one place this does throw is the constructor, on a missing credential or
|
|
19
|
+
* an unsealed policy. That is a wiring error made by a human at deploy time,
|
|
20
|
+
* not an event during an agent's run.
|
|
21
|
+
*/
|
|
22
|
+
|
|
23
|
+
import {
|
|
24
|
+
Decentrys, type Assessment, type CallOptions, type DecentrysConfig, type FetchLike,
|
|
25
|
+
type Transport,
|
|
26
|
+
} from '@decentrys/protect';
|
|
27
|
+
|
|
28
|
+
import type { AgentAction, AgentDecision, AgentOutcomeRecord, AgentPolicy } from './model';
|
|
29
|
+
import { type SealedPolicy, evaluatePolicy, narrowPolicy, newDecisionId } from './policy';
|
|
30
|
+
import {
|
|
31
|
+
SpendLedger, isReservationLedger, type AgentUsage, type ReservationLedger,
|
|
32
|
+
} from './ledger';
|
|
33
|
+
import { type AgentActionExplanation, explainAgentAction } from './explain';
|
|
34
|
+
|
|
35
|
+
export const AGENT_SDK_VERSION = '0.1.0';
|
|
36
|
+
|
|
37
|
+
const DEFAULT_AUDIT_LOG_ENTRIES = 1_000;
|
|
38
|
+
|
|
39
|
+
export interface AgentGuardConfig {
|
|
40
|
+
/**
|
|
41
|
+
* The operator's policy, already sealed.
|
|
42
|
+
*
|
|
43
|
+
* Typed as `SealedPolicy`, so a plain object cannot be passed here — an
|
|
44
|
+
* agent that constructs its own limits and hands them over does not
|
|
45
|
+
* typecheck and would not survive `sealPolicy`'s cloning if it did.
|
|
46
|
+
*/
|
|
47
|
+
policy: SealedPolicy;
|
|
48
|
+
/** An existing Protect client. Supply this or `apiKey`. */
|
|
49
|
+
protect?: Decentrys;
|
|
50
|
+
apiKey?: string;
|
|
51
|
+
baseUrl?: string;
|
|
52
|
+
timeoutMs?: number;
|
|
53
|
+
retries?: number;
|
|
54
|
+
fetch?: FetchLike;
|
|
55
|
+
/** For tests, and for operators routing through their own gateway. */
|
|
56
|
+
transport?: Transport;
|
|
57
|
+
/**
|
|
58
|
+
* Where cumulative spend and rate state lives. Defaults to an in-process
|
|
59
|
+
* `SpendLedger` — read its file comment before running more than one
|
|
60
|
+
* instance, because a per-process cap on N processes is an N-times cap.
|
|
61
|
+
*
|
|
62
|
+
* A source that can only be read (see `ReservationLedger`) disables holds;
|
|
63
|
+
* `reservesBudget` reports which of the two this guard got.
|
|
64
|
+
*/
|
|
65
|
+
ledger?: AgentUsage | ReservationLedger;
|
|
66
|
+
/** How many decisions to keep in memory for `auditLog()` / `explain()`. */
|
|
67
|
+
auditLogEntries?: number;
|
|
68
|
+
/**
|
|
69
|
+
* Called with every decision, before it is returned.
|
|
70
|
+
*
|
|
71
|
+
* The in-memory log is a convenience, not an audit trail: it dies with the
|
|
72
|
+
* process. This hook is where a real one is written. It is invoked
|
|
73
|
+
* synchronously and its exceptions are swallowed — a logging sink must not
|
|
74
|
+
* be able to change a verdict, in either direction.
|
|
75
|
+
*/
|
|
76
|
+
onDecision?: (decision: AgentDecision) => void;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
export interface AssessAgentTransactionOptions extends CallOptions {
|
|
80
|
+
/**
|
|
81
|
+
* A per-call restriction. It can only *narrow* — see `narrowPolicy`. There
|
|
82
|
+
* is no parameter anywhere in this class that widens a policy, which is what
|
|
83
|
+
* makes "the agent cannot raise its own limits" a property of the code
|
|
84
|
+
* rather than a rule someone has to remember.
|
|
85
|
+
*/
|
|
86
|
+
restrict?: AgentPolicy;
|
|
87
|
+
/** An assessment the caller already holds, to avoid a second round trip. */
|
|
88
|
+
assessment?: Assessment;
|
|
89
|
+
now?: Date;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
interface AuditEntry {
|
|
93
|
+
decision: AgentDecision;
|
|
94
|
+
outcome?: AgentOutcomeRecord;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
export class AgentGuard {
|
|
98
|
+
private readonly protect: Decentrys;
|
|
99
|
+
private readonly sealed: SealedPolicy;
|
|
100
|
+
private readonly ledgerImpl: AgentUsage;
|
|
101
|
+
private readonly reserving: ReservationLedger | null;
|
|
102
|
+
private readonly auditEntries: AuditEntry[] = [];
|
|
103
|
+
private readonly auditLimit: number;
|
|
104
|
+
private readonly onDecision: ((decision: AgentDecision) => void) | undefined;
|
|
105
|
+
|
|
106
|
+
constructor(config: AgentGuardConfig) {
|
|
107
|
+
if (!config.policy || typeof config.policy !== 'object' || typeof config.policy.fingerprint !== 'string') {
|
|
108
|
+
throw new Error(
|
|
109
|
+
'AgentGuard: a sealed policy is required. Build one with sealPolicy({ ... }) at wiring time, '
|
|
110
|
+
+ 'from a human-owned configuration — not from anything the agent produces.',
|
|
111
|
+
);
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
if (config.protect) {
|
|
115
|
+
this.protect = config.protect;
|
|
116
|
+
} else if (config.apiKey || config.transport) {
|
|
117
|
+
const protectConfig: DecentrysConfig = {
|
|
118
|
+
apiKey: config.apiKey ?? 'transport-provided',
|
|
119
|
+
// Protect's own failMode only shapes the wording of an unavailable
|
|
120
|
+
// assessment; AgentGuard's `failMode` decides what actually happens.
|
|
121
|
+
// Pinning it to `open` keeps the two from being applied twice.
|
|
122
|
+
failMode: 'open',
|
|
123
|
+
...(config.baseUrl === undefined ? {} : { baseUrl: config.baseUrl }),
|
|
124
|
+
...(config.timeoutMs === undefined ? {} : { timeoutMs: config.timeoutMs }),
|
|
125
|
+
...(config.retries === undefined ? {} : { retries: config.retries }),
|
|
126
|
+
...(config.fetch === undefined ? {} : { fetch: config.fetch }),
|
|
127
|
+
...(config.transport === undefined ? {} : { transport: config.transport }),
|
|
128
|
+
};
|
|
129
|
+
this.protect = new Decentrys(protectConfig);
|
|
130
|
+
} else {
|
|
131
|
+
throw new Error(
|
|
132
|
+
'AgentGuard: pass an apiKey or an existing Decentrys client. Screening a counterparty is the '
|
|
133
|
+
+ 'part of this that no local policy can do — an allowlist cannot tell you the address on it '
|
|
134
|
+
+ 'was labelled malicious yesterday.',
|
|
135
|
+
);
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
this.sealed = config.policy;
|
|
139
|
+
this.ledgerImpl = config.ledger ?? new SpendLedger();
|
|
140
|
+
this.reserving = isReservationLedger(this.ledgerImpl) ? this.ledgerImpl : null;
|
|
141
|
+
this.auditLimit = config.auditLogEntries ?? DEFAULT_AUDIT_LOG_ENTRIES;
|
|
142
|
+
this.onDecision = config.onDecision;
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
/** The policy in force. Frozen — reading it is safe, changing it is impossible. */
|
|
146
|
+
get policy(): SealedPolicy {
|
|
147
|
+
return this.sealed;
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
/** Configurations that are legal but worth an operator's attention. */
|
|
151
|
+
get policyWarnings(): readonly string[] {
|
|
152
|
+
return this.sealed.warnings;
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
/**
|
|
156
|
+
* Whether admitted actions hold budget before they execute.
|
|
157
|
+
*
|
|
158
|
+
* False when the guard was given a read-only usage source. Cumulative caps
|
|
159
|
+
* then count settled history only, so two actions that each fit under the
|
|
160
|
+
* cap can both be admitted and together exceed it. Exposed rather than
|
|
161
|
+
* assumed, because the difference is invisible until it costs something.
|
|
162
|
+
*/
|
|
163
|
+
get reservesBudget(): boolean {
|
|
164
|
+
return this.reserving !== null;
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
// -------------------------------------------------------------------------
|
|
168
|
+
// The main path
|
|
169
|
+
// -------------------------------------------------------------------------
|
|
170
|
+
|
|
171
|
+
/**
|
|
172
|
+
* Assess a proposed action and decide whether the agent may sign it.
|
|
173
|
+
*
|
|
174
|
+
* Screening happens first and always — including when a local rule has
|
|
175
|
+
* already failed. A record saying an action exceeded its value cap, while
|
|
176
|
+
* omitting that its counterparty was confirmed malicious, would send an
|
|
177
|
+
* operator to raise the cap and ship the same hole.
|
|
178
|
+
*
|
|
179
|
+
* On `allow` or `require_human_approval` the action's value and its slot in
|
|
180
|
+
* the rate window are held immediately; call `confirm()` when it lands or
|
|
181
|
+
* `release()` when it does not. The hold is taken synchronously in the same
|
|
182
|
+
* turn as the evaluation, so two concurrent calls in one process cannot both
|
|
183
|
+
* pass a cap that only one of them fits under. Across processes they can —
|
|
184
|
+
* see `ledger.ts`, which says so plainly.
|
|
185
|
+
*/
|
|
186
|
+
async assessAgentTransaction(
|
|
187
|
+
action: AgentAction, options: AssessAgentTransactionOptions = {},
|
|
188
|
+
): Promise<AgentDecision> {
|
|
189
|
+
const policy = options.restrict ? narrowPolicy(this.sealed, options.restrict) : this.sealed;
|
|
190
|
+
const decisionId = newDecisionId();
|
|
191
|
+
|
|
192
|
+
let assessment: Assessment | undefined = options.assessment;
|
|
193
|
+
if (!assessment) {
|
|
194
|
+
assessment = await this.screen(action, options);
|
|
195
|
+
}
|
|
196
|
+
const unavailable = unavailableReason(assessment);
|
|
197
|
+
|
|
198
|
+
// Everything from here down is synchronous. That is deliberate: an `await`
|
|
199
|
+
// between the evaluation and the reservation is the gap through which an
|
|
200
|
+
// agent's parallel calls each see the same untouched budget.
|
|
201
|
+
const decision = evaluatePolicy({
|
|
202
|
+
action,
|
|
203
|
+
policy,
|
|
204
|
+
assessment: unavailable ? null : assessment,
|
|
205
|
+
...(unavailable === undefined ? {} : { assessmentUnavailable: unavailable }),
|
|
206
|
+
usage: this.ledgerImpl,
|
|
207
|
+
decisionId,
|
|
208
|
+
...(options.now === undefined ? {} : { now: options.now }),
|
|
209
|
+
});
|
|
210
|
+
|
|
211
|
+
if (decision.verdict !== 'deny' && this.reserving) {
|
|
212
|
+
const reservation = this.reserving.reserve({
|
|
213
|
+
decisionId,
|
|
214
|
+
agentId: action.agentId,
|
|
215
|
+
// Reaching here with no stated value means no value-based rule was
|
|
216
|
+
// configured; if one had been, this would already be a denial.
|
|
217
|
+
valueUsd: action.valueUsd ?? 0,
|
|
218
|
+
...(action.idempotencyKey === undefined ? {} : { idempotencyKey: action.idempotencyKey }),
|
|
219
|
+
...(options.now === undefined ? {} : { now: options.now }),
|
|
220
|
+
});
|
|
221
|
+
decision.reservationId = reservation.decisionId;
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
this.record(decision);
|
|
225
|
+
return decision;
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
/**
|
|
229
|
+
* Decide without going to the network.
|
|
230
|
+
*
|
|
231
|
+
* For an operator replaying a decision against a different policy, and for
|
|
232
|
+
* an agent runtime that already holds an assessment. It applies the same
|
|
233
|
+
* rules; what it cannot do is discover that a counterparty was labelled
|
|
234
|
+
* since the assessment was taken.
|
|
235
|
+
*/
|
|
236
|
+
evaluate(
|
|
237
|
+
action: AgentAction,
|
|
238
|
+
options: { assessment?: Assessment; assessmentUnavailable?: string; restrict?: AgentPolicy; now?: Date } = {},
|
|
239
|
+
): AgentDecision {
|
|
240
|
+
const policy = options.restrict ? narrowPolicy(this.sealed, options.restrict) : this.sealed;
|
|
241
|
+
const decision = evaluatePolicy({
|
|
242
|
+
action,
|
|
243
|
+
policy,
|
|
244
|
+
assessment: options.assessment ?? null,
|
|
245
|
+
...(options.assessment
|
|
246
|
+
? {}
|
|
247
|
+
: { assessmentUnavailable: options.assessmentUnavailable ?? 'no assessment was supplied to evaluate()' }),
|
|
248
|
+
usage: this.ledgerImpl,
|
|
249
|
+
...(options.now === undefined ? {} : { now: options.now }),
|
|
250
|
+
});
|
|
251
|
+
this.record(decision);
|
|
252
|
+
return decision;
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
// -------------------------------------------------------------------------
|
|
256
|
+
// Settling
|
|
257
|
+
// -------------------------------------------------------------------------
|
|
258
|
+
|
|
259
|
+
/** The agent signed and broadcast it. The held value becomes committed. */
|
|
260
|
+
confirm(decisionId: string, options: { txHash?: string; now?: Date } = {}): AgentOutcomeRecord | null {
|
|
261
|
+
const outcome = this.reserving?.confirm(decisionId, options) ?? null;
|
|
262
|
+
if (outcome) this.attachOutcome(outcome);
|
|
263
|
+
return outcome;
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
/** The agent did not proceed. The held value returns to the budget. */
|
|
267
|
+
release(decisionId: string, options: { note?: string; now?: Date } = {}): AgentOutcomeRecord | null {
|
|
268
|
+
const outcome = this.reserving?.release(decisionId, options) ?? null;
|
|
269
|
+
if (outcome) this.attachOutcome(outcome);
|
|
270
|
+
return outcome;
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
// -------------------------------------------------------------------------
|
|
274
|
+
// The trail
|
|
275
|
+
// -------------------------------------------------------------------------
|
|
276
|
+
|
|
277
|
+
/**
|
|
278
|
+
* Explain a decision, by id or by value.
|
|
279
|
+
*
|
|
280
|
+
* Returns `null` for an id this process never made or no longer holds —
|
|
281
|
+
* stated rather than reconstructed, because an explanation assembled from a
|
|
282
|
+
* decision that is no longer in hand is a guess wearing a record's clothes.
|
|
283
|
+
*/
|
|
284
|
+
explain(decision: AgentDecision | string): AgentActionExplanation | null {
|
|
285
|
+
if (typeof decision !== 'string') return explainAgentAction(decision);
|
|
286
|
+
const entry = this.auditEntries.find((e) => e.decision.decisionId === decision);
|
|
287
|
+
return entry ? explainAgentAction(entry.decision) : null;
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
/**
|
|
291
|
+
* Recent decisions, newest last.
|
|
292
|
+
*
|
|
293
|
+
* In memory and bounded; it does not survive a restart. Use `onDecision` for
|
|
294
|
+
* a trail that does. Saying which of the two this is matters — an operator
|
|
295
|
+
* who believes this is the audit log will discover otherwise at the worst
|
|
296
|
+
* possible moment.
|
|
297
|
+
*/
|
|
298
|
+
auditLog(filter: { agentId?: string; verdict?: AgentDecision['verdict'] } = {}): AuditEntry[] {
|
|
299
|
+
return this.auditEntries
|
|
300
|
+
.filter((e) => (!filter.agentId || e.decision.agentId === filter.agentId))
|
|
301
|
+
.filter((e) => (!filter.verdict || e.decision.verdict === filter.verdict))
|
|
302
|
+
.map((e) => ({ decision: e.decision, ...(e.outcome ? { outcome: e.outcome } : {}) }));
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
/**
|
|
306
|
+
* A guard for a narrower task, sharing this one's ledger and client.
|
|
307
|
+
*
|
|
308
|
+
* The shared ledger is the point: a sub-task that spends against a tighter
|
|
309
|
+
* per-task cap still spends against the agent's daily one.
|
|
310
|
+
*/
|
|
311
|
+
withPolicy(restriction: AgentPolicy): AgentGuard {
|
|
312
|
+
return new AgentGuard({
|
|
313
|
+
policy: narrowPolicy(this.sealed, restriction),
|
|
314
|
+
protect: this.protect,
|
|
315
|
+
ledger: this.ledgerImpl,
|
|
316
|
+
auditLogEntries: this.auditLimit,
|
|
317
|
+
...(this.onDecision === undefined ? {} : { onDecision: this.onDecision }),
|
|
318
|
+
});
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
// -------------------------------------------------------------------------
|
|
322
|
+
// Internals
|
|
323
|
+
// -------------------------------------------------------------------------
|
|
324
|
+
|
|
325
|
+
/**
|
|
326
|
+
* Route the action to the right Protect endpoint.
|
|
327
|
+
*
|
|
328
|
+
* An approval is assessed on its spender *and its allowance together*,
|
|
329
|
+
* which is a different question from "is this transaction dangerous" and
|
|
330
|
+
* the one an unlimited approval turns on.
|
|
331
|
+
*/
|
|
332
|
+
private async screen(action: AgentAction, options: CallOptions): Promise<Assessment> {
|
|
333
|
+
const call: CallOptions = {
|
|
334
|
+
...(options.signal === undefined ? {} : { signal: options.signal }),
|
|
335
|
+
...(options.skipCache === undefined ? {} : { skipCache: options.skipCache }),
|
|
336
|
+
};
|
|
337
|
+
|
|
338
|
+
if (action.type === 'approve' && action.token && action.to) {
|
|
339
|
+
const result = await this.protect.screenApproval({
|
|
340
|
+
chain: action.chain,
|
|
341
|
+
owner: action.from,
|
|
342
|
+
spender: action.to,
|
|
343
|
+
token: action.token,
|
|
344
|
+
...(action.approvalAmount === undefined ? {} : { amount: action.approvalAmount }),
|
|
345
|
+
}, call);
|
|
346
|
+
return result.assessment;
|
|
347
|
+
}
|
|
348
|
+
|
|
349
|
+
const result = await this.protect.assessTransaction({
|
|
350
|
+
chain: action.chain,
|
|
351
|
+
from: action.from,
|
|
352
|
+
...(action.to === undefined ? {} : { to: action.to }),
|
|
353
|
+
...(action.value === undefined ? {} : { value: action.value }),
|
|
354
|
+
...(action.data === undefined ? {} : { data: action.data }),
|
|
355
|
+
...(action.raw === undefined ? {} : { raw: action.raw }),
|
|
356
|
+
...(action.origin === undefined ? {} : { origin: action.origin }),
|
|
357
|
+
}, call);
|
|
358
|
+
return result.assessment;
|
|
359
|
+
}
|
|
360
|
+
|
|
361
|
+
private record(decision: AgentDecision): void {
|
|
362
|
+
this.auditEntries.push({ decision });
|
|
363
|
+
while (this.auditEntries.length > this.auditLimit) this.auditEntries.shift();
|
|
364
|
+
if (!this.onDecision) return;
|
|
365
|
+
try {
|
|
366
|
+
this.onDecision(decision);
|
|
367
|
+
} catch {
|
|
368
|
+
// A sink that throws must not turn a deny into an exception the agent
|
|
369
|
+
// handles by retrying, nor an allow into a failure. The decision stands.
|
|
370
|
+
}
|
|
371
|
+
}
|
|
372
|
+
|
|
373
|
+
private attachOutcome(outcome: AgentOutcomeRecord): void {
|
|
374
|
+
const entry = this.auditEntries.find((e) => e.decision.decisionId === outcome.decisionId);
|
|
375
|
+
if (entry) entry.outcome = outcome;
|
|
376
|
+
}
|
|
377
|
+
}
|
|
378
|
+
|
|
379
|
+
/**
|
|
380
|
+
* Distinguish "Decentrys answered, and found nothing" from "Decentrys did not
|
|
381
|
+
* answer".
|
|
382
|
+
*
|
|
383
|
+
* Protect never throws: an unreachable service produces an assessment whose
|
|
384
|
+
* `unknowns` say so. Both of those are `NO_CRITICAL_RISK_DETECTED` on the
|
|
385
|
+
* surface, and for an autonomous signer treating the second as the first is
|
|
386
|
+
* the whole failure — it is a clean bill of health issued by an outage.
|
|
387
|
+
*/
|
|
388
|
+
export function unavailableReason(assessment: Assessment | undefined): string | undefined {
|
|
389
|
+
if (!assessment) return 'no assessment was obtained';
|
|
390
|
+
const unknown = assessment.unknowns.find((u) => u.reason === 'PROVIDER_UNAVAILABLE');
|
|
391
|
+
return unknown ? unknown.statement : undefined;
|
|
392
|
+
}
|
|
@@ -0,0 +1,148 @@
|
|
|
1
|
+
import { describe, expect, it } from 'vitest';
|
|
2
|
+
import { classify, type Assessment } from '@decentrys/protect';
|
|
3
|
+
|
|
4
|
+
import { evaluatePolicy, sealPolicy } from './policy';
|
|
5
|
+
import { explainAgentAction } from './explain';
|
|
6
|
+
import type { AgentAction } from './model';
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* The question this file exists to answer is not "why was that blocked".
|
|
10
|
+
*
|
|
11
|
+
* A block explains itself, loudly, at the moment it happens. The hard question
|
|
12
|
+
* is **"why was that allowed"**, asked weeks later by someone looking at a
|
|
13
|
+
* drained account — and a record that lists only what went wrong cannot answer
|
|
14
|
+
* it at all.
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
const NOW = new Date('2026-09-06T12:00:00Z');
|
|
18
|
+
const AGENT = 'agent-treasury-1';
|
|
19
|
+
const CLEAN: Assessment = classify({ now: NOW });
|
|
20
|
+
|
|
21
|
+
function action(overrides: Partial<AgentAction> = {}): AgentAction {
|
|
22
|
+
return {
|
|
23
|
+
agentId: AGENT,
|
|
24
|
+
chain: 'ethereum',
|
|
25
|
+
type: 'transfer',
|
|
26
|
+
from: '0x1111111111111111111111111111111111111111',
|
|
27
|
+
to: '0x2222222222222222222222222222222222222222',
|
|
28
|
+
valueUsd: 250,
|
|
29
|
+
...overrides,
|
|
30
|
+
};
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
describe('explaining an allow', () => {
|
|
34
|
+
const policy = sealPolicy({
|
|
35
|
+
version: '2026-09-treasury-v3',
|
|
36
|
+
maxValuePerActionUsd: 1_000,
|
|
37
|
+
allowedCounterparties: ['0x2222222222222222222222222222222222222222'],
|
|
38
|
+
spendWindows: [{ label: 'daily', windowMs: 86_400_000, maxValueUsd: 10_000 }],
|
|
39
|
+
});
|
|
40
|
+
const decision = evaluatePolicy({ action: action(), policy, assessment: CLEAN, now: NOW });
|
|
41
|
+
const explanation = explainAgentAction(decision);
|
|
42
|
+
|
|
43
|
+
it('names each rule that permitted it, with the limit and what was seen', () => {
|
|
44
|
+
expect(decision.verdict).toBe('allow');
|
|
45
|
+
|
|
46
|
+
const value = explanation.rulesApplied.find((r) => r.rule === 'VALUE_PER_ACTION');
|
|
47
|
+
expect(value?.outcome).toBe('pass');
|
|
48
|
+
expect(value?.limit).toBe(1_000);
|
|
49
|
+
expect(value?.observed).toBe(250);
|
|
50
|
+
|
|
51
|
+
expect(explanation.text).toContain('VALUE_PER_ACTION');
|
|
52
|
+
expect(explanation.text).toContain('[limit 1000]');
|
|
53
|
+
expect(explanation.text).toContain('[observed 250]');
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* The difference an operator most needs and is most often denied: "the rate
|
|
58
|
+
* ceiling was checked and this was the third action this minute" versus
|
|
59
|
+
* "there is no rate ceiling". A summary renders both as a pass.
|
|
60
|
+
*/
|
|
61
|
+
it('separates what was checked from what was never bounded', () => {
|
|
62
|
+
expect(explanation.unbounded.join(' ')).toMatch(/no rate ceiling/i);
|
|
63
|
+
expect(explanation.headline).toMatch(/unbounded by this policy/);
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
/** An allow must be tied to the exact rules and the exact action it ran on. */
|
|
67
|
+
it('carries enough provenance to reproduce the decision', () => {
|
|
68
|
+
expect(explanation.provenance.policyFingerprint).toBe(policy.fingerprint);
|
|
69
|
+
expect(explanation.provenance.policyVersion).toBe('2026-09-treasury-v3');
|
|
70
|
+
expect(explanation.provenance.actionDigest).toBe(decision.actionDigest);
|
|
71
|
+
expect(explanation.provenance.riskLevel).toBe('NO_CRITICAL_RISK_DETECTED');
|
|
72
|
+
expect(explanation.provenance.riskModelVersion).toBe(CLEAN.modelVersion);
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
it('asks nothing of the operator when nothing needs changing', () => {
|
|
76
|
+
expect(explanation.because).toEqual([]);
|
|
77
|
+
expect(explanation.toProceed).toEqual([]);
|
|
78
|
+
});
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
describe('explaining a refusal', () => {
|
|
82
|
+
it('leads with the rule that stopped it and what the operator would change', () => {
|
|
83
|
+
const policy = sealPolicy({ maxValuePerActionUsd: 100 });
|
|
84
|
+
const decision = evaluatePolicy({
|
|
85
|
+
action: action({ valueUsd: 5_000 }), policy, assessment: CLEAN, now: NOW,
|
|
86
|
+
});
|
|
87
|
+
const explanation = explainAgentAction(decision);
|
|
88
|
+
|
|
89
|
+
expect(explanation.verdict).toBe('deny');
|
|
90
|
+
expect(explanation.headline).toMatch(/^Denied: /);
|
|
91
|
+
expect(explanation.because.join(' ')).toMatch(/exceeds the \$100 per-action cap/);
|
|
92
|
+
expect(explanation.toProceed.join(' ')).toMatch(/maxValuePerActionUsd/);
|
|
93
|
+
});
|
|
94
|
+
|
|
95
|
+
/**
|
|
96
|
+
* The remediation is addressed to the operator, who is the only party able
|
|
97
|
+
* to change a sealed policy. An agent can read it and still cannot act on
|
|
98
|
+
* it — which is what `sealPolicy` is for.
|
|
99
|
+
*/
|
|
100
|
+
it('does not repeat itself when several rules share one remedy', () => {
|
|
101
|
+
const policy = sealPolicy({ allowedCounterparties: ['0xaaa'], blockedCounterparties: ['0xbbb'] });
|
|
102
|
+
const decision = evaluatePolicy({
|
|
103
|
+
action: action({ to: '0xbbb' }), policy, assessment: CLEAN, now: NOW,
|
|
104
|
+
});
|
|
105
|
+
const explanation = explainAgentAction(decision);
|
|
106
|
+
|
|
107
|
+
expect(new Set(explanation.toProceed).size).toBe(explanation.toProceed.length);
|
|
108
|
+
});
|
|
109
|
+
|
|
110
|
+
it('distinguishes a hold for a person from a refusal', () => {
|
|
111
|
+
const policy = sealPolicy({ humanApprovalAboveUsd: 100 });
|
|
112
|
+
const explanation = explainAgentAction(evaluatePolicy({
|
|
113
|
+
action: action({ valueUsd: 5_000 }), policy, assessment: CLEAN, now: NOW,
|
|
114
|
+
}));
|
|
115
|
+
|
|
116
|
+
expect(explanation.verdict).toBe('require_human_approval');
|
|
117
|
+
expect(explanation.headline).toMatch(/^Held for human approval/);
|
|
118
|
+
expect(explanation.text).toContain('HOLD ');
|
|
119
|
+
});
|
|
120
|
+
|
|
121
|
+
it('says plainly when no risk assessment was behind the decision', () => {
|
|
122
|
+
const explanation = explainAgentAction(evaluatePolicy({
|
|
123
|
+
action: action(), policy: sealPolicy({}), assessment: null,
|
|
124
|
+
assessmentUnavailable: 'timeout after 4000ms', now: NOW,
|
|
125
|
+
}));
|
|
126
|
+
|
|
127
|
+
expect(explanation.text).toMatch(/not assessed — timeout after 4000ms/);
|
|
128
|
+
expect(explanation.provenance.riskLevel).toBeUndefined();
|
|
129
|
+
});
|
|
130
|
+
});
|
|
131
|
+
|
|
132
|
+
describe('the explanation is a rendering, not a second opinion', () => {
|
|
133
|
+
/**
|
|
134
|
+
* An explanation generated separately from the decision eventually explains
|
|
135
|
+
* a decision that was not made. This one can only restate the record.
|
|
136
|
+
*/
|
|
137
|
+
it('reports exactly the verdict and rules the decision recorded', () => {
|
|
138
|
+
const decision = evaluatePolicy({
|
|
139
|
+
action: action({ valueUsd: 5_000 }), policy: sealPolicy({ maxValuePerActionUsd: 100 }),
|
|
140
|
+
assessment: CLEAN, now: NOW,
|
|
141
|
+
});
|
|
142
|
+
const explanation = explainAgentAction(decision);
|
|
143
|
+
|
|
144
|
+
expect(explanation.verdict).toBe(decision.verdict);
|
|
145
|
+
expect(explanation.rulesApplied).toBe(decision.evaluations);
|
|
146
|
+
expect(explanation.decisionId).toBe(decision.decisionId);
|
|
147
|
+
});
|
|
148
|
+
});
|