How We Built the Consent and Approval Engine for Agent Tool Access
Per-user, per-tool, per-trust-tier consent that respects RBAC caps. The state machine design for time-boxed, single-use approvals. How we kept the consent check under 5ms on the hot path.
The consent engine is the gate between "this agent is connected" and "this agent is authorized." Before any tool is exposed to an AI agent, a human must explicitly consent — specifying which tools, at what trust tier, for how long, capped by what their RBAC actually allows. The consent must be fast to grant, fast to check on the hot path, and impossible to exceed the user's own permissions. This is how we built it.
The consent model: per-user, per-tool, per-tier, time-boxed
A consent record answers five questions:
- Who: which human user is delegating access to the agent
- What: which MCP servers and tools the agent can access
- At what level: trust tier — low/medium/high, controlling whether each call needs re-approval
- For how long: expiration time — minutes, hours, or days
- Capped by what: the user's RBAC permissions — immutable ceiling
State machine design
Consents have a lifecycle — they are not just a boolean flag. We modeled it as a finite state machine with four states:
CREATED → ACTIVE → EXPIRED
↓
REVOKED- CREATED: consent has been granted by the user but not yet used — the agent has not made its first tool call under this consent
- ACTIVE: the consent is in use — tool calls are being evaluated against it
- EXPIRED: the time window has elapsed — no new tool calls are allowed
- REVOKED: the user manually revoked the consent before expiry — immediate termination
Check path: sub-5ms on the hot path
The consent check runs on every tool call. It must complete in under 5ms to avoid being the bottleneck in an already-latency-sensitive agent pipeline. The check evaluates four conditions:
async function checkConsent(agentCall: AgentCall): Promise<ConsentResult> {
const consent = await getConsent(agentCall.userId, agentCall.scopeId)
// 1. Existence check — is there an active consent?
if (!consent || consent.state !== 'ACTIVE') return { allowed: false, reason: 'no-active-consent' }
// 2. Expiry check — has the time window elapsed?
if (Date.now() > consent.expiresAt) return { allowed: false, reason: 'expired' }
// 3. Tool check — is this tool in the consented set?
if (!consent.tools.includes(agentCall.toolName)) return { allowed: false, reason: 'tool-not-consented' }
// 4. Trust tier check — does this operation need re-approval?
if (consent.trustTier === 'low' && !agentCall.preapproved) {
return { allowed: false, reason: 'requires-reapproval', nextStep: 'hitl' }
}
// 5. RBAC cap — does the user actually have this permission?
const rbac = await checkRbac(agentCall.userId, agentCall.scopeId, agentCall.resource, agentCall.action)
if (!rbac.allowed) return { allowed: false, reason: 'rbac-cap-exceeded' }
return { allowed: true }
}Single-use HITL approvals
For sensitive operations, the human-in-the-loop (HITL) system issues single-use approvals. Unlike consent, which covers a category of tools for a time window, a HITL approval covers exactly one operation — consumed atomically on the next matching call:
// HITL approval: single-use, atomic consumption
async function consumeApproval(approvalId: string): Promise<boolean> {
// Conditional delete — only consume if the approval still exists and is unused
const result = await db.deleteItem(
{ PK: `APPROVAL#${approvalId}`, SK: 'PENDING' },
{ ConditionExpression: 'attribute_exists(PK)' }
)
return result.success // true = consumed, false = already consumed or expired
}DynamoDB conditional deletes provide the atomicity. If two concurrent tool calls try to consume the same approval, exactly one succeeds — the other gets a "ConditionalCheckFailed" and the call is denied.
Tradeoffs
- Consent is scoped, not global: each scope (tenant) has its own consent store. A user consents for one tenant at a time. This avoids the complexity of cross-tenant consent management.
- No delegation chains: consent is a direct user → agent delegation. We considered supporting delegation chains (user A delegates to agent, agent delegates to sub-agent) but the security implications — revoking a mid-chain consent — created unacceptable complexity for v1.
- Synchronous RBAC check on every call: the RBAC cap is re-evaluated on every tool call, not cached at consent time. This means if a user's role is revoked mid-session, the agent loses access immediately — not when the consent expires.