@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/explain.ts ADDED
@@ -0,0 +1,204 @@
1
+ /**
2
+ * Turning a decision into something a person can be held to.
3
+ *
4
+ * The question this file exists to answer is not "why was that blocked" — a
5
+ * block explains itself, loudly, at the moment it happens. It is **"why was
6
+ * that allowed"**, asked weeks later by someone looking at a drained account.
7
+ * A record that lists only what went wrong cannot answer it, so
8
+ * `explainAgentAction` renders every rule that was applied, the bound it
9
+ * carried and the value it saw, alongside the axes on which the policy set no
10
+ * bound at all. An allow with nothing behind it reads, correctly, as an allow
11
+ * with nothing behind it.
12
+ *
13
+ * Nothing here recomputes anything. The explanation is a rendering of the
14
+ * decision record and cannot disagree with it — an explanation that is
15
+ * generated separately from the decision eventually explains a decision that
16
+ * was not made.
17
+ */
18
+
19
+ import { RISK_LEVEL_MEANING } from '@decentrys/protect';
20
+
21
+ import type { AgentDecision, AgentRuleId, AgentVerdict, RuleEvaluation } from './model';
22
+
23
+ export interface AgentActionExplanation {
24
+ decisionId: string;
25
+ agentId: string;
26
+ verdict: AgentVerdict;
27
+ /** One sentence. What happened and, at the top level, why. */
28
+ headline: string;
29
+ /** The failing or escalating rules, in the words they recorded. */
30
+ because: string[];
31
+ /**
32
+ * Every rule that ran, passes included. This is the part that makes an
33
+ * allow explainable rather than merely unobjected-to.
34
+ */
35
+ rulesApplied: RuleEvaluation[];
36
+ /**
37
+ * Axes this policy left unbounded.
38
+ *
39
+ * An operator reading an allow deserves to know the difference between "the
40
+ * rate ceiling was checked and this was the third action this minute" and
41
+ * "there is no rate ceiling". Both produce a pass in the eyes of a summary;
42
+ * only one of them is a control.
43
+ */
44
+ unbounded: string[];
45
+ /**
46
+ * What would have to change for this to be permitted.
47
+ *
48
+ * Addressed to the operator, who is the only party that can change a sealed
49
+ * policy. The agent can read this and still cannot act on it, which is the
50
+ * point of `sealPolicy`.
51
+ */
52
+ toProceed: string[];
53
+ /** Enough to reproduce the decision against the same inputs. */
54
+ provenance: {
55
+ policyFingerprint: string;
56
+ policyVersion?: string;
57
+ actionDigest: string;
58
+ modelVersion: string;
59
+ riskModelVersion?: string;
60
+ riskLevel?: string;
61
+ decidedAt: string;
62
+ };
63
+ /** A plain-text rendering, for a log line, a ticket or a human review queue. */
64
+ text: string;
65
+ }
66
+
67
+ const VERDICT_HEADLINE: Record<AgentVerdict, string> = {
68
+ allow: 'Permitted',
69
+ require_human_approval: 'Held for human approval',
70
+ deny: 'Denied',
71
+ };
72
+
73
+ /** What an operator would change, per rule, to permit the action. */
74
+ const REMEDIATION: Record<AgentRuleId, string> = {
75
+ AGENT_BINDING: 'Use the policy sealed for this agent, or bind this policy to it.',
76
+ ACTION_TYPE: 'Add this action type to `allowedActions`, or remove it from `deniedActions`.',
77
+ CHAIN: 'Add this chain to `allowedChains`.',
78
+ COUNTERPARTY: 'Add this counterparty to `allowedCounterparties`, or remove it from `blockedCounterparties`.',
79
+ CONTRACT: 'Add this contract to `allowedContracts`.',
80
+ TOKEN: 'Add this token to `allowedTokens`.',
81
+ APPROVAL_ALLOWANCE: 'Have the agent request a bounded allowance, or set `allowUnlimitedApprovals` — '
82
+ + 'which makes any later compromise of the spender unbounded.',
83
+ VALUE_PER_ACTION: 'Raise `maxValuePerActionUsd`, or state the action\'s `valueUsd`.',
84
+ CUMULATIVE_SPEND: 'Wait for the window to roll over, raise the window\'s `maxValueUsd`, or state the '
85
+ + 'action\'s `valueUsd`.',
86
+ RATE_LIMIT: 'Wait for the window to roll over, or raise the window\'s `maxActions`. A rate ceiling being '
87
+ + 'hit repeatedly is usually the agent looping, not the ceiling being wrong.',
88
+ RISK_LEVEL: 'A person should review the evidence on the decision\'s assessment before this is permitted.',
89
+ ASSESSMENT_AVAILABILITY: 'Restore connectivity to Decentrys, or accept unscreened actions by setting '
90
+ + '`failMode: \'open\'` — which signs during precisely the window an attacker would choose.',
91
+ HUMAN_APPROVAL_THRESHOLD: 'A person must approve this action. Raise the threshold only if the operator '
92
+ + 'intends actions of this size to proceed unattended.',
93
+ };
94
+
95
+ export function explainAgentAction(decision: AgentDecision): AgentActionExplanation {
96
+ const failing = decision.evaluations.filter((e) => e.outcome === 'fail');
97
+ const escalating = decision.evaluations.filter((e) => e.outcome === 'escalate');
98
+ const blocking = [...failing, ...escalating];
99
+
100
+ const headline = buildHeadline(decision, failing, escalating);
101
+ const because = blocking.map((e) => e.statement);
102
+
103
+ const unbounded = decision.evaluations
104
+ .filter((e) => e.outcome === 'not_configured')
105
+ .map((e) => e.statement);
106
+
107
+ // Deduplicated: two rules can share a remedy, and telling an operator the
108
+ // same thing twice trains them to skim the list.
109
+ const toProceed = [...new Set(blocking.map((e) => REMEDIATION[e.rule]))];
110
+
111
+ const explanation: AgentActionExplanation = {
112
+ decisionId: decision.decisionId,
113
+ agentId: decision.agentId,
114
+ verdict: decision.verdict,
115
+ headline,
116
+ because,
117
+ rulesApplied: decision.evaluations,
118
+ unbounded,
119
+ toProceed,
120
+ provenance: {
121
+ policyFingerprint: decision.policyFingerprint,
122
+ ...(decision.policyVersion === undefined ? {} : { policyVersion: decision.policyVersion }),
123
+ actionDigest: decision.actionDigest,
124
+ modelVersion: decision.modelVersion,
125
+ ...(decision.assessment === undefined ? {} : {
126
+ riskModelVersion: decision.assessment.modelVersion,
127
+ riskLevel: decision.assessment.riskLevel,
128
+ }),
129
+ decidedAt: decision.decidedAt,
130
+ },
131
+ text: '',
132
+ };
133
+
134
+ explanation.text = render(decision, explanation);
135
+ return explanation;
136
+ }
137
+
138
+ function buildHeadline(
139
+ decision: AgentDecision, failing: RuleEvaluation[], escalating: RuleEvaluation[],
140
+ ): string {
141
+ const prefix = VERDICT_HEADLINE[decision.verdict];
142
+ if (decision.verdict === 'allow') {
143
+ const checked = decision.evaluations.filter((e) => e.outcome === 'pass').length;
144
+ const unconfigured = decision.evaluations.filter((e) => e.outcome === 'not_configured').length;
145
+ return `${prefix}: ${checked} ${checked === 1 ? 'rule' : 'rules'} were applied and passed`
146
+ + (unconfigured > 0
147
+ ? `, and ${unconfigured} ${unconfigured === 1 ? 'axis was' : 'axes were'} left unbounded by this policy.`
148
+ : '.');
149
+ }
150
+ const driving = failing[0] ?? escalating[0];
151
+ return driving
152
+ ? `${prefix}: ${driving.statement}`
153
+ : `${prefix}.`;
154
+ }
155
+
156
+ function render(decision: AgentDecision, explanation: AgentActionExplanation): string {
157
+ const lines: string[] = [
158
+ `${explanation.headline}`,
159
+ '',
160
+ `Agent: ${decision.agentId}`,
161
+ `Decision: ${decision.decisionId} at ${decision.decidedAt}`,
162
+ `Policy: ${decision.policyVersion ? `${decision.policyVersion} ` : ''}#${decision.policyFingerprint}`,
163
+ `Action: #${decision.actionDigest}`,
164
+ ];
165
+
166
+ if (decision.assessment) {
167
+ lines.push(
168
+ `Risk: ${decision.assessment.riskLevel} `
169
+ + `(confidence ${decision.assessment.confidence}) — `
170
+ + `${RISK_LEVEL_MEANING[decision.assessment.riskLevel]}`,
171
+ );
172
+ } else if (decision.assessmentUnavailable) {
173
+ lines.push(`Risk: not assessed — ${decision.assessmentUnavailable}`);
174
+ }
175
+
176
+ lines.push('', 'Rules applied:');
177
+ for (const rule of decision.evaluations) {
178
+ const bound = rule.limit === undefined ? '' : ` [limit ${rule.limit}]`;
179
+ const seen = rule.observed === undefined ? '' : ` [observed ${rule.observed}]`;
180
+ lines.push(` ${symbolFor(rule.outcome)} ${rule.rule}: ${rule.statement}${bound}${seen}`);
181
+ }
182
+
183
+ if (explanation.unbounded.length > 0) {
184
+ lines.push('', 'Left unbounded by this policy:');
185
+ for (const line of explanation.unbounded) lines.push(` - ${line}`);
186
+ }
187
+
188
+ if (explanation.toProceed.length > 0) {
189
+ lines.push('', 'For this to proceed, the operator would have to:');
190
+ for (const line of explanation.toProceed) lines.push(` - ${line}`);
191
+ }
192
+
193
+ return lines.join('\n');
194
+ }
195
+
196
+ function symbolFor(outcome: RuleEvaluation['outcome']): string {
197
+ switch (outcome) {
198
+ case 'pass': return 'PASS ';
199
+ case 'fail': return 'FAIL ';
200
+ case 'escalate': return 'HOLD ';
201
+ case 'not_applicable': return 'n/a ';
202
+ case 'not_configured': return 'unset';
203
+ }
204
+ }
package/src/index.ts ADDED
@@ -0,0 +1,5 @@
1
+ export * from './model';
2
+ export * from './policy';
3
+ export * from './ledger';
4
+ export * from './explain';
5
+ export * from './client';
@@ -0,0 +1,192 @@
1
+ import { describe, expect, it } from 'vitest';
2
+ import { SpendLedger, isReservationLedger } from './ledger';
3
+
4
+ /**
5
+ * The ledger is where a cumulative limit either works or quietly does not.
6
+ *
7
+ * Its whole job is to be right about time and about double-counting: a hold
8
+ * that never expires strangles an agent that crashed, a hold that expires too
9
+ * eagerly lets one through, and a retry counted twice shrinks a budget the
10
+ * operator set. None of these fail loudly — they fail as a number being
11
+ * wrong — so they are tested against an injected clock rather than by waiting.
12
+ */
13
+
14
+ const T0 = new Date('2026-09-06T12:00:00Z').getTime();
15
+ const AGENT = 'agent-1';
16
+
17
+ function ledgerAt(nowMs: { value: number }, options: { reservationTtlMs?: number; retentionMs?: number } = {}) {
18
+ return new SpendLedger({ ...options, clock: () => nowMs.value });
19
+ }
20
+
21
+ describe('reservations', () => {
22
+ /**
23
+ * The race this exists to close. An agent asking a hundred times before the
24
+ * first answer is recorded would otherwise get a hundred allows against a
25
+ * cap of one.
26
+ */
27
+ it('counts a held reservation immediately, before anything is confirmed', () => {
28
+ const now = { value: T0 };
29
+ const ledger = ledgerAt(now);
30
+
31
+ ledger.reserve({ decisionId: 'd1', agentId: AGENT, valueUsd: 400 });
32
+
33
+ expect(ledger.spentUsdSince(AGENT, T0 - 60_000)).toBe(400);
34
+ expect(ledger.reservation('d1')?.state).toBe('held');
35
+ });
36
+
37
+ it('keeps the value once the action is confirmed', () => {
38
+ const now = { value: T0 };
39
+ const ledger = ledgerAt(now);
40
+ ledger.reserve({ decisionId: 'd1', agentId: AGENT, valueUsd: 400 });
41
+
42
+ const outcome = ledger.confirm('d1', { txHash: '0xabc' });
43
+
44
+ expect(outcome?.state).toBe('confirmed');
45
+ expect(outcome?.txHash).toBe('0xabc');
46
+ expect(ledger.spentUsdSince(AGENT, T0 - 60_000)).toBe(400);
47
+ });
48
+
49
+ it('returns the value to the budget when the action did not happen', () => {
50
+ const now = { value: T0 };
51
+ const ledger = ledgerAt(now);
52
+ ledger.reserve({ decisionId: 'd1', agentId: AGENT, valueUsd: 400 });
53
+
54
+ ledger.release('d1', { note: 'the swap route disappeared' });
55
+
56
+ expect(ledger.spentUsdSince(AGENT, T0 - 60_000)).toBe(0);
57
+ });
58
+
59
+ /**
60
+ * An agent that crashed between the allow and the broadcast must not hold
61
+ * its own budget until the window rolls over — that is an outage caused by
62
+ * the guard rather than by anything dangerous.
63
+ */
64
+ it('expires a hold nobody settled, and gives the budget back', () => {
65
+ const now = { value: T0 };
66
+ const ledger = ledgerAt(now, { reservationTtlMs: 300_000 });
67
+ ledger.reserve({ decisionId: 'd1', agentId: AGENT, valueUsd: 400 });
68
+
69
+ now.value = T0 + 299_000;
70
+ expect(ledger.spentUsdSince(AGENT, T0 - 60_000)).toBe(400);
71
+
72
+ now.value = T0 + 301_000;
73
+ expect(ledger.spentUsdSince(AGENT, T0 - 60_000)).toBe(0);
74
+ expect(ledger.reservation('d1')?.state).toBe('expired');
75
+ });
76
+
77
+ /**
78
+ * A spend that lands after its hold lapsed is real money that escaped the
79
+ * cap. The total is corrected, and the record says the TTL let it through
80
+ * rather than quietly absorbing it — that is a tuning signal, not noise.
81
+ */
82
+ it('records that a hold had already lapsed when a late confirmation arrives', () => {
83
+ const now = { value: T0 };
84
+ const ledger = ledgerAt(now, { reservationTtlMs: 60_000 });
85
+ ledger.reserve({ decisionId: 'd1', agentId: AGENT, valueUsd: 400 });
86
+
87
+ now.value = T0 + 120_000;
88
+ ledger.spentUsdSince(AGENT, 0); // triggers expiry
89
+ const outcome = ledger.confirm('d1');
90
+
91
+ expect(outcome?.state).toBe('confirmed');
92
+ expect(outcome?.note).toMatch(/already expired/i);
93
+ expect(ledger.spentUsdSince(AGENT, 0)).toBe(400);
94
+ });
95
+
96
+ /**
97
+ * A retry after a timeout is one action. Counting it twice would tighten an
98
+ * agent's own cap against it for no reason — and would do so precisely when
99
+ * the network is already unreliable.
100
+ */
101
+ it('does not charge a retried action twice', () => {
102
+ const now = { value: T0 };
103
+ const ledger = ledgerAt(now);
104
+
105
+ const first = ledger.reserve({ decisionId: 'd1', agentId: AGENT, valueUsd: 400, idempotencyKey: 'task-7' });
106
+ const second = ledger.reserve({ decisionId: 'd2', agentId: AGENT, valueUsd: 400, idempotencyKey: 'task-7' });
107
+
108
+ expect(second.decisionId).toBe(first.decisionId);
109
+ expect(ledger.spentUsdSince(AGENT, 0)).toBe(400);
110
+ });
111
+
112
+ /** Idempotency keys are the caller's, so one agent's cannot collide with another's. */
113
+ it('namespaces idempotency keys per agent', () => {
114
+ const now = { value: T0 };
115
+ const ledger = ledgerAt(now);
116
+
117
+ ledger.reserve({ decisionId: 'd1', agentId: 'agent-a', valueUsd: 400, idempotencyKey: 'task-7' });
118
+ ledger.reserve({ decisionId: 'd2', agentId: 'agent-b', valueUsd: 400, idempotencyKey: 'task-7' });
119
+
120
+ expect(ledger.spentUsdSince('agent-a', 0)).toBe(400);
121
+ expect(ledger.spentUsdSince('agent-b', 0)).toBe(400);
122
+ });
123
+
124
+ /** A negative value would be a credit against the agent's own cap. */
125
+ it('refuses to let a negative value buy back budget', () => {
126
+ const now = { value: T0 };
127
+ const ledger = ledgerAt(now);
128
+
129
+ ledger.reserve({ decisionId: 'd1', agentId: AGENT, valueUsd: 1_000 });
130
+ ledger.reserve({ decisionId: 'd2', agentId: AGENT, valueUsd: -900 });
131
+
132
+ expect(ledger.spentUsdSince(AGENT, 0)).toBe(1_000);
133
+ });
134
+
135
+ it('ignores a value that is not a number at all', () => {
136
+ const now = { value: T0 };
137
+ const ledger = ledgerAt(now);
138
+ ledger.reserve({ decisionId: 'd1', agentId: AGENT, valueUsd: Number.NaN });
139
+
140
+ expect(ledger.spentUsdSince(AGENT, 0)).toBe(0);
141
+ });
142
+
143
+ it('settles nothing for a decision it never issued', () => {
144
+ const ledger = ledgerAt({ value: T0 });
145
+ expect(ledger.confirm('never-existed')).toBeNull();
146
+ expect(ledger.release('never-existed')).toBeNull();
147
+ });
148
+ });
149
+
150
+ describe('retention', () => {
151
+ /**
152
+ * Retention must outlive the longest policy window, or a cumulative cap
153
+ * silently stops being cumulative — the most dangerous kind of expiry,
154
+ * because the guard keeps answering and the answers become wrong.
155
+ */
156
+ it('drops entries only once they are older than the retention period', () => {
157
+ const now = { value: T0 };
158
+ const ledger = ledgerAt(now, { retentionMs: 86_400_000 });
159
+ ledger.reserve({ decisionId: 'd1', agentId: AGENT, valueUsd: 400 });
160
+ ledger.confirm('d1');
161
+
162
+ now.value = T0 + 86_000_000;
163
+ expect(ledger.list(AGENT)).toHaveLength(1);
164
+
165
+ now.value = T0 + 90_000_000;
166
+ ledger.spentUsdSince(AGENT, 0);
167
+ expect(ledger.list(AGENT)).toHaveLength(0);
168
+ });
169
+
170
+ it('hands out copies, so a caller cannot edit the ledger through them', () => {
171
+ const ledger = ledgerAt({ value: T0 });
172
+ ledger.reserve({ decisionId: 'd1', agentId: AGENT, valueUsd: 400 });
173
+
174
+ const snapshot = ledger.list();
175
+ snapshot[0]!.valueUsd = 0;
176
+
177
+ expect(ledger.spentUsdSince(AGENT, 0)).toBe(400);
178
+ });
179
+ });
180
+
181
+ describe('isReservationLedger', () => {
182
+ /**
183
+ * A read-only usage source (a warehouse query, a metrics store) can answer
184
+ * how much an agent has spent without being able to take a hold. The guard
185
+ * has to be able to tell, because the difference is invisible until two
186
+ * concurrent actions that each fit under a cap are both admitted.
187
+ */
188
+ it('distinguishes a source that can hold budget from one that can only be read', () => {
189
+ expect(isReservationLedger(new SpendLedger())).toBe(true);
190
+ expect(isReservationLedger({ spentUsdSince: () => 0, actionsSince: () => 0 })).toBe(false);
191
+ });
192
+ });