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.

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

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

Diagram

allow

deny

ask

🤖 Agent

Permission Check

✅ Execute

❌ Block

❓ User prompt

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.

Diagram
Tool (native or MCP)PermissionManagerAgentLLMTool (native or MCP)PermissionManagerAgentLLMSchema buildCall time (native OR MCP)is_denied(tool_name)?deny -> drop from schemacall(tool_name, args)is_denied(tool_name)?deny -> blockexecute (when allowed)

Doom-loop protection

A configured PermissionManager now delivers the doom-loop protection its docstring advertises (PR #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
{
    "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.

Per-tool limit is windowed. A single tool is flagged once it reaches max_tool_calls // 2 calls inside window_seconds — not over the agent’s lifetime. A healthy slow tool (e.g. a once-per-minute status poll) is never permanently denied, so long-running agents no longer need reset_tool() to recover. Lifetime totals are still reported by get_stats() (PR #5959, fixes #5957).

Diagram
ToolDoomLoopDetectorAgentToolDoomLoopDetectorAgentrecord_and_check(“status_poll”)drop records older than window_secondscount status_poll calls in windowis_loop=False (below max_tool_calls // 2)execute status_pollresult

Thread-safe. The detector holds an internal RLock around record, check, record_and_check, get_stats, reset, and reset_tool, so it is safe to share across concurrent tool-execution gates.

Quick Start

  1. Simple Usage
    python
    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")
  2. With Configuration

    Use PermissionManager directly for programmatic rule management:

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


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.

Diagram

Secret-File Read Gate

No — main.py, data.txt

Yes — .env, *.pem

Approved / deny

Secret-specific allow

Broad read:* allow

No rule

📖 read:file

🔍 Secret basename?

⚙️ Normal rule check

Explicit rule?

✅ Rule wins

❓ Ask user

Gated basenames

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

CategoryGlobsExample paths
Environment files.env, .env, .env., .env..env, prod.env, .env.local, config/.env
PEM / TLS keys.pem, .keyserver.pem, certs/tls.key
SSH keysid_rsa, id_dsa, id_ecdsa, id_ed25519~/.ssh/id_ed25519
Keystores.pfx, .p12keystore.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

Diagram

Yes — always/session

No

Explicit deny

Secret-specific allow

Broad allow — read:*

No

read:secret-file

Persistent approval?

✅ Allow

Matching rule?

❌ Deny

Fall through to gate

❓ Ask user

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

RuleTreated asWhy
read:*.envSecret-specific ✅Doesn’t match ordinary files like main.py
read:id_rsaSecret-specific ✅Doesn’t match ordinary files
read:*.pemSecret-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

  1. Default — reading .env prompts

    Reading .env prompts for approval — no configuration required.

    python
    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]:
  2. Opt in — secret-specific allow

    Add a secret-specific allow rule — a broad read:* alone won’t do.

    python
    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
            },
        },
    )
  3. Harden — secret-specific deny

    Add a secret-specific deny rule to reject even with user approval.

    python
    from praisonaiagents import Agent
    
    agent = Agent(
        name="Sandboxed",
        instructions="Never touch credentials",
        approval={"permissions": {"read:*.env": "deny"}},
    )

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

ShapeBehaviour
dict (flat "pattern": "action")Loaded via PermissionManager(agent_name=...).load_rules_from_config(dict)
list / tuple of PermissionRuleEach rule added via add_rule
PermissionManager instanceReused as-is — no throwaway manager, no ~/.praisonai/permissions/ disk I/O

Pass a pre-built manager to reuse it directly:

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

ActionDescription
ALLOWAutomatically allow the operation
DENYAutomatically deny the operation
ASKRequire user approval

Permission Modes

Global modes for subagent delegation — see Permission Modes for full details.

ModeValueDescription
DEFAULTdefaultStandard permission checking
PLANplanRead-only exploration
ACCEPT_EDITSaccept_editsAuto-accept file edits
DONT_ASKdont_askAuto-deny all prompts
BYPASSbypass_permissionsSkip all checks

Configuration Options

PermissionRule

OptionTypeDefaultDescription
patternstr—Glob or regex pattern to match
actionPermissionActionASKallow, deny, or ask
descriptionstr""Human-readable description
is_regexboolFalseUse regex instead of glob
priorityint0Higher priority rules checked first
agent_namestrNoneApply to a specific agent only
enabledboolTrueWhether the rule is active
Diagram

None

support

support

billing

None unnamed

Approval scope: agent_name

Unscoped — matches every caller

Caller agent_name

✅ Match

❌ No match

❌ No match — was silently allowed before PR #3545

PermissionManager

OptionTypeDefaultDescription
storage_dirstr~/.praisonai/permissionsDirectory for persistent approvals
agent_namestrNoneFilter rules to one agent
approval_callbackCallableNoneCustom callback for user approval
workspace_rootstrNone on direct PermissionManager(...); auto-set to os.getcwd() when the Agent builds one from permissions= (PraisonAI PR #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.

approve() — reusable scope kwargs

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

OptionTypeDefaultDescription
reusable_scopeboolFalseWhen 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`NoneExplicit 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

OptionTypeDefaultDescription
patternstr—Glob pattern this approval covers (fnmatch semantics)
approvedbool—Whether the action was approved
scopestr"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/
derivedboolFalseTrue when pattern was auto-generated by reusable-scope derivation. 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.

ArgTypeDefaultDescription
targetstr—The exact action being approved, e.g. "bash:git status -s"
approvedbool—True to allow, False to deny
scopestr"once"once (single-use — dropped after first match), session (this process), always (persisted)
agent_namestr \NoneNoneRestrict this approval to one agent
reusable_scopeboolFalseOpt in to store a generalised prefix glob instead of the literal target (see Reusable command-prefix approvals). Ignored when scope="once".
patternstr \NoneNoneExplicit 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
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.

ArgTypeDefaultDescription
tool_namestr—Tool name to check — bare ("write_file") or namespaced ("tool:write_file")
agent_namestr \NoneNoneRestrict the check to one agent’s rules
python
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

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 approvedStored patternAlso allows
bash:git status -sbash:git status *bash:git status, bash:git status .
bash:npm run buildbash:npm run *bash:npm run test, bash:npm run lint
bash:docker compose up -dbash:docker compose *bash:docker compose down, bash:docker compose logs
bash:ls -labash:ls *bash:ls, bash:ls src/
python
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)

InputResultWhy
bash:git (bare, no subcommand)bash:git (literal)A globbed prefix would auto-approve every git subcommand
bash:cd /tmp && rm xliteralA &&/`\/;/$()/>` 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/hostsread:/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
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.

ScopeFirst matchSecond matchWhere stored
onceConsumed and removedRe-promptsIn-memory only
sessionKeptAuto-approvesIn-memory only
alwaysKeptAuto-approvesPersisted to ~/.praisonai/permissions/approvals.json
Diagram
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

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

Crash-safe rule persistence

Persisted rules and approvals survive a crash byte-for-byte (PR #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.

GuaranteeWhy it matters
Temp file → fsync → os.replaceThe rules file is never half-written
Serialise before touching the targetA bad value fails cleanly; the old file is untouched
A downgraded deny rule after restart is impossibleNo silent security regression from a truncated write

A corrupt PermissionAllowlist file is moved aside and loaded as empty (fail-closed) — see Corrupt-allowlist quarantine.


How It Works

Diagram
Permissions ModuleAgentUserPermissions ModuleAgentUserRequestProcess requestResultResponse

Compound shell commands (&&, ||, |, subshells) are decomposed and evaluated independently — deny beats ask beats allow. See 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.

python
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

Deny destructive commands first

Add high-priority deny rules for bash:rm , bash:sudo , and similar patterns before broader allow rules.

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.

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.

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

Watch for doom loops

DoomLoopDetector flags repeated identical tool calls — reset or change strategy when is_loop is true.

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.

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.

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.

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.


CLI Reference

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

bash
# 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
SubcommandDescription
listList 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
resetReset all rules to factory defaults
export <file>Export all rules to a JSON file
import-rules <file>Import rules from a JSON file

Related