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.

ApprovalDecision. Rich HITL actions

A handler that returns bool keeps working exactly as before — True allows, False denies. Return an ApprovalDecision when you need more than allow/deny:

from loomflow import ApprovalDecision async def approve(call, user_id): verdict = await review_ui.ask(call, user_id) match verdict.kind: case "yes": return ApprovalDecision(action="allow") case "no": return ApprovalDecision( action="deny", reason="reviewer declined") case "edited": return ApprovalDecision( action="edit", edited_args=verdict.args) case "yes-always": return ApprovalDecision(action="remember_allow") case "no-never": return ApprovalDecision(action="remember_deny")
actionEffect
"allow" / "deny"Same as True / False, plus an optional reason surfaced in the deny message and audit log.
"edit"Run the tool with edited_args instead of the model-planned args. edited_args=None keeps the originals (plain allow).
"remember_allow" / "remember_deny"Decide and cache the decision for (user_id, tool_name) for the remainder of this run.

Edited args are audited. When the handler edits args, the tool host re-validates them at execute time exactly as it would the originals, the model’s transcript is not rewritten, and the audit log gets an explicit tool_call_edited entry carrying both args (what actually ran) and original_args (what the model planned) — the trail always reflects the call that executed.

Approval memory is per-run. A remembered decision short-circuits subsequent ask gates for the same user + tool without re-invoking the handler — “approve once per run”, the CLI-style yes, and don’t ask me again affordance. The cache is re-initialised on every run and never persisted, and it’s keyed per user_id, so one tenant’s “always allow” never leaks into another’s run.

A raising handler is still fail-closed: the pending call is denied and a warning is logged. So is an unrecognised action shape.

Park on a signal. Out-of-band approvals

Handlers are async and the runtime signal channel is public, so a handler can park the run until an out-of-band decision arrives — no polling, no framework machinery beyond the signal API:

from loomflow import Agent, ApprovalDecision, InProcRuntime, get_run_context runtime = InProcRuntime() async def approve(call, user_id): ctx = get_run_context() await notify_slack(call, user_id) # out-of-band ask payload = await runtime.wait_for_signal( # park the run ctx.session_id, "approval" ) return ApprovalDecision(action=payload["action"]) agent = Agent( "deploy when approved", permissions=StandardPermissions(mode=Mode.DEFAULT), runtime=runtime, approval_handler=approve, ) # ...meanwhile, when the human reacts in Slack: await agent.signal("session-1", "approval", {"action": "allow"})

The run genuinely parks mid-approval — no tokens burn while waiting — and agent.signal(session_id, name, payload) unblocks it from any task in the process. Runtimes without signal support warn and no-op instead of deadlocking.

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