Approval
Require human approval before agents run dangerous tools
Approval pauses an agent before it runs a risky tool and asks a human (or another channel) to allow or deny it.
from praisonaiagents import Agent
agent = Agent(
name="Coder",
instructions="Refactor safely",
tools=["shell", "write_file"],
)
agent.start("Remove unused imports in utils.py")The user requests a code change; the agent pauses for approval before running dangerous tools.
Quick Start
- Default (safe, no setup needed)
Dangerous tools are gated automatically. Just run your agent — it will ask before doing anything destructive:
python from praisonaiagents import Agent agent = Agent( name="Coder", instructions="Refactor utils.py", tools=["shell", "write_file"], # approval is automatic: asks on a TTY, denies in CI ) agent.start() - Bypass safety (opt-out)
To restore the old unrestricted behaviour:
python from praisonaiagents import Agent agent = Agent( name="Admin", instructions="Manage the server", approval="bypass", # run dangerous tools silently ) agent.start("Clean up old logs")Or via environment variable:
bash PRAISONAI_TOOL_SAFETY=off praisonai code - Deny silently (no prompts)
Block dangerous tools without prompting (useful for CI that should fail fast):
python agent = Agent( name="Reviewer", instructions="Review code only", approval=False, # deny dangerous tools silently ) - Full Configuration
python agent = Agent( name="Admin", instructions="...", approval={ "enabled": True, "backend": "slack", "approve_all_tools": False, "timeout": 120, "approve_level": "high", }, )
Typo-safe Preset Names (PR #4128)
The approval= string is matched against a closed set of preset names, case-insensitively, whitespace-tolerant, with - and _ treated identically:
from praisonaiagents import Agent
# All of these apply the full read-only deny set:
Agent(instructions="t", approval="read_only")
Agent(instructions="t", approval="read-only")
Agent(instructions="t", approval="READ_ONLY")
Agent(instructions="t", approval=" read-only ")
# Typo raises — no silent bypass:
Agent(instructions="t", approval="read_onl")
# ValueError: Unknown preset 'read_onl' for parameter 'approval'.
# Did you mean 'read_only'? Valid presets: read_only, plan, ...
# Pass False explicitly if you want no approval:
Agent(instructions="t", approval=False)If your code today passes a typo’d string and silently runs without approval, it will now raise at construction time — that is the point of the change. Update the string to the correct preset, or set approval=False to opt out explicitly.
Declare approval on the tool itself
Gate a custom tool in one line — right where you define it:
from praisonaiagents import Agent, tool
@tool(approval=True)
def delete_account(user_id: str) -> str:
"""Permanently delete a user account."""
return f"Deleted {user_id}"
agent = Agent(instructions="Help users.", tools=[delete_account])
agent.start("Delete account u_42")No agent config needed — the tool carries its own approval requirement everywhere it goes. @tool(approval=…) uses the same vocabulary as agent-level Agent(approval=…): the decorator says what needs approval, the agent config says how to ask. (@tool(requires_approval=…) still works as a deprecated alias.)
This is an additive third path alongside the built-in DEFAULT_DANGEROUS_TOOLS set (below) and the agent-level approve_tools map.
Full reference for the @tool(approval=...) decorator parameter
What counts as dangerous
The DEFAULT_DANGEROUS_TOOLS set (from praisonaiagents/approval/registry.py) determines which tools trigger approval:
| Tool name | Risk level | Description |
|---|---|---|
execute_command | critical | Runs arbitrary shell commands |
kill_process | critical | Terminates running processes |
execute_code | critical | Executes arbitrary code |
acp_execute_command | critical | ACP shell command execution |
write_file | high | Writes / creates a file |
delete_file | high | Deletes a file |
move_file | high | Moves / renames a file |
copy_file | high | Copies a file |
acp_create_file | high | ACP file creation |
acp_edit_file | high | ACP file edits |
acp_delete_file | high | ACP file deletion |
execute_query | high | Executes a database query |
github_create_branch | high | Creates/checks out a local git branch |
github_commit_and_push | high | Commits tracked changes and pushes to origin |
github_create_pull_request | high | Opens a pull request via gh pr create |
evaluate | medium | Evaluates code expressions |
crawl | medium | Crawls web URLs |
scrape_page | medium | Scrapes a web page |
Read-only tools (search, read_file, etc.) are not in this set and run without gating.
Doom-loop safety gate
A detected doom/repeat loop routes through this same approval pipeline as a synthetic doom_loop target at critical risk — instead of a hardcoded block.
| Target | Risk level | Description |
|---|---|---|
doom_loop | critical | A repeated tool-call loop was detected; approval decides whether to continue |
The default posture still stops (backward-compatible). An explicit allow lets a legitimate repeat continue — e.g. polling a build-status endpoint:
YAML
permissions:
doom_loop: allowPython
from praisonaiagents import Agent
from praisonaiagents.permissions import PermissionManager
agent = Agent(
instructions="Poll the build status until it finishes.",
permission_manager=PermissionManager(rules={"doom_loop": "allow"}),
)
agent.start("Check the CI build every few seconds until done")The allow override is honoured from any of: PRAISONAI_AUTO_APPROVE env, YAML auto-approve, a PermissionManager rule, or an interactive backend continue. The gate is fail-closed — deny, timeout, no backend, or any error falls back to the historical hard-stop. Both chat() / start() (sync, since PR #3776) and achat() / astart() (async, since PR #4858) route the critical verdict through this same gate. The async path uses the registry’s native approve_async(...) so an async-only or event-loop-bound approval backend runs on the caller’s loop.
Interactive vs non-interactive
PraisonAI checks whether both stdin and stdout are TTYs to decide what to do when no approval= argument is passed:
| Context | Result |
|---|---|
| Terminal (TTY) | Ask — the CLI approval backend prompts before each dangerous tool |
| CI / piped / script | Deny — default permission preset blocks destructive ops |
The default preset specifically blocks: execute_command, kill_process, execute_code, acp_execute_command, delete_file, move_file, copy_file, acp_delete_file, github_create_branch, github_commit_and_push, github_create_pull_request. Write and create operations (write_file, acp_create_file, acp_edit_file) still run — they are blocked only under the safe or read_only presets.
How tools are pruned from the LLM
Denied tools are filtered out of both the function schema and the system prompt before the LLM ever sees them.
| Layer | Before v1.6.91 | Since v1.6.91 |
|---|---|---|
| Function schema sent to LLM | All tools advertised; denial only at execution | Denied tools removed from the schema |
"You have access to the following tools: …" in system prompt | All tool names listed | Only allowed tool names listed |
from praisonaiagents import Agent
def execute_command(command: str) -> str:
"""Run a shell command."""
...
def read_file(path: str) -> str:
"""Read a file."""
...
agent = Agent(
name="Reader",
instructions="Help me explore the codebase",
tools=[execute_command, read_file],
approval="safe", # blocks execute_command (dangerous)
)
agent.start("Show me what's in main.py")
# The model is offered ONLY read_file.
# execute_command is invisible — no denied-call loops, no wasted turns.| Preset | Pruned from LLM? | Use when |
|---|---|---|
approval="full" / "bypass" | No — everything visible | You fully trust the environment |
approval="default" (auto-applied in CI / non-TTY) | Yes — shell exec + destructive file ops removed | Default safety, write-friendly |
approval="safe" / "read_only" | Yes — all dangerous tools removed | Read-only / review agents |
approval={"permissions": {"*": "deny", ...}} | Yes — anything matched as deny removed | Custom allow-lists |
approval={"permissions": {"bash:rm *": "deny", ...}} | Yes — pattern-matched deny rules removed (native + MCP) | Fine-grained rule-based safety |
Pattern-based rules and MCP tools
The same pruning path now covers pattern-based rules — not just presets. Rules loaded from .praisonai/permissions/, YAML, CLI flags, or a PermissionManager are consulted via PermissionManager.is_denied() when the schema is built, and MCP tools go through the identical gate.
from praisonaiagents import Agent
from praisonaiagents.mcp import MCP
agent = Agent(
name="Ops",
instructions="Run ops tasks",
tools=MCP("http://localhost:8000/sse"),
approval={
"permissions": {
"tool:delete_*": "deny", # MCP-namespaced tool names
},
},
)
# delete_* MCP tools never appear in the schema and are blocked at call time
# with {"permission_denied": True}.MCP tools using a tool:<name> prefix match rules written against either the bare name or the prefixed form.
Config objects never weaken the default deny set
A config object that only tunes timeout=, backend=, etc. keeps the full default deny set — configuring approval is never weaker than passing nothing.
Precedence, from weakest input to strongest:
| # | You pass | Deny set applied |
|---|---|---|
| 1 | approval=None | Env-driven default deny set |
| 2 | approval=ApprovalConfig(timeout=30) (no permissions=) | Env-driven default deny set |
| 3 | approval={"timeout": 30} (no permissions=) | Env-driven default deny set |
| 4 | approval=ApprovalConfig(permissions={...}) | Empty — the policy owns denial |
| 5 | approval=ApprovalConfig(permissions={}) | Empty — explicit opt-out is intentional |
| 6 | PRAISONAI_TOOL_SAFETY=off | Empty — full bypass, regardless of config |
The default deny set contains eleven tools: execute_command, kill_process, execute_code, acp_execute_command, delete_file, move_file, copy_file, acp_delete_file, github_create_branch, github_commit_and_push, github_create_pull_request.
from praisonaiagents import Agent
from praisonaiagents.approval.protocols import ApprovalConfig
# Configuration change (timeout only) — default denials PRESERVED
Agent(name="t", instructions="t", approval=ApprovalConfig(timeout=30))._perm_deny
# → includes execute_command, delete_file, kill_process, execute_code, …
# Dict form is symmetric
Agent(name="t", instructions="t", approval={"timeout": 30})._perm_deny
# → includes execute_command, …
# Explicit declarative policy — the policy owns denial, the preset does not shadow it
Agent(
name="t",
instructions="t",
approval=ApprovalConfig(permissions={"bash:rm *": "deny"}),
)._perm_deny
# → frozenset() (the PermissionManager holds the rule)An unknown PRAISONAI_TOOL_SAFETY value falls back to the "default" preset (with a logged warning), so a typo never silently empties the deny set.
Bypassing safety
Three ways to restore unrestricted behaviour:
1. CLI flag
praisonai code --dangerously-skip-approval
# also sets PRAISON_APPROVAL_MODE=auto / PRAISONAI_TOOL_SAFETY=off for any subprocess tree--dangerously-skip-approval and --no-safe do two things, not one:
- Set env vars —
PRAISON_APPROVAL_MODE=auto,PRAISONAI_TOOL_SAFETY=off— read by the CLI’s own tool wiring. - Register
AutoApproveBackendon the approval registry — read by the core@require_approvaldecorator that gates critical tools likeacp_execute_command. Without this the flag was a no-op for any registry-decorated tool.
Known PRAISON_APPROVAL_MODE values for the CLI tool wiring:
prompt— safe default written bypraisonai code --safe; every HIGH-risk call goes through@require_approval(ask on TTY, deny off-TTY).auto— full privileges; opt-in via--no-safe/--dangerously-skip-approval.manual— all write operations require approval.scoped— safe operations auto-approved; dangerous ones require approval.
Any other non-empty value is logged as a warning and treated as prompt (fail-closed). Even in auto, edit_file / apply_patch only get a standing approval when the edit group is in the requested tool list — enable_edit=True alone is not enough.
Cleanup contract:
- A safe-mode run removes the
AutoApproveBackenda prior--no-safeinstalled in the same process (REPL/worker/test). - Cleanup removes only the backend this module installed — a caller-supplied backend or one installed by
--planis preserved. - A per-agent backend still wins: the core consults
Agent(approval=…)first, so--planand an--agentprofile’s permission scope are unaffected by the global bypass. --planremains mutually exclusive with--no-safe/--dangerously-skip-approval(exits1).
| Approval resolution order (highest → lowest) |
|---|
1. Per-agent backend (Agent(approval=…), --plan, --agent <profile>) |
2. Global registry backend (installed by --no-safe / --dangerously-skip-approval → AutoApproveBackend) |
3. PRAISON_APPROVAL_MODE / PRAISONAI_TOOL_SAFETY env vars (CLI-side tool wiring only) |
| 4. Safe-by-default gate (prompt in TTY; deny in non-TTY) |
2. Environment variable
PRAISONAI_TOOL_SAFETY=off praisonai codeAccepted “off” values: off, full, none, 0, false.
3. Python / YAML
from praisonaiagents import Agent
Agent(tools=["shell", "write_file"], approval="bypass") # silent allow
Agent(tools=["shell", "write_file"], approval=False) # silent deny (no prompts)
Agent(tools=["shell", "write_file"], approval="safe") # block all dangerous tools
Agent(tools=["shell", "write_file"], approval="read_only") # alias of "safe"
Agent(tools=["shell", "write_file"], approval="full") # no restrictionsHow users approve
Standing grants and attached backends
An attached approval backend now honours the same standing registry grants as the no-backend path since PR #4878.[^backend-standing-grants]
The same standing-grant check now runs for any agent with an attached approval backend — including the default ConsoleBackend and every chat/gateway front-end — since PR #4878. Before #4878 the backend branch went straight to the prompt and ignored PRAISONAI_AUTO_APPROVE, YAML approvals, and session grants.
Before calling the backend, _resolve_approval_decision checks each standing grant in order — first hit wins and the backend is never asked:
| # | Check | Grant source |
|---|---|---|
| 1 | is_env_auto_approve() | PRAISONAI_AUTO_APPROVE env var |
| 2 | is_yaml_approved(tool_name) | YAML approve: list |
| 3 | is_auto_approved(tool_name, scope_id) | Registry mark_approved grant |
| 4 | is_already_approved(tool_name, tool_args, scope_id) | Exact-args approval cache |
| 5 | _is_session_scoped(agent_name, tool_name, tool_args, scope_id) | [s] session grant recorded at the prompt |
| 6 | Backend prompt | No standing grant matched |
If any standing grant is present, an ApprovalDecision(approved=True, reason="Approved by a standing registry grant") is returned directly. When the backend does prompt and the user picks [s] this session or [a] always, the decision is fed back into the registry (mark_approved + _persist_scoped_decision) so every later matching call in the same run is served from the fast path.
Set PRAISONAI_AUTO_APPROVE=true and every gated call runs immediately — even on the default TTY case where a ConsoleBackend is attached automatically:
import os
from praisonaiagents import Agent
os.environ["PRAISONAI_AUTO_APPROVE"] = "true"
agent = Agent(
name="Shell",
instructions="Run shell commands safely.",
tools=["shell"],
)
agent.start("List the files in the current directory")Before PR #4878 this env var was ignored — the default ConsoleBackend still prompted 3 out of 3 identical calls. After PR #4878 it works: zero prompts, the tool runs immediately.
A session grant picked at the prompt is now remembered for the rest of the run:
from praisonaiagents import Agent
agent = Agent(name="Shell", instructions="Run shell commands.", tools=["shell"])
# First call — ConsoleBackend prompts; user picks [s] this session.
agent.chat("git status")
# Every later matching call is served from the registry — no re-prompt.
agent.chat("git status --short")
agent.chat("git log --oneline -n 5")Before PR #4878 the second and third calls each re-prompted because the session grant was never persisted; after PR #4878 they run silently.
[^backend-standing-grants]: Fixed in PraisonAI PR #4878. Measured after the fix: with PRAISONAI_AUTO_APPROVE=true the backend prompted 0 of 3 identical calls (was 3 of 3); with the env var unset it still prompted 3 of 3 — the gate must still ask when no grant exists.
Stopped-turn safety
A /stop — or a superseding turn under busy_mode="interrupt" — while an approval is parked on a human now drops the resolution fail-closed. The tool does not run, and no session/always grant is persisted for the abandoned turn. This closes a real safety gap for asynchronous chat approvals (Telegram/Slack/Discord/Webhook/HTTP) where a reviewer can tap Allow minutes or hours later — long after the originating user cancelled. Enforced by the core SDK since PraisonAI #4950, so it applies to every backend that goes through the registry.
See Approval Protocol → Live-Authority Binding.
Diff preview in approval prompts
When an agent proposes a file-mutating tool call, the approval prompt shows a coloured unified diff of the pending change instead of the raw tool arguments.
The reviewer asks the agent to edit a file, then approves the concrete diff — not just the tool name.
from praisonaiagents import Agent
agent = Agent(
name="Coder",
instructions="Edit files as requested",
tools=["edit_file", "write_file"],
approval=True, # console prompt now shows a diff for edit_file / write_file
)
agent.start("Add type hints to utils.py")Tools covered
| Tool | Preview |
|---|---|
edit_file | Unified diff of the file with old_string → new_string applied (honours replace_all) |
acp_edit_file | Whole-file diff of the current file vs new_content |
write_file, acp_create_file | Diff of the current file (or empty) vs content |
apply_patch | The patch argument is already a unified diff — returned verbatim |
| Any other tool | No diff — the argument summary is shown instead |
edit_file reads the on-disk file, applies the replacement honouring replace_all (default False = first occurrence only), and diffs before against after; it falls back to diffing old_string vs new_string directly when the file isn’t readable.
Before and after
Before this change, the prompt printed raw arguments truncated at ~97 characters:
🔒 Tool Approval Required
Function: edit_file
Risk Level: HIGH
Agent: Coder
Arguments:
filepath: /Users/.../very/long/path/to/utils.py
old_string: def add(a, b):\n return a + b\n\ndef sub(a, b):\n return a - ...
new_string: def add(a: int, b: int) -> int:\n return a + b\n\ndef sub(a: ...Now, when context["diff"] is present, the prompt shows the coloured diff:
🔒 Tool Approval Required
Function: edit_file
Risk Level: HIGH
Agent: Coder
Diff:
--- /Users/.../utils.py (before)
+++ /Users/.../utils.py (after)
@@ -1,3 +1,3 @@
-def add(a, b):
- return a + b
+def add(a: int, b: int) -> int:
+ return a + bThe console colours each line: +++/--- bold, @@ hunk headers cyan, + lines green, - lines red, context lines dim.
This is the core-SDK console preview — it reaches any backend. The praisonai-code Rich/Textual TUI has its own Change Preview; the two are complementary. See Approval Backends for how each backend surfaces the diff.
Configuration
Denial guarantees
When you gate a tool with @require_approval, PraisonAI guarantees the following. These are enforced by the test suite.
from praisonaiagents import Agent, require_approval
@require_approval(risk_level="critical")
def wipe_database(target: str) -> str:
"""Never runs unless a human says yes."""
return f"wiped {target}"
agent = Agent(
name="Ops",
instructions="Only wipe when the operator approves.",
tools=[wipe_database],
)
# If the operator denies, PermissionError is raised and wipe_database's body
# never runs. If the approval backend crashes, the same thing happens
# (fail-closed). If the operator approves with modified_args={"target": "sandbox"},
# the tool runs against 'sandbox' instead of the original argument.
agent.start("Wipe prod")| # | Guarantee | What it means for you |
|---|---|---|
| 1 | Denied means blocked | If the approver returns approved=False, the tool body never runs. Sync and async tools both raise PermissionError: Execution of <tool> denied: <reason>. |
| 2 | Denials are never cached | Denying write_file(path='/etc/passwd') once does not silently deny the next identical call — the backend is asked again. Only approvals are cached (see Argument-aware approval cache). |
| 3 | Fail-closed on backend errors | If the approval backend raises, the call is denied with PermissionError: Approval request failed for <tool>. The tool body never runs on a broken backend. |
| 4 | Approver rewrites are applied | If the approval callback returns modified_args={"target": "sandbox"}, the tool runs with the rewritten values — the callback can sanitise a dangerous call into a safe one. |
| 5 | Sync tools refuse from async contexts | Calling a sync @require_approval tool from inside a running event loop raises PermissionError without asking the backend, because the default console prompt cannot do I/O on the loop. Use an async tool or a non-console backend. |
graph TB
Call[🔧 @require_approval tool call] --> Ctx{Sync tool<br/>in running loop?}
Ctx -->|yes| Refuse[⛔ PermissionError:<br/>Approval request failed]
Ctx -->|no| Ask[🛡️ Ask approval backend]
Ask -->|backend raises| Fail[⛔ PermissionError:<br/>Approval request failed]
Ask -->|approved=False| Deny[⛔ PermissionError:<br/>Execution of tool denied]
Ask -->|approved=True| Modified{modified_args?}
Modified -->|yes| RunMod[▶️ Run with rewritten args]
Modified -->|no| Run[▶️ Run with original args]
Deny -.->|next identical call| Ask
classDef call fill:#8B0000,stroke:#7C90A0,color:#fff
classDef gate fill:#F59E0B,stroke:#7C90A0,color:#fff
classDef ok fill:#10B981,stroke:#7C90A0,color:#fff
classDef bad fill:#8B0000,stroke:#7C90A0,color:#fff
class Call call
class Ctx,Ask,Modified gate
class Run,RunMod ok
class Refuse,Fail,Deny badThe dotted arrow from Deny back to Ask is the “denials are not cached” guarantee — each denied call re-invokes the backend rather than reusing a cached decision.
These behaviours are covered by tests/unit/approval/test_approval_denial_blocks_execution.py in the SDK — if you refactor the approval path, keep those tests green.
| Option | Type | Default | Description | ||
|---|---|---|---|---|---|
enabled | bool | true | Turn approval on/off — safe by default | ||
backend | str | "console" | One of: console, slack, telegram, discord, webhook, http, agent, auto, none | ||
approve_all_tools | bool | false | If true, every tool needs approval (not just risky ones) | ||
timeout | float | null | Seconds to wait for a decision; null = no timeout | ||
approve_level | `ApprovalLevel \ | null` | null | Auto-approve up to this risk level: low, medium, high, critical | |
guardrails | str | null | Free-text guardrail description | ||
default_policy | `”deny” \ | “prompt” \ | “allow”` | "prompt" | Policy when no per-tool entry matches |
approve_tools | `Dict[str, ApprovalLevel] \ | null` | null | Per-tool granularity, e.g. {"shell": "critical"} | |
permissions | Dict[str, Any] | null | Declarative allow/deny/ask rules. See Declarative Permissions. |
YAML
agents:
admin:
role: Server admin
approval:
enabled: true
backend: slack
timeout: 120
approve_level: high
default_policy: prompt
approve_tools:
shell: critical
read_file: lowHook installation
Register a before_tool hook that enforces the policy:
from praisonai._approval_spec import ApprovalSpec
spec = ApprovalSpec(
enabled=True,
default_policy="prompt",
approve_tools={"shell": "critical"},
)
spec.install_hook()Shorthands: approval: true (console), approval: slack (named backend), approval: false / null (off).
Unknown keys raise
ValueError— typos likeapprove_levels:will fail loudly.
CLI
praisonai "deploy" --approval slack --approval-timeout 120 --approve-level high| CLI flag | YAML / Python field |
|---|---|
--trust | backend: auto |
--approval <name> | backend: <name> |
--approve-all-tools | approve_all_tools: true |
--approval-timeout <s> | timeout: <s> |
--approve-level <l> | approve_level: <l> |
--allow <pattern> | permissions: { "<pattern>": allow } |
--deny <pattern> | permissions: { "<pattern>": deny } |
--permissions <file> | Load rules from YAML/JSON file |
--permission-default <action> | permissions: { "*": <action> } |
--guardrail "<txt>" | guardrails: "<txt>" |
YAML workflow approval gating
praisonai run workflow.yaml honours the same approval and permission flags as a single-agent praisonai run "<prompt>". The flags are threaded onto the workflow engine, so a YAML workflow can no longer bypass the approval gate a prompt run enforces.
praisonai run workflow.yaml \
--deny "bash:rm *" \
--allow "bash:pytest *" \
--approval console \
--approval-timeout 60| # | Behaviour | Detail |
|---|---|---|
| 1 | Explicit --approval wins | --approval console (or plan, accept-edits, bypass) sets the backend for the workflow run. |
| 2 | Permission rules imply console | Any --allow / --deny / --permissions / --permission-default with no --approval activates a console backend so deny / ask patterns are actually enforced. |
| 3 | --approve-all-tools / --approval-timeout propagate | Both reach the YAML run, matching the prompt semantics. |
| 4 | No flags → no gate | A plain praisonai run workflow.yaml with no approval or permission flags threads no override, preserving the legacy default exactly. |
| 5 | Composes with session flags | --continue, --session, --fork, --no-save flow into the same engine; a --no-save run with only --allow sets approval without persisting a session. |
| 6 | Rule-source precedence | CLI flags and project config (.praisonai/permissions.yaml, etc.) merge into one rule set — the same merge the prompt path uses. |
See Run › YAML Workflows Are Permission-Gated for the CLI-side walk-through and Permissions for how the rules are merged.
Using approval with async agents
When using async agents (.achat(), .astart(), or async tools), the default console backend will fail with PermissionError. Configure a non-console backend:
from praisonaiagents import Agent
from praisonaiagents.approval import get_approval_registry, WebhookBackend
# Configure webhook backend for async compatibility
get_approval_registry().set_backend(
WebhookBackend(url="http://localhost:8080/approve")
)
agent = Agent(
name="AsyncBot",
instructions="Process requests asynchronously",
approval=True
)
# This now works with async agents
await agent.astart("Delete old files")Available non-console backends: webhook, http, slack, telegram, discord, agent.
Argument-aware approval cache
Approval grants are scoped to the exact tool arguments — calling the same tool with different arguments triggers a fresh approval — unless you approve with reusable_scope=True, which stores a derived prefix pattern that covers arg variants. See Reusable Approval Scopes.
graph LR
Call[🔧 Tool Call] --> Key{🔑 Cache Key}
Key -->|same args| Hit[✅ Cached — skip prompt]
Key -->|different args| Miss[🛡️ New prompt]
Key -->|critical tool| Always[⚠️ Always prompt]
classDef call fill:#8B0000,stroke:#7C90A0,color:#fff
classDef key fill:#F59E0B,stroke:#7C90A0,color:#fff
classDef hit fill:#10B981,stroke:#7C90A0,color:#fff
classDef miss fill:#6366F1,stroke:#7C90A0,color:#fff
class Call call
class Key key
class Hit hit
class Miss,Always missThe cache key formula is:
"{tool_name}:{sha256(json.dumps(arguments, sort_keys=True))[:16]}"So write_file({"path": "/tmp/a.txt"}) and write_file({"path": "/tmp/b.txt"}) produce different keys and each requires its own approval.
Critical-risk tools (execute_command, kill_process, execute_code) always re-prompt regardless of the cache — is_already_approved returns False unconditionally for them.
For persistent approvals across sessions, see Reusable Approval Scopes — once PraisonAI PR #2576 is merged, the pattern will be auto-derived from a command-arity table so git status -s and git status --short share one rule (bash:git status *).
Worked example:
from praisonaiagents import Agent
agent = Agent(
name="FileBot",
instructions="Write files as requested",
approval=True,
)
# First call — prompts for approval
agent.start("Write 'hello' to /tmp/a.txt")
# Second call with SAME path — no prompt (cached)
agent.start("Write 'hello again' to /tmp/a.txt")
# Third call with DIFFERENT path — prompts again
agent.start("Write 'world' to /tmp/b.txt")How approval decisions are keyed
mark_approved() and is_already_approved() take the effective arguments and the requesting agent alongside the tool name. The @require_approval decorator binds the call’s arguments via inspect.signature, merges any modified_args the approval callback returns, and derives the cache key from that effective call — not from the tool name alone.
mark_approved(tool_name: str, arguments: dict | None = None, agent_name: str | None = None) -> None
is_already_approved(tool_name: str, arguments: dict | None = None, agent_name: str | None = None) -> bool| Item | Post PR#3678 behaviour |
|---|---|
| Approval cache key | (agent_name or "*", tool_name, effective_arguments) |
mark_approved("write_file", args, agent_name="A") | Only unlocks the same call when Agent A requests it; Agent B still re-prompts |
mark_approved("write_file", args) (no agent) | Uses * sentinel; only satisfies calls made outside any agent |
mark_approved("write_file") | Only unlocks write_file() with no args, under the * sentinel |
modified_args from the callback | Rolled into the cache key, so the effective (sanitised) call is what’s remembered |
Automatic agent_name capture | Agent._check_tool_approval_sync/_async pass getattr(self, "name", None) into mark_approved; user code rarely needs to pass it explicitly |
For C-callables with no bindable signature, the key falls back to a positional __args__ tuple.
graph LR
Call[🔧 Tool call] --> Bind[🔗 Bind args via inspect.signature]
Bind --> Merge{✏️ modified_args?}
Merge -->|yes| Eff[Effective args = merged]
Merge -->|no| Eff2[Effective args = bound]
Eff --> Key[🔑 key = agent_name + tool_name + args]
Eff2 --> Key
Key --> Cache{Already approved?}
Cache -->|hit| Skip[✅ Run without prompt]
Cache -->|miss| Ask[🛡️ Prompt for approval]
classDef call fill:#8B0000,stroke:#7C90A0,color:#fff
classDef proc fill:#189AB4,stroke:#7C90A0,color:#fff
classDef gate fill:#F59E0B,stroke:#7C90A0,color:#fff
classDef ok fill:#10B981,stroke:#7C90A0,color:#fff
class Call call
class Bind,Eff,Eff2 proc
class Merge,Key,Cache,Ask gate
class Skip okBecause approvals are keyed on the real arguments, approving one call no longer collapses to a per-tool flag:
approved /tmp/notes.txt: True
approved /etc/cron.d/evil: False # was True before the fixfrom praisonaiagents import Agent, require_approval
@require_approval(risk_level="high")
def write_file(path: str, content: str) -> str:
"""Write a file — a human must approve before each write."""
with open(path, "w") as f:
f.write(content)
return f"wrote {path}"
agent = Agent(
name="File Writer",
instructions="Write user files on request.",
tools=[write_file],
)
agent.start("Save 'hello' to notes.txt")
# → prompts, then writes notes.txt.
# A later write_file('/etc/passwd', ...) re-prompts — the earlier
# approval only covered notes.txt, not every write_file call.Always pass arguments when you mark_approved() in tests
mark_approved("write_file") now only matches an argument-less call. In tests that pre-approve a specific call, pass the same dict the tool will receive: mark_approved("write_file", arguments={"path": "/tmp/a", "content": "hi"}).
If your test exercises an Agent, also pass agent_name= so the pre-approval matches the agent the SDK will look up for. Without it, the registry stores against * and the agent path will re-prompt.
Use modified_args to lock the caller to the sanitised value
When your approval callback returns modified_args, the cache key follows the modified args — so the approved (sanitised) call is exactly what runs and what is remembered.
Custom callbacks: prefer per-argument bucketing
Prompt once per unique high-risk argument (for example, one prompt per file path) rather than once per tool name. This keeps a single approval from unlocking an unrelated target.
Approval cache bounds and cleanup
The cache that remembers granted approvals stays small in long-running processes.
- Bounded. Each context remembers at most 1,024 granted
(scope, tool, args)approvals. Past that, the oldest exact-args “once” approvals drop out, so a person may be asked again for the same call. No safety rule changes. - Dead agents are pruned. When an agent is closed or garbage-collected, its keys are removed on the next approval write in that context.
See Agent Lifecycle Cleanup and MCP Lifecycle.
Per-agent approval scoping
Approvals granted inside a multi-agent run are scoped to the agent that received them. If a permissive agent and a stricter agent share the same run and both call the same tool with the same arguments, the stricter agent still re-prompts — the permissive agent’s approval does not carry over. Calls made outside any agent (e.g. bare @require_approval module functions in tests) use a * sentinel and never satisfy an agent-scoped lookup.
from praisonaiagents.approval.registry import ApprovalRegistry
reg = ApprovalRegistry()
reg.mark_approved("write_file", {}, agent_name="permissive")
reg.is_already_approved("write_file", {}, agent_name="permissive") # True
reg.is_already_approved("write_file", {}, agent_name="strict") # FalsePath-scoped shell approvals
Shell tool approvals are keyed on the actual path being touched — particularly out-of-workspace paths — not on the shell tool name alone. This is the same argument-scoping idea applied to shell targets: approving rm /tmp/build-cache no longer unlocks rm /etc/hosts; the second path re-prompts.
When workspace_root is set, a command touching any path outside it emits a distinct external_dir:<parent>/ target — so a broad bash: grant no longer silently covers /etc, ~/.ssh, or sibling repos. Grant external_dir:* explicitly to opt back into the old broad behaviour.
How out-of-workspace paths get a separate external_dir: approval so a broad shell grant stops at your project root
Durable, Per-Agent Scoping
On the gateway, “allow always” grants persist across restart and default to being scoped to the approving agent — one agent’s approval no longer authorises every other agent. Grants live in a SQLite store at ~/.praisonai/state/gateway/approvals.sqlite and expire after 90 days by default — checked on every request, so a long-running gateway honours the cap without a restart.
The in-process approval registry is now agent-scoped too: an in-context approval for one agent never pre-authorises an identical call from another agent in the same run. See Per-agent approval scoping.
Gateway Scoped ApprovalsDurable, agent-scoped allow-always grants, the scope_to_agent / scope_to_args resolver options, and the /api/approval/allow-list endpoint
Troubleshooting
| Error | Cause | Fix |
|---|---|---|
PermissionError: Approval request failed for <tool> | Async agent with console backend, or the backend raised (fail-closed) | Configure a non-console backend — see Denial guarantees |
RuntimeError: Tool '<tool>' requires approval but cannot use console I/O from async context. | Same root cause, surfaced earlier | Same fix |
| Tool I expected to be called was never tried by the model | Tool name is in the active deny set / preset and is pruned from the LLM’s view | Either widen permissions (e.g. approval="full") or change your permissions rules; check the agent’s _perm_deny at runtime |
Best Practices
Console approval
No env vars needed; you see the prompt directly in the terminal. This is now the default when running in an interactive session.
Slack approval
Routes approval requests to a channel humans already watch — great for CI pipelines that need human gating.
Set timeouts
Without a timeout, the agent blocks indefinitely waiting for a decision.
Use approve_level
approve_level: high lets safe tools run without prompts and only gates the dangerous ones.
Restore old behavior for trusted environments
Use approval="bypass" (or its identical aliases "yolo" / "full_auto") or PRAISONAI_TOOL_SAFETY=off when you control the environment fully and want the pre-4.6.27 unrestricted behaviour.
Use approval='safe' for review and plan agents
Use approval="safe" or approval="read_only" for review/plan agents — the model is offered only read tools, so it can’t waste turns calling write or shell tools that would just be denied.
Related
Require a person to sign off a task’s output before the next task consumes it
CardAll backend protocols (Slack, Telegram, Discord, Webhook, HTTP, Agent)
CardFull CLI flag reference
CardInteractive terminal approval experience
CardRestart-safe pending approvals for bots
CardPermission modes (plan, accept-edits, bypass)
CardAgent cleanup and GC-safe finalizers
CardMCP shutdown and GC-safe finalizers
CardScreen/mouse/keyboard control — canonical per-action approval-callback example