Permissions
The can_use_tool callback is the SDK's policy check. When you provide one, the agent consults it before every tool call: shell commands, file edits, and read-only tools like read_file and grep alike. Your callback can allow the call, deny it with a reason the model sees, or change its arguments.
Earlier SDK versions consulted the callback only for calls that would have prompted a person. A callback that denies unfamiliar tool names by default now also denies read_file, grep and the other read-only tools. Use ctx.requires_human to keep the old behavior: allow when it is False, apply your policy when it is True.
How It Works
For each tool call the SDK calls your can_use_tool function with:
tool_name— the concrete tool being invoked (for example"bash","read_file", or"replace_by_string")tool_input— the arguments the agent passed to the toolctx— aToolPermissionContextwith the call ID, the reason you are being asked, and any confirmation preview
Your callback returns PermissionResult.allow(), PermissionResult.allow(updated_input=...), or PermissionResult.deny(reason).
can_use_tool receives individual tool names such as "read_file", "bash", "create_file", or "replace_by_string" — not group names.
Basic Callback
from adal_agent_sdk import (
AdalAgentClient,
AdalAgentOptions,
PermissionResult,
ToolPermissionContext,
)
async def can_use_tool(
tool_name: str,
tool_input: dict,
ctx: ToolPermissionContext,
):
# Block dangerous commands
if tool_name == "bash":
command = tool_input.get("command", "")
if "rm -rf" in command:
return PermissionResult.deny("Blocked dangerous shell command")
# Allow everything else
return PermissionResult.allow()
options = AdalAgentOptions(
workspace=".",
can_use_tool=can_use_tool,
)
What a Deny Does
A deny ends the current turn. The model sees your reason as the tool's result, so it knows why the call was refused, and the run stops there. The agent keeps the conversation, so the next client.query(...) continues from that point:
await client.query("Summarize the repo")
async for event in client.receive_events():
... # ends after the denied call
await client.query("Do it without running any shell commands")
async for event in client.receive_events():
... # the model already knows the first attempt was refused, and why
Modifying Tool Input
You can approve a tool call while changing its arguments. The changed arguments are what runs. The model's own call stays as it wrote it, and the tool result tells the model that the arguments were changed and what actually ran, so it does not retry the original:
async def can_use_tool(tool_name, tool_input, ctx):
if tool_name == "bash":
command = tool_input.get("command", "")
# Force quiet mode on pytest
if command.strip() == "pytest":
return PermissionResult.allow(
updated_input={**tool_input, "command": "pytest -q"}
)
# Add timeout to long-running commands
if "npm install" in command:
return PermissionResult.allow(
updated_input={**tool_input, "timeout": 120}
)
return PermissionResult.allow()
Using Context
ToolPermissionContext tells you why you are being asked:
| Field | Meaning |
|---|---|
tool_call_id | Unique ID of this call |
reason | "prompt": an interactive session would show a confirmation dialog for this call. "policy": nobody would be asked (read-only tool, session grant, or yolo); your callback is the only check. |
requires_human | True when reason == "prompt" |
confirmation | For edit tools, the preview shown to a person: fileName, fileDiff, title (camelCase keys). Empty for other tools. |
async def can_use_tool(tool_name, tool_input, ctx):
# Let read-only calls through, audit everything else
if not ctx.requires_human:
return PermissionResult.allow()
# Inspect the edit preview
diff = (ctx.confirmation or {}).get("fileDiff", "")
if "+++ secret" in diff:
return PermissionResult.deny("Edit touches a sensitive file")
return PermissionResult.allow()
Common Patterns
Read-only agent
ALLOWED = {"read_file", "grep", "glob", "web_search", "fetch_url"}
async def can_use_tool(tool_name, tool_input, ctx):
if tool_name in ALLOWED:
return PermissionResult.allow()
return PermissionResult.deny(f"{tool_name} is not allowed: this agent is read-only")
Block File Writes Outside a Directory
import os
SAFE_DIR = "/tmp/sandbox"
async def can_use_tool(tool_name, tool_input, ctx):
if tool_name in ("create_file", "replace_by_string", "rewrite_file", "delete_lines"):
path = tool_input.get("file_path", "")
abs_path = os.path.abspath(path)
if not abs_path.startswith(SAFE_DIR):
return PermissionResult.deny(f"Writes only allowed in {SAFE_DIR}")
return PermissionResult.allow()
Log All Tool Calls
import logging
logger = logging.getLogger("adal_audit")
async def can_use_tool(tool_name, tool_input, ctx):
logger.info("tool=%s reason=%s input=%s id=%s", tool_name, ctx.reason, tool_input, ctx.tool_call_id)
return PermissionResult.allow()
Interactive Approval (Terminal)
The callback runs on the event loop, so blocking input must be moved to a thread:
import asyncio
async def can_use_tool(tool_name, tool_input, ctx):
if not ctx.requires_human:
return PermissionResult.allow()
print(f"\n⚠️ Agent wants to run: {tool_name}")
print(f" Input: {tool_input}")
answer = await asyncio.to_thread(input, " Allow? [y/N]: ")
if answer.strip().lower() == "y":
return PermissionResult.allow()
return PermissionResult.deny("User denied")
Timeouts
The runtime waits can_use_tool_timeout seconds (default 30) for your answer. If the callback takes longer, the call is denied, the model is told the client did not answer in time, and the turn ends. Nothing is left waiting on the backend, and the next query works normally. Raise the timeout for callbacks that wait on a person:
options = AdalAgentOptions(
workspace=".",
can_use_tool=can_use_tool,
can_use_tool_timeout=600, # ten minutes for a human reviewer
)
Permission Modes
permission_mode decides when a person would be asked in an interactive session. With a can_use_tool callback, the callback is consulted in every mode; the mode only sets ctx.reason and ctx.requires_human.
| Mode | Without a callback | requires_human is True for |
|---|---|---|
"default" | Shell commands and file edits are refused (nobody can confirm them) and the turn ends | shell commands and file edits |
"acceptEdits" | File edits run; shell commands that are not read-only are refused | shell commands |
"yolo" | Every tool runs | nothing |
An SDK session has no person to ask, so without a callback a call that would need one is refused at once rather than waited on. The turn's tool.completed event has status: "cancelled", result.metadata.permission == "unattended", and result.observation explains how to allow the call. With a callback, the callback is that answer.
Read-only shell commands (ls, git status, grep, ...) and project commands (test, build and lint runners, local git add/commit/switch, mkdir/cp/mv inside the workspace) never require a person, so they arrive with reason="policy".
Unknown mode values fail the session start with an error.
Use "yolo" without a callback for fully automated pipelines where you trust the prompt. Use a callback when you need an audit trail or guardrails; the mode then only tells you which calls a person would have seen.
Error Handling
If your callback raises an exception, the tool call is denied with the exception message and the turn ends:
async def can_use_tool(tool_name, tool_input, ctx):
# If this raises, the tool is denied with the error message
validate_input(tool_input)
return PermissionResult.allow()
Without a Callback
Without can_use_tool, the SDK never consults your code. Tools that a person would confirm (reason="prompt") are denied, because nobody can answer; read-only tools run. Use permission_mode="yolo" to run everything, or provide a callback.