> ## Documentation Index
> Fetch the complete documentation index at: https://praison.ai/docs/llms.txt
> Use this file to discover all available pages before exploring further.

# 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 theme={"theme":{"light":"vitesse-light","dark":"vitesse-dark"}}
from praisonaiagents import Agent

agent = Agent(
    name="Coder",
    instructions="Refactor safely",
    tools=["shell", "write_file"],
)
agent.start("Remove unused imports in utils.py")
```

<Note>
  **Durability:** For chat bots, pending approvals can survive a gateway restart when you configure an [`ApprovalStore`](/docs/features/durable-approvals). Each `ApprovalRequest` includes `approval_id`, `agent_name`, and `session_id` for correlation.
</Note>

<Note>
  **Two-layer defence for inbound bots:** [Gateway Tool Policy](/docs/features/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.
</Note>

<Warning>
  **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](#what-counts-as-dangerous) below.
</Warning>

<Note>
  **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](#how-tools-are-pruned-from-the-llm).
</Note>

<Note>
  **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.
</Note>

<Note>
  **Sync and Async Parity**: Approval checks now work uniformly in both sync and async tool execution paths.
</Note>

<Warning>
  **Approval on served agents (PraisonAI PR [#4285](https://github.com/MervinPraison/PraisonAI/pull/4285), closes [#4284](https://github.com/MervinPraison/PraisonAI/issues/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](/docs/features/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](/docs/features/agent-cloning#guardrails-and-approval-travel-with-each-clone).
</Warning>

<Warning>
  **Config objects no longer weaken the default deny set (PraisonAI PR [#4234](https://github.com/MervinPraison/PraisonAI/pull/4234), closes [#4228](https://github.com/MervinPraison/PraisonAI/issues/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](#config-objects-never-weaken-the-default-deny-set).
</Warning>

The user requests a code change; the agent pauses for approval before running dangerous tools.

```mermaid theme={"theme":{"light":"vitesse-light","dark":"vitesse-dark"}}
graph LR
    Agent[🤖 Agent] --> Tool{🔧 Tool Call}
    Tool -->|risky| Gate{🛡️ TTY?}
    Gate -->|Interactive| Ask[❓ Ask User]
    Gate -->|Non-interactive| Deny[❌ Deny]
    Ask -->|✅ allow| Run[▶️ Execute]
    Ask -->|❌ deny| Skip[⏹️ Skip]
    Tool -->|safe| Run

    classDef agent fill:#8B0000,color:#fff
    classDef tool fill:#189AB4,color:#fff

    class Agent agent
    class Tool,Gate,Ask,Run,Skip,Deny tool
```

## Quick Start

<Steps>
  <Step title="Default (safe, no setup needed)">
    Dangerous tools are gated automatically. Just run your agent — it will ask before doing anything destructive:

    ```python theme={"theme":{"light":"vitesse-light","dark":"vitesse-dark"}}
    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()
    ```
  </Step>

  <Step title="Bypass safety (opt-out)">
    To restore the old unrestricted behaviour:

    ```python theme={"theme":{"light":"vitesse-light","dark":"vitesse-dark"}}
    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 theme={"theme":{"light":"vitesse-light","dark":"vitesse-dark"}}
    PRAISONAI_TOOL_SAFETY=off praisonai code
    ```
  </Step>

  <Step title="Deny silently (no prompts)">
    Block dangerous tools without prompting (useful for CI that should fail fast):

    ```python theme={"theme":{"light":"vitesse-light","dark":"vitesse-dark"}}
    agent = Agent(
        name="Reviewer",
        instructions="Review code only",
        approval=False,    # deny dangerous tools silently
    )
    ```
  </Step>

  <Step title="Full Configuration">
    ```python theme={"theme":{"light":"vitesse-light","dark":"vitesse-dark"}}
    agent = Agent(
        name="Admin",
        instructions="...",
        approval={
            "enabled": True,
            "backend": "slack",
            "approve_all_tools": False,
            "timeout": 120,
            "approve_level": "high",
        },
    )
    ```
  </Step>
</Steps>

***

## Typo-safe Preset Names (PR #4128)

<Warning>
  **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.
</Warning>

The `approval=` string is matched against a closed set of preset names, case-insensitively, whitespace-tolerant, with `-` and `_` treated identically:

```python theme={"theme":{"light":"vitesse-light","dark":"vitesse-dark"}}
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.

```mermaid theme={"theme":{"light":"vitesse-light","dark":"vitesse-dark"}}
sequenceDiagram
    participant User
    participant Agent
    participant Validator as validate_preset_string()
    participant Registry as PERMISSION_PRESETS

    User->>Agent: Agent(approval="read_onl")
    Agent->>Validator: check "read_onl"
    Validator->>Registry: normalize + lookup
    Registry-->>Validator: not found
    Validator-->>Agent: ValueError("Did you mean 'read_only'?")
    Agent-->>User: raise (construction fails)

    Note over User,Registry: Old behaviour: silent empty deny set → all tools callable
```

***

## Declare approval on the tool itself

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

```python theme={"theme":{"light":"vitesse-light","dark":"vitesse-dark"}}
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.

<Card title="Tool Approval" icon="shield-check" href="/docs/features/tool-requires-approval">
  Full reference for the `@tool(approval=...)` decorator parameter
</Card>

***

## 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     |
| `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:

<Tabs>
  <Tab title="YAML">
    ```yaml theme={"theme":{"light":"vitesse-light","dark":"vitesse-dark"}}
    permissions:
      doom_loop: allow
    ```
  </Tab>

  <Tab title="Python">
    ```python theme={"theme":{"light":"vitesse-light","dark":"vitesse-dark"}}
    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")
    ```
  </Tab>
</Tabs>

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](https://github.com/MervinPraison/PraisonAI/pull/3776)) and `achat()` / `astart()` (async, since [PR #4858](https://github.com/MervinPraison/PraisonAI/pull/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.

<Note>
  `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.
</Note>

***

## 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         |

```mermaid theme={"theme":{"light":"vitesse-light","dark":"vitesse-dark"}}
graph TB
    Start[🚀 Agent starts] --> Check{stdin AND stdout are TTY?}
    Check -->|Yes| Ask[❓ CLI approval backend asks user]
    Check -->|No| Preset[🛡️ 'default' preset — deny destructive ops]
    Ask -->|allow| Exec[▶️ Execute tool]
    Ask -->|deny| Block[⛔ Skip tool]
    Preset --> Block

    classDef start fill:#6366F1,stroke:#7C90A0,color:#fff
    classDef check fill:#F59E0B,stroke:#7C90A0,color:#fff
    classDef ok fill:#10B981,stroke:#7C90A0,color:#fff
    classDef bad fill:#8B0000,stroke:#7C90A0,color:#fff

    class Start start
    class Check check
    class Ask,Exec ok
    class Block,Preset bad
```

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.

```mermaid theme={"theme":{"light":"vitesse-light","dark":"vitesse-dark"}}
graph LR
    subgraph "Pruned advertised surface"
        Tools[🧰 Agent tools] --> Tier{🛡️ Permission tier\nallowed?}
        Tier -->|allowed| Pattern{🛡️ PermissionManager\nis_denied?}
        Tier -->|denied| Drop[🚫 Dropped]
        Pattern -->|no| Schema[📋 LLM Function Schema]
        Pattern -->|no| Prompt[📝 System Prompt:\n'You have access to ...']
        Pattern -->|deny| Drop
    end

    classDef input fill:#6366F1,stroke:#7C90A0,color:#fff
    classDef gate fill:#F59E0B,stroke:#7C90A0,color:#fff
    classDef ok fill:#10B981,stroke:#7C90A0,color:#fff
    classDef warn fill:#8B0000,stroke:#7C90A0,color:#fff

    class Tools input
    class Tier,Pattern gate
    class Schema,Prompt ok
    class Drop warn
```

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

```python theme={"theme":{"light":"vitesse-light","dark":"vitesse-dark"}}
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.
```

<Note>
  `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.
</Note>

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

<Note>
  **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](/docs/features/command-aware-permissions).
</Note>

### 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 theme={"theme":{"light":"vitesse-light","dark":"vitesse-dark"}}
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.

<Tip>
  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.
</Tip>

***

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

```mermaid theme={"theme":{"light":"vitesse-light","dark":"vitesse-dark"}}
graph TB
    In[approval= ?] --> A{approval is None?}
    A -->|Yes| Def[🛡️ Env-driven default deny set]
    A -->|No| B{Config object?<br/>ApprovalConfig / dict}
    B -->|No — string / bool / backend| Legacy[🔧 Existing string/bool/backend path]
    B -->|Yes| C{permissions= supplied?}
    C -->|No — absent| Def
    C -->|Yes — even empty| Owned[📜 Declarative policy owns denial]
    Env[PRAISONAI_TOOL_SAFETY=off] -.-> Bypass[⚡ Empty deny set]
    Def -.->|env override| Bypass

    classDef gate fill:#F59E0B,stroke:#7C90A0,color:#fff
    classDef safe fill:#10B981,stroke:#7C90A0,color:#fff
    classDef owned fill:#6366F1,stroke:#7C90A0,color:#fff
    classDef bypass fill:#8B0000,stroke:#7C90A0,color:#fff

    class A,B,C gate
    class Def,Legacy safe
    class Owned owned
    class Bypass,Env bypass
```

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 eight tools: `execute_command`, `kill_process`, `execute_code`, `acp_execute_command`, `delete_file`, `move_file`, `copy_file`, `acp_delete_file`.

```python theme={"theme":{"light":"vitesse-light","dark":"vitesse-dark"}}
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)
```

<Note>
  **`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".
</Note>

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

  ```python theme={"theme":{"light":"vitesse-light","dark":"vitesse-dark"}}
  # PRAISONAI_TOOL_SAFETY=off
  Agent(name="t", instructions="t", approval=ApprovalConfig(timeout=30))._perm_deny
  # → frozenset()
  ```
</Warning>

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:

```mermaid theme={"theme":{"light":"vitesse-light","dark":"vitesse-dark"}}
graph TB
    Q{How are you running?}
    Q -->|CLI one-off| CLI["praisonai code --dangerously-skip-approval"]
    Q -->|Env / CI| ENV["PRAISONAI_TOOL_SAFETY=off praisonai code"]
    Q -->|Python / YAML| PY["Agent(..., approval='bypass')"]

    classDef q fill:#F59E0B,stroke:#7C90A0,color:#fff
    classDef ans fill:#189AB4,stroke:#7C90A0,color:#fff

    class Q q
    class CLI,ENV,PY ans
```

**1. CLI flag**

```bash theme={"theme":{"light":"vitesse-light","dark":"vitesse-dark"}}
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.

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 theme={"theme":{"light":"vitesse-light","dark":"vitesse-dark"}}
PRAISONAI_TOOL_SAFETY=off praisonai code
```

Accepted "off" values: `off`, `full`, `none`, `0`, `false`.

**3. Python / YAML**

```python theme={"theme":{"light":"vitesse-light","dark":"vitesse-dark"}}
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
```

<Note>
  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](/docs/docs/features/permission-modes#every-alias-resolves-to-the-same-mode) for the full alias table.
</Note>

***

## How users approve

```mermaid theme={"theme":{"light":"vitesse-light","dark":"vitesse-dark"}}
sequenceDiagram
    participant Agent
    participant Approval
    participant User

    Agent->>Approval: Tool call (write_file)
    Approval->>User: Allow write_file? (Slack/console/...)
    User-->>Approval: Yes / No
    Approval-->>Agent: Decision
    Agent->>Agent: Execute or skip
```

<Note>
  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](/docs/docs/features/interactive-approval#scope-choice) for the full scope semantics.
</Note>

## Standing grants and attached backends

An attached approval backend now honours the same standing registry grants as the no-backend path since [PR #4878](https://github.com/MervinPraison/PraisonAI/pull/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](https://github.com/MervinPraison/PraisonAI/pull/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.

```mermaid theme={"theme":{"light":"vitesse-light","dark":"vitesse-dark"}}
sequenceDiagram
    participant Agent
    participant Registry
    participant Backend

    Agent->>Registry: is_env_auto_approve? / is_yaml_approved? / is_auto_approved? / is_already_approved? / _is_session_scoped?
    alt Any standing grant hits
        Registry-->>Agent: approved (skip backend)
    else No standing grant
        Agent->>Backend: request_approval(request)
        Backend-->>Agent: ApprovalDecision (approved / denied, scope=once/session/always)
        Agent->>Registry: _persist_scoped_decision + mark_approved (best-effort)
    end
```

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

```python theme={"theme":{"light":"vitesse-light","dark":"vitesse-dark"}}
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 theme={"theme":{"light":"vitesse-light","dark":"vitesse-dark"}}
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.

<Note>
  **Fail-closed contract preserved.** No standing grant → the backend is asked. A denied backend response is still a denial. A one-shot `[o]` once approval is *not* recorded as a standing session grant. A grant-lookup or bookkeeping exception → the historical behaviour is preserved (the code falls through and asks).
</Note>

[^backend-standing-grants]: Fixed in [PraisonAI PR #4878](https://github.com/MervinPraison/PraisonAI/pull/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](https://github.com/MervinPraison/PraisonAI/pull/4950), so it applies to every backend that goes through the registry.

See [Approval Protocol → Live-Authority Binding](/docs/features/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.

```mermaid theme={"theme":{"light":"vitesse-light","dark":"vitesse-dark"}}
graph LR
    Call[📝 File tool call] --> Build[⚙️ build_diff_preview]
    Build --> Ctx[🔧 ApprovalRequest.context&#91;'diff'&#93;]
    Ctx --> Backend[🧑 Backend renders / attaches]
    Backend --> Out[✅ Reviewer sees the real change]

    classDef input fill:#6366F1,stroke:#7C90A0,color:#fff
    classDef process fill:#F59E0B,stroke:#7C90A0,color:#fff
    classDef backend fill:#189AB4,stroke:#7C90A0,color:#fff
    classDef output fill:#10B981,stroke:#7C90A0,color:#fff

    class Call input
    class Build process
    class Ctx,Backend backend
    class Out output
```

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

```python theme={"theme":{"light":"vitesse-light","dark":"vitesse-dark"}}
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 + b
```

The console colours each line: `+++`/`---` bold, `@@` hunk headers cyan, `+` lines green, `-` lines red, context lines dim.

<Note>
  **Every backend receives the diff.** The preview rides on `ApprovalRequest.context["diff"]`, a stable public field. The `console` backend renders it inline; Slack, Telegram, Discord, webhook, and HTTP backends can render or attach it too. Custom-backend authors read it with `request.context.get("diff")`.
</Note>

<Warning>
  The preview is advisory and never breaks the gate. `build_diff_preview` swallows every exception and returns `None`, Rich markup is escaped so code containing `[a-z]` / `[TODO]` renders literally, and output is bounded at 40 lines (overflow becomes `... (diff truncated)`). When no diff is available, the panel keeps the pre-existing `Arguments:` block.
</Warning>

This is the core-SDK console preview — it reaches any backend. The `praisonai-code` Rich/Textual TUI has its own [Change Preview](/docs/features/interactive-approval#change-preview); the two are complementary. See [Approval Backends](/docs/features/approval-backends) for how each backend surfaces the diff.

## Configuration

<Warning>
  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](#denial-guarantees) (guarantee 5). Configure a non-console backend or drive the agent from sync code.
</Warning>

### Denial guarantees

When you gate a tool with `@require_approval`, PraisonAI guarantees the following. These are enforced by the test suite.

```python theme={"theme":{"light":"vitesse-light","dark":"vitesse-dark"}}
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](#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. |

```mermaid theme={"theme":{"light":"vitesse-light","dark":"vitesse-dark"}}
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.

| 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](/docs/features/declarative-permissions). |

<Note>
  Approval backends decide *how* to ask a human (Slack, console, …). Declarative `permissions` decide *whether* to ask at all in non-interactive runs.
</Note>

## YAML

```yaml theme={"theme":{"light":"vitesse-light","dark":"vitesse-dark"}}
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 theme={"theme":{"light":"vitesse-light","dark":"vitesse-dark"}}
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 theme={"theme":{"light":"vitesse-light","dark":"vitesse-dark"}}
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.

<Warning>
  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.
</Warning>

```bash theme={"theme":{"light":"vitesse-light","dark":"vitesse-dark"}}
praisonai run workflow.yaml \
  --deny "bash:rm *" \
  --allow "bash:pytest *" \
  --approval console \
  --approval-timeout 60
```

```mermaid theme={"theme":{"light":"vitesse-light","dark":"vitesse-dark"}}
graph TB
    Start[praisonai run workflow.yaml] --> Explicit{--approval set?}
    Explicit -->|Yes| Backend[Use that backend]
    Explicit -->|No| Rules{Any --allow / --deny /<br/>--permissions / --permission-default?}
    Rules -->|Yes| Console[Implicit console backend<br/>so deny / ask rules are enforced]
    Rules -->|No| Config{Project config rules?}
    Config -->|Yes| Console
    Config -->|No| Legacy[No gate — legacy default preserved]

    classDef start fill:#6366F1,stroke:#7C90A0,color:#fff
    classDef gate fill:#F59E0B,stroke:#7C90A0,color:#fff
    classDef backend fill:#189AB4,stroke:#7C90A0,color:#fff
    classDef legacy fill:#8B0000,stroke:#7C90A0,color:#fff

    class Start start
    class Explicit,Rules,Config gate
    class Backend,Console backend
    class Legacy legacy
```

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

<Warning>
  **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.
</Warning>

See [Run › YAML Workflows Are Permission-Gated](/docs/cli/run#yaml-workflows-are-permission-gated) for the CLI-side walk-through and [Permissions](/docs/features/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 theme={"theme":{"light":"vitesse-light","dark":"vitesse-dark"}}
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](/docs/features/reusable-approval-scopes).

<Note>
  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](/docs/docs/features/permissions#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.
</Note>

```mermaid theme={"theme":{"light":"vitesse-light","dark":"vitesse-dark"}}
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](/docs/features/reusable-approval-scopes) — once [PraisonAI PR #2576](https://github.com/MervinPraison/PraisonAI/pull/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 theme={"theme":{"light":"vitesse-light","dark":"vitesse-dark"}}
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")
```

<Note>
  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.
</Note>

### 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 theme={"theme":{"light":"vitesse-light","dark":"vitesse-dark"}}
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.

```mermaid theme={"theme":{"light":"vitesse-light","dark":"vitesse-dark"}}
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 theme={"theme":{"light":"vitesse-light","dark":"vitesse-dark"}}
approved /tmp/notes.txt: True
approved /etc/cron.d/evil: False   # was True before the fix
```

```python theme={"theme":{"light":"vitesse-light","dark":"vitesse-dark"}}
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.
```

<AccordionGroup>
  <Accordion title="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.
  </Accordion>

  <Accordion title="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.
  </Accordion>

  <Accordion title="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.
  </Accordion>
</AccordionGroup>

### 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 theme={"theme":{"light":"vitesse-light","dark":"vitesse-dark"}}
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.

<Card title="Workspace Boundary" icon="shield-halved" href="/docs/features/workspace-boundary">
  How out-of-workspace paths get a separate `external_dir:` approval so a broad shell grant stops at your project root
</Card>

### 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](#per-agent-approval-scoping).

<Card title="Gateway Scoped Approvals" icon="user-lock" href="/docs/features/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
</Card>

## 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](#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

<AccordionGroup>
  <Accordion title="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.
  </Accordion>

  <Accordion title="Slack approval">
    Routes approval requests to a channel humans already watch — great for CI pipelines that need human gating.
  </Accordion>

  <Accordion title="Set timeouts">
    Without a timeout, the agent blocks indefinitely waiting for a decision.
  </Accordion>

  <Accordion title="Use approve_level">
    `approve_level: high` lets safe tools run without prompts and only gates the dangerous ones.
  </Accordion>

  <Accordion title="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.
  </Accordion>

  <Accordion title="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.
  </Accordion>
</AccordionGroup>

## Related

<CardGroup cols={2}>
  <Card icon="plug" href="/docs/features/approval-protocol">
    All backend protocols (Slack, Telegram, Discord, Webhook, HTTP, Agent)
  </Card>

  <Card icon="terminal" href="/docs/cli/tool-approval">
    Full CLI flag reference
  </Card>

  <Card icon="shield-check" href="/docs/features/interactive-approval">
    Interactive terminal approval experience
  </Card>

  <Card icon="database" href="/docs/features/durable-approvals">
    Restart-safe pending approvals for bots
  </Card>

  <Card icon="shield" href="/docs/features/permission-modes">
    Permission modes (plan, accept-edits, bypass)
  </Card>

  <Card icon="computer-mouse" href="/docs/features/computer-use-tools">
    Screen/mouse/keyboard control — canonical per-action approval-callback example
  </Card>
</CardGroup>
