WardenAuthAgent Security
PricingDocsCompareBlogLearnChangelog
Sign inGet started free
All posts
How We Built
July 19, 2026
12 min read

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
The RBAC cap is the most important constraint. A user with "viewer" access cannot consent to an agent performing "delete" operations — regardless of what the consent screen shows or the agent requests. The consent is a convenience layer on top of mandatory RBAC enforcement, not a bypass.

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:

text
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:

typescript
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:

typescript
// 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.

Back to blogTry WardenAuth free →
© 2026 ecarrizo. All rights reserved.
PricingDocsCompareBlogLearnChangelogStatusGlossaryContactTermsPrivacy