Expand description
The policy engine every automated value-moving action must pass (issue #763). Default deny everywhere; fail closed on any port error. The README below is the crate documentation: the decision order, the ports, and the limits of what a policy alone can enforce.
§cratefield-module-guardrails
The policy engine every automated value-moving action must pass. A job that
would move a venture’s or a user’s funds — send a transaction, sign a
permit, approve a spender, swap — describes itself as a Request and gets
one of two answers: an opaque Approval that only the engine can mint, or
a typed Denied with every reason. Default deny everywhere, and fail
closed on any port error: a simulator that cannot be reached, an oracle with
no quote, a ledger that cannot be read, an audit sink that cannot record —
each is a refusal, never a shrug.
use cratefield_module_guardrails::*;
// Placeholder addresses, built at runtime so no long hex literal sits in
// this file; 40 hex digits is a 20-byte address.
let from = format!("0x{}", "1".repeat(40));
let spender = format!("0x{}", "2".repeat(40));
let engine = Guardrails::builder()
.policy(Policy::new()
.chain(Chain::Evm { chain_id: 1 })
.contract(&from, &[SEL_APPROVE])
.spender(&spender)
.subject("job-7", SubjectPolicy::new()
.caps(Caps::uniform(
&TokenKey::native(Chain::Evm { chain_id: 1 }),
5_000_000, // $5 per action, in micro-USD
20_000_000, // $20 per UTC day
20_000_000, // $20 per rolling period
time::Duration::hours(24),
200, // 2% max slippage
))
.auto_revoke(true)))
.kill_switch(MemoryKillSwitch::new())
.simulator(ScriptedSimulator::new().clean())
.prices(FixedPrices::new())
.ledger(MemoryLedger::new())
.audit(MemoryAudit::new())
.allowances(MemoryAllowances::new())
.build()
.expect("every port wired");§The decision order
Guardrails::check runs one gate after another and stops at the first that
refuses — a request refused statically never reaches the simulator:
- Kill switch — engaged at the global, venture or subject scope is a deny before anything else is consulted, and is checked again at the moment of signing, so a job holding an approval is still stopped;
- Hard denies —
setApprovalForAll, unlimited approvals (approve,increaseAllowance, Permit2approve, SPLApproveatu64::MAX), permits (EIP-2612, DAI-style, Permit2) to a spender the policy does not name, with an unlimited/unboundable amount, or with calldata too truncated to read, EIP-7702 authorizations withchain_id == 0, Solana owner reassignment (SystemAssign/AssignWithSeed, SPLSetAuthority) andAdvanceNonceAccount— from the request alone, whatever the allowlist says; - Allowlists — the chain; the contract with its explicit selector
set (or the Solana program); every destination the action moves funds
to (native transfers, decoded ERC-20
transfer/transferFromrecipients, SystemTransferand SPLTransfer/TransferCheckeddestinations); approval spenders; EIP-7702 delegates; - Simulation — required, via the
Simulatorport (eth_simulateV1/simulateTransactionin an adapter). Denied on: a revert, an unlimited approval, an approval to a non-allowlisted spender (an approval of exactly zero is a revoke, and exempt), a credit to a non-allowlisted destination, a delegatecall into a non-allowlisted contract; - Caps and slippage — per action, per UTC day and per rolling period,
in micro-USD per token, priced through the
Pricesport from the simulation’s outflows. A subject with no caps, a token with no cap entry (zero), no price, a swap overmax_slippage_bps, or a swap-shaped action (the actor both pays and receives) with no quote at all is denied; a non-positive or out-of-range period denies the subject rather than silently disabling the rolling window; - Ledger — the priced outflows are recorded, only on the way to an allow;
- Audit — one entry per decision, allow or deny, on
check, on every sign and on every hygiene finding; a sink error is a deny.
An “unlimited” amount is one at or above 2^128 — MAX_UINT256, Permit2’s
MAX_UINT160 and every practical infinite approval are all far above the
line, and no honest bounded amount comes near it.
§The ports, and their fakes
| Port | Answers | Fake |
|---|---|---|
KillSwitch | engaged / engage / release over Scope::{Global, Venture, Subject} | MemoryKillSwitch |
Simulator | the SimulationReport: success, balance changes, approvals, call trace | ScriptedSimulator |
Prices | micro-USD for (chain, token, raw amount); None is no quote | FixedPrices |
SpendLedger | spent(subject, token, since) / record(...) | MemoryLedger |
AuditSink | record(&AuditEntry) | MemoryAudit, FailingAudit |
AllowanceSource | a wallet’s open allowances | MemoryAllowances |
WalletSigner | the actual signing (e.g. a Turnkey adapter) | RecordingSigner |
Around them: Guardrails::check mints Approvals; GuardedSigner::sign
consumes one and re-checks all three kill-switch scopes first (in-flight
jobs die at signing time); Guardrails::job_allowed lets the actor skip
scheduling for a killed subject; turnkey_deny_policy renders a scope as
a Turnkey EFFECT_DENY policy body so the remote signer refuses even if
this process is bypassed (None for ids outside [A-Za-z0-9._:-]+, which
could rewrite the condition);
Guardrails::scan_allowances finds old
allowances today’s policy would not issue and builds approve(spender, 0)
revokes — sent through check itself when the subject sets auto_revoke,
and reported unremediable when a malformed spender can name no revoke.
GuardrailsModule mounts the engine as a harness module: no tables, a
status route, and a scheduled hook that runs the hygiene scan over
configured watched wallets.
§Limits
- No RPC or provider adapter ships here. The
Simulator,AllowanceSourceandWalletSignerports need an adapter per provider; this crate is the policy, not the plumbing. - The ledger is not transactional.
spent+recordis a check-then-act pair; callers must serialize per subject (one actor per subject, jobs one at a time), which is the harness’s own contract. Concurrent checks for one subject can exceed a cap by the overlap. - The hygiene scan is EVM-only — an
approve(spender, 0)revoke has no Solana twin here. - Permit2 batch permits are always denied as unboundable: their amounts live in a dynamic array no static check can bound.
- Every port error denies, and an audit-sink error after the ledger recorded leaves the spend recorded — over-counting is the conservative direction.
Structs§
- Allowance
- One open allowance on a wallet.
- Allowance
Error - Why the allowance source could not answer.
- Allowlist
- One layer of the allowlist: the chains, contracts, programs, destinations and spenders named at this layer.
- Approval
- A token that exists only because
Guardrails::checkreturnedOk. Opaque and deliberately notClone: one approval is one audited decision. Constructible by nobody outside this crate — the privateSealfield sees to that. - Approval
Change - One approval the simulation observed being taken.
- Audit
Entry - One recorded decision.
- Audit
Error - Why the audit sink could not record.
- Authorization7702
- An EIP-7702 authorization carried by an EVM transaction: a delegation of
the signer’s code to
address. - Balance
Change - One balance change the simulation observed:
deltain the token’s smallest unit, negative for a debit. - Build
Error - Why the builder refused to build: a required port was not wired.
- Call
Frame - One frame of the simulation’s call trace.
- Caps
- The spend windows, per subject. Every map is micro-USD keyed by token;
a missing entry is a cap of zero.
periodis the rolling window theper_periodmap is measured over. - Denied
- A refusal, with every reason the engine collected.
- Failing
Audit - An audit sink that always errors, for the fail-closed test.
- Fixed
Prices - A price oracle with a fixed table: token key to micro-USD per smallest unit. A token missing from the table has no quote.
- Guarded
Signer - The signer hosts hand to code that moves value.
signconsumes the approvalGuardrails::checkissued and re-checks all three kill-switch scopes — so engaging the switch after a check blocks the in-flight job at the moment of signing, and audits that decision too. - Guardrails
- The engine. Built once per venture, from ports and a
Policy; seeGuardrails::builder. - Guardrails
Builder - Builds a
Guardrails. Every port is required except the allowance source; the clock and id generator default to core’s. - Guardrails
Module - The engine as a harness
Module: no tables, no routes to speak of, and ascheduledhook that runs the hygiene scan over the watched wallets. - Hygiene
Finding - One finding: the allowance, why it is a finding, and the remedy.
- Kill
Switch Error - Why the kill switch could not answer or act.
- Ledger
Error - Why the ledger could not answer or record.
- Memory
Allowances - An allowance source with a fixed list.
- Memory
Audit - An audit sink in memory, inspectable by the test.
- Memory
Kill Switch - A kill switch in memory. Engagements are a set of scope keys.
- Memory
Ledger - A spend ledger in memory: an append-only list of entries.
- Policy
- The whole policy: a global layer plus one
SubjectPolicyper subject. - Prices
Error - Why valuation failed.
- Recorded
Spend - One recorded entry, with its subject and instant.
- Recording
Signer - A wallet signer that records every request it is asked to sign and answers with the configured script.
- Request
- A value-moving action an actor wants to take, as the engine sees it.
- Scripted
Simulator - A simulator that answers every call with the configured script and records the requests it was asked about.
- Sign
Error - Why the wallet signer refused.
- Simulation
Report - What the simulation observed.
success == falseis a revert: denied, not an error — a revert is an answer about the request, an unavailable simulator is a control-plane fault. - Solana
Instruction - One Solana instruction.
- Spend
Entry - One recorded outflow: what token, how much in micro-USD.
- Subject
Policy - One subject’s layer of the policy: an
Allowlist, the subject’s caps, and whether the hygiene scan may revoke automatically. - Swap
Quote - The swap context of an action, when it is a swap: the quoted output and
the minimum the caller will accept. Slippage is
(quoted_out - min_out) / quoted_outin basis points. - Token
Key - What token a balance change or a cap is about: the chain plus the token’s
address (EVM, lowercase hex) or mint (Solana, base58), or
"native"for the chain’s own coin. - Watched
Wallet - One watched wallet for the module’s scheduled hygiene scan.
Enums§
- Action
- What the acting address wants to do.
- Call
Kind - The kind of a call frame in the simulation trace.
- CapExceeded
- Which spend window a cap breach landed in.
- Chain
- Which chain an action runs on.
- Deny
Reason - Why the engine refused, typed so sinks can group and alert.
Displayrenders the compact text that goes in logs. - Hard
Deny - The statically-known dangerous shapes, refused before any allowlist is consulted.
- Hygiene
Reason - Why a finding is a finding.
- NotAllowlisted
- Which allowlist an action fell outside.
- Remedy
- What to do about a finding.
- Scope
- How wide a kill switch engagement reaches.
- Script
- What a
ScriptedSimulatoranswers with. - Severity
- How much a decision should interest an operator.
- Sign
Script - What a
RecordingSignerdoes when asked to sign. - SimError
- Why the simulation could not be produced. A reverted execution is not
an error — it is
SimulationReport::reverted. - Verdict
- What was decided.
Constants§
- SEL_
APPROVE approve(address,uint256)— the classic ERC-20 approval.- SEL_
INCREASE_ ALLOWANCE increaseAllowance(address,uint256).- SEL_
PERMI T2_ APPROVE approve(address,address,uint160,uint48)— Permit2’sAllowanceTransferapproval: token, spender, amount, expiration.- SEL_
PERMI T2_ BATCH permit(address,((address,uint160,uint48,uint48)[],address,uint256),bytes)— Permit2AllowanceTransfer, batch. The amounts live in a dynamic array, so no static bound exists and the form is refused outright.- SEL_
PERMI T2_ SINGLE permit(address,((address,uint160,uint48,uint48),address,uint256),bytes)— Permit2AllowanceTransfer, single: owner, details (token, amount, expiration, nonce), spender, deadline, signature.- SEL_
PERMIT_ 2612 permit(address,address,uint256,uint256,uint8,bytes32,bytes32)— EIP-2612: owner, spender, value, deadline, then the signature.- SEL_
PERMIT_ DAI permit(address,address,uint256,uint256,bool,uint8,bytes32,bytes32)— DAI’s non-standard permit: owner, spender, value, deadline, allowed, then the signature.- SEL_
SET_ APPROVAL_ FOR_ ALL setApprovalForAll(address,bool)— every token, now and in the future.- SEL_
TRANSFER transfer(address,uint256)— decoded for its recipient, not denied.- SEL_
TRANSFER_ FROM transferFrom(address,address,uint256)— decoded for its recipient.- SPL_
TOKEN_ 2022_ PROGRAM - The Token-2022 program. Fragments, as above; the test pins the value.
- SPL_
TOKEN_ PROGRAM - The SPL Token program. Fragments, as above; the test pins the value.
- SYSTEM_
PROGRAM - The Solana System program: base58 of 32 zero bytes. Built from short fragments so no long base58-shaped literal sits in this file; the test below pins the exact value.
- SYS_
ADVANCE_ NONCE - System
AdvanceNonceAccount: advances a durable nonce. - SYS_
ASSIGN - System
Assign(u32 little-endian discriminant): reassigns account owner. - SYS_
ASSIGN_ WITH_ SEED - System
AssignWithSeed: reassigns account owner, seeded form. - SYS_
TRANSFER - System
Transfer(decoded for its destination, not denied). - TOK_
APPROVE - SPL Token
Approve. - TOK_
APPROVE_ CHECKED - SPL Token
ApproveChecked. - TOK_
REVOKE - SPL Token
Revoke. - TOK_
SET_ AUTHORITY - SPL Token
SetAuthority. - TOK_
TRANSFER - SPL Token
Transfer(u8 discriminant). - TOK_
TRANSFER_ CHECKED - SPL Token
TransferChecked.
Traits§
- Allowance
Source - The port that reads a wallet’s open allowances. An adapter wraps the
provider’s
allowance(token, owner, spender)calls over the token list it watches. - Audit
Sink - Where decisions are recorded. Wired with a database-backed sink in production; every decision passes through it, and a sink error fails the decision closed.
- Kill
Switch - Where engagements live. Backed by durable state in production; the
in-memory fake in
crate::fakes::MemoryKillSwitchexists for tests. - Prices
- The valuation port: raw amount to micro-USD.
- Simulator
- The port the engine requires an implementation of. Adapters wrap the provider’s simulation endpoints; the fakes in this crate script the reports tests need.
- Spend
Ledger - The memory port: what a subject has already spent in a window, and the write side that accumulates it.
- Wallet
Signer - The port that turns an approved request into a signed transaction or
signature — the thing a Turnkey-style provider adapter implements.
Reached only through
GuardedSigner, which re-checks the kill switch first.
Functions§
- allowlist_
denies - The allowlist half of the engine: everything that is refused because the policy does not name it. Runs after the hard denies, which is why it may assume the request’s shape is not itself dangerous.
- hard_
denies - Every hard deny the request itself carries. Cheap: no port, no I/O — the engine runs this before anything that can fail, and a non-empty answer means the simulator is never called.
- revoke_
action - The revoke action for one allowance on an EVM chain, or
Nonewhen the allowance’s spender cannot name a revoke target (seerevoke_calldata). - revoke_
calldata - The
approve(spender, 0)calldata for a revoke, orNonewhenspenderis not exactly0xplus 40 hex digits — a revoke naming the zero address would revoke nothing and report success. - solana_
destination - The destination account a Solana instruction moves funds to, if it is a
move-funds instruction the policy knows: System
Transfer(to is account 1), SPLTransfer(account 1) andTransferChecked(account 2). - system_
discriminant - The System program’s u32 little-endian instruction discriminant.
- tok_
discriminant - The SPL Token programs’ u8 instruction discriminant.
- turnkey_
deny_ policy - The kill switch rendered as a Turnkey policy body, so the remote signer refuses even if this process is bypassed, crashed or compromised: two independent enforcement points, one intent.
- u256_
from_ u128 vas a big-endianU256.- u256_
is_ unlimited - Whether
v >= 2^128— the crate’s “unlimited” line. - u256_
to_ u128 - The low 128 bits of
v, orNonewhen the value needs more than 128 bits (and so cannot be a micro-USD figure or a counted token amount). - word
- The 32-byte word at
at(byte offset from the start ofdata), when present. - word_
addr - The lowercase
0x-hex address in the 32-byte word atat(the word’s last 20 bytes), when present.
Type Aliases§
- U256
- A 256-bit unsigned integer, big-endian: the shape of an EVM
uint256word.