Appearance
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):
| Status | Stored as | Runtime behaviour |
|---|---|---|
| Permitir siempre | always_allow | Tool is included in the LLM tools map; executes immediately without user confirmation |
| Pedir aprobación | needs_approval | Tool is included but the SDK pauses with state='approval-requested' before execution; user must respond |
| Bloqueado | blocked | Tool 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 pattern | Default |
|---|---|
create_*, update_*, delete_* | needs_approval |
list_*, search_* | always_allow |
mcp__* | needs_approval |
| Everything else | always_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)- If the user has an active override for
(userId, agentId, toolName)→ auto-approve, regardless of permission status. - If no override exists, the
agent_tool_permission.permission_statusgoverns. - If no permission row exists,
always_allowis 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
}| Input | Output |
|---|---|
Tool in blocked | null — caller must filter |
Tool in needsApproval | Tool 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 requestThis 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
| Column | Type | Notes |
|---|---|---|
id | UUID v7 | Primary key |
tool_call_id | text | SDK-assigned tool call identifier |
user_id | UUID | Authenticated user |
agent_id | UUID | FK → ai.agents ON DELETE CASCADE |
chat_id | UUID | Chat context |
tool_name | text | Name of the tool (e.g. create_task) |
decision | enum | approved | denied | denied_with_reason (3-value) |
with_override | boolean | true if user had an approve-always override at audit time |
reason | text nullable | Max 2000 chars; only set when decision='denied_with_reason' |
created_at | timestamptz | Immutable; append-only table |
Decision enum mapping:
| User action | decision | with_override |
|---|---|---|
| Approve once | approved | false |
| Approve always | approved | true |
| Deny | denied | false |
| Deny with reason | denied_with_reason | false |
No PII stored: tool input arguments are never persisted. Only the
tool_nameis stored alongside the decision metadata. (REQ-AUDIT-2)
CQRS Events
| Event | Publisher | Handler |
|---|---|---|
ToolApprovalDecisionEvent | CreateMessageStreamHandler | LogToolApprovalDecisionHandler |
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
- One snapshot per request —
preloadForAgentis called once; all providers share the same frozen snapshot. - Blocked = absent — blocked tools never appear in
streamText'stoolsobject; the LLM cannot call them even with prompt injection. - Override resolution is in-memory —
needsApproval: 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. - Audit never blocks —
LogToolApprovalDecisionHandlercatches and swallows all exceptions. Tool execution is never delayed by audit writes. - No tool args in audit —
agent_tool_approval_auditstores the tool name and decision only. Input arguments are rendered client-side for the user but never sent to the server's audit path.
Related Pages
- Internal Tools — static tool catalog, provider architecture
- Chat and Message Streaming — full chat stream lifecycle
- Agent Tool Overrides & Permissions API — endpoint reference
- Agent Internal Tools UI — 3-state selector, ToolApprovalCard