Skip to Content
SecurityPermissions and modes

Permissions and modes

A permissions policy decides, for every tool call the model makes, whether it runs (allow), is refused (deny), or waits for a decision from a person or system (ask). Use one whenever the agent has tools that change things, such as writing files, running shell commands or deleting accounts, and when different users of one shared agent should get different rights.

With no policy configured, every call is allowed (AllowAll).

Modes

from loomflow import Mode Mode.DEFAULT # gate destructive tools through ask → approval Mode.ACCEPT_EDITS # auto-approve file edits; other destructive tools ask Mode.BYPASS # allow everything (CI / sandbox only)
ModeNon-destructive toolsEdit tools (write, edit)Other destructive tools
BYPASSallowallowallow
ACCEPT_EDITSallowallowask_(...), then the approval handler decides
DEFAULTallowask_(...)ask_(...)

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.

ACCEPT_EDITS works like the Agent SDK’s acceptEdits: it auto-approves only the file-edit tools, by default the built-in write and edit. Every other destructive tool, bash included, still asks. If you renamed the built-ins or ship your own editors, name them with edit_tools=:

StandardPermissions(mode=Mode.ACCEPT_EDITS, edit_tools=["write", "edit", "apply_patch"])

StandardPermissions

Mode plus an allow-list or 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("...", model="claude-opus-4-7", 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 mode is ACCEPT_EDITS and the tool is in edit_tools: allow.
  5. If the tool is destructive: ask_(...), handled by the approval handler.
  6. Otherwise: allow.

PerUserPermissions

Routes the policy decision per user_id, so one Agent can run in BYPASS for staff while still 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("...", model="claude-opus-4-7", permissions=perms) await agent.run("clean up old records", user_id="admin_alice")

Unknown user_ids, and runs with no user_id at all, fall through to default, so make default your strictest policy. The framework forwards the live user_id from the active RunContext into every permissions.check(...) call; you don’t wire it manually.

Custom policies

Any object with this one async method satisfies the Permissions protocol, no base class needed. This one blocks destructive tools outside business hours:

from collections.abc import Mapping from datetime import datetime from typing import Any from loomflow import Agent 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_() 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("...", model="claude-opus-4-7", permissions=BusinessHoursPermissions())

The same pattern covers geofencing, role-based access, cost-tier gating, and so on. Use user_id to look up the caller’s role, and return ask_(...) instead of deny_(...) when a person should decide. A custom policy can also be one of the values inside a PerUserPermissions mapping.

PermissionDecision shapes

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

Approval handlers

An approval handler turns an ask into a real yes or no. StandardPermissions(mode=Mode.DEFAULT) returns ask_(...) for destructive tools; the agent then calls your handler with the pending ToolCall and the run’s user_id. The handler is async, so it can wait on a terminal prompt, a Slack approval or a ticket queue without blocking the agent loop. Return True to run the call, False to refuse it. Only True, an action name ("allow", "deny", …) or an ApprovalDecision can approve; any other return value denies.

import anyio from loomflow import Agent, Mode, StandardPermissions, tool from loomflow.core.types import ToolCall @tool(destructive=True) def delete_user_data(user: str) -> str: """Delete all stored data for a user.""" return f"deleted data for {user}" async def approve(call: ToolCall, user_id: str | None) -> bool: question = f"{user_id} wants {call.tool}({call.args}). Allow? [y/N] " answer = await anyio.to_thread.run_sync(input, question) return answer.strip().lower() == "y" agent = Agent( "You manage user accounts.", model="claude-opus-4-7", tools=[delete_user_data], permissions=StandardPermissions(mode=Mode.DEFAULT), approval_handler=approve, )

What happens when approval fails

A broken approval flow must never quietly let a gated tool run. Every failure path below ends in a deny.

  • Handler returns False: the call doesn’t run. The model gets a denied tool result and the run continues, so it can react on its next turn.
  • No handler wired (approval_handler=None): ask is treated as deny.
  • Handler raises: treated as deny, and a warning is logged.
  • Handler returns something other than a bool, an action name or an ApprovalDecision ("no", {"approved": False}, None, …): treated as deny, and a warning is logged. Before 0.17 the result was read with bool(), so a truthy value like "deny" approved the call.
  • Handler returns an unrecognised ApprovalDecision action: treated as deny, and a warning is logged.

The denied result’s reason is the first one available: the reason on your ApprovalDecision, the reason the policy passed to ask_(...) (for StandardPermissions, "<tool>: destructive call requires approval"), or a fallback of "approval declined" (or "approval required; no approver" when no handler is wired).

ApprovalDecision: edit, remember, or explain

A handler can return a plain bool (True allows, False denies) or an action name as a string ("allow", "deny", "edit", "remember_allow", "remember_deny"), which counts as the matching ApprovalDecision. 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, and the model’s transcript is not rewritten. The audit log gets an explicit tool_call_edited entry carrying both args (what actually ran) and original_args (what the model planned), so the trail always reflects the call that executed.

Approval memory is per run. A remembered decision answers later ask gates for the same user and tool without calling the handler again: the “yes, and don’t ask me again” option familiar from CLI tools. The cache starts empty on every run, is never persisted, and is keyed per user_id, so one tenant’s “always allow” never leaks into another’s run.

Out-of-band approvals: park the run on a signal

When the answer comes from somewhere else (a Slack button, a web dashboard, a ticket), the handler can park the run until it arrives. Handlers are async and the runtime signal channel is public, so no polling or extra machinery is needed:

from loomflow import ( Agent, ApprovalDecision, InProcRuntime, Mode, StandardPermissions, get_run_context, ) runtime = InProcRuntime() async def approve(call, user_id): ctx = get_run_context() await notify_slack(call, user_id, session_id=ctx.session_id) # out-of-band ask try: payload = await runtime.wait_for_signal( # park ctx.session_id, "approval", timeout_s=900, ) except TimeoutError: return ApprovalDecision(action="deny", reason="no answer in 15 minutes") return ApprovalDecision(action=payload["action"]) agent = Agent( "deploy when approved", model="claude-opus-4-7", permissions=StandardPermissions(mode=Mode.DEFAULT), runtime=runtime, approval_handler=approve, ) # The run parks inside approve(): # await agent.run("deploy v2", session_id="deploy-1") # Meanwhile, when the human reacts in Slack, from any task in the process: await agent.signal("deploy-1", "approval", {"action": "allow"})

The run genuinely parks mid-approval, so no tokens burn while it waits, and agent.signal(session_id, name, payload) unblocks it from any task in the process. timeout_s= makes wait_for_signal raise TimeoutError instead of waiting forever, so an unanswered request fails closed. Runtimes without signal support log a warning and do nothing instead of deadlocking.

Permissions and budgets are separate

Permissions and budgets are independent layers. A run can be allowed by permissions but stopped by its budget, and the reverse. 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 edit files without asking; other destructive tools still go to the handler).

Last updated on