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

# Permissions Module

> Pattern-based permission rules, persistent approvals, and doom loop detection

Pattern-based rules let you allow, deny, or ask about tool calls — with persistent approvals and doom loop detection.

<Info>
  For the CLI, see [`praisonai permissions`](/docs/cli/permissions) and [Interactive Tool Approval](/docs/features/interactive-approval).
</Info>

<Note>
  To also block reads, writes, and shell commands **outside** a project root, see [Workspace Boundary](/docs/features/workspace-boundary).
</Note>

<Warning>
  **Reading secret files (`.env`, private keys, `*.pem`, `id_rsa`, etc.) now defaults to `ask` — even with a broad `"read:*": "allow"` rule.** A catch-all read allow no longer silently authorises credential files; you need a secret-specific rule (e.g. `"read:*.env": "allow"`) to opt in. See [Built-in Secret-File Read Gate](#built-in-secret-file-read-gate).
</Warning>

```python theme={"theme":{"light":"vitesse-light","dark":"vitesse-dark"}}
from praisonaiagents import Agent

agent = Agent(
    name="Safe Coder",
    instructions="Run shell commands safely",
    approval={
        "permissions": {
            "read:*": "allow",
            "bash:rm *": "deny",
            "*": "ask",
        },
    },
)
agent.start("List files in the project root")
```

<Note>
  Shell commands containing `$IFS`, `${VAR}`, or bare `$VAR` are escalated to `ASK` rather than silently allowed — see [Command-Aware Permissions → Shell expansion escalates to ASK](/docs/docs/features/command-aware-permissions#shell-expansion-escalates-to-ask).
</Note>

The user prompts the agent; permission rules allow, deny, or ask before each tool executes.

```mermaid theme={"theme":{"light":"vitesse-light","dark":"vitesse-dark"}}
graph LR
    Agent[🤖 Agent] --> Check{Permission Check}
    Check -->|allow| Run[✅ Execute]
    Check -->|deny| Block[❌ Block]
    Check -->|ask| Prompt[❓ User prompt]

    classDef agent fill:#8B0000,stroke:#7C90A0,color:#fff
    classDef check fill:#F59E0B,stroke:#7C90A0,color:#fff
    classDef ok fill:#10B981,stroke:#7C90A0,color:#fff
    classDef block fill:#EF4444,stroke:#7C90A0,color:#fff

    class Agent agent
    class Check check
    class Run ok
    class Block block
    class Prompt check
```

## How It Works

Two seams enforce a deny rule: the schema-hide seam at build time and the call-time gate — both for native and MCP tools.

```mermaid theme={"theme":{"light":"vitesse-light","dark":"vitesse-dark"}}
sequenceDiagram
    participant Model as LLM
    participant Agent
    participant Mgr as PermissionManager
    participant Tool as Tool (native or MCP)

    Note over Agent,Mgr: Schema build
    Agent->>Mgr: is_denied(tool_name)?
    Mgr-->>Agent: deny -> drop from schema

    Note over Model,Tool: Call time (native OR MCP)
    Model->>Agent: call(tool_name, args)
    Agent->>Mgr: is_denied(tool_name)?
    Mgr-->>Agent: deny -> block
    Agent->>Tool: execute (when allowed)
```

### Doom-loop protection

A configured `PermissionManager` now delivers the doom-loop protection its docstring advertises ([PR #5338](https://github.com/MervinPraison/PraisonAI/pull/5338)). Whether built from `approval={"permissions": {...}}` or an explicit `PermissionManager(...)`, its `DoomLoopDetector` runs at the live tool-execution gate.

The detector is consulted **exactly once per approved tool call**, with the final (post-approval, possibly rewritten) arguments — rejected attempts and arg rewrites don't double-count against the threshold.

On a genuine loop, the gate blocks the call with a denial dict:

```python theme={"theme":{"light":"vitesse-light","dark":"vitesse-dark"}}
{
    "error": "Tool 'bash' blocked: repeated tool call",
    "permission_denied": True,
    "loop_blocked": True,
}
```

To override with a `doom_loop: allow` permission, see [Approval → Doom-loop safety gate](/docs/features/approval#doom-loop-safety-gate).

<Note>
  The built-in [Loop Detection](/docs/features/doom-loop-detection) and [Escalation Doom-Loop](/docs/features/escalation-doom-loop) systems run independently of `PermissionManager` — this gate is what makes `PermissionManager`'s own advertised capability actually work.
</Note>

## Quick Start

<Steps>
  <Step title="Simple Usage">
    ```python theme={"theme":{"light":"vitesse-light","dark":"vitesse-dark"}}
    from praisonaiagents import Agent

    agent = Agent(
        name="CI Worker",
        instructions="Run safely without prompts",
        approval={
            "permissions": {
                "read:*": "allow",
                "bash:rm *": "deny",
            },
        },
    )
    agent.start("Check the deployment status")
    ```
  </Step>

  <Step title="With Configuration">
    Use `PermissionManager` directly for programmatic rule management:

    ```python theme={"theme":{"light":"vitesse-light","dark":"vitesse-dark"}}
    from praisonaiagents.permissions import (
        PermissionManager,
        PermissionRule,
        PermissionAction,
    )

    manager = PermissionManager()
    manager.add_rule(PermissionRule(
        pattern="bash:rm *",
        action=PermissionAction.DENY,
        description="Block destructive commands",
    ))
    manager.add_rule(PermissionRule(
        pattern="read:*",
        action=PermissionAction.ALLOW,
    ))

    result = manager.check("bash:rm -rf /tmp")
    print(result.is_denied)  # True
    ```

    For YAML and CLI surfaces, see [Declarative Permissions](/docs/features/declarative-permissions).
  </Step>
</Steps>

<Note>
  Permission rules resolve identically whether you run a direct prompt or a YAML workflow — `praisonai run workflow.yaml --deny "bash:rm *"` uses the same CLI-flag + project-config merge as `praisonai run "<prompt>"`. A permission rule with no `--approval` implicitly selects the `console` backend so the rule is enforced. See [Approval › YAML workflow approval gating](/docs/features/approval#yaml-workflow-approval-gating).
</Note>

***

## Built-in Secret-File Read Gate

Reading a `.env` or private-key file and forwarding its contents to the model provider is a silent secret-leak path. The core permission manager closes it by defaulting reads of well-known credential-file names to **ask** — even when a broad `"read:*": "allow"` rule is in effect. Safe example/template files stay allowed, and explicit user rules always override.

```mermaid theme={"theme":{"light":"vitesse-light","dark":"vitesse-dark"}}
graph LR
    subgraph "Secret-File Read Gate"
        Read[📖 read:file] --> Basename{🔍 Secret basename?}
        Basename -->|No — main.py, data.txt| Normal[⚙️ Normal rule check]
        Basename -->|Yes — .env, *.pem| Rule{Explicit rule?}
        Rule -->|Approved / deny| Explicit[✅ Rule wins]
        Rule -->|Secret-specific allow| Explicit
        Rule -->|Broad read:* allow| Ask[❓ Ask user]
        Rule -->|No rule| Ask
    end

    classDef input fill:#6366F1,stroke:#7C90A0,color:#fff
    classDef check fill:#F59E0B,stroke:#7C90A0,color:#fff
    classDef result fill:#10B981,stroke:#7C90A0,color:#fff
    classDef ask fill:#8B0000,stroke:#7C90A0,color:#fff

    class Read input
    class Basename,Rule check
    class Normal,Explicit result
    class Ask ask
```

### Gated basenames

Case-insensitive `fnmatch` against the file's basename (path prefix ignored — `config/.env` and `.env` both gate).

| Category | Globs | Example paths |
| - | - | - |
| Environment files | `.env`, `*.env`, `.env.*`, `*.env.*` | `.env`, `prod.env`, `.env.local`, `config/.env` |
| PEM / TLS keys | `*.pem`, `*.key` | `server.pem`, `certs/tls.key` |
| SSH keys | `id_rsa`, `id_dsa`, `id_ecdsa`, `id_ed25519` | `~/.ssh/id_ed25519` |
| Keystores | `*.pfx`, `*.p12` | `keystore.pfx`, `bundle.p12` |

### Not gated (safe siblings)

Documentation templates without real secrets fall through to normal rules — a broad `read:*` allow reads them without prompting:

| Globs |
| - |
| `*.env.example`, `.env.example` |
| `*.env.sample`, `.env.sample` |
| `*.env.template`, `.env.template` |
| `*.example`, `*.sample`, `*.template` |

### Override precedence

```mermaid theme={"theme":{"light":"vitesse-light","dark":"vitesse-dark"}}
graph TB
    Read[read:secret-file] --> Approve{Persistent approval?}
    Approve -->|Yes — always/session| Allow[✅ Allow]
    Approve -->|No| RuleCheck{Matching rule?}
    RuleCheck -->|Explicit deny| Deny[❌ Deny]
    RuleCheck -->|Secret-specific allow| Allow
    RuleCheck -->|Broad allow — read:*| Fallthrough
    RuleCheck -->|No| Fallthrough
    Fallthrough[Fall through to gate] --> Ask[❓ Ask user]

    classDef input fill:#6366F1,stroke:#7C90A0,color:#fff
    classDef check fill:#F59E0B,stroke:#7C90A0,color:#fff
    classDef good fill:#10B981,stroke:#7C90A0,color:#fff
    classDef bad fill:#EF4444,stroke:#7C90A0,color:#fff
    classDef ask fill:#8B0000,stroke:#7C90A0,color:#fff

    class Read input
    class Approve,RuleCheck,Fallthrough check
    class Allow good
    class Deny bad
    class Ask ask
```

| Priority | Override | Wins because |
| - | - | - |
| 1 | Persistent approval (`manager.approve("read:.env", approved=True, scope="always")`) | User has explicitly approved this exact target |
| 2 | Secret-specific `deny` rule (`"read:*.env": "deny"`) | Hardening always beats a soft `ask` |
| 3 | Secret-specific `allow` rule (`"read:*.env": "allow"`, `"read:id_rsa": "allow"`, `"read:*.pem": "allow"`) | Author was explicit about opting the secret in |
| 4 | Regex rules (`is_regex=True`) | Conservatively treated as specific opt-ins |
| — | Broad `"read:*": "allow"` catch-all | **Ignored for secrets** — falls through to the gate |
| — | No rule | Falls through to the gate |

### What counts as "secret-specific"?

A rule is a secret-specific opt-in when its glob does **not** match ordinary files. A glob that also matches ordinary files (like `read:*`) is treated as broad and does **not** silently authorise secrets:

| Rule | Treated as | Why |
| - | - | - |
| `read:*.env` | Secret-specific ✅ | Doesn't match ordinary files like `main.py` |
| `read:id_rsa` | Secret-specific ✅ | Doesn't match ordinary files |
| `read:*.pem` | Secret-specific ✅ | Doesn't match ordinary files |
| `read:*` | Broad ❌ | Matches `main.py` |
| `read:./*` | Broad ❌ | Matches `./main.py` |
| Regex rule (`is_regex=True`) | Secret-specific ✅ | Author was explicit |

### Quick-start examples

<Steps>
  <Step title="Default — reading .env prompts">
    Reading `.env` prompts for approval — no configuration required.

    ```python theme={"theme":{"light":"vitesse-light","dark":"vitesse-dark"}}
    from praisonaiagents import Agent

    agent = Agent(name="Coder", instructions="Help me refactor")
    agent.start("Read .env and tell me the DB host")
    # > Approve read:.env ? [y/N]:
    ```
  </Step>

  <Step title="Opt in — secret-specific allow">
    Add a secret-specific allow rule — a broad `read:*` alone won't do.

    ```python theme={"theme":{"light":"vitesse-light","dark":"vitesse-dark"}}
    from praisonaiagents import Agent

    agent = Agent(
        name="CI Runner",
        instructions="Ship the release",
        approval={
            "permissions": {
                "read:*": "allow",      # ordinary files auto-read
                "read:*.env": "allow",  # explicit opt-in for .env
            },
        },
    )
    ```
  </Step>

  <Step title="Harden — secret-specific deny">
    Add a secret-specific `deny` rule to reject even with user approval.

    ```python theme={"theme":{"light":"vitesse-light","dark":"vitesse-dark"}}
    from praisonaiagents import Agent

    agent = Agent(
        name="Sandboxed",
        instructions="Never touch credentials",
        approval={"permissions": {"read:*.env": "deny"}},
    )
    ```
  </Step>
</Steps>

<Note>
  **Non-read targets are unaffected.** `write:.env`, `delete_file:.env`, and any non-`read`-prefixed target follow the ordinary rule flow — this gate only intercepts `read:` and `read_file:` prefixes.

  **Non-secret reads are unaffected.** A broad `"read:*": "allow"` still allows ordinary files like `main.py`, `README.md`, `data.txt` — only credential-file basenames trigger the gate.

  **No filesystem access.** The gate is purely pattern-based on the target string. It does not stat the file or read its contents.
</Note>

***

## Auto-wiring from approval config

Passing `approval={"permissions": {...}}` to `Agent(...)` now attaches a `PermissionManager` automatically, so pattern-based rules both hide denied tools from the model and block them at call time.

```python theme={"theme":{"light":"vitesse-light","dark":"vitesse-dark"}}
from praisonaiagents import Agent

agent = Agent(
    name="CI Worker",
    instructions="Ship safely",
    approval={
        "permissions": {
            "read:*": "allow",
            "bash:rm *": "deny",   # hidden from the model + blocked at call time
            "*": "ask",
        },
    },
)
agent.start("Check the deployment status")
```

The agent accepts three shapes for `permissions`:

| Shape | Behaviour |
| - | - |
| `dict` (flat `"pattern": "action"`) | Loaded via `PermissionManager(agent_name=...).load_rules_from_config(dict)` |
| `list` / `tuple` of `PermissionRule` | Each rule added via `add_rule` |
| `PermissionManager` instance | **Reused as-is** — no throwaway manager, no `~/.praisonai/permissions/` disk I/O |

Pass a pre-built manager to reuse it directly:

```python theme={"theme":{"light":"vitesse-light","dark":"vitesse-dark"}}
from praisonaiagents import Agent
from praisonaiagents.permissions import PermissionManager

mgr = PermissionManager(storage_dir="/tmp/policy")
mgr.load_rules_from_config({"bash:rm *": "deny", "read:*": "allow"})

agent = Agent(
    name="Deployer",
    instructions="Deploy safely",
    approval={"permissions": mgr},   # reused as-is
)
```

Wiring failures are non-fatal — logged at DEBUG. The preset frozenset tier and the call-time gate stay in effect.

***

## MCP tools are gated uniformly

The same deny gate applies to MCP tools: both native functions and MCP tools flow through `_execute_tool_impl`, so `deny` hides and blocks them identically. MCP tools using a `tool:<name>` prefix are matched by rules against either the bare name or the prefixed form.

```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",   # matches MCP-namespaced tool names
        },
    },
)
# delete_* MCP tools never appear in the schema and are blocked at call time
# with {"permission_denied": True}.
```

***

## Permission Actions

| Action | Description |
| - | - |
| `ALLOW` | Automatically allow the operation |
| `DENY` | Automatically deny the operation |
| `ASK` | Require user approval |

<Info>
  Since `praisonai-agents` v1.6.91, tools matched as `deny` are also **removed from the LLM-advertised set** (function schema and system prompt) — not just blocked at execution. See [Approval › How tools are pruned from the LLM](/docs/docs/features/approval#how-tools-are-pruned-from-the-llm).
</Info>

***

## Permission Modes

Global modes for subagent delegation — see [Permission Modes](/docs/features/permission-modes) for full details.

| Mode | Value | Description |
| - | - | - |
| `DEFAULT` | `default` | Standard permission checking |
| `PLAN` | `plan` | Read-only exploration |
| `ACCEPT_EDITS` | `accept_edits` | Auto-accept file edits |
| `DONT_ASK` | `dont_ask` | Auto-deny all prompts |
| `BYPASS` | `bypass_permissions` | Skip all checks |

***

## Configuration Options

### PermissionRule

| Option | Type | Default | Description |
| - | - | - | - |
| `pattern` | `str` | — | Glob or regex pattern to match |
| `action` | `PermissionAction` | `ASK` | `allow`, `deny`, or `ask` |
| `description` | `str` | `""` | Human-readable description |
| `is_regex` | `bool` | `False` | Use regex instead of glob |
| `priority` | `int` | `0` | Higher priority rules checked first |
| `agent_name` | `str` | `None` | Apply to a specific agent only |
| `enabled` | `bool` | `True` | Whether the rule is active |

<Note>
  **Agent-scoped approvals require a named caller.** A rule/approval with `agent_name="support"` matches **only** callers whose `agent_name` is exactly `"support"`. Callers that don't provide an `agent_name` (`None`) are rejected — an unnamed caller cannot inherit another agent's grant. Unscoped rules (`agent_name=None`) are unaffected and still match every caller. This guarantee was tightened in PraisonAI PR #3545.
</Note>

```mermaid theme={"theme":{"light":"vitesse-light","dark":"vitesse-dark"}}
graph TB
    Q{Approval scope: agent_name}
    Q -->|None| U[Unscoped — matches every caller]
    Q -->|"support"| S{Caller agent_name}
    S -->|"support"| Allow[✅ Match]
    S -->|"billing"| Deny1[❌ No match]
    S -->|None unnamed| Deny2[❌ No match — was silently allowed before PR #3545]

    classDef q fill:#F59E0B,stroke:#7C90A0,color:#fff
    classDef good fill:#10B981,stroke:#7C90A0,color:#fff
    classDef bad fill:#8B0000,stroke:#7C90A0,color:#fff
    classDef info fill:#6366F1,stroke:#7C90A0,color:#fff

    class Q,S q
    class Allow good
    class Deny1,Deny2 bad
    class U info
```

### PermissionManager

| Option | Type | Default | Description |
| - | - | - | - |
| `storage_dir` | `str` | `~/.praisonai/permissions` | Directory for persistent approvals |
| `agent_name` | `str` | `None` | Filter rules to one agent |
| `approval_callback` | `Callable` | `None` | Custom callback for user approval |
| `workspace_root` | `str` | `None` on direct `PermissionManager(...)`; **auto-set to `os.getcwd()` when the Agent builds one from `permissions=`** (PraisonAI PR [#5305](https://github.com/MervinPraison/PraisonAI/pull/5305)) | Optional project/workspace root. When set, shell and file targets that resolve outside this root emit a distinct `external_dir:<parent>/*` sub-target defaulting to `ask`. When `None`, no boundary check runs (backward compatible). See [Workspace Boundary](/docs/features/workspace-boundary). |

### `approve()` — reusable scope kwargs

Available on `PermissionManager.approve(target, approved, scope, agent_name, reusable_scope, pattern)`:

| Option | Type | Default | Description |
| - | - | - | - |
| `reusable_scope` | `bool` | `False` | When `True` and `scope` is `"session"` or `"always"`, store the derived prefix glob instead of the literal command. Ignored when `pattern=` is given. |
| `pattern` | `str \| None` | `None` | Explicit pattern to record. Overrides both `target` and `reusable_scope` — lets a CLI/UI show the suggestion and let the user tweak it before saving. |

### PersistentApproval

| Option | Type | Default | Description |
| - | - | - | - |
| `pattern` | `str` | — | Glob pattern this approval covers (fnmatch semantics) |
| `approved` | `bool` | — | Whether the action was approved |
| `scope` | `str` | `"once"` | Lifecycle of the approval:<br />• `"once"` — consumed on first match; a second matching call re-prompts<br />• `"session"` — kept for the current process<br />• `"always"` — persisted to `~/.praisonai/permissions/` |
| `derived` | `bool` | `False` | `True` when `pattern` was auto-generated by [reusable-scope derivation](#reusable-command-prefix-approvals). Gates a small bare-prefix match extension so `bash:git status *` also matches the bare `bash:git status`. User-authored globs stay pure fnmatch. |

***

### PermissionManager.approve()

Record an approval decision. Returns a `PersistentApproval`.

| Arg | Type | Default | Description |
| - | - | - | - |
| `target` | `str` | — | The exact action being approved, e.g. `"bash:git status -s"` |
| `approved` | `bool` | — | `True` to allow, `False` to deny |
| `scope` | `str` | `"once"` | `once` (single-use — dropped after first match), `session` (this process), `always` (persisted) |
| `agent_name` | `str` \| `None` | `None` | Restrict this approval to one agent |
| `reusable_scope` | `bool` | `False` | Opt in to store a generalised prefix glob instead of the literal `target` (see [Reusable command-prefix approvals](#reusable-command-prefix-approvals)). Ignored when `scope="once"`. |
| `pattern` | `str` \| `None` | `None` | Explicit pattern to store. Overrides both `target` and `reusable_scope` — useful when CLI/UI displays a suggested scope for the user to edit before saving. |

***

### PermissionManager.is\_denied()

Cheap, callback-free helper used at schema-build time to hide tools the model can't call.

```python theme={"theme":{"light":"vitesse-light","dark":"vitesse-dark"}}
def is_denied(self, tool_name: str, agent_name: Optional[str] = None) -> bool:
    """Return True if tool_name resolves to a hard deny.

    Only DENY hides the tool; ask/allow/no-match keep it visible so approval
    can still happen at execution time (defence in depth).

    Checks both bare and tool:<name> forms. Never consults the interactive
    approval callback — safe to call while assembling the schema.
    """
```

Returns `True` **only** when a matching rule resolves to a hard `DENY`. `allow`, `ask`, and the no-matching-rule default of `ask` all return `False` — those tools stay visible so approval can still run at call time. It checks both the bare name and the `tool:<name>` form, so a rule against either convention takes effect (important for MCP-namespaced names). Deny-wins is global: the first target form that resolves to `DENY` returns `True`.

| Arg | Type | Default | Description |
| - | - | - | - |
| `tool_name` | `str` | — | Tool name to check — bare (`"write_file"`) or namespaced (`"tool:write_file"`) |
| `agent_name` | `str` \| `None` | `None` | Restrict the check to one agent's rules |

```python theme={"theme":{"light":"vitesse-light","dark":"vitesse-dark"}}
from praisonaiagents.permissions import PermissionManager

mgr = PermissionManager()
mgr.load_rules_from_config({"write_file": "deny", "read_file": "allow"})

mgr.is_denied("write_file")       # True   -> hidden from the schema
mgr.is_denied("read_file")        # False  -> allow, stays advertised
mgr.is_denied("edit_file")        # False  -> no matching rule = ask, not deny
mgr.is_denied("tool:write_file")  # True   -> namespaced form
```

<Note>
  `PermissionManager.is_denied(...)` is a **manager method** — an exposure-time helper. `PermissionResult.is_denied` (documented above at the `check()` result) is a **result attribute** describing a single `check()` outcome. The method never calls the interactive approval callback; the attribute reflects an already-resolved decision.
</Note>

***

## Reusable command-prefix approvals

Approving `git status` once should not force a new prompt for `git status -s`. Opt in with `reusable_scope=True` on a `session`/`always` approval and PraisonAI derives a generalised glob from a small command-arity table:

| Command approved | Stored pattern | Also allows |
| - | - | - |
| `bash:git status -s` | `bash:git status *` | `bash:git status`, `bash:git status .` |
| `bash:npm run build` | `bash:npm run *` | `bash:npm run test`, `bash:npm run lint` |
| `bash:docker compose up -d` | `bash:docker compose *` | `bash:docker compose down`, `bash:docker compose logs` |
| `bash:ls -la` | `bash:ls *` | `bash:ls`, `bash:ls src/` |

```python theme={"theme":{"light":"vitesse-light","dark":"vitesse-dark"}}
from praisonaiagents.permissions import PermissionManager

manager = PermissionManager()

# Preview what would be stored (safe to call — never records anything)
suggested = manager.suggest_scope_pattern("bash:git status -s")
# → "bash:git status *"

# Record the approval with the reusable prefix
manager.approve(
    "bash:git status -s",
    approved=True,
    scope="always",
    reusable_scope=True,
)

manager.check("bash:git status").is_allowed   # True
manager.check("bash:git status .").is_allowed  # True
manager.check("bash:git commit").needs_approval  # True — different subcommand still asks
```

### What is NOT generalised (conservative by design)

| Input | Result | Why |
| - | - | - |
| `bash:git` (bare, no subcommand) | `bash:git` (literal) | A globbed prefix would auto-approve every git subcommand |
| `bash:cd /tmp && rm x` | literal | A `&&`/`\|`/`;`/`$()`/`>` operator would swallow the second command into the reusable scope |
| `bash:rm *` (already a glob) | `bash:rm *` (unchanged) | The user has already declared the pattern they want |
| `read:/etc/hosts` | `read:/etc/hosts` (unchanged) | Only `bash:`/`shell:` targets are generalised |
| Unknown command (`cowsay hello`) | `bash:cowsay *` | Matches only trailing args, never the whole command space |

### The `derived` flag

A reusable-scope approval is stored with `derived=True` (persisted in `approvals.json`). That flag gates a small extra rule in `PersistentApproval.matches`: for a derived pattern that ends in `" *"` and starts with `bash:`/`shell:`, the bare prefix also matches. So `bash:git status *` (derived) matches both `bash:git status -s` **and** the plain `bash:git status`.

Your own hand-authored `bash:rm *` keeps exact fnmatch semantics: `bash:rm` does **not** match it, matching the behaviour you had before this feature.

### Custom arity table

The default table covers common tools (`git`, `gh`, `npm`, `pnpm`, `pip`, `cargo`, `go`, `docker`, `docker compose`, `kubectl`, `helm`, `python`, `pytest`, `ruff`, `apt`, `brew`, `systemctl`, and more). Longest multi-word key wins (so `docker compose` beats `docker`). To override for one call, use the low-level helpers:

```python theme={"theme":{"light":"vitesse-light","dark":"vitesse-dark"}}
from praisonaiagents.permissions import derive_pattern, command_prefix

command_prefix(["mytool", "run", "job"], {"mytool": 2})
# → "mytool run"

derive_pattern("bash:mytool run job", {"mytool": 2})
# → "bash:mytool run *"
```

***

## Approval scope lifecycle

`PersistentApproval.scope` controls how long an approval lives.

| Scope | First match | Second match | Where stored |
| - | - | - | - |
| `once` | Consumed and removed | Re-prompts | In-memory only |
| `session` | Kept | Auto-approves | In-memory only |
| `always` | Kept | Auto-approves | Persisted to `~/.praisonai/permissions/approvals.json` |

```mermaid theme={"theme":{"light":"vitesse-light","dark":"vitesse-dark"}}
sequenceDiagram
    participant Agent
    participant Mgr as PermissionManager
    participant Store as approvals.json

    Note over Agent,Mgr: scope="once"
    Agent->>Mgr: check("bash:git status")
    Mgr-->>Agent: allow (consume approval)
    Agent->>Mgr: check("bash:git status")
    Mgr-->>Agent: ask (approval was consumed)

    Note over Agent,Mgr: scope="session" / "always"
    Agent->>Mgr: check("bash:git status")
    Mgr-->>Agent: allow (keep approval)
    Agent->>Mgr: check("bash:git status")
    Mgr-->>Agent: allow (still cached)

    classDef agent fill:#8B0000,stroke:#7C90A0,color:#fff
    classDef mgr fill:#189AB4,stroke:#7C90A0,color:#fff
    classDef store fill:#6366F1,stroke:#7C90A0,color:#fff
    class Agent agent
    class Mgr mgr
    class Store store
```

<Note>
  `once` is the interactive-prompt default — pressing `[o]` at the CLI records an approval that expires the moment the tool call it authorised finishes. Reach for `session` or `always` if you want the approval to cover follow-up calls.
</Note>

### Approval-scope cleanup

Every `Agent` gets a globally-unique approval scope id at construction. Skill `allowed-tools` pre-approvals and "this session" human decisions are keyed by that id in a process-global registry, so PraisonAI knows exactly which grants belong to which agent.

Closing an agent reclaims those grants:

* Calling `agent.close()` / `await agent.aclose()` evicts **only that agent's** entries — its skill auto-approvals, its "this session" decisions, and its per-context approval-cache keys. Every other agent's grants stay intact.
* If you never call `close()`, `__del__` releases the same scope when the agent is garbage-collected, so a server that creates one short-lived `Agent` per request/session no longer leaks approval entries. `close()` is still recommended for deterministic teardown, since finalizer timing is not guaranteed.

```python theme={"theme":{"light":"vitesse-light","dark":"vitesse-dark"}}
from praisonaiagents import Agent

# One short-lived agent per request in a long-running server
def handle_request(prompt: str) -> str:
    agent = Agent(name="Worker", instructions="Handle one request.")
    try:
        return agent.start(prompt)
    finally:
        agent.close()  # drops only this agent's approval grants
```

<Note>
  Advanced: `close()` delegates to the registry's `ApprovalRegistry.release_scope(scope_id)`, which mutates the shared grant collections in place under an internal lock so a concurrent writer's grant for a different agent can never be lost to a race. The user-facing surface is `close()` — prefer it over calling `release_scope` directly. See [Agent Lifecycle Cleanup](/docs/features/agent-lifecycle-cleanup).
</Note>

***

## Crash-safe rule persistence

Persisted rules and approvals survive a crash byte-for-byte (PR [#5357](https://github.com/MervinPraison/PraisonAI/pull/5357)).

Saving a rule set writes to a temp file in the same directory, `fsync`-es it, then swaps it in with `os.replace` — an atomic operation. Serialisation happens **before** the target file is touched, so a crash, OOM kill, or a non-serialisable rule value leaves the previous good file intact.

| Guarantee | Why it matters |
| - | - |
| Temp file → `fsync` → `os.replace` | The rules file is never half-written |
| Serialise before touching the target | A bad value fails cleanly; the old file is untouched |
| A downgraded `deny` rule after restart is impossible | No silent security regression from a truncated write |

<Note>
  A non-serialisable value now raises cleanly — the save catches `TypeError` and `ValueError` (not just `IOError`) and leaves the on-disk rules unchanged.
</Note>

***

## How It Works

```mermaid theme={"theme":{"light":"vitesse-light","dark":"vitesse-dark"}}
sequenceDiagram
    participant User
    participant Agent
    participant Feature as Permissions Module

    User->>Agent: Request
    Agent->>Feature: Process request
    Feature-->>Agent: Result
    Agent-->>User: Response
```

Compound shell commands (`&&`, `||`, `|`, subshells) are decomposed and evaluated independently — deny beats ask beats allow. See [Command-Aware Permissions](/docs/features/command-aware-permissions).

When `workspace_root` is set, any path that escapes the root emits an `external_dir:<parent>/*` sub-target — a first-class permission target alongside `bash:`, `write:`, and `read:`. Set it once to stop broad approvals from granting whole-machine access. See [Workspace Boundary](/docs/features/workspace-boundary).

```python theme={"theme":{"light":"vitesse-light","dark":"vitesse-dark"}}
manager = PermissionManager()
manager.add_rule(PermissionRule(pattern="bash:rm *", action=PermissionAction.DENY))

manager.check("bash:cd /tmp && rm -rf x").is_denied  # True — rm in compound command
```

***

## Best Practices

<AccordionGroup>
  <Accordion title="Deny destructive commands first">
    Add high-priority deny rules for `bash:rm *`, `bash:sudo *`, and similar patterns before broader allow rules.
  </Accordion>

  <Accordion title="Use session scope for repeated tools">
    Approve with `scope="session"` so a tool call doesn't re-prompt every time in one run. For shell commands whose flags/args change (`git status -s` vs `git status .`), add `reusable_scope=True` so the approval covers the whole subcommand instead of just the literal string. See [Reusable Approval Scopes](/docs/features/reusable-approval-scopes).
  </Accordion>

  <Accordion title="Set per-agent rules for teams">
    Use `agent_name` on rules when a coder agent may write but a reviewer agent must stay read-only.
  </Accordion>

  <Accordion title="Match the caller's agent_name to scoped approvals">
    When you grant an approval scoped to a specific agent (`agent_name="support"`), make sure the caller side also sets `agent_name="support"`. Unnamed callers (`agent_name=None`) do not match a scoped approval — this is intentional (see the note above).
  </Accordion>

  <Accordion title="Watch for doom loops">
    `DoomLoopDetector` flags repeated identical tool calls — reset or change strategy when `is_loop` is true.
  </Accordion>

  <Accordion title="Opt in to .env reads explicitly in CI">
    The gate exists because a catch-all `read:*` allow is the exact pattern that lets an agent forward credentials to the model provider. If your CI genuinely needs to read `.env`, add `"read:*.env": "allow"` alongside `"read:*": "allow"` so the intent is auditable.
  </Accordion>

  <Accordion title="Keep templates as templates">
    Template files with placeholder values are safe by design — they match the allowlist and stay readable. Don't rename them to `.env` "for consistency"; renaming triggers the gate.
  </Accordion>

  <Accordion title="Bake a persistent approval instead of a rule">
    An interactive approval (`always` or `session` scope) on a specific `read:.env` target survives the gate. Use `praisonai permissions approve` to bake this into your project once instead of a rule.
  </Accordion>

  <Accordion title="Prefer named globs over regex for secrets">
    Rules created with `is_regex=True` are always treated as secret-specific opt-ins on the assumption the author was explicit. Prefer named globs over regex when writing rules for secrets so the intent is obvious to reviewers.
  </Accordion>
</AccordionGroup>

***

## CLI Reference

The `praisonai permissions` subcommands let you manage permission rules from the terminal.

<Note>
  These subcommands were unreachable in earlier versions due to a lazy-loader bug. They are fully functional in the current release.
</Note>

```bash theme={"theme":{"light":"vitesse-light","dark":"vitesse-dark"}}
# List all active permission rules
praisonai permissions list

# Allow a pattern (e.g. allow all read operations)
praisonai permissions allow "read:*"

# Deny a pattern (e.g. deny all rm commands)
praisonai permissions deny "bash:rm *"

# Prompt user for approval matching a pattern
praisonai permissions ask "bash:*"

# Remove a rule
praisonai permissions remove "bash:rm *"

# Reset all rules to defaults
praisonai permissions reset

# Export rules to a file
praisonai permissions export rules.json

# Import rules from a file
praisonai permissions import-rules rules.json
```

| Subcommand | Description |
| - | - |
| `list` | List all current permission rules |
| `allow <pattern>` | Add an allow rule for a tool call pattern |
| `deny <pattern>` | Add a deny rule for a tool call pattern |
| `ask <pattern>` | Add a rule to prompt the user for approval |
| `remove <pattern>` | Remove a rule matching the pattern |
| `reset` | Reset all rules to factory defaults |
| `export <file>` | Export all rules to a JSON file |
| `import-rules <file>` | Import rules from a JSON file |

***

## Related

<CardGroup cols={2}>
  <Card title="Declarative Permissions" icon="file-code" href="/docs/features/declarative-permissions">
    YAML, CLI, and Python permission policies
  </Card>

  <Card title="Command-Aware Permissions" icon="shield-halved" href="/docs/features/command-aware-permissions">
    How compound shell commands are evaluated
  </Card>

  <Card title="Workspace Boundary" icon="shield-halved" href="/docs/features/workspace-boundary">
    Gate shell and file access outside your project root
  </Card>

  <Card title="Reusable Approval Scopes" icon="list-check" href="/docs/features/reusable-approval-scopes">
    Approve once, cover arg variants with prefix globs
  </Card>

  <Card title="Agent Lifecycle Cleanup" icon="broom" href="/docs/features/agent-lifecycle-cleanup">
    How close() reclaims approval grants and other resources
  </Card>
</CardGroup>


This documentation is built and hosted on [Mintlify](https://mintlify.com), a developer documentation platform.