Skip to main content
Reusable scopes let one persistent approval cover every trailing-arg variant of the same command — approve git status and future git status -s runs never re-prompt.
Planned feature — not yet in the current SDK release. reusable_scope, suggest_scope_pattern, and the derived field on PersistentApproval are being added in PraisonAI PR #2576. Until that PR is merged and synced here, calling manager.approve(..., reusable_scope=True) will raise TypeError. Use explicit glob patterns with the current API instead:
from praisonaiagents.permissions import PermissionManager, PersistentApproval
from praisonaiagents.permissions.rules import PermissionAction

manager = PermissionManager()
manager.approve("bash:git status *", True, scope="always")

Quick Start

1

Enable reusable scopes on your Agent

Add one line to opt in — approval fatigue drops immediately:
from praisonaiagents import Agent

agent = Agent(
    name="Shell Assistant",
    instructions="Run git commands the user asks for",
    tools=["shell"],
    approval={"reusable_scope": True},
)

agent.start("Show me the status and then the last five commits")
The first time the agent runs git status -s, the user sees the prompt once. Every subsequent git status <flags> variant is auto-approved for the session.
2

Preview and store scopes programmatically

For advanced workflows — CLIs, approval UIs, or testing — call the PermissionManager API directly:
from praisonaiagents.permissions import PermissionManager

mgr = PermissionManager()

suggested = mgr.suggest_scope_pattern("bash:git status -s")
print(suggested)
# => "bash:git status *"

mgr.approve(
    target="bash:git status -s",
    approved=True,
    scope="always",
    reusable_scope=True,
)
suggest_scope_pattern() lets you surface the derived pattern in your UI before committing it, so users can review what always will cover.
3

Inspect the suggested pattern before saving

from praisonaiagents.permissions import PermissionManager

manager = PermissionManager()

suggested = manager.suggest_scope_pattern("bash:git status -s")
print(suggested)  # 'bash:git status *'

approval = manager.approve(
    target="bash:git status -s",
    approved=True,
    scope="always",
    pattern=suggested,
)
Pass pattern= to override the derived pattern with any string you prefer.
4

Persistence round-trip

import tempfile
from praisonaiagents.permissions import PermissionManager

with tempfile.TemporaryDirectory() as d:
    mgr1 = PermissionManager(storage_dir=d)
    mgr1.approve(
        "bash:git log --oneline",
        approved=True,
        scope="always",
        reusable_scope=True,
    )

    # Reload from disk
    mgr2 = PermissionManager(storage_dir=d)
    print(mgr2.check("bash:git log --oneline -5").is_allowed)  # True

How It Works

When reusable_scope=True and scope is "session" or "always", PermissionManager.approve() calls suggest_scope_pattern() internally. That delegates to arity.derive_pattern(), which looks up the command in a small arity table and builds a glob.
SituationBehaviour
Bare command (bash:git)Kept literal — approving git alone never auto-approves git push.
Compound command (bash:cd /tmp && rm x)Kept literal — second op never swallowed into the reusable scope.
Pipe / redirect / substitution (|, >, $(...))Kept literal.
Non-shell target (read:, write:, edit:, …)Unchanged — only bash:/shell: are generalised.
Already-globbed target (bash:git *)Unchanged.
scope="once"Always literal, even with reusable_scope=True.
User-authored bash:rm * globKeeps exact fnmatch semantics — a bare bash:rm still does not match.
The stored PersistentApproval has derived=True, which enables a bare-prefix fallback in matches(): bash:git status * (derived) also matches the bare bash:git status.
SituationStored patternMatches
reusable_scope=False (default)Literal bash:git status -sOnly that exact command
reusable_scope=True, known commandDerived bash:git status *Any git status … — including bare git status
reusable_scope=True, bare command (bash:git)Literal bash:gitOnly bare git — never all git subcommands
reusable_scope=True, compound (cd /tmp && rm x)Literal (unchanged)Only that compound command
reusable_scope=True, non-shell (read:/etc/hosts)Literal (unchanged)Non-shell prefixes are never generalised
pattern="bash:git *" explicit overrideUser’s exact stringderived=False — exact fnmatch semantics, no bare-prefix fallback

The Arity Table

arity.derive_pattern uses this built-in table to decide how many leading tokens to keep as the reusable prefix.
CategoryCommandsArityExample
Version controlgit, gh, hg, svn2git status -sbash:git status *
Package managersnpm, yarn, pnpm, pip, cargo, go, poetry, uv, make2npm run buildbash:npm run *
Containersdocker2docker pull ubuntubash:docker pull *
Containersdocker compose2docker compose up -dbash:docker compose *
Containerskubectl, helm2kubectl get podsbash:kubectl get *
Python toolingpython, python32python -m pytestbash:python -m *
Python toolingruff2ruff check .bash:ruff check *
Python toolingpytest1pytest tests/bash:pytest *
Systemapt, apt-get, brew, systemctl2apt install pkgbash:apt install *
Unknown / other1 (conservative)foo bar bazbash:foo * (only if there are trailing args)
Multi-word keys win over single-word keys. docker compose (a 2-word key, arity 2) takes precedence over docker (arity 2), so docker compose up -d derives bash:docker compose *. Commands not in the table default to arity 1 — only the first token is kept.

Behaviour table

Escape hatches — these always stay literal:
ConditionExample targetStored as
Non-shell targetread:/etc/hostsread:/etc/hosts
Already globbedbash:git status *bash:git status *
Contains &&, ||, |, ;, &bash:cd /tmp && rm -rf xexact string
Contains $(, backtick, >, <, newlinebash:echo $(whoami)exact string
Bare single token equals the full commandbash:gitbash:git
The bare-single-token rule is the most important: approving bash:git stays literal — it never automatically covers bash:git push --force.

Extend the arity table for your own tools

Pass a custom arity_map to derive_pattern() to add commands not in the built-in table:
from praisonaiagents.permissions import derive_pattern

my_tools = {"mytool": 2, "mytool sub": 3}

pattern = derive_pattern("bash:mytool sub action --flag", arity_map=my_tools)
print(pattern)
# => "bash:mytool sub action *"

What Never Gets Generalised

derive_pattern is conservative. These cases always return the literal target unchanged:
  • Non-shell targets — anything that doesn’t start with bash: or shell:.
  • Empty commandsbash: with no command string.
  • Existing globs — targets already containing * or ?.
  • Shell control operators — targets containing &&, ||, |, ;, &, $(, `, >, <, or a newline.
  • Bare single-token commandsbash:git, bash:ls — never become bash:git *.
This prevents a compound-command approval from silently expanding its scope to cover a second unrelated command.

Common Patterns

Approve once, cover all trailing-arg variants
from praisonaiagents.permissions import PermissionManager

manager = PermissionManager()

manager.approve("bash:git status -s", True, scope="always", reusable_scope=True)

manager.check("bash:git status").is_allowed          # True
manager.check("bash:git status --short").is_allowed  # True
manager.check("bash:git status").is_allowed          # True (bare form)
manager.check("bash:git push").is_allowed            # False — new prompt
Bare command stays literal
manager.approve("bash:git", True, scope="always", reusable_scope=True)

approval = manager.check("bash:git")
print(approval.pattern)  # 'bash:git'  — NOT 'bash:git *'
Compound commands stay literal
manager.approve("bash:cd /tmp && rm x", True, scope="always", reusable_scope=True)

approval = manager.check("bash:cd /tmp && rm x")
print(approval.pattern)  # 'bash:cd /tmp && rm x'  — literal, not generalised

Session vs Always scope

from praisonaiagents.permissions import PermissionManager

mgr = PermissionManager()

mgr.approve(
    target="bash:npm run build",
    approved=True,
    scope="session",
    reusable_scope=True,
)

mgr.approve(
    target="bash:git status -s",
    approved=True,
    scope="always",
    reusable_scope=True,
)
Use session for exploratory work and always only for commands you trust unconditionally across all future sessions.

Preview before storing

from praisonaiagents.permissions import PermissionManager

mgr = PermissionManager()

targets = [
    "bash:git log --oneline",
    "bash:npm run test",
    "bash:docker ps",
]

for t in targets:
    print(f"{t!r}{mgr.suggest_scope_pattern(t)!r}")
'bash:git log --oneline'  →  'bash:git log *'
'bash:npm run test'       →  'bash:npm run *'
'bash:docker ps'          →  'bash:docker *'
Preview and override before saving
from praisonaiagents.permissions import PermissionManager

manager = PermissionManager()

target = "bash:npm run build"
preview = manager.suggest_scope_pattern(target)
# preview = "bash:npm run *"

# User narrows the scope — override with explicit pattern
manager.approve(
    target=target,
    approved=True,
    scope="session",
    pattern="bash:npm run build",  # explicit override: stays literal
)
Custom arity map for project-specific commands
from praisonaiagents.permissions.arity import derive_pattern

# Teach the engine about your own CLI tool
custom_map = {"mycli": 2}
pattern = derive_pattern("bash:mycli deploy prod", arity_map=custom_map)
print(pattern)  # "bash:mycli deploy *"

Best Practices

Routine VCS and build commands (git status, npm run, pytest) are the best candidates. They have stable subcommand prefixes and many trailing-arg variants.
Call suggest_scope_pattern() in your CLI or approval UI so users can see exactly what always will cover before confirming. A pattern like bash:rm * deserves a careful second look:
from praisonaiagents.permissions import PermissionManager

mgr = PermissionManager()
pattern = mgr.suggest_scope_pattern("bash:rm -rf /tmp/build")
print(f"This will auto-approve: {pattern}")
A bad derived pattern that uses always persists across all future sessions. Use session by default for reusable scopes — if the pattern turns out to be safe, upgrade it to always deliberately:
mgr.approve(target="bash:git status -s", approved=True, scope="session", reusable_scope=True)
If suggest_scope_pattern returns a pattern that is broader or narrower than you want, override it with pattern=. The stored approval will have derived=False, keeping exact fnmatch semantics.
A cd /tmp && rm x approval covers only that exact string. If you want to reuse two operations independently, create two separate approvals — one for cd variants and one for rm variants.
Path approvals such as read:/etc/hosts or write:/tmp/report.txt must remain exact. File paths that look like prefixes (read:/etc/) would grant access to all files under /etc/, which is a security anti-pattern. The literal-only behaviour for non-shell targets is by design.
If your agent uses an internal tool (mytool) or a domain-specific CLI (kubectl), pass a custom arity_map to derive_pattern() so the prefix is derived correctly:
from praisonaiagents.permissions import derive_pattern

corp_tools = {
    "deploy": 2,
    "deploy rollback": 2,
}

pattern = derive_pattern("bash:deploy rollback service-a --dry-run", arity_map=corp_tools)
# => "bash:deploy rollback *"
A deny rule on bash:rm * still wins. Reusable scopes only extend allow approvals — deny is enforced at the rule level. See Command-Aware Permissions.

API Reference

PermissionManager.approve()

manager.approve(
    target: str,
    approved: bool,
    scope: str = "once",           # "once" | "session" | "always"
    agent_name: Optional[str] = None,
    reusable_scope: bool = False,  # derive prefix glob when True
    pattern: Optional[str] = None, # override target and derivation
) -> PersistentApproval
ParameterTypeDefaultDescription
targetstrThe target that was approved/denied (e.g. "bash:git status -s")
approvedboolWhether approved (True) or denied (False)
scopestr"once""once", "session", or "always"
agent_namestr | NoneNoneScope to a specific agent
reusable_scopeboolFalseWhen True and scope is session/always, derive a reusable prefix glob
patternstr | NoneNoneExplicit pattern — overrides target and reusable_scope

PermissionManager.suggest_scope_pattern()

manager.suggest_scope_pattern(target: str) -> str
Exception-safe wrapper around arity.derive_pattern. Returns the derived glob pattern, or the original target unchanged if derivation is not possible.

PersistentApproval.derived

FieldTypeDefaultDescription
derivedboolFalseTrue when the pattern was auto-generated by derive_pattern(). Enables the bare-prefix fallback in matches(). Loaded from approvals.json — older files default to False (backward compatible).

PermissionManager API Reference

Full PermissionManager configuration and rule options

Interactive Approval

Terminal-prompt approval flow — [A] persists a narrow command-prefix rule, [T] grants blanket tool access

Declarative Permissions

Pre-declare allow/deny rules in YAML, CLI, or Python

Permissions

Full PermissionManager Python SDK reference

Command-Aware Permissions

How compound shell commands are evaluated

Durable Approvals

Persist approvals across restarts

Workspace Boundary

Restrict agent file access to the project directory