Skip to content

Tool Permission Enforcement

Every assistant tool is gated by ABAC permissions at two layers: the registry filters which tools the LLM sees, and each handler checks permissions again before accessing data. This defense-in-depth pattern ensures unauthorized access is impossible even if the model hallucinates a tool call.

User connects to assistant WebSocket
→ collect_assistant_tools(&session)
→ Registry layer: only include tools the user's permissions allow
→ build_assistant_system_prompt() receives filtered tool_names
→ System prompt only describes available tools
→ LLM receives tool definitions + prompt matching permissions
→ Tool called by LLM
→ Handler layer: checks permission again before executing
→ Returns Forbidden if check fails (defense-in-depth)

collect_assistant_tools() in tool_registry_service.rs builds the tool list based on Session permissions. Each tool is only added when the user holds a qualifying permission.

src/mods/assistant/services/tool_registry_service.rs
fn has_any_of(session: &Session, permissions: &[Permission]) -> bool {
session.is_super_admin
|| session.is_owner
|| permissions.iter().any(|p| session.permissions.contains(p))
}
pub fn collect_assistant_tools(session: &Session) -> Vec<Tool> {
let mut tools = Vec::new();
let can_list_contacts = has_any_of(session, &[
Permission::Contact(ContactPermission::Collection(ContactCollectionPermission::List)),
Permission::Contact(ContactPermission::Collection(ContactCollectionPermission::ListAssigned)),
]);
if can_list_contacts {
tools.push(build_search_contacts_tool(session.clone()));
tools.push(build_get_contact_tags_tool(session.clone()));
}
// ... more gates per category
tools
}

Super admins and org owners bypass all gates — they see every tool.

Each tool handler independently verifies permissions using the Resource trait before accessing data. This catches any edge case where the registry might be out of sync.

// Example: get_recent_calls handler
let has_list = CallResource::has_collection_permission(
&session, CallCollectionPermission::List
);
if !has_list {
return Err(ToolError::forbidden("Call:Collection:List"));
}
CategoryToolsRequired Permission
Contact — readsearch_contacts, get_contact_tagsContact:Collection:List or ListAssigned
Contact — viewget_contact_details, find_contact_by_phoneContact:Instance:View or ViewAssigned
Contact — writeupdate_contact, manage_contact_emails/phones/tagsContact:Instance:Update or UpdateAssigned
Contact — createcreate_contact, create_contact_tagContact:Collection:Create
Notesadd_contact_noteContactNote:Collection:Create + contact view
Callsget_recent_callsCall:Collection:List
Callsget_call_detailsCall:Instance:View
Messagesget_contact_messages, get_unanswered_contactsMessage:Collection:List
Messagessend_smsMessage:Collection:Create
Analyticsget_dashboard_stats, get_engagement_stats, get_activity_counts, get_plan_statsDashboard:Instance:ViewOrg or ViewOwn
Workflowget_plans_for_contactPlan:Collection:List + contact view
Workflowcreate_planPlan:Collection:Create + contact update
Workflowget_plan_templatesPlanTemplate:Collection:List
Tasks — readget_tasksTask:Collection:List
Tasks — viewget_task_detailsTask:Instance:View
Tasks — createcreate_taskTask:Collection:Create
Tasks — writeupdate_task, complete_task, dismiss_task, reopen_taskTask:Instance:Update
Analyzers — readget_analyzersAnalyzer:Collection:List
Analyzers — viewget_analyzer_detailsAnalyzer:Instance:View
Analyzers — createcreate_analyzerAnalyzer:Collection:Create
Analyzers — writeupdate_analyzerAnalyzer:Instance:Update
Voice Agents — readget_agentsAgent:Collection:List
Voice Agents — viewget_agent_detailsAgent:Instance:View
Voice Agents — createcreate_agentAgent:Collection:Create
Voice Agents — writeupdate_agentAgent:Instance:Update
Voice Agents — guideget_prompt_writing_guideAgent:Collection:List
Text Agents — readget_text_agentsTextAgent:Collection:List
Text Agents — viewget_text_agent_detailsTextAgent:Instance:View
Text Agents — createcreate_text_agentTextAgent:Collection:Create
Text Agents — writeupdate_text_agentTextAgent:Instance:Update
Infrastructureget_phone_numbersPhone:Collection:List
Infrastructureget_phone_detailsPhone:Instance:View
Infrastructureupdate_phone, assign_phone_agent, assign_phone_text_agent, assign_phone_analyzers, set_phone_agentPhone:Instance:Update
Infrastructurebuy_phone_numberPhone:Collection:Create
Infrastructuresearch_available_numbersTwilio:Collection:Manage
Infrastructureget_knowledge_basesKnowledge:Collection:List

Analytics tools use resolve_dashboard_scope(&session) to restrict data visibility:

  • Dashboard:Instance:ViewOrg — sees all organization data
  • Dashboard:Instance:ViewOwn — sees only data for assigned phones and contacts

The scope resolution passes phone_ids and member_id filters down to the analytics services, so scoped users never see data outside their assignments.

build_assistant_system_prompt() receives the filtered tool_names list and builds prompt sections dynamically. Only capabilities the user can actually use appear in the prompt:

let has: HashSet<&str> = tool_names.iter().map(|s| s.as_str()).collect();
prompt.push_str(&build_capabilities_section(&has));
prompt.push_str(&build_workflows_section(&has));

This reduces token usage (~30% fewer prompt tokens for restricted users) and eliminates hallucinated tool calls for tools the user can’t access.

When you add a new assistant tool, enforce permissions at both layers:

  1. Handler — add a Resource permission check at the top of your tool function
  2. Registry — add a has_any_of() gate in collect_assistant_tools() matching your handler check
  3. System prompt — if your tool needs prompt guidance, add it to the relevant section builder in build_system_prompt_service.rs gated by has.contains("your_tool_name")
// 1. Handler check
let has_perm = MyResource::has_collection_permission(&session, MyCollectionPermission::List);
if !has_perm {
return Err(ToolError::forbidden("MyResource:Collection:List"));
}
// 2. Registry gate (in collect_assistant_tools)
if has_any_of(session, &[Permission::MyResource(
MyResourcePermission::Collection(MyResourceCollectionPermission::List),
)]) {
tools.push(build_my_tool(session.clone()));
}
PathPurpose
src/mods/assistant/services/tool_registry_service.rsPermission-gated tool collection with has_any_of()
src/mods/assistant/services/build_system_prompt_service.rsDynamic prompt builder filtered by available tools
src/mods/assistant/services/assistant_service.rsOrchestrator that wires registry → prompt → LLM
src/mods/assistant/tools/Individual tool handlers with runtime permission checks