@decentrys/agent 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/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
+ });