@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/ledger.ts
ADDED
|
@@ -0,0 +1,303 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Cumulative spend and rate accounting.
|
|
3
|
+
*
|
|
4
|
+
* ## What this is, honestly
|
|
5
|
+
*
|
|
6
|
+
* **This ledger is in-process and in-memory.** It counts what one instance of
|
|
7
|
+
* one process admitted. It is not shared, not durable, and not correct across
|
|
8
|
+
* a restart or a horizontal scale-out: two replicas each enforcing a $10,000
|
|
9
|
+
* daily cap enforce $20,000 between them, and a restart resets the day to
|
|
10
|
+
* zero.
|
|
11
|
+
*
|
|
12
|
+
* That is stated rather than papered over, and the shape of the API is chosen
|
|
13
|
+
* so it can be fixed without a rewrite: `evaluatePolicy` depends only on the
|
|
14
|
+
* `AgentUsage` interface below, so a Redis- or Postgres-backed implementation
|
|
15
|
+
* of two methods replaces this class entirely. A distributed limit needs
|
|
16
|
+
* shared storage with atomic reservation, which is a later concern and a real
|
|
17
|
+
* one — do not deploy a fleet of agents behind this class and describe the cap
|
|
18
|
+
* as enforced.
|
|
19
|
+
*
|
|
20
|
+
* ## Why reservations, rather than counting on the way out
|
|
21
|
+
*
|
|
22
|
+
* An agent asks "may I", gets an allow, and then signs. If budget were only
|
|
23
|
+
* counted when the transaction is confirmed, an agent could ask a hundred
|
|
24
|
+
* times before the first answer was recorded and get a hundred allows against
|
|
25
|
+
* a cap of one. So an admitted decision holds its value immediately, and the
|
|
26
|
+
* hold is released when the caller says the action did not happen or when it
|
|
27
|
+
* expires unclaimed. The failure direction is a briefly over-tight budget,
|
|
28
|
+
* which is the right way for this to be wrong.
|
|
29
|
+
*/
|
|
30
|
+
|
|
31
|
+
import type { AgentOutcomeRecord } from './model';
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* What `evaluatePolicy` needs to know about the past.
|
|
35
|
+
*
|
|
36
|
+
* Deliberately two read methods and nothing else. Anything satisfying this can
|
|
37
|
+
* be handed to the evaluator — including a shared store, which is how this
|
|
38
|
+
* becomes correct across processes.
|
|
39
|
+
*/
|
|
40
|
+
export interface AgentUsage {
|
|
41
|
+
/** Value held or committed by this agent at or after `sinceMs`. */
|
|
42
|
+
spentUsdSince(agentId: string, sinceMs: number): number;
|
|
43
|
+
/** Decisions this guard admitted for the agent at or after `sinceMs`. */
|
|
44
|
+
actionsSince(agentId: string, sinceMs: number): number;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* A usage source that can also hold budget.
|
|
49
|
+
*
|
|
50
|
+
* Kept separate from `AgentUsage` because the two are genuinely different
|
|
51
|
+
* capabilities: a read-only source (a warehouse query, a metrics store) can
|
|
52
|
+
* answer "how much has this agent spent today" without being able to take a
|
|
53
|
+
* hold. `AgentGuard` accepts either, and says which it got — a guard running
|
|
54
|
+
* on a read-only source enforces cumulative caps against *settled* history
|
|
55
|
+
* only, which cannot stop two concurrent actions that each fit alone.
|
|
56
|
+
*/
|
|
57
|
+
export interface ReservationLedger extends AgentUsage {
|
|
58
|
+
reserve(input: {
|
|
59
|
+
decisionId: string;
|
|
60
|
+
agentId: string;
|
|
61
|
+
valueUsd: number;
|
|
62
|
+
idempotencyKey?: string;
|
|
63
|
+
now?: Date;
|
|
64
|
+
}): Reservation;
|
|
65
|
+
confirm(decisionId: string, options?: { txHash?: string; now?: Date }): AgentOutcomeRecord | null;
|
|
66
|
+
release(decisionId: string, options?: { note?: string; now?: Date }): AgentOutcomeRecord | null;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
export function isReservationLedger(usage: AgentUsage): usage is ReservationLedger {
|
|
70
|
+
const candidate = usage as Partial<ReservationLedger>;
|
|
71
|
+
return typeof candidate.reserve === 'function'
|
|
72
|
+
&& typeof candidate.confirm === 'function'
|
|
73
|
+
&& typeof candidate.release === 'function';
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
export type ReservationState = 'held' | 'confirmed' | 'released' | 'expired';
|
|
77
|
+
|
|
78
|
+
export interface Reservation {
|
|
79
|
+
decisionId: string;
|
|
80
|
+
agentId: string;
|
|
81
|
+
valueUsd: number;
|
|
82
|
+
idempotencyKey?: string;
|
|
83
|
+
state: ReservationState;
|
|
84
|
+
createdAtMs: number;
|
|
85
|
+
expiresAtMs: number;
|
|
86
|
+
settledAtMs?: number;
|
|
87
|
+
txHash?: string;
|
|
88
|
+
note?: string;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
export interface SpendLedgerOptions {
|
|
92
|
+
/**
|
|
93
|
+
* How long an unsettled hold survives. Default 5 minutes — long enough for a
|
|
94
|
+
* signature and a broadcast, short enough that an agent that crashed
|
|
95
|
+
* mid-action does not hold its budget until the window rolls over.
|
|
96
|
+
*/
|
|
97
|
+
reservationTtlMs?: number;
|
|
98
|
+
/**
|
|
99
|
+
* How long settled entries are kept. Must exceed the longest policy window,
|
|
100
|
+
* or a cumulative cap silently stops being cumulative. Default 8 days.
|
|
101
|
+
*/
|
|
102
|
+
retentionMs?: number;
|
|
103
|
+
/** A hard ceiling on entries, so a runaway agent cannot exhaust memory. */
|
|
104
|
+
maxEntries?: number;
|
|
105
|
+
/**
|
|
106
|
+
* The clock, injected for the same reason Protect injects `fetch`: expiry
|
|
107
|
+
* and retention are time-dependent behaviour, and behaviour that can only be
|
|
108
|
+
* exercised by waiting is behaviour that does not get tested.
|
|
109
|
+
*/
|
|
110
|
+
clock?: () => number;
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
const DEFAULT_RESERVATION_TTL_MS = 5 * 60_000;
|
|
114
|
+
const DEFAULT_RETENTION_MS = 8 * 24 * 60 * 60_000;
|
|
115
|
+
const DEFAULT_MAX_ENTRIES = 50_000;
|
|
116
|
+
|
|
117
|
+
export class SpendLedger implements ReservationLedger {
|
|
118
|
+
private readonly reservationTtlMs: number;
|
|
119
|
+
private readonly retentionMs: number;
|
|
120
|
+
private readonly maxEntries: number;
|
|
121
|
+
private readonly clock: () => number;
|
|
122
|
+
private readonly entries: Reservation[] = [];
|
|
123
|
+
private readonly byDecision = new Map<string, Reservation>();
|
|
124
|
+
private readonly byIdempotencyKey = new Map<string, Reservation>();
|
|
125
|
+
|
|
126
|
+
constructor(options: SpendLedgerOptions = {}) {
|
|
127
|
+
this.reservationTtlMs = options.reservationTtlMs ?? DEFAULT_RESERVATION_TTL_MS;
|
|
128
|
+
this.retentionMs = options.retentionMs ?? DEFAULT_RETENTION_MS;
|
|
129
|
+
this.maxEntries = options.maxEntries ?? DEFAULT_MAX_ENTRIES;
|
|
130
|
+
this.clock = options.clock ?? (() => Date.now());
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
/**
|
|
134
|
+
* Hold budget for an admitted decision.
|
|
135
|
+
*
|
|
136
|
+
* A repeated `idempotencyKey` returns the existing hold instead of taking a
|
|
137
|
+
* second one. An agent retrying a call after a timeout is one action, and
|
|
138
|
+
* counting it twice would tighten its own cap against it for no reason.
|
|
139
|
+
*/
|
|
140
|
+
reserve(input: {
|
|
141
|
+
decisionId: string;
|
|
142
|
+
agentId: string;
|
|
143
|
+
valueUsd: number;
|
|
144
|
+
idempotencyKey?: string;
|
|
145
|
+
now?: Date;
|
|
146
|
+
}): Reservation {
|
|
147
|
+
const nowMs = input.now ? input.now.getTime() : this.clock();
|
|
148
|
+
this.prune(nowMs);
|
|
149
|
+
|
|
150
|
+
if (input.idempotencyKey) {
|
|
151
|
+
const existing = this.byIdempotencyKey.get(this.keyFor(input.agentId, input.idempotencyKey));
|
|
152
|
+
if (existing && existing.state !== 'released' && existing.state !== 'expired') return existing;
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
const reservation: Reservation = {
|
|
156
|
+
decisionId: input.decisionId,
|
|
157
|
+
agentId: input.agentId,
|
|
158
|
+
// A negative value would be a credit against the agent's own cap, which
|
|
159
|
+
// is a way to spend more than the cap allows. Clamped, not trusted.
|
|
160
|
+
valueUsd: Number.isFinite(input.valueUsd) ? Math.max(0, input.valueUsd) : 0,
|
|
161
|
+
...(input.idempotencyKey ? { idempotencyKey: input.idempotencyKey } : {}),
|
|
162
|
+
state: 'held',
|
|
163
|
+
createdAtMs: nowMs,
|
|
164
|
+
expiresAtMs: nowMs + this.reservationTtlMs,
|
|
165
|
+
};
|
|
166
|
+
|
|
167
|
+
this.entries.push(reservation);
|
|
168
|
+
this.byDecision.set(reservation.decisionId, reservation);
|
|
169
|
+
if (input.idempotencyKey) {
|
|
170
|
+
this.byIdempotencyKey.set(this.keyFor(input.agentId, input.idempotencyKey), reservation);
|
|
171
|
+
}
|
|
172
|
+
this.enforceCeiling();
|
|
173
|
+
return reservation;
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
/** The action happened. The hold becomes a permanent commitment. */
|
|
177
|
+
confirm(decisionId: string, options: { txHash?: string; now?: Date } = {}): AgentOutcomeRecord | null {
|
|
178
|
+
return this.settle(decisionId, 'confirmed', {
|
|
179
|
+
...(options.txHash === undefined ? {} : { txHash: options.txHash }),
|
|
180
|
+
...(options.now === undefined ? {} : { now: options.now }),
|
|
181
|
+
});
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
/** The action did not happen. The hold is returned to the budget. */
|
|
185
|
+
release(decisionId: string, options: { note?: string; now?: Date } = {}): AgentOutcomeRecord | null {
|
|
186
|
+
return this.settle(decisionId, 'released', {
|
|
187
|
+
...(options.note === undefined ? {} : { note: options.note }),
|
|
188
|
+
...(options.now === undefined ? {} : { now: options.now }),
|
|
189
|
+
});
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
/**
|
|
193
|
+
* Value held or committed in the window.
|
|
194
|
+
*
|
|
195
|
+
* Released and expired holds do not count. They are actions that did not
|
|
196
|
+
* happen, and charging an agent for them would shrink a cap the operator
|
|
197
|
+
* set for real spending.
|
|
198
|
+
*/
|
|
199
|
+
spentUsdSince(agentId: string, sinceMs: number): number {
|
|
200
|
+
this.prune(this.clock());
|
|
201
|
+
let total = 0;
|
|
202
|
+
for (const entry of this.entries) {
|
|
203
|
+
if (entry.agentId !== agentId) continue;
|
|
204
|
+
if (entry.state === 'released' || entry.state === 'expired') continue;
|
|
205
|
+
if (entry.createdAtMs < sinceMs) continue;
|
|
206
|
+
total += entry.valueUsd;
|
|
207
|
+
}
|
|
208
|
+
return total;
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
/**
|
|
212
|
+
* Decisions admitted in the window — regardless of how they settled.
|
|
213
|
+
*
|
|
214
|
+
* A rate ceiling counts what the guard admitted, not what landed on chain.
|
|
215
|
+
* An agent that spins through assess-and-abandon at machine speed is exactly
|
|
216
|
+
* the loop the ceiling exists to catch, and it would be invisible to a
|
|
217
|
+
* counter that only saw confirmed transactions.
|
|
218
|
+
*/
|
|
219
|
+
actionsSince(agentId: string, sinceMs: number): number {
|
|
220
|
+
this.prune(this.clock());
|
|
221
|
+
let count = 0;
|
|
222
|
+
for (const entry of this.entries) {
|
|
223
|
+
if (entry.agentId !== agentId) continue;
|
|
224
|
+
if (entry.createdAtMs < sinceMs) continue;
|
|
225
|
+
count += 1;
|
|
226
|
+
}
|
|
227
|
+
return count;
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
reservation(decisionId: string): Reservation | undefined {
|
|
231
|
+
return this.byDecision.get(decisionId);
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
/** Snapshot for inspection. Copies, so a caller cannot edit the ledger. */
|
|
235
|
+
list(agentId?: string): Reservation[] {
|
|
236
|
+
return this.entries
|
|
237
|
+
.filter((entry) => !agentId || entry.agentId === agentId)
|
|
238
|
+
.map((entry) => ({ ...entry }));
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
clear(): void {
|
|
242
|
+
this.entries.length = 0;
|
|
243
|
+
this.byDecision.clear();
|
|
244
|
+
this.byIdempotencyKey.clear();
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
// -------------------------------------------------------------------------
|
|
248
|
+
|
|
249
|
+
private settle(
|
|
250
|
+
decisionId: string, state: 'confirmed' | 'released',
|
|
251
|
+
options: { txHash?: string; note?: string; now?: Date },
|
|
252
|
+
): AgentOutcomeRecord | null {
|
|
253
|
+
const now = options.now ?? new Date(this.clock());
|
|
254
|
+
const entry = this.byDecision.get(decisionId);
|
|
255
|
+
if (!entry) return null;
|
|
256
|
+
// An expired hold that later confirms is a real and dangerous case: the
|
|
257
|
+
// budget was already returned, so the spend happened outside the cap. It
|
|
258
|
+
// is recorded as confirmed so the total is right, and the note says the
|
|
259
|
+
// hold had lapsed, because that is a tuning signal for the TTL.
|
|
260
|
+
const lapsed = entry.state === 'expired';
|
|
261
|
+
entry.state = state;
|
|
262
|
+
entry.settledAtMs = now.getTime();
|
|
263
|
+
if (options.txHash !== undefined) entry.txHash = options.txHash;
|
|
264
|
+
if (options.note !== undefined) entry.note = options.note;
|
|
265
|
+
|
|
266
|
+
return {
|
|
267
|
+
decisionId,
|
|
268
|
+
state,
|
|
269
|
+
...(entry.txHash === undefined ? {} : { txHash: entry.txHash }),
|
|
270
|
+
...(lapsed
|
|
271
|
+
? { note: 'The hold had already expired when this was settled; the reservation TTL may be too short.' }
|
|
272
|
+
: entry.note === undefined ? {} : { note: entry.note }),
|
|
273
|
+
at: now.toISOString(),
|
|
274
|
+
};
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
private prune(nowMs: number): void {
|
|
278
|
+
for (const entry of this.entries) {
|
|
279
|
+
if (entry.state === 'held' && entry.expiresAtMs <= nowMs) entry.state = 'expired';
|
|
280
|
+
}
|
|
281
|
+
for (let i = this.entries.length - 1; i >= 0; i -= 1) {
|
|
282
|
+
const entry = this.entries[i]!;
|
|
283
|
+
if (nowMs - entry.createdAtMs <= this.retentionMs) continue;
|
|
284
|
+
this.entries.splice(i, 1);
|
|
285
|
+
this.byDecision.delete(entry.decisionId);
|
|
286
|
+
if (entry.idempotencyKey) this.byIdempotencyKey.delete(this.keyFor(entry.agentId, entry.idempotencyKey));
|
|
287
|
+
}
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
private enforceCeiling(): void {
|
|
291
|
+
while (this.entries.length > this.maxEntries) {
|
|
292
|
+
const entry = this.entries.shift();
|
|
293
|
+
if (!entry) break;
|
|
294
|
+
this.byDecision.delete(entry.decisionId);
|
|
295
|
+
if (entry.idempotencyKey) this.byIdempotencyKey.delete(this.keyFor(entry.agentId, entry.idempotencyKey));
|
|
296
|
+
}
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
/** Idempotency keys are the caller's, so they are namespaced per agent. */
|
|
300
|
+
private keyFor(agentId: string, idempotencyKey: string): string {
|
|
301
|
+
return `${agentId}${idempotencyKey}`;
|
|
302
|
+
}
|
|
303
|
+
}
|
package/src/model.ts
ADDED
|
@@ -0,0 +1,324 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* AgentGuard — the types.
|
|
3
|
+
*
|
|
4
|
+
* Protect informs a person, who then decides. AgentGuard sits in front of
|
|
5
|
+
* something with no judgement that will do exactly what it was told, at
|
|
6
|
+
* machine speed, as many times as its loop runs. Three consequences shape
|
|
7
|
+
* everything in this file:
|
|
8
|
+
*
|
|
9
|
+
* 1. **A warning is not a control.** Protect deliberately warns and never
|
|
10
|
+
* blocks, because the person holding the keys should decide. An agent
|
|
11
|
+
* cannot heed a warning — a warning it ignores is an allow with extra
|
|
12
|
+
* words. So AgentGuard's outcomes are `allow`, `require_human_approval`
|
|
13
|
+
* and `deny`, and the middle one means *stop and hand this to a person*.
|
|
14
|
+
*
|
|
15
|
+
* 2. **The operator is not the agent.** Limits are set by a human ahead of
|
|
16
|
+
* time. The agent must not be able to widen them — not by argument, not by
|
|
17
|
+
* mutating the object it was handed, not by passing an override. That is
|
|
18
|
+
* enforced structurally in `policy.ts` (`sealPolicy`, `narrowPolicy`),
|
|
19
|
+
* not by convention.
|
|
20
|
+
*
|
|
21
|
+
* 3. **Every decision must survive being questioned later.** "The agent spent
|
|
22
|
+
* the treasury" is answered by an audit record that names each rule, the
|
|
23
|
+
* limit it enforced and the value it observed. An allow that cannot be
|
|
24
|
+
* explained is as bad as a wrong one, so a passing rule is recorded just
|
|
25
|
+
* as loudly as a failing one.
|
|
26
|
+
*
|
|
27
|
+
* The risk model is *not* reimplemented here. Classification, the seven risk
|
|
28
|
+
* levels and `applyPolicy` come from `@decentrys/protect` unchanged; this
|
|
29
|
+
* package adds the limits an autonomous actor needs and a human does not.
|
|
30
|
+
*/
|
|
31
|
+
|
|
32
|
+
import type { Assessment, Policy, RiskLevel } from '@decentrys/protect';
|
|
33
|
+
|
|
34
|
+
export const AGENT_MODEL_VERSION = 'agent-1.0.0';
|
|
35
|
+
|
|
36
|
+
// ---------------------------------------------------------------------------
|
|
37
|
+
// What an agent proposes to do
|
|
38
|
+
// ---------------------------------------------------------------------------
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* The kinds of action a policy can talk about.
|
|
42
|
+
*
|
|
43
|
+
* These are coarse on purpose. "May swap, may not approve" is a sentence an
|
|
44
|
+
* operator can write and check; "may call selector 0x095ea7b3" is not. An
|
|
45
|
+
* action whose type the integrator cannot determine is `unknown`, which is a
|
|
46
|
+
* first-class value rather than a default — see `deniedActions` below.
|
|
47
|
+
*/
|
|
48
|
+
export const AGENT_ACTION_TYPES = [
|
|
49
|
+
'transfer',
|
|
50
|
+
'swap',
|
|
51
|
+
'approve',
|
|
52
|
+
'contract_call',
|
|
53
|
+
'bridge',
|
|
54
|
+
'stake',
|
|
55
|
+
'unstake',
|
|
56
|
+
'deploy',
|
|
57
|
+
'sign_message',
|
|
58
|
+
'unknown',
|
|
59
|
+
] as const;
|
|
60
|
+
export type AgentActionType = (typeof AGENT_ACTION_TYPES)[number];
|
|
61
|
+
|
|
62
|
+
export interface AgentAction {
|
|
63
|
+
/** Which agent proposes it. Limits are held per agent, not per process. */
|
|
64
|
+
agentId: string;
|
|
65
|
+
chain: string;
|
|
66
|
+
type: AgentActionType;
|
|
67
|
+
/** The wallet the agent signs with. */
|
|
68
|
+
from: string;
|
|
69
|
+
/** Counterparty or contract. Absent for a deploy or a bare message signature. */
|
|
70
|
+
to?: string;
|
|
71
|
+
/** Token contract, for a transfer, swap or approval. */
|
|
72
|
+
token?: string;
|
|
73
|
+
/** Base units, as a string. Numbers lose precision at these magnitudes. */
|
|
74
|
+
value?: string;
|
|
75
|
+
/**
|
|
76
|
+
* The action's value in USD, as the integrator prices it.
|
|
77
|
+
*
|
|
78
|
+
* **`undefined` is not zero.** An action with no value at all (a message
|
|
79
|
+
* signature, a zero-value call) should say `0`; leaving this out means "I do
|
|
80
|
+
* not know what this is worth", and a value cap cannot be enforced against
|
|
81
|
+
* an unknown. See `VALUE_UNKNOWN` in `policy.ts` for what happens then, and
|
|
82
|
+
* why it is the only defensible answer for an autonomous signer.
|
|
83
|
+
*
|
|
84
|
+
* AgentGuard never prices anything itself. Pricing belongs to whoever runs
|
|
85
|
+
* the agent, with the feed and the staleness rules they are willing to be
|
|
86
|
+
* accountable for.
|
|
87
|
+
*/
|
|
88
|
+
valueUsd?: number;
|
|
89
|
+
/** EVM calldata. */
|
|
90
|
+
data?: string;
|
|
91
|
+
/** Non-EVM families carry their serialized payload here. */
|
|
92
|
+
raw?: string;
|
|
93
|
+
/** The site or service that asked for this, when there is one. */
|
|
94
|
+
origin?: string;
|
|
95
|
+
/** Base units, or `unlimited`. Only meaningful for `type: 'approve'`. */
|
|
96
|
+
approvalAmount?: string;
|
|
97
|
+
/** What the agent believes it is doing, in its own words. Recorded, never trusted. */
|
|
98
|
+
intent?: string;
|
|
99
|
+
/**
|
|
100
|
+
* Set by the caller so a retried action is not counted twice against a
|
|
101
|
+
* cumulative cap. Two calls carrying the same key reuse one reservation.
|
|
102
|
+
*/
|
|
103
|
+
idempotencyKey?: string;
|
|
104
|
+
proposedAt?: string;
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
// ---------------------------------------------------------------------------
|
|
108
|
+
// Policy
|
|
109
|
+
// ---------------------------------------------------------------------------
|
|
110
|
+
|
|
111
|
+
/** A cumulative limit: how much may be committed inside a rolling window. */
|
|
112
|
+
export interface SpendWindow {
|
|
113
|
+
/** A name an operator recognises in an audit record — 'daily', 'per-hour'. */
|
|
114
|
+
label: string;
|
|
115
|
+
windowMs: number;
|
|
116
|
+
maxValueUsd: number;
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
/** A rate ceiling: how many actions may be admitted inside a rolling window. */
|
|
120
|
+
export interface RateWindow {
|
|
121
|
+
label: string;
|
|
122
|
+
windowMs: number;
|
|
123
|
+
maxActions: number;
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
/**
|
|
127
|
+
* What an operator can express.
|
|
128
|
+
*
|
|
129
|
+
* Every field is optional and every omitted field means *unconstrained on that
|
|
130
|
+
* axis* — with one deliberate exception, `allowUnlimitedApprovals`, which
|
|
131
|
+
* defaults to refusing. An unlimited approval is the single action that turns
|
|
132
|
+
* a bounded mistake into an unbounded one, and defaulting it open would make
|
|
133
|
+
* the safe configuration the one you have to remember to write.
|
|
134
|
+
*/
|
|
135
|
+
export interface AgentPolicy {
|
|
136
|
+
/** Free text for the operator's own change control. Recorded in every decision. */
|
|
137
|
+
version?: string;
|
|
138
|
+
/**
|
|
139
|
+
* Bind this policy to one agent. When set, an action from a different
|
|
140
|
+
* `agentId` is denied rather than evaluated — a policy leaking across agents
|
|
141
|
+
* is how one agent's generous limits become another's.
|
|
142
|
+
*/
|
|
143
|
+
agentId?: string;
|
|
144
|
+
|
|
145
|
+
// --- risk, from @decentrys/protect ---------------------------------------
|
|
146
|
+
|
|
147
|
+
/**
|
|
148
|
+
* Risk level → action, exactly the `Policy` type Protect uses. Merged over
|
|
149
|
+
* `DEFAULT_AGENT_RISK_POLICY`; see that constant for why the agent default
|
|
150
|
+
* differs from the consumer one.
|
|
151
|
+
*/
|
|
152
|
+
risk?: Policy;
|
|
153
|
+
/** Shorthand: deny anything strictly above this level. Applied on top of `risk`. */
|
|
154
|
+
maxRiskLevel?: RiskLevel;
|
|
155
|
+
|
|
156
|
+
// --- value ---------------------------------------------------------------
|
|
157
|
+
|
|
158
|
+
maxValuePerActionUsd?: number;
|
|
159
|
+
/** Rolling cumulative caps. Several may run at once — per-hour and per-day. */
|
|
160
|
+
spendWindows?: SpendWindow[];
|
|
161
|
+
|
|
162
|
+
// --- counterparties and contracts ----------------------------------------
|
|
163
|
+
|
|
164
|
+
/**
|
|
165
|
+
* When present, an allowlist: any counterparty not in it is denied. Absent
|
|
166
|
+
* means unconstrained, which is a real choice an operator may make and not
|
|
167
|
+
* one AgentGuard makes for them.
|
|
168
|
+
*/
|
|
169
|
+
allowedCounterparties?: string[];
|
|
170
|
+
/** Always denied, even if also allowlisted. Denial wins. */
|
|
171
|
+
blockedCounterparties?: string[];
|
|
172
|
+
/** When present, an allowlist over the contract the action calls. */
|
|
173
|
+
allowedContracts?: string[];
|
|
174
|
+
/** When present, an allowlist over `token`. */
|
|
175
|
+
allowedTokens?: string[];
|
|
176
|
+
|
|
177
|
+
// --- what the agent may do -----------------------------------------------
|
|
178
|
+
|
|
179
|
+
allowedActions?: AgentActionType[];
|
|
180
|
+
deniedActions?: AgentActionType[];
|
|
181
|
+
allowedChains?: string[];
|
|
182
|
+
/** Default `false`. Set true only if an unbounded allowance is genuinely intended. */
|
|
183
|
+
allowUnlimitedApprovals?: boolean;
|
|
184
|
+
|
|
185
|
+
// --- rate ----------------------------------------------------------------
|
|
186
|
+
|
|
187
|
+
/** An agent stuck in a loop is a real failure mode, not a hypothetical one. */
|
|
188
|
+
rateWindows?: RateWindow[];
|
|
189
|
+
|
|
190
|
+
// --- escalation and availability -----------------------------------------
|
|
191
|
+
|
|
192
|
+
/** Above this value, a human decides even when every other rule passes. */
|
|
193
|
+
humanApprovalAboveUsd?: number;
|
|
194
|
+
/** At or above this level, a human decides even when the risk policy allows. */
|
|
195
|
+
humanApprovalAtRiskLevel?: RiskLevel;
|
|
196
|
+
/** What to do when Decentrys could not be reached. Default `escalate`. */
|
|
197
|
+
failMode?: AgentFailMode;
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
/**
|
|
201
|
+
* What AgentGuard does when the risk check could not run.
|
|
202
|
+
*
|
|
203
|
+
* Protect offers `open | warn | closed` and defaults to `warn`, which is right
|
|
204
|
+
* for a wallet: tell the person, let them decide. **`warn` is not offered
|
|
205
|
+
* here, because there is nobody to warn.** A warning delivered to an
|
|
206
|
+
* autonomous process is an allow that logs.
|
|
207
|
+
*
|
|
208
|
+
* - `open` — proceed on operator-set limits alone.
|
|
209
|
+
* - `escalate` — the local policy still applies and still binds; only the
|
|
210
|
+
* external risk check is missing, so a human decides. **Default.**
|
|
211
|
+
* - `closed` — stop.
|
|
212
|
+
*
|
|
213
|
+
* `escalate` is the default because it is the only one of the three that
|
|
214
|
+
* loses nothing. `open` signs unscreened transactions during exactly the
|
|
215
|
+
* window an attacker would choose to create. `closed` halts the agent
|
|
216
|
+
* completely, which for a liquidation or a market-making agent is its own
|
|
217
|
+
* loss and pushes operators to configure `open` to avoid it. `escalate`
|
|
218
|
+
* spends a human's attention instead of the treasury, and where an integrator
|
|
219
|
+
* has no approval path it degrades to a stop — the safe direction.
|
|
220
|
+
*
|
|
221
|
+
* Note that a local rule that *denies* still denies during an outage. An
|
|
222
|
+
* unreachable Decentrys removes one input; it does not suspend the operator's
|
|
223
|
+
* limits.
|
|
224
|
+
*/
|
|
225
|
+
export type AgentFailMode = 'open' | 'escalate' | 'closed';
|
|
226
|
+
|
|
227
|
+
// ---------------------------------------------------------------------------
|
|
228
|
+
// Decisions
|
|
229
|
+
// ---------------------------------------------------------------------------
|
|
230
|
+
|
|
231
|
+
/**
|
|
232
|
+
* Three outcomes, never two.
|
|
233
|
+
*
|
|
234
|
+
* Collapsing `require_human_approval` into `deny` loses the difference between
|
|
235
|
+
* "this must not happen" and "a person needs to look at this", and an operator
|
|
236
|
+
* who cannot see that difference in the log cannot tune the policy.
|
|
237
|
+
*/
|
|
238
|
+
export type AgentVerdict = 'allow' | 'require_human_approval' | 'deny';
|
|
239
|
+
|
|
240
|
+
/** The rules AgentGuard can apply. Stable identifiers — they end up in logs. */
|
|
241
|
+
export const AGENT_RULES = [
|
|
242
|
+
'AGENT_BINDING',
|
|
243
|
+
'ACTION_TYPE',
|
|
244
|
+
'CHAIN',
|
|
245
|
+
'COUNTERPARTY',
|
|
246
|
+
'CONTRACT',
|
|
247
|
+
'TOKEN',
|
|
248
|
+
'APPROVAL_ALLOWANCE',
|
|
249
|
+
'VALUE_PER_ACTION',
|
|
250
|
+
'CUMULATIVE_SPEND',
|
|
251
|
+
'RATE_LIMIT',
|
|
252
|
+
'RISK_LEVEL',
|
|
253
|
+
'ASSESSMENT_AVAILABILITY',
|
|
254
|
+
'HUMAN_APPROVAL_THRESHOLD',
|
|
255
|
+
] as const;
|
|
256
|
+
export type AgentRuleId = (typeof AGENT_RULES)[number];
|
|
257
|
+
|
|
258
|
+
/**
|
|
259
|
+
* `not_configured` and `not_applicable` are distinct and both are recorded.
|
|
260
|
+
*
|
|
261
|
+
* "There was no counterparty allowlist" and "there was one and this
|
|
262
|
+
* counterparty was on it" produce the same allow and mean completely
|
|
263
|
+
* different things to whoever reads the record afterwards.
|
|
264
|
+
*/
|
|
265
|
+
export type RuleOutcome = 'pass' | 'fail' | 'escalate' | 'not_applicable' | 'not_configured';
|
|
266
|
+
|
|
267
|
+
export interface RuleEvaluation {
|
|
268
|
+
rule: AgentRuleId;
|
|
269
|
+
outcome: RuleOutcome;
|
|
270
|
+
/** One sentence naming the limit and what was seen against it. */
|
|
271
|
+
statement: string;
|
|
272
|
+
/** The configured bound, when there was one. */
|
|
273
|
+
limit?: string | number;
|
|
274
|
+
/** What this action presented against that bound. */
|
|
275
|
+
observed?: string | number;
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
export interface AgentDecision {
|
|
279
|
+
/** Stable id for this decision, carried into the reservation and the log. */
|
|
280
|
+
decisionId: string;
|
|
281
|
+
agentId: string;
|
|
282
|
+
verdict: AgentVerdict;
|
|
283
|
+
/** The failing or escalating rules, in evaluation order. Empty on a clean allow. */
|
|
284
|
+
reasons: string[];
|
|
285
|
+
/**
|
|
286
|
+
* Every rule that was considered, passing ones included.
|
|
287
|
+
*
|
|
288
|
+
* This is what makes an *allow* explainable. A record that lists only what
|
|
289
|
+
* went wrong cannot answer "why was this permitted", which is the question
|
|
290
|
+
* asked after the money is gone.
|
|
291
|
+
*/
|
|
292
|
+
evaluations: RuleEvaluation[];
|
|
293
|
+
/** The Protect assessment this decision used, when one was obtained. */
|
|
294
|
+
assessment?: Assessment;
|
|
295
|
+
/** Why no assessment was available, when none was. */
|
|
296
|
+
assessmentUnavailable?: string;
|
|
297
|
+
/** Fingerprint of the sealed policy in force, so the record is reproducible. */
|
|
298
|
+
policyFingerprint: string;
|
|
299
|
+
policyVersion?: string;
|
|
300
|
+
/**
|
|
301
|
+
* Present on `allow` and `require_human_approval`. Value and rate budget is
|
|
302
|
+
* held against this id until it is confirmed or released; see `ledger.ts`.
|
|
303
|
+
*/
|
|
304
|
+
reservationId?: string;
|
|
305
|
+
/** A digest of the action, so a log entry can be tied to what was proposed. */
|
|
306
|
+
actionDigest: string;
|
|
307
|
+
modelVersion: string;
|
|
308
|
+
decidedAt: string;
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
/**
|
|
312
|
+
* What actually happened to a reserved action.
|
|
313
|
+
*
|
|
314
|
+
* Recorded separately from the decision, because "AgentGuard allowed it" and
|
|
315
|
+
* "the agent then executed it" are different facts and an audit trail that
|
|
316
|
+
* conflates them cannot show an allow that was never used.
|
|
317
|
+
*/
|
|
318
|
+
export interface AgentOutcomeRecord {
|
|
319
|
+
decisionId: string;
|
|
320
|
+
state: 'confirmed' | 'released' | 'expired';
|
|
321
|
+
txHash?: string;
|
|
322
|
+
note?: string;
|
|
323
|
+
at: string;
|
|
324
|
+
}
|