Skip to Content
DocsSecurityPermissions and modes

Permissions and modes

Permissions decide yes / no / ask for every tool call.

Modes

from loomflow import Mode Mode.DEFAULT # gate destructive tools through ask → approval Mode.ACCEPT_EDITS # auto-approve destructive tools (no ask) Mode.BYPASS # allow everything (CI / sandbox only)
ModeNon-destructive toolsDestructive tools
BYPASSallowallow
ACCEPT_EDITSallowallow
DEFAULTallowask_(...) → approval handler decides

A tool is “destructive” when it’s marked @tool(destructive=True) or constructed with Tool(destructive=True). The built-in write_tool, edit_tool, and bash_tool are all destructive.

StandardPermissions

Mode + allow-list / deny-list:

from loomflow import Agent, Mode, StandardPermissions permissions = StandardPermissions( mode=Mode.DEFAULT, denied_tools=["delete_account", "send_email"], # blocklist # allowed_tools=[...], # or allowlist ) agent = Agent("...", permissions=permissions)

Resolution order inside check:

  1. If the tool is in denied_tools → deny.
  2. If allowed_tools is set and the tool isn’t in it → deny.
  3. If mode is BYPASS → allow.
  4. If the tool is destructive and mode isn’t ACCEPT_EDITSask_(...) (handled by the approval handler).
  5. Otherwise → allow.

PerUserPermissions

Route the policy decision per user_id. One Agent can run in BYPASS for staff while gating destructive tools for end users:

from loomflow import Agent, Mode, StandardPermissions from loomflow.security import PerUserPermissions policies = { "admin_alice": StandardPermissions(mode=Mode.BYPASS), "service_account": StandardPermissions( mode=Mode.DEFAULT, allowed_tools=["read", "search"], ), } perms = PerUserPermissions( policies=policies, default=StandardPermissions( mode=Mode.DEFAULT, denied_tools=["delete_account", "send_email"], ), ) agent = Agent("...", permissions=perms)

The framework forwards the live user_id from the active RunContext into every permissions.check(...) call automatically.

Custom policies

Satisfy the Permissions protocol. One async method:

from collections.abc import Mapping from typing import Any from loomflow.core.types import PermissionDecision, ToolCall class BusinessHoursPermissions: """Block destructive tools outside 9am-5pm local time.""" async def check( self, call: ToolCall, *, context: Mapping[str, Any], user_id: str | None = None, ) -> PermissionDecision: if not call.is_destructive(): return PermissionDecision.allow_() from datetime import datetime now = datetime.now() if 9 <= now.hour < 17: return PermissionDecision.allow_() return PermissionDecision.deny_( f"destructive calls disabled outside business hours (now {now:%H:%M})" ) agent = Agent("...", permissions=BusinessHoursPermissions())

Same pattern for geofencing, role-based access, cost-tier gating, etc.

PermissionDecision shapes

PermissionDecision.allow_() # tool runs PermissionDecision.deny_("reason") # tool result = denied PermissionDecision.ask_("destructive call requires approval") # → approval handler

Approval handler. Turning ask into a real decision

StandardPermissions(mode=Mode.DEFAULT) returns ask_(...) for destructive tools. Without an approval handler, ask falls back to deny. The agent never silently bypasses the gate. Wire a handler to surface the decision to a human / Slack / ticket queue:

async def approve(call, user_id): return await my_slack_app.request_approval(call.tool, user_id) agent = Agent( "...", permissions=StandardPermissions(mode=Mode.DEFAULT), approval_handler=approve, )

See Approval handlers for the full failure-mode contract.

Per-user budget caps interact

Permissions and budgets are independent layers. A run can be allowed by permissions but blocked by BudgetExceeded. And vice versa. See Per-user budget caps.

BYPASS is for CI and sandboxes only. It allows every tool call, including destructive ones, without any gate. Production configs should use DEFAULT (with an approval handler) or ACCEPT_EDITS (when you genuinely want the agent to write without asking).

Last updated on