@moneolabs/guard
Spending policy for AI agents, evaluated before anything is signed.
npm install @moneolabs/guard
The guard sits between what an agent intends and what actually gets signed. It answers with a
verdict, a reason, and the version of the policy that decided, then writes all three to a ledger.
A blocked payment is never broadcast, so it costs nothing.
This package has no service behind it and needs none. It is pure evaluation over a ledger you can
keep wherever you already keep financial records.
Use
import { createGuard } from "@moneolabs/guard";
const guard = createGuard({
perTransaction: { max: "$250" },
rolling24h: { max: "$2,000" },
velocity: { max: 20, per: "1m" },
counterparties: "allowlist-only",
allow: ["x402:*", "0x9f3c...a71b", "stripe:acct_1Nz"],
deny: ["mixer:*"],
escalate: { above: "$500" },
});
const decision = await guard.check({
action: "transfer",
to: "0x9f3c...a71b",
amount: "$8,200",
agent: "ops-03",
});
decision.verdict;
decision.reason;
decision.rule;
Reserve, then settle
check() does not just answer, it reserves. An allowed decision counts against the budget from the
moment it is made, because a payment in flight is money you no longer have. Tell it what happened:
const decision = await guard.check({ action: "pay", to: "vendor:acme", amount: "$100" });
if (decision.verdict === "allow") {
try {
const receipt = await payVendor();
await decision.settle(receipt.amount);
} catch {
await decision.release();
}
}
Forgetting to settle is safe. Forgetting to release is not, which is why wrap() exists.
Wrap something you already have
const payVendor = guard.wrap(
async (vendorId: string, usd: number) => internalPayments.send(vendorId, usd),
{
action: "pay",
amount: (_vendor, usd) => `$${usd}`,
to: (vendor) => vendor,
},
);
await payVendor("acme", 40);
await payVendor("acme", 9000);
Escalation
Above a threshold, the decision holds instead of blocking and waits for a person.
const decision = await guard.check({ action: "pay", to: "vendor:acme", amount: "$900" });
if (decision.verdict === "hold") {
const outcome = await decision.wait({ timeout: "4h" });
if (outcome.granted) await wallet.pay();
}
guard.resolve(decision.approvalId!, { granted: true, by: "finance@example.com" });
Approvers are pluggable: manualApprover, autoApprover, loggingApprover, webhookApprover, or
your own. A held movement does not consume budget until it settles.
Rules
Evaluated in this order. The first block wins, so a banned counterparty is never reported as merely
over budget.
deny | The counterparty matches a denylist pattern. |
assets | The asset is denied, or is outside an allowlist. |
actions | The action kind is denied, or is outside an allowlist. |
counterparties: "allowlist-only" | The counterparty is not on the allowlist, or is missing entirely. |
perTransaction | The USD value of one movement is over the cap. |
velocity | Too many movements inside the window. |
budgets / rolling24h | The rolling window would go over its cap. |
escalate | Nothing above blocked, but it needs a human. Produces hold. |
Patterns use * as a wildcard and match case insensitively, so x402:* covers every x402 endpoint
and 0xABCD matches 0xabcd.
An intent with no counterparty is blocked under allowlist-only. Omitting a field must never be a
way around a rule.
Non-dollar assets
Limits are in USD, agents move whatever they hold. Pass a PriceSource and the guard values each
movement before applying a limit.
import { fixedPrices } from "@moneolabs/core";
const guard = createGuard(
{ perTransaction: { max: "$100" } },
{ prices: fixedPrices({ AAPL: 309.92 }) },
);
await guard.check({ action: "trade", amount: "2 AAPL" });
Dollar-pegged assets skip the lookup. Anything else without a price is refused rather than guessed.
Simulate before you ship
import { simulate, eventsFromLedger } from "@moneolabs/guard";
const report = await simulate(proposedPolicy, eventsFromLedger(lastMonth));
report.counts;
report.blockedUsd;
report.results[0];
Nothing real is touched. This is how you find out that a budget would have blocked a third of last
month before it blocks a third of next month.
Reading the ledger
await guard.usage();
await guard.history({ verdict: "block" });
Blocked attempts are kept, not discarded. They are the most useful thing you own when tuning a
policy: they are the record of what your agents actually tried to do.
Bring your own store by implementing DecisionLedger. The default is in memory.
Testing
Pass a manualClock and rolling windows become instant.
import { manualClock } from "@moneolabs/core";
const clock = manualClock(0);
const guard = createGuard({ rolling24h: { max: "$100" } }, { clock });
await (await guard.check({ action: "pay", amount: "$100" })).settle();
await clock.advance("25h");
License
MIT