Skip to content

Tool Permissions & HITL Approval

The granular tool permissions system replaces the previous binary on/off model with a three-tier permission level per (agent, tool) pair. It also introduces a Human-in-the-Loop (HITL) approval pause — backed by the Vercel AI SDK v6 needsApproval closure — that lets users approve or deny tool invocations at runtime. Users can opt into "approve always" overrides that skip the prompt for future invocations of the same tool.


Three-Tier Permission Model

Every tool registered for an agent carries exactly one of three permission statuses (REQ-PERM-1):

StatusStored asRuntime behaviour
Permitir siemprealways_allowTool is included in the LLM tools map; executes immediately without user confirmation
Pedir aprobaciónneeds_approvalTool is included but the SDK pauses with state='approval-requested' before execution; user must respond
BloqueadoblockedTool is excluded from the tools map entirely — the LLM never sees its schema or description

Permissions are stored in the ai.agent_tool_permissions table with a UNIQUE(agent_id, tool_name) constraint. Providers (operations, mcp, rag) can each hold entries in the same table via the provider_key column.

Default Permission Resolution

When a tool is first added via PUT /agents/:agentId/tools, the server applies defaults if no explicit status is provided (REQ-PERM-3):

Tool name patternDefault
create_*, update_*, delete_*needs_approval
list_*, search_*always_allow
mcp__*needs_approval
Everything elsealways_allow

Resolution Order

When computing whether a tool executes immediately or prompts the user, the system resolves:

user_override (approve_always)
  > agent_tool_permission.status
    > system_default (always_allow)
  1. If the user has an active override for (userId, agentId, toolName) → auto-approve, regardless of permission status.
  2. If no override exists, the agent_tool_permission.permission_status governs.
  3. If no permission row exists, always_allow is the system default.

Snapshot Preload Pattern

To avoid N+1 database queries during tool construction, IToolPermissionService.preloadForAgent(agentId, userId) runs once per chat request inside ToolRegistry.resolveForAgent before any provider's build() is called. (REQ-PERM-2)

ts
interface IAgentPermissionSnapshot {
  alwaysAllow:  Set<string>; // tool names
  needsApproval: Set<string>;
  blocked:       Set<string>;
  userOverrides: Set<string>; // tool names with approve-always override for this user
}

The snapshot is a plain in-memory object. Each provider receives it as a parameter to build(agent, trackingContext, snapshot, cleanupCallbacks). No snapshot data leaves the request scope.

Security invariant: if preloadForAgent throws, the entire stream request fails with HTTP 500. The system never silently downgrades to a permissive mode.


wrapToolWithPermission Utility

A pure function in tools/shared/ that applies the snapshot to a single tool definition. Used by every provider that builds tools.

ts
function wrapToolWithPermission<T>(
  name: string,
  def: T,
  snap: IAgentPermissionSnapshot,
): T | null {
  if (snap.blocked.has(name)) return null;                               // ← filtered
  if (snap.needsApproval.has(name))
    return { ...def, needsApproval: async () => !snap.userOverrides.has(name) };
  return def;                                                            // ← unchanged
}
InputOutput
Tool in blockednull — caller must filter
Tool in needsApprovalTool with needsApproval closure; returns true unless user has an override
Tool in alwaysAllow (or no row)Unchanged tool definition

Blocked Tool Filtering

null entries returned by wrapToolWithPermission are filtered at the ToolRegistry.resolveForAgent level — they are never included in the tools map passed to streamText. The LLM never receives the schema or description of a blocked tool. (REQ-PERM-2)


Audit Event Flow

For every user approval decision delivered in the chat stream, CreateMessageStreamHandler detects tool-approval-response parts before calling streamText, then publishes a ToolApprovalDecisionEvent. (REQ-AUDIT-1)

The handler computes:

ts
const withOverride = snapshot.userOverrides.has(toolName);
// withOverride=true → the user had an approve-always override before this request

This value is embedded in the event so LogToolApprovalDecisionHandler can set the with_override column without an extra DB query.

LogToolApprovalDecisionHandler writes the audit row with best-effort semantics — any exception is caught and logged (wide-event). DB errors from the audit path never propagate to the stream or to the client. (REQ-AUDIT-3)

Audit Schema

Table: ai.agent_tool_approval_audit

ColumnTypeNotes
idUUID v7Primary key
tool_call_idtextSDK-assigned tool call identifier
user_idUUIDAuthenticated user
agent_idUUIDFK → ai.agents ON DELETE CASCADE
chat_idUUIDChat context
tool_nametextName of the tool (e.g. create_task)
decisionenumapproved | denied | denied_with_reason (3-value)
with_overridebooleantrue if user had an approve-always override at audit time
reasontext nullableMax 2000 chars; only set when decision='denied_with_reason'
created_attimestamptzImmutable; append-only table

Decision enum mapping:

User actiondecisionwith_override
Approve onceapprovedfalse
Approve alwaysapprovedtrue
Denydeniedfalse
Deny with reasondenied_with_reasonfalse

No PII stored: tool input arguments are never persisted. Only the tool_name is stored alongside the decision metadata. (REQ-AUDIT-2)


CQRS Events

EventPublisherHandler
ToolApprovalDecisionEventCreateMessageStreamHandlerLogToolApprovalDecisionHandler

ToolApprovalDecisionEvent payload: { toolCallId, userId, agentId, chatId, toolName, decision, reason, withOverride }.


Data Flow

sequenceDiagram
    participant C as Client
    participant API as API /chat
    participant TR as ToolRegistry
    participant TPS as ToolPermissionService
    participant SDK as Vercel AI SDK
    participant DB as Database

    C->>API: PUT /tools (set permissionStatus per tool)
    API->>DB: upsert ai.agent_tool_permissions

    C->>API: POST /tool-overrides (optional approve-always)
    API->>DB: upsert ai.agent_tool_user_overrides

    C->>API: POST /agent/:id/chat (streamText request)
    API->>TPS: preloadForAgent(agentId, userId)
    TPS->>DB: SELECT permissions + overrides (single query)
    TPS-->>API: IAgentPermissionSnapshot

    API->>TR: resolveForAgent(agent, ctx)
    TR->>TR: wrapToolWithPermission for each tool
    note over TR: blocked → null (filtered)<br/>needs_approval → def + needsApproval closure<br/>always_allow → def unchanged

    TR-->>SDK: tools map (no blocked tools)
    SDK->>SDK: LLM invokes needs_approval tool
    SDK-->>C: part { state: 'approval-requested' }

    C->>C: Render ToolApprovalCard
    C->>API: POST /tool-overrides (if "Approve always")
    C->>API: POST /chat with tool-approval-response parts

    API->>API: Detect tool-approval-response parts
    API->>API: compute withOverride from snapshot
    API->>API: publish ToolApprovalDecisionEvent
    API->>DB: LogToolApprovalDecisionHandler writes audit row (best-effort)

    API->>SDK: resume streamText
    SDK-->>C: tool result / execution-denied

Key Invariants

  1. One snapshot per requestpreloadForAgent is called once; all providers share the same frozen snapshot.
  2. Blocked = absent — blocked tools never appear in streamText's tools object; the LLM cannot call them even with prompt injection.
  3. Override resolution is in-memoryneedsApproval: async () => !snap.userOverrides.has(name) reads the Set captured at request start. A new override saved by "Approve always" takes effect on the next request (the auto-resume re-POST), which is exactly the correct semantic.
  4. Audit never blocksLogToolApprovalDecisionHandler catches and swallows all exceptions. Tool execution is never delayed by audit writes.
  5. No tool args in auditagent_tool_approval_audit stores the tool name and decision only. Input arguments are rendered client-side for the user but never sent to the server's audit path.