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

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

***

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