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.

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

Diagram

risky

Interactive

Non-interactive

✅ allow

❌ deny

safe

🤖 Agent

🔧 Tool Call

🛡️ TTY?

❓ Ask User

❌ Deny

▶️ Execute

⏹️ Skip

Quick Start

  1. 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()
  2. 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
  3. 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
    )
  4. 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:

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

Diagram
PERMISSION_PRESETSvalidate_preset_string()AgentUserPERMISSION_PRESETSvalidate_preset_string()AgentUserOld behaviour: silent empty deny set → all tools callableAgent(approval=”read_onl”)check “read_onl”normalize + lookupnot foundValueError(“Did you mean ‘read_only’?”)raise (construction fails)

Declare approval on the tool itself

Gate a custom tool in one line — right where you define it:

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

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:

Tool nameRisk levelDescription
execute_commandcriticalRuns arbitrary shell commands
kill_processcriticalTerminates running processes
execute_codecriticalExecutes arbitrary code
acp_execute_commandcriticalACP shell command execution
write_filehighWrites / creates a file
delete_filehighDeletes a file
move_filehighMoves / renames a file
copy_filehighCopies a file
acp_create_filehighACP file creation
acp_edit_filehighACP file edits
acp_delete_filehighACP file deletion
execute_queryhighExecutes a database query
github_create_branchhighCreates/checks out a local git branch
github_commit_and_pushhighCommits tracked changes and pushes to origin
github_create_pull_requesthighOpens a pull request via gh pr create
evaluatemediumEvaluates code expressions
crawlmediumCrawls web URLs
scrape_pagemediumScrapes 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.

TargetRisk levelDescription
doom_loopcriticalA 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

yaml
permissions:
  doom_loop: allow

Python

python
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:

ContextResult
Terminal (TTY)Ask — the CLI approval backend prompts before each dangerous tool
CI / piped / scriptDeny — default permission preset blocks destructive ops
Diagram

Yes

No

allow

deny

🚀 Agent starts

stdin AND stdout are TTY?

❓ CLI approval backend asks user

🛡️ ‘default’ preset — deny destructive ops

▶️ Execute tool

⛔ Skip tool

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.

Diagram

Pruned advertised surface

allowed

denied

no

no

deny

🧰 Agent tools

🛡️ Permission tier\nallowed?

🛡️ PermissionManager\nis_denied?

🚫 Dropped

📋 LLM Function Schema

📝 System Prompt:\n’You have access to …’

LayerBefore v1.6.91Since v1.6.91
Function schema sent to LLMAll tools advertised; denial only at executionDenied tools removed from the schema
"You have access to the following tools: …" in system promptAll tool names listedOnly allowed tool names listed
python
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.
PresetPruned from LLM?Use when
approval="full" / "bypass"No — everything visibleYou fully trust the environment
approval="default" (auto-applied in CI / non-TTY)Yes — shell exec + destructive file ops removedDefault safety, write-friendly
approval="safe" / "read_only"Yes — all dangerous tools removedRead-only / review agents
approval={"permissions": {"*": "deny", ...}}Yes — anything matched as deny removedCustom 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.

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

Diagram

Yes

No

No — string / bool / backend

Yes

No — absent

Yes — even empty

env override

approval= ?

approval is None?

🛡️ Env-driven default deny set

Config object?
ApprovalConfig / dict

🔧 Existing string/bool/backend path

permissions= supplied?

📜 Declarative policy owns denial

PRAISONAI_TOOL_SAFETY=off

⚡ Empty deny set

Precedence, from weakest input to strongest:

#You passDeny set applied
1approval=NoneEnv-driven default deny set
2approval=ApprovalConfig(timeout=30) (no permissions=)Env-driven default deny set
3approval={"timeout": 30} (no permissions=)Env-driven default deny set
4approval=ApprovalConfig(permissions={...})Empty — the policy owns denial
5approval=ApprovalConfig(permissions={})Empty — explicit opt-out is intentional
6PRAISONAI_TOOL_SAFETY=offEmpty — 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.

python
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:

Diagram

CLI one-off

Env / CI

Python / YAML

How are you running?

praisonai code –dangerously-skip-approval

PRAISONAI_TOOL_SAFETY=off praisonai code

Agent(…, approval=’bypass’)

1. CLI flag

bash
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 AutoApproveBackend on the approval registry — read by the core @require_approval decorator that gates critical tools like acp_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 by praisonai 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 AutoApproveBackend a prior --no-safe installed in the same process (REPL/worker/test).
  • Cleanup removes only the backend this module installed — a caller-supplied backend or one installed by --plan is preserved.
  • A per-agent backend still wins: the core consults Agent(approval=…) first, so --plan and an --agent profile’s permission scope are unaffected by the global bypass.
  • --plan remains mutually exclusive with --no-safe / --dangerously-skip-approval (exits 1).
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

bash
PRAISONAI_TOOL_SAFETY=off praisonai code

Accepted “off” values: off, full, none, 0, false.

3. Python / YAML

python
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 restrictions

How users approve

Diagram
UserApprovalAgentUserApprovalAgentTool call (write_file)Allow write_file? (Slack/console/…)Yes / NoDecisionExecute or skip

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:

#CheckGrant source
1is_env_auto_approve()PRAISONAI_AUTO_APPROVE env var
2is_yaml_approved(tool_name)YAML approve: list
3is_auto_approved(tool_name, scope_id)Registry mark_approved grant
4is_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
6Backend promptNo 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.

Diagram
BackendRegistryAgentBackendRegistryAgentalt[Any standing grant hits][No standing grant]is_env_auto_approve? / is_yaml_approved? / is_auto_approved? / is_already_approved? / _is_session_scoped?approved (skip backend)request_approval(request)ApprovalDecision (approved / denied, scope=once/session/always)_persist_scoped_decision + mark_approved (best-effort)

Set PRAISONAI_AUTO_APPROVE=true and every gated call runs immediately — even on the default TTY case where a ConsoleBackend is attached automatically:

python
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:

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

Diagram

📝 File tool call

⚙️ build_diff_preview

🔧 ApprovalRequest.context&[‘diff’&]

🧑 Backend renders / attaches

✅ Reviewer sees the real change

The reviewer asks the agent to edit a file, then approves the concrete diff — not just the tool name.

python
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

ToolPreview
edit_fileUnified diff of the file with old_string → new_string applied (honours replace_all)
acp_edit_fileWhole-file diff of the current file vs new_content
write_file, acp_create_fileDiff of the current file (or empty) vs content
apply_patchThe patch argument is already a unified diff — returned verbatim
Any other toolNo 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 + b

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

python
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")
#GuaranteeWhat it means for you
1Denied means blockedIf the approver returns approved=False, the tool body never runs. Sync and async tools both raise PermissionError: Execution of <tool> denied: <reason>.
2Denials are never cachedDenying 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).
3Fail-closed on backend errorsIf the approval backend raises, the call is denied with PermissionError: Approval request failed for <tool>. The tool body never runs on a broken backend.
4Approver rewrites are appliedIf 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.
5Sync tools refuse from async contextsCalling 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.
Diagram
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 bad

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.

OptionTypeDefaultDescription
enabledbooltrueTurn approval on/off — safe by default
backendstr"console"One of: console, slack, telegram, discord, webhook, http, agent, auto, none
approve_all_toolsboolfalseIf true, every tool needs approval (not just risky ones)
timeoutfloatnullSeconds to wait for a decision; null = no timeout
approve_level`ApprovalLevel \null`nullAuto-approve up to this risk level: low, medium, high, critical
guardrailsstrnullFree-text guardrail description
default_policy`”deny” \“prompt” \“allow”`"prompt"Policy when no per-tool entry matches
approve_tools`Dict[str, ApprovalLevel] \null`nullPer-tool granularity, e.g. {"shell": "critical"}
permissionsDict[str, Any]nullDeclarative allow/deny/ask rules. See Declarative Permissions.

YAML

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: low

Hook installation

Register a before_tool hook that enforces the policy:

python
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 like approve_levels: will fail loudly.

CLI

bash
praisonai "deploy" --approval slack --approval-timeout 120 --approve-level high
CLI flagYAML / Python field
--trustbackend: auto
--approval <name>backend: <name>
--approve-all-toolsapprove_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.

bash
praisonai run workflow.yaml \
  --deny "bash:rm *" \
  --allow "bash:pytest *" \
  --approval console \
  --approval-timeout 60
Diagram

Yes

No

Yes

No

Yes

No

praisonai run workflow.yaml

–approval set?

Use that backend

Any –allow / –deny /
–permissions / –permission-default?

Implicit console backend
so deny / ask rules are enforced

Project config rules?

No gate — legacy default preserved

#BehaviourDetail
1Explicit --approval wins--approval console (or plan, accept-edits, bypass) sets the backend for the workflow run.
2Permission rules imply consoleAny --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 propagateBoth reach the YAML run, matching the prompt semantics.
4No flags → no gateA plain praisonai run workflow.yaml with no approval or permission flags threads no override, preserving the legacy default exactly.
5Composes 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.
6Rule-source precedenceCLI 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:

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

Diagram
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 miss

The 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:

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

python
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
ItemPost 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 callbackRolled into the cache key, so the effective (sanitised) call is what’s remembered
Automatic agent_name captureAgent._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.

Diagram
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 ok

Because approvals are keyed on the real arguments, approving one call no longer collapses to a per-tool flag:

text
approved /tmp/notes.txt: True
approved /etc/cron.d/evil: False   # was True before the fix
python
from 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.

Diagram

Yes

No

✍️ Approval write

🧹 Prune dead agents’ keys

📏 Over 1,024?

✂️ Drop oldest

✅ Store

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

python
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")      # False

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.

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.

Workspace Boundary

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 Approvals

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

Troubleshooting

ErrorCauseFix
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 earlierSame fix
Tool I expected to be called was never tried by the modelTool name is in the active deny set / preset and is pruned from the LLM’s viewEither 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