Skip to main content
Approval pauses an agent before it runs a risky tool and asks a human (or another channel) to allow or deny it.
Durability: For chat bots, pending approvals can survive a gateway restart when you configure an ApprovalStore. Each ApprovalRequest includes approval_id, agent_name, and session_id for correlation.
Two-layer defence for inbound bots: Gateway Tool Policy runs before dispatch — dangerous tools are never even advertised to the model on untrusted routes. Approval runs after the model decides to call a tool, catching anything that slips through. Use both for defence in depth.
Behaviour change (PR #2369): Dangerous built-in tools (execute_command, kill_process, execute_code, delete_file, move_file, copy_file, and others) are now gated by default. Interactive sessions (TTY) ask before running; non-interactive sessions (CI/pipes) deny. Read-only tools are unaffected. See What counts as dangerous below.
Pruned tool surface (since v1.6.91): Denied tools are removed from the model’s view — both the function schema and the system-prompt enumeration. The model never wastes a turn calling a tool it cannot execute. See How tools are pruned from the LLM.
Earlier change (PR #2122): Approval is enabled by default. YAML configs that omitted the approval key previously got enabled: false; they now get the prompting policy.
Sync and Async Parity: Approval checks now work uniformly in both sync and async tool execution paths.
Approval on served agents (PraisonAI PR #4285, closes #4284): Agent.clone_for_channel() now forwards the configured approval policy (string preset, bool, ApprovalConfig, dict, or backend object) to every per-channel clone. On earlier releases the clone was constructed with the approval policy dropped — the operator’s read_only / safe / custom config was silently ignored on every bot gateway channel and every invoke-API call. Upgrade to a release that includes #4285 — no code change is required. See Agent Cloning → Guardrails and approval travel with each clone.
Config objects no longer weaken the default deny set (PraisonAI PR #4234, closes #4228): Before #4234, passing approval=ApprovalConfig(timeout=30) or approval={"timeout": 30} — a knob about how long to wait — silently dropped all 8 default denials, leaving execute_command, delete_file, kill_process, and execute_code callable. Configuring approval was a weaker posture than passing nothing. A config object with no permissions= policy now inherits the same env-driven default deny set that approval=None uses. Upgrade to a release that includes #4234 — no code change is required. See Config objects never weaken the default deny set.
The user requests a code change; the agent pauses for approval before running dangerous tools.

Quick Start

1

Default (safe, no setup needed)

Dangerous tools are gated automatically. Just run your agent — it will ask before doing anything destructive:
2

Bypass safety (opt-out)

To restore the old unrestricted behaviour:
Or via environment variable:
3

Deny silently (no prompts)

Block dangerous tools without prompting (useful for CI that should fail fast):
4

Full Configuration


Typo-safe Preset Names (PR #4128)

Fixed in PraisonAI 4.x (PR #4128): approval="read_onl" (or any misspelled preset) used to silently produce an empty deny set, leaving all 17 dangerous tools callable instead of the intended read-only sandbox. It now raises ValueError at construction time with a “Did you mean …?” suggestion.
The approval= string is matched against a closed set of preset names, case-insensitively, whitespace-tolerant, with - and _ treated identically:
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:
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.

Tool Approval

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: 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. The default posture still stops (backward-compatible). An explicit allow lets a legitimate repeat continue — e.g. polling a build-status endpoint:
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.
doom_loop is a namespaced internal target (__doom_loop__), deliberately kept out of DEFAULT_DANGEROUS_TOOLS so the safe / read_only presets are unaffected and it can never gate a real user tool.

Interactive vs non-interactive

PraisonAI checks whether both stdin and stdout are TTYs to decide what to do when no approval= argument is passed: The default preset specifically blocks: execute_command, kill_process, execute_code, acp_execute_command, delete_file, move_file, copy_file, acp_delete_file. 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.
ask and allow tools stay advertised — approval still runs at execution time as defence in depth. Only tools whose permission tier resolves to a hard deny (preset deny set, or explicit *: deny rule) disappear from the model’s view.
Schema and call-time now agree (PR #4234). Prior to #4234, a pattern-matched deny rule like bash:rm * was pruned from the LLM’s schema but not enforced at call time if the tool was allowed by name — the deny gate only checked the tool name. After #4234, both the schema and the call-time gate honour argument-scoped patterns uniformly. See Command-Aware Permissions → argument-scoped deny enforcement.

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.
MCP tools using a tool:<name> prefix match rules written against either the bare name or the prefixed form.
When you set approval="safe" on a code-review agent and notice the model never tries to edit or run shell — that’s pruning working. No prompts appear because the model isn’t asking.

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: The default deny set contains eight tools: execute_command, kill_process, execute_code, acp_execute_command, delete_file, move_file, copy_file, acp_delete_file.
permissions=None vs permissions={}. The fallback is triggered by an is None check, not falsiness. permissions=None (absent) inherits the env-driven default deny set. permissions={} (an explicit empty policy) is a caller who intentionally opted into an empty declarative policy — it keeps owning denial and the preset stays empty. Reach for permissions={} only when you mean “no denials from me”.
Once you pass a config object, PRAISONAI_TOOL_SAFETY=off (or full / none / 0 / false) is the only way to get an empty deny set — short of an explicit permissions= policy that owns denial itself.
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
2. Environment variable
Accepted “off” values: off, full, none, 0, false. 3. Python / YAML
Every string that PermissionMode.resolve() recognises — plan, accept_edits, dont_ask, bypass, and their aliases (yolo, auto_edit, full_auto, suggest, prompt, reject, no_ask) — is also valid on Agent(approval=…). See Permission Modes for the full alias table.

How users approve

On the console backend, the prompt now offers five choices: [o] once, [s] this session, [a] always, [n] no, [d] deny & redirect. See Interactive Tool Approval for the full scope semantics.

Configuration

The default console backend needs a sync call stack. Calling a sync @require_approval tool from an async context raises PermissionError before the backend is asked — see Denial guarantees (guarantee 5). Configure a non-console backend or drive the agent from sync code.

Denial guarantees

When you gate a tool with @require_approval, PraisonAI guarantees the following. These are enforced by the test suite.
The 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.
Approval backends decide how to ask a human (Slack, console, …). Declarative permissions decide whether to ask at all in non-interactive runs.

YAML

Hook installation

Register a before_tool hook that enforces the policy:
Shorthands: approval: true (console), approval: slack (named backend), approval: false / null (off).
Unknown keys raise ValueError — typos like approve_levels: will fail loudly.

CLI

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.
On older versions, --approval, --allow, --deny, --permissions, and --permission-default were silently dropped on the YAML path — a workflow file ran ungated even when you passed them. They are now enforced. Runs with no permission or approval flags are unchanged.
The surprising one: with no --approval but any --allow / --deny / --permissions / --permission-default, the workflow run activates a console backend automatically. In a non-interactive pipeline, add an explicit --approval (e.g. --approval bypass for a trusted CI run, or a non-console backend) so the run doesn’t block waiting on a console prompt.
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:
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.
Persistent shell approvals (scope="always" / "session") can opt into a reusable command-prefix scope: approving bash:git status -s records the pattern bash:git status * and covers all trailing-arg variants of the same subcommand. See Reusable command-prefix approvals. Compound commands (&&, |, ;, $()) and bare commands with no subcommand stay literal. Interactive users reach the same scopes through the [s] session and [a] always keys in the console prompt.
The cache key formula is:
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:
This is a behavior change from previous versions where approving a tool name once would auto-approve all subsequent calls to that tool in the same context, regardless of arguments. Now only calls with identical arguments skip the prompt.

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.
For C-callables with no bindable signature, the key falls back to a positional __args__ tuple. Because approvals are keyed on the real arguments, approving one call no longer collapses to a per-tool flag:
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.
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.
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.

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.

Path-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.

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. 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 Approvals

Durable, agent-scoped allow-always grants, the scope_to_agent / scope_to_args resolver options, and the /api/approval/allow-list endpoint

Troubleshooting

Best Practices

No env vars needed; you see the prompt directly in the terminal. This is now the default when running in an interactive session.
Routes approval requests to a channel humans already watch — great for CI pipelines that need human gating.
Without a timeout, the agent blocks indefinitely waiting for a decision.
approve_level: high lets safe tools run without prompts and only gates the dangerous ones.
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" 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.
All backend protocols (Slack, Telegram, Discord, Webhook, HTTP, Agent)
Full CLI flag reference
Interactive terminal approval experience
Restart-safe pending approvals for bots
Permission modes (plan, accept-edits, bypass)
Screen/mouse/keyboard control — canonical per-action approval-callback example