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.
Quick Start
- 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 subsequentgit status <flags>variant is auto-approved for the session. - Preview and store scopes programmatically
For advanced workflows — CLIs, approval UIs, or testing — call the
PermissionManagerAPI 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 whatalwayswill cover. - 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. - 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.
| Situation | Behaviour |
|---|---|
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 * glob | Keeps 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.
| Situation | Stored pattern | Matches |
|---|---|---|
reusable_scope=False (default) | Literal bash:git status -s | Only that exact command |
reusable_scope=True, known command | Derived bash:git status * | Any git status … — including bare git status |
reusable_scope=True, bare command (bash:git) | Literal bash:git | Only 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 override | User’s exact string | derived=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.
| Category | Commands | Arity | Example |
|---|---|---|---|
| Version control | git, gh, hg, svn | 2 | git status -s → bash:git status * |
| Package managers | npm, yarn, pnpm, pip, cargo, go, poetry, uv, make | 2 | npm run build → bash:npm run * |
| Containers | docker | 2 | docker pull ubuntu → bash:docker pull * |
| Containers | docker compose | 2 | docker compose up -d → bash:docker compose * |
| Containers | kubectl, helm | 2 | kubectl get pods → bash:kubectl get * |
| Python tooling | python, python3 | 2 | python -m pytest → bash:python -m * |
| Python tooling | ruff | 2 | ruff check . → bash:ruff check * |
| Python tooling | pytest | 1 | pytest tests/ → bash:pytest * |
| System | apt, apt-get, brew, systemctl | 2 | apt 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:
| Condition | Example target | Stored as | |||
|---|---|---|---|---|---|
| Non-shell target | read:/etc/hosts | read:/etc/hosts | |||
| Already globbed | bash:git status * | bash:git status * | |||
Contains &&, `\ | \ | , \ | , ;, &` | bash:cd /tmp && rm -rf x | exact string |
Contains $(, backtick, >, <, newline | bash:echo $(whoami) | exact string | |||
| Bare single token equals the full command | bash:git | bash: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:orshell:. - 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 becomebash: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.
| Mechanism | Where it lives | Scope |
|---|---|---|
| Reusable prefix scope | PermissionManager | Agent-wide — any matching bash:git … call |
Skill allowed-tools grant | Per-agent approval-scope id | Only 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
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 promptBare 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 generalisedSession 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
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:
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:
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:
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()
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| Parameter | Type | Default | Description | |
|---|---|---|---|---|
target | str | — | The target that was approved/denied (e.g. "bash:git status -s") | |
approved | bool | — | Whether approved (True) or denied (False) | |
scope | str | "once" | "once", "session", or "always" | |
agent_name | `str \ | None` | None | Scope to a specific agent |
reusable_scope | bool | False | When True and scope is session/always, derive a reusable prefix glob | |
pattern | `str \ | None` | None | Explicit pattern — overrides target and reusable_scope |
PermissionManager.suggest_scope_pattern()
manager.suggest_scope_pattern(target: str) -> strException-safe wrapper around arity.derive_pattern. Returns the derived glob pattern, or the original target unchanged if derivation is not possible.
PersistentApproval.derived
| Field | Type | Default | Description |
|---|---|---|---|
derived | bool | False | True 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). |
Full PermissionManager configuration and rule options
Related
Terminal-prompt approval flow — [a] always persists a narrow command-prefix rule using this scope system
Pre-declare allow/deny rules in YAML, CLI, or Python
PermissionsFull PermissionManager Python SDK reference
How compound shell commands are evaluated
Durable ApprovalsPersist approvals across restarts
Workspace BoundaryRestrict agent file access to the project directory