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.
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.
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.
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:
{
"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).
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
- 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") - With Configuration
Use
PermissionManagerdirectly 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) # TrueFor 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.
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
| 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
- Default — reading .env prompts
Reading
.envprompts 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]: - 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 }, }, ) - Harden — secret-specific deny
Add a secret-specific
denyrule 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.
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:
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.
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 |
Permission Modes
Global modes for subagent delegation — see 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 |
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) | 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):
| 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. 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). 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.
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 |
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 formReusable 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/ |
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 asksWhat 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:
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 |
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 storeApproval-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-livedAgentper request/session no longer leaks approval entries.close()is still recommended for deterministic teardown, since finalizer timing is not guaranteed.
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 grantsCrash-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.
| 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 |
A corrupt PermissionAllowlist file is moved aside and loaded as empty (fail-closed) — see Corrupt-allowlist quarantine.
How It Works
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.
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 commandBest 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.
# 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
YAML, CLI, and Python permission policies
Command-Aware PermissionsHow compound shell commands are evaluated
Workspace BoundaryGate shell and file access outside your project root
Reusable Approval ScopesApprove once, cover arg variants with prefix globs
Agent Lifecycle CleanupHow close() reclaims approval grants and other resources