Reusable Approval Scopes

Approve a command once and cover every trailing-arg variant with an arity-derived prefix glob

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.

Diagram

Reusable Scope Derivation

literal

reusable_scope=True

matches

🤖 Agent runs
bash:git status -s

Approve?

💾 stores
bash:git status -s

⚙️ derive_pattern

💾 stores
bash:git status *

Later:
bash:git status .

✅ Auto-allow

Quick Start

  1. Enable reusable scopes on your Agent

    Add one line to opt in — approval fatigue drops immediately:

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

    python
    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
    python
    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
    python
    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 (&#124;, >, $(...))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.
Diagram
arity.derive_patternPermissionManagerUserarity.derive_patternPermissionManagerUserFuture check(“bash:git status”) → auto-allowapprove(“bash:git status -s”, reusable_scope=True, scope=”always”)derive_pattern(“bash:git status -s”)“bash:git status *”store PersistentApproval(pattern=”bash:git status *”, derived=True)PersistentApproval

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 -s → bash:git status *
Package managersnpm, yarn, pnpm, pip, cargo, go, poetry, uv, make2npm run build → bash:npm run *
Containersdocker2docker pull ubuntu → bash:docker pull *
Containersdocker compose2docker compose up -d → bash:docker compose *
Containerskubectl, helm2kubectl get pods → bash:kubectl get *
Python toolingpython, python32python -m pytest → bash:python -m *
Python toolingruff2ruff check . → bash:ruff check *
Python toolingpytest1pytest tests/ → bash:pytest *
Systemapt, apt-get, brew, systemctl2apt install pkg → bash:apt install *
Unknown / other—1 (conservative)foo bar baz → bash:foo * (only if there are trailing args)

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:

python
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 commands — bash: with no command string.
  • Existing globs — targets already containing * or ?.
  • Shell control operators — targets containing &&, ||, |, ;, &, $(, ` `, >, <`, or a newline.
  • Bare single-token commands — bash:git, bash:ls — never become bash:git *.

This prevents a compound-command approval from silently expanding its scope to cover a second unrelated command.


How this differs from skill grants

Reusable prefix scopes (bash:git *) live on the PermissionManager and cover any matching tool call within that agent’s scope. Skill allowed-tools auto-approval is separate: it is keyed to the specific agent instance that activated the skill, not to a command prefix.

MechanismWhere it livesScope
Reusable prefix scopePermissionManagerAgent-wide — any matching bash:git … call
Skill allowed-tools grantPer-agent approval-scope idOnly the agent instance that activated the skill

Two unnamed Agent() instances (both defaulting to name="Agent") never share a skill grant, because each carries a unique per-instance approval-scope id. See Tool Approval → Scope of a skill grant.


Common Patterns

Approve once, cover all trailing-arg variants

python
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

python
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

python
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

python
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

python
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

python
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

python
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

Use reusable_scope=True for routine shell commands

Routine VCS and build commands (git status, npm run, pytest) are the best candidates. They have stable subcommand prefixes and many trailing-arg variants.

Preview the scope before storing

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:

python
from praisonaiagents.permissions import PermissionManager

mgr = PermissionManager()
pattern = mgr.suggest_scope_pattern("bash:rm -rf /tmp/build")
print(f"This will auto-approve: {pattern}")
Prefer session over always for reusable scopes

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:

python
mgr.approve(target="bash:git status -s", approved=True, scope="session", reusable_scope=True)
Pass an explicit pattern= to override the suggestion

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.

Compound approvals are intentionally narrow

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.

Non-shell targets stay literal — that is intentional

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.

Extend ARITY for your own CLI tools

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:

python
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 *"
Not a replacement for deny rules

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

python
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
targetstr—The target that was approved/denied (e.g. "bash:git status -s")
approvedbool—Whether approved (True) or denied (False)
scopestr"once""once", "session", or "always"
agent_name`str \None`NoneScope to a specific agent
reusable_scopeboolFalseWhen True and scope is session/always, derive a reusable prefix glob
pattern`str \None`NoneExplicit pattern — overrides target and reusable_scope

PermissionManager.suggest_scope_pattern()

python
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


Related