@decentrys/agent
v0.1.3
Published
AgentGuard: policy enforcement in front of an autonomous on-chain agent. An agent cannot widen the limits its operator set.
Maintainers
Readme
@decentrys/agent
AgentGuard: stops an autonomous agent doing something its operator never authorised.
Why this exists separately from @decentrys/protect
Protect informs a human, who then decides. An agent has no judgement and will do exactly what it is told, at machine speed, repeatedly. A person seeing "unlimited approval requested" may reconsider; an agent will not.
So where Protect warns and never blocks, AgentGuard exists to be able to stop an action — under a policy its operator set in advance.
Install
npm install @decentrys/agent@decentrys/protect is a dependency and is installed with it; the risk model
is not reimplemented here. Node 18+. Types included.
Getting an API key
Sign in at decentrys.com/developers and create a key.
| Prefix | Where it belongs | Why |
|---|---|---|
| dk_pub_live_… | Publishable. Ships inside a wallet, extension or mobile app. | Bounded to the origins you register and to read-only Protect endpoints. Anyone can extract it from your bundle; that's expected, and it's why it can't do anything dangerous. |
| dk_live_… | Secret. Server-side only. | Full scope access. If this ends up in a client bundle it is a leaked credential the moment it ships. |
Secret key only. An agent runtime is server-side.
1. The operator writes a policy, ahead of time
import { AgentGuard, sealPolicy } from '@decentrys/agent';
const policy = sealPolicy({
version: 'treasury-rebalancer-3', // recorded on every decision
agentId: 'rebalancer-1', // this policy is not another agent's
maxValuePerActionUsd: 5_000,
spendWindows: [{ label: 'daily', windowMs: 86_400_000, maxValueUsd: 25_000 }],
rateWindows: [{ label: 'per-minute', windowMs: 60_000, maxActions: 10 }],
allowedActions: ['swap', 'transfer'], // may swap, may NOT approve
allowedChains: ['ethereum', 'base'],
blockedCounterparties: ['0xbad…'],
allowUnlimitedApprovals: false, // this is already the default
maxRiskLevel: 'CAUTION', // deny anything strictly above
humanApprovalAboveUsd: 10_000,
failMode: 'escalate',
});
const guard = new AgentGuard({ apiKey: process.env.DECENTRYS_API_KEY!, policy });
for (const warning of guard.policyWarnings) console.warn(warning);Every field is optional, and an omitted field means unconstrained on that
axis — with one exception: allowUnlimitedApprovals defaults to refusing,
because an unlimited approval is the one action that turns a bounded mistake
into an unbounded one.
sealPolicy deep-freezes the object, clones your arrays away from you, and
brands the result with a module-private symbol. AgentGuard accepts nothing
else, so a policy the agent constructed does not typecheck and would not
survive sealing if it did.
Full field list: version · agentId · risk · maxRiskLevel ·
maxValuePerActionUsd · spendWindows · rateWindows ·
allowedCounterparties · blockedCounterparties · allowedContracts ·
allowedTokens · allowedActions · deniedActions · allowedChains ·
allowUnlimitedApprovals · humanApprovalAboveUsd ·
humanApprovalAtRiskLevel · failMode.
Action types: transfer · swap · approve · contract_call · bridge ·
stake · unstake · deploy · sign_message · unknown.
2. The agent's execution loop asks before it signs
const decision = await guard.assessAgentTransaction({
agentId: 'rebalancer-1', // required — limits are held per agent, not per process
chain: 'ethereum',
type: 'swap', // an AgentActionType, not a free string
from: agentWallet,
to: routerAddress,
valueUsd: 2_500, // as YOU price it; AgentGuard never prices anything
idempotencyKey: taskId, // a retry is one action, not two
});
switch (decision.verdict) {
case 'allow': {
try {
const txHash = await signAndSend();
guard.confirm(decision.decisionId, { txHash }); // the hold becomes a commitment
} catch {
guard.release(decision.decisionId, { note: 'broadcast failed' }); // hand the budget back
}
break;
}
case 'require_human_approval':
await notifyOperator(guard.explain(decision));
break;
case 'deny':
console.error(guard.explain(decision)?.text);
break;
}confirm and release take the decisionId, not the decision object, and
are synchronous — they return an AgentOutcomeRecord | null, null when this
guard holds no reservation for that id.
Three verdicts, not two. Protect's warn has no recipient here — there's no
human watching — so sealPolicy throws if a risk map contains warn or
warn_strong, rather than silently reinterpreting it.
3. The agent cannot widen its own limits
There is no widening function in this package. narrowPolicy only ever
tightens: minimum of caps, intersection of allowlists, union of blocklists,
windows accumulated, allowUnlimitedApprovals true only if both sides say so,
and the stricter failMode.
// A guard for a narrower sub-task, sharing this one's ledger and Protect client.
const subTask = guard.withPolicy({ maxValuePerActionUsd: 500, allowedActions: ['swap'] });withPolicy takes a plain AgentPolicy restriction and narrows internally —
don't pre-apply narrowPolicy yourself. The shared ledger is the point: a
sub-task spending against a tighter per-task cap still spends against the
agent's daily one.
A per-call restriction works the same way and is equally one-directional:
await guard.assessAgentTransaction(action, { restrict: { maxValuePerActionUsd: 100 } });4. Every allow is explainable afterwards
decision.evaluations // EVERY rule considered, passes included
decision.reasons // the failing and escalating ones
decision.policyFingerprint // which rules were in force
decision.actionDigest // ties the record to what was proposed
decision.assessment // the Protect assessment used, when one was obtained
const explanation = guard.explain(decision); // AgentActionExplanation | null
explanation?.headline; // one sentence: what happened, and at the top level why
explanation?.because; // the rules that blocked or escalated, in their own words
explanation?.rulesApplied; // all of them
explanation?.unbounded; // axes this policy set no bound on at all
explanation?.toProceed; // what the OPERATOR would have to change
explanation?.text; // plain-text rendering, for a log line or a review queue
guard.auditLog({ verdict: 'deny' }); // in memory, bounded, dies with the processEvaluation never short-circuits, so a record can't say "exceeded its cap" while
omitting "and the counterparty was a confirmed drainer". not_configured is
distinguished from not_applicable, so you can see which axes your policy left
unbounded — the difference between "the rate ceiling was checked" and "there
is no rate ceiling".
auditLog() is a convenience, not an audit trail. For one that survives a
restart, pass onDecision — invoked synchronously for every decision, with its
exceptions swallowed so a logging sink can never change a verdict:
new AgentGuard({ apiKey, policy, onDecision: (d) => writeAuditRow(d) });evaluatePolicy is pure and clock-injected, and a decision carries its policy
fingerprint and action digest, so a recorded decision replays to the same
answer. guard.evaluate(action, { assessment }) runs the same rules with no
network call.
failMode defaults to escalate
open signs unscreened during exactly the window an attacker would choose.
closed halts the agent entirely — for a liquidation agent that's its own loss,
and it pressures operators into choosing open. escalate spends a human's
attention instead of the treasury.
An outage removes one input but never suspends your limits: a local deny
still denies under failMode: 'open'.
The default risk mapping for an agent (DEFAULT_AGENT_RISK_POLICY, exported)
allows up to CAUTION, escalates ELEVATED_RISK through CRITICAL_THREAT to
a person, and denies KNOWN_MALICIOUS — the one level requiring
analyst-verified evidence, and one no value justifies signing to.
Three behaviours worth knowing before you ship
An unpriced action is denied when any value rule exists — a per-action cap
or a spend window. Pass valueUsd: 0 explicitly if an action genuinely moves
nothing. For an autonomous signer, "we couldn't price it" must not resolve to
"so we signed it".
An empty allowlist denies everything, rather than reading as unconfigured.
Otherwise narrowPolicy could widen a policy whenever two allowlists it
intersected had nothing in common.
An approval with no stated approvalAmount is denied. The size of an
allowance is the whole of what is being granted, and unlimited, max, or any
number at or above 2²⁵⁵ counts as unlimited.
Known limit: the ledger is in-process
The bundled SpendLedger is in-process and in-memory. Two replicas each
enforcing $10,000/day enforce $20,000 between them, and a restart resets the
window to zero. Do not deploy a fleet behind it and describe the cap as
enforced.
Fixing it needs no rewrite. evaluatePolicy depends only on the two-method
AgentUsage interface, so a Redis- or Postgres-backed implementation replaces
the class entirely:
import { AgentGuard, type AgentUsage } from '@decentrys/agent';
const shared: AgentUsage = {
spentUsdSince: (agentId, sinceMs) => /* … */ 0,
actionsSince: (agentId, sinceMs) => /* … */ 0,
};
const guard = new AgentGuard({ apiKey, policy, ledger: shared });
guard.reservesBudget; // false — a read-only source cannot take holdsImplement ReservationLedger (reserve / confirm / release on top of
AgentUsage) to keep holds, and reservesBudget reports true. With a
read-only source, cumulative caps count settled history only, so two concurrent
actions that each fit alone can both be admitted.
Errors
Nothing on the call path throws — an exception thrown at an agent is a
condition it handles by retrying, or by taking the branch that skips the guard.
A deny it must read is harder to route around.
The constructor throws, on an unsealed policy or on neither an apiKey nor a
protect client being supplied. sealPolicy throws on a warn action, and
narrowPolicy throws when two policies are bound to different agents. All of
those are wiring errors a human makes at deploy time.
The rest of the SDK
| Package | For |
|---|---|
| @decentrys/protect | Pre-sign risk assessment for wallets and dapps |
| @decentrys/ui-sdk | React components that render Protect results |
| @decentrys/sentinel-sdk | Monitoring deployed contracts and treasuries |
| @decentrys/risk-sdk | Screening for exchanges and custodians |
| @decentrys/dri-sdk | Fund tracing and recovery intelligence |
| @decentrys/agent | Policy enforcement for autonomous agents |
Licence
MIT © Decentrys Labs
