Security spec for Sentry: status, scope, the trust + threat model, per-function contract invariants, and how to report a vulnerability.
Sentry is an unaudited prototype. No third-party audit. Lean 4 specifies and
verifies the policy validation core — PolicyLib.validate precedence/monotonicity
plus the SentryQueue state machine, no sorry — see
verification/lean/. The Solidity contracts (Solidity
0.8.26) have manual review only — no symbolic execution or Halmos/Echidna
fuzzing. Lean proves properties about the model, not about the deployed bytecode.
Do not place high-value flows behind Sentry without independent security review.
In scope:
contracts/src/SentryOracle.solcontracts/src/SentryQueue.solcontracts/src/SentryAgentRegistry.solcontracts/src/PolicyLib.solcontracts/src/PolicyNormalizer.solcontracts/src/PolicyTypes.sol- The integration helpers under
contracts/src/integration/ - The TypeScript SDK in
sdk/src/and the CLI incli/src/
Out of scope:
- Test harnesses under
contracts/test/. - The reference integration under
examples/(CounterAgent,Counter) — discussed in the trust model only because the assumptions it exposes apply to any integrator. - Off-chain prompt-injection defence, MEV protection, gas-price oracles, and the upstream LLM. Sentry validates calldata against a published policy; it does not interpret model output.
Found a vulnerability? Open a GitHub security advisory on this repository rather than a public issue. For private disclosure, email the maintainer listed in the repo metadata.
Sentry is a synchronous, no-custody on-chain policy oracle. It validates calldata against a published policy and returns a decision. It does not interpret intent, hold funds, or execute calls.
The on-chain gate rejects calldata that violates the published policy. From
SentryOracle.checkIntent and the PolicyLib.validate chain it drives:
- A
targetoutside the policy's allow-list →TARGET_NOT_ALLOWED. - An allowed target with a forbidden selector →
SELECTOR_NOT_ALLOWED. - Calldata whose first 4 bytes don't match the claimed
intent.selector→SELECTOR_MISMATCH. - Calldata shorter than 4 bytes →
BAD_CALLDATA. intent.valueover the per-call cap →VALUE_CAP.- Cumulative spend over the per-day cap, as reported by the asker →
DAILY_CAP. - A paused or expired policy →
PAUSED/EXPIRED, re-checked atSentryQueue.dispatch(alongside apolicyVersionequality check) so a queued intent cannot ship after the owner pulls the kill switch or edits the policy. - Non-immediate tiers surfaced as a rejection rather than a silent pass:
TIER_DELAYED→(false, "REQUIRES_DELAY"),TIER_VETO_REQUIRED→(false, "REQUIRES_VETO"), so a naive consumer cannot bypass the queue.
- Prompt injection at the LLM layer. If the upstream model is jailbroken into producing wrong-but-policy-valid calldata, Sentry lets it through. Sentry validates calldata against a published policy; it does not interpret intent or model output. Mitigate upstream and keep policies narrow.
- Compromised policy owner key. The owner can
updatePolicyto widen the surface,vetolegitimate intents, or pause the policy entirely. Treat the owner key like any other privileged on-chain role. - Compromised asker contract / operator. Sentry cannot constrain an asker
that lies about
spentToday, fails to revoke approvals, or runs side effects outside the gate. - Reentrancy in the target.
SentryOracleis pure-view andSentryQueuenever executes the call, so neither holds anonReentrantguard — there is nothing to reenter. Reentrancy safety lives in the asker contract. - MEV / ordering attacks on the eventual dispatch transaction.
- Off-chain concerns generally: prompt-injection defence, MEV protection, gas-price oracles, and the upstream LLM are all out of scope.
Sentry's safety rests on a small set of assumptions. Each is a place where the gate's guarantee ends and yours begins.
- Sentry never holds funds.
SentryOracleis pure-view (registry plus validators).SentryQueuestoresIntentmetadata and state-machine transitions only; it neither receivesvaluenor executes calls. - The policy owner is the on-chain authority. The address that called
publishPolicy— or accepted ownership via the two-step handoff (transferPolicyOwnershipthenacceptPolicyOwnership) — can update, pause, transfer, or veto. There is no role above the owner. - The asker contract is trusted by its operator. Sentry only gates the intents an asker submits; it cannot constrain what the asker does outside the gate (spend tracking, value handling, post-dispatch execution).
- The caller executes after dispatch.
SentryQueue.dispatchreturns theIntentstruct to the caller and emitsDispatched; the caller performstarget.call{value: i.value}(i.data). Sentry never executes. If the asker forgets to execute, the intent is markedCommittedbut nothing ships.
Five assumptions are worth calling out individually because each is a sharp edge for integrators:
-
SentryQueue.dispatchdoes not re-runPolicyLib.validate. It does not re-check value caps, daily spend, the target allow-list, or the selector allow-list against the live policy. The only policy re-checks at dispatch time arepaused,expiresAt, and apolicyVersionequality check:dispatchreadsoracle.policyHealthand comparesoracle.policyVersion(policyId)against the version snapshotted atenqueue. If the owner edits the policy (anyupdatePolicy, narrowing or widening) betweenenqueueanddispatch,policyVersionno longer matches anddispatchrevertsPolicyChanged("UPDATED")— the queued intent becomes undispatchable rather than shipping under stale validation. Spend/cap correctness for the original snapshot still remains the asker's responsibility, sincespentTodayis never re-read here. -
updatePolicyhas no on-chain timelock. Ownership transfer is two-step, but policy content updates take effect immediately and mutate the samepolicyIdin place (policies[policyId].copy(input)inupdatePolicy). Pinning apolicyIdtherefore does not protect against in-place edits — the asker is pinned to that id and the bytes underneath change. Note that in-flight queued intents are not silently re-validated against the edited policy:SentryQueue.dispatchsnapshotspolicyVersionat enqueue and revertsPolicyChanged("UPDATED")if the policy was edited before dispatch (see assumption 1), so an edit invalidates queued intents rather than re-validating them. Mitigations: treat policies as immutable andpublishPolicya freshlabel(newpolicyId) when a change is needed; watchPolicyUpdated(policyId, owner)events so operators re-enqueue or re-pin under the new policy after a change; or hold the owner key behind a timelocked multisig. -
SentryQueue.enqueueis open to any caller. Sentry treatsmsg.senderas theaskerand stores it; there is no per-policy asker allow-list. A hostile enqueue is still validated against the policy, so the worst it can do is create queue noise and (forTIER_DELAYED) leave a record only that hostile caller can later dispatch. ForTIER_VETO_REQUIREDthe owner must activelydispatch, so hostile enqueues are inert unless ratified. Operators who care should filterEnqueuedevents byasker. -
spentTodayis tracked by the asker, not Sentry. Sentry has no view into the asker's wallet balance or historical spend — the asker passesspentTodayintocheckIntent,checkSelector, andenqueue. A buggy asker that under-reportsspentTodaydefeats theDAILY_CAPcheck. The contract surface assumes the asker is honest about its own ledger. -
PolicyNotFoundis a revert, not a denial.checkIntent,checkSelector,tierAndDelay, andpolicyHealthall revertPolicyNotFoundfor an unpublishedpolicyIdrather than returningok == false, so a consumer cannot silently treat a misconfigured reference as "policy denied." A reference to the wrong id fails loudly.
The samples under examples/ are reference integrations meant to be copied.
They are not vulnerabilities in contracts/src/, but the patterns they
demonstrate are exactly the patterns an integrator inherits. Watch for these
when adapting them:
-
Gate first, side-effect second. A view-only
checkIntent/checkSelectorcall costs nothing and reverts nothing on its own — so run it before any state change. The danger pattern is approve-before-gate: grant a router allowance, then check the policy, then bail on rejection while leaving a live allowance a malicious target couldtransferFrom. The correct ordering is to gate first and only grant the allowance once the gate passes, keeping approve+call an atomic pair. If your flow forces approve-first, reset the allowance to0on rejection or use a permit-style flow that ties approval to a single call. The surviving sample,CounterAgentinexamples/sentry-counter/, is single-outbound and uses thesentryGuardedmodifier so the gate runs before the call by construction — preserve that ordering when you add side effects. -
spentTodayaccounting is the asker's job, and it is easy to get wrong. If you increment a daily counter at enqueue time, a latervetoorexpireIfStaleleaves the budget consumed until the next UTC-day rollover (block.timestamp / 1 days). Production integrators should either store per-execId(enqueueDay, enqueueAmount)and roll back on veto/expire, or knowingly accept the cap-consumed-until-midnight behaviour. -
The day boundary is UTC. Any per-day spend tracking uses
block.timestamp / 1 days, which rolls over at 00:00 UTC. Integrators in non-UTC operations should expect that.
The frontend-gating packages (e.g. @sentry-somnia/react under
packages/sentry-react/) run a Sentry policy preflight before opening the
wallet. useSentryGuardedWrite evaluates the policy locally, from a spec, or
via the on-chain oracle, then calls writeContract only if it passes. A rejected
preflight throws SentryPreflightRejectedError and never shows a wallet prompt.
Preflight runs in the user's browser and can be bypassed by anyone who calls the
agent contract directly. The real gate is the on-chain checkIntent /
checkSelector call inside the agent, or the sentryGuarded modifier that wraps
it. If that gate is missing from the contract, frontend preflight does not make
the flow safe.
Per-function preconditions, postconditions, and authorization for
SentryOracle and SentryQueue, lifted from the contract source.
This is a lookup reference. For the threat narrative behind these guarantees, see Security model above.
Source of truth: contracts/src/SentryOracle.sol, contracts/src/SentryQueue.sol,
contracts/src/PolicyLib.sol, contracts/src/PolicyTypes.sol. Solidity 0.8.26.
Convention used below:
- Pre — what must hold before the call, else the listed revert fires.
- Post — the observable state change after a successful call.
- Auth — who may call. "Anyone" means no caller restriction.
These hold across every function in both contracts.
- No custody. Neither contract has a
payablefunction, holds a balance, or forwardsvalue.SentryOracleis pure registry + view.SentryQueuestores metadata and state only. The externaltarget.call{value: i.value}(i.data)is always the caller's job afterdispatchreturns theIntent. - No execution. Neither contract performs the gated external call.
checkIntent/checkSelectorareview;dispatchmutates state and returns theIntentstruct but does not call intotarget. - Policy authority is the policy owner. Every mutating
SentryOraclefunction exceptpublishPolicycheckspolicyOwner[policyId] == msg.sender. There is no global admin and no contract owner. policyIdis content-addressed by publisher.policyId == keccak256(abi.encode(msg.sender, label)). A given(publisher, label)pair maps to exactly one policy slot for the life of the contract.- Unknown
policyIdreverts, never silently denies. Every oracle read exceptpolicyIdForrevertsPolicyNotFoundwhenpolicyOwner[policyId] == address(0), so a misconfigured reference cannot be misread as "policy denied".
State touched: policies (private), policyOwner, pendingPolicyOwner, policyVersion. Errors: NotPolicyOwner, NotPendingOwner, NoPendingTransfer, PolicyExists, PolicyNotFound, ZeroAddress.
- Auth: anyone.
msg.senderbecomes the owner. - Pre:
policyOwner[policyId] == address(0)for the derivedpolicyId, else revertPolicyExists.inputmust satisfy the normalizer bounds below, elsePolicyNormalizer.copyreverts. - Post:
policyOwner[policyId] = msg.sender; policy content copied in;policyVersion[policyId] = 1. EmitsPolicyPublished(policyId, msg.sender, label). - Invariant:
policyId == keccak256(abi.encode(msg.sender, label))— equal to whatpolicyIdFor(msg.sender, label)returns.
PolicyInput shape bounds enforced by PolicyNormalizer.copy (see the policy spec section in SKILL.md): MAX_TARGETS = 20, MAX_SELECTORS_PER_TARGET = 10. A zero-address target (ZeroTarget), a zero-value selector (ZeroSelector), a duplicate target (DuplicateTarget), a duplicate selector (DuplicateSelector), an out-of-range tier (InvalidTier), and delaySeconds != 0 on a non-TIER_DELAYED tier (InvalidDelay) each revert with a named error; over-MAX_TARGETS reverts TooManyTargets and over-MAX_SELECTORS_PER_TARGET reverts TooManySelectors. Larger policies revert; they do not silently truncate. (An empty targets array or a target with no selectors does not revert — copy only validates the entries present.)
- Auth:
policyOwner[policyId]only, else revertNotPolicyOwner. - Pre:
inputsatisfies the normalizer bounds. - Post: policy content replaced in place under the same
policyId;policyVersion[policyId] += 1. EmitsPolicyUpdated(policyId, msg.sender). - Invariant:
policyIdis unchanged — there is no on-chain timelock and pinning apolicyIddoes not protect against in-place edits. The monotonically increasingpolicyVersionis the only on-chain signal of a content change, andSentryQueue.dispatchkeys itsUPDATEDre-check on it. To freeze content, never callupdatePolicy; publish a freshlabelinstead. See the Trust assumptions section above.
- Auth:
policyOwner[policyId]only, else revertNotPolicyOwner. - Pre:
newOwner != address(0), else revertZeroAddress. - Post:
pendingPolicyOwner[policyId] = newOwner. Ownership does not change. EmitsPolicyOwnershipTransferStarted(policyId, msg.sender, newOwner). - Invariant: calling again overwrites any prior pending nominee. No effect on
policyOwneruntilacceptPolicyOwnership.
- Auth:
pendingPolicyOwner[policyId]only, else revertNotPendingOwner. - Pre: a pending nominee equal to
msg.senderexists (implied by the auth check; if none is set,pending == address(0) != msg.senderrevertsNotPendingOwner). - Post:
policyOwner[policyId] = msg.sender;pendingPolicyOwner[policyId]deleted. EmitsPolicyOwnershipTransferred(policyId, previousOwner, msg.sender). - Invariant: the previous owner immediately loses all update/transfer/veto rights. Ownership change is atomic with clearing the pending slot.
- Auth:
policyOwner[policyId]only, else revertNotPolicyOwner. - Pre:
pendingPolicyOwner[policyId] != address(0), else revertNoPendingTransfer. - Post:
pendingPolicyOwner[policyId]deleted. EmitsPolicyOwnershipTransferCancelled(policyId, msg.sender, cancelledNominee).
-
Auth: anyone.
-
Pre:
policyOwner[policyId] != address(0), else revertPolicyNotFound. -
Post: none (pure view, no state change).
-
Invariant (safe-by-default): returns
(true, bytes32(0))only when (a)PolicyLib.validate(intent, spentToday)returnsok == true, and (b) the tier for(intent.target, intent.selector)isTIER_IMMEDIATE. Otherwise:- validation failure →
(false, <PolicyLib reason>). TIER_DELAYED→(false, "REQUIRES_DELAY").TIER_VETO_REQUIRED→(false, "REQUIRES_VETO").
A naive consumer that only proceeds on
ok == truetherefore cannot dispatch aDELAYEDorVETO_REQUIREDintent — it is forced throughSentryQueue. - validation failure →
PolicyLib.validate reason precedence, in evaluation order (first failing check wins):
| Order | Reason | Condition |
|---|---|---|
| 1 | PAUSED |
p.paused |
| 2 | EXPIRED |
block.timestamp > p.expiresAt |
| 3 | BAD_CALLDATA |
i.data.length < 4 |
| 4 | SELECTOR_MISMATCH |
first 4 bytes of i.data != i.selector |
| 5 | TARGET_NOT_ALLOWED |
target not in allow-list |
| 6 | SELECTOR_NOT_ALLOWED |
selector not allowed for that target |
| 7 | VALUE_CAP |
i.value > valueCapPerCall[target][selector] |
| 8 | DAILY_CAP |
spentToday > dailySpendWeiCap OR i.value > dailySpendWeiCap - spentToday |
This precedence is property-tested (test/properties/PolicyLibProperties.t.sol).
checkSelector(bytes32 policyId, address target, bytes4 selector, uint256 value, uint256 spentToday) → (bool ok, bytes32 reason) — view
- Auth: anyone.
- Pre:
policyOwner[policyId] != address(0), else revertPolicyNotFound. - Post: none.
- Invariant: calldata-less variant of
checkIntent. Internally synthesizes anIntentwithdata = abi.encodePacked(selector)(soBAD_CALLDATAandSELECTOR_MISMATCHcannot fire) and the remaining intent fields zeroed (agentId,requestId,promptHash,taskClass). Same tier handling and same safe-by-default guarantee ascheckIntent. This is the function thesentryGuardedmodifier onSentryAgentBaseuses.
tierAndDelay(bytes32 policyId, address target, bytes4 selector) → (uint8 tier, uint32 delaySeconds) — view
- Auth: anyone.
- Pre:
policyOwner[policyId] != address(0), else revertPolicyNotFound. - Invariant: returns the configured
tieranddelaySecondsfor the pair. Use to distinguish "policy denied" from "policy requires queueing" and to size a queue window.SentryQueue.enqueuereads it to computeearliestCommitAt.
- Auth: anyone.
- Pre:
policyOwner[policyId] != address(0), else revertPolicyNotFound. - Invariant: returns only the kill-switch fields. Designed for
SentryQueue.dispatchre-validation, where the dispatcher (the policy owner forVETO_REQUIRED) may lack the asker'sspentTodayand so cannot re-run a fullcheckIntent.
- Auth: anyone. The only oracle read that does not revert
PolicyNotFound(it is pure and touches no state). - Invariant: returns
keccak256(abi.encode(publisher, label))— thepolicyIdthatpublishPolicywould assign for that pair. Off-chain helper for deriving the canonical id before publishing.
SentryQueue is a metadata + state-machine + audit-trail contract bound to one immutable oracle (set in the constructor). It holds no funds and executes no calls.
State.None ──enqueue──▶ State.Pending ──dispatch──▶ State.Committed
│
├──veto──▶ State.Vetoed
│
└──expireIfStale──▶ State.Expired
State.Pending is the only state from which a transition is possible. Committed, Vetoed, and Expired are terminal. Every transition guards q.state != State.Pending with revert NotPending, so each execId transitions at most once.
COMMIT_WINDOW_SECONDS = 7 days, hard-coded. nextExecId starts at 1 and increments per enqueue; execId == 0 is never assigned (slot is permanently State.None).
Errors: NotPending, TooEarly, PastDeadline, NotAuthorizedDispatcher, NotPolicyOwner, NotQueueable(bytes32 reason), PolicyChanged(bytes32 reason).
-
Auth: anyone.
msg.senderis stored asasker. (There is no per-policy asker allow-list; a hostile enqueue is inert —DELAYEDrecords are only dispatchable by that same hostile caller, andVETO_REQUIREDrecords require the policy owner to ratify. FilterEnqueuedbyasker.) -
Pre:
oracle.checkIntent(policyId, intent, spentToday)must returnok == falsewithreason ∈ {"REQUIRES_DELAY", "REQUIRES_VETO"}.ok == truerevertsNotQueueable("IMMEDIATE_NO_QUEUE_NEEDED"); any other reason revertsNotQueueable(reason). (InheritscheckIntent'sPolicyNotFoundrevert for unknownpolicyId.) -
Post: allocates
execId = nextExecId++and writes aQueuedIntentwithstate = State.Pending, storing the fullintent, theasker, thetierandpolicyVersionread from the oracle, and:enqueuedAt = block.timestampearliestCommitAt = block.timestamp + delaySecondsdeadline = earliestCommitAt + COMMIT_WINDOW_SECONDS(i.e.+ 7 days)
Emits
Enqueued(execId, policyId, asker, tier, earliestCommitAt, deadline, keccak256(intent.data)). -
Invariant: the recorded
policyVersionpins the policy content snapshot at enqueue time;dispatchlater compares against the live version.
- Auth (split by tier):
TIER_VETO_REQUIRED→msg.sender == oracle.policyOwner(q.policyId), else revertNotPolicyOwner. "No veto" alone does not ship the intent; the owner must actively dispatch.- any other tier (
TIER_DELAYED) →msg.sender == q.asker, else revertNotAuthorizedDispatcher. The delay is the confidence interval; there is no separate human-consent step.
- Pre (in order, via
_checkDispatchAuthorizedthen_checkPolicyStillActive):q.state == State.Pending, else revertNotPending.block.timestamp >= q.earliestCommitAt, else revertTooEarly.block.timestamp <= q.deadline, else revertPastDeadline.- tier-based authorization (above).
- policy still active:
oracle.policyHealth(q.policyId)must reportpaused == false(else revertPolicyChanged("PAUSED")) andblock.timestamp <= expiresAt(else revertPolicyChanged("EXPIRED")); andoracle.policyVersion(q.policyId) == q.policyVersion, else revertPolicyChanged("UPDATED").
- Post:
q.state = State.Committed. Returnsq.intentfor the caller to execute. EmitsDispatched(execId, msg.sender, q.policyId, keccak256(abi.encode(intent))). - Invariant — what
dispatchdoes NOT re-check: it does not re-runPolicyLib.validate, so it does not re-check value caps, daily spend, target allow-list, or selector allow-list. The only policy re-checks arepaused,expiresAt, and thepolicyVersionequality. A policy content edit between enqueue and dispatch is caught by theUPDATEDversion check (the queued intent becomes undispatchable), but spend/cap correctness for the original snapshot remains the asker's responsibility —spentTodayis supplied by the asker and never re-read here. See the Trust assumptions section above. - Invariant — caller executes:
dispatchreturns theIntentand emitsDispatched; it performs no external call. If the caller never executestarget.call{value: i.value}(i.data), the record staysCommittedand nothing ships. A seconddispatchof the sameexecIdrevertsNotPending.
- Auth:
msg.sender == oracle.policyOwner(q.policyId)only, else revertNotPolicyOwner. - Pre:
q.state == State.Pending, else revertNotPending. - Post:
q.state = State.Vetoed(terminal). EmitsVetoed(execId, q.policyId, reason). - Invariant: veto reads the current
oracle.policyOwner, so a post-enqueue ownership transfer moves veto authority to the new owner. No time bound — a pending intent can be vetoed any time before it is dispatched or expired.
- Auth: anyone.
- Pre:
q.state == State.Pending, else revertNotPending; andblock.timestamp > q.deadline, else revertTooEarly. - Post:
q.state = State.Expired(terminal). EmitsExpired(execId, q.policyId). - Invariant: the only permissionless state transition. It cannot fire before
deadline, and once a record isCommitted/Vetoedit is unreachable.
- Auth: anyone.
- Pre: none. An unused
execIdreturns a zero-valued struct (state == State.None); these reads never revert. - Invariant:
getRecordreturns the full struct includingintent.data;getRecordHeaderreturns every fixed-size field (includingtarget,selector,value,requestId) but omits the unboundedintent.data, for cheap filtering. Audit-trail reads only — no state change.