Gateway Scoped Approvals
Durable, agent-scoped allow-always grants for gateway exec approvals — persist across restart, don't leak across agents
Allow-always grants now persist across gateway restart and default to being scoped to the approving agent.
Quick Start
- Enable durable scoped approvals
Durable, agent-scoped grants are on by default — just launch the gateway. The allow-list opens a SQLite store at
~/.praisonai/state/gateway/approvals.sqlite.python from praisonai.gateway.exec_approval import ExecApprovalManager manager = ExecApprovalManager() # durable=True by default - Grant Once, Auto-Approve Forever (per Agent)
Resolve a pending request with
allow_always=True. The grant is scoped to the resolving agent by default — one agent’s approval never authorises another.python from praisonai.gateway.exec_approval import ExecApprovalManager, Resolution manager = ExecApprovalManager() request_id, future = await manager.register( tool_name="shell_exec", arguments={"cmd": "ls -la"}, agent_name="shell-bot", ) # From your bot / UI handler: manager.resolve(request_id, Resolution(approved=True, allow_always=True)) # Next identical call from "shell-bot" auto-approves — even after a restart. - Scope even tighter to arguments
Add
scope_to_args=Trueso only subsequent calls with the same argument shape auto-approve; a different argument re-prompts.python from praisonai.gateway.exec_approval import Resolution manager.resolve( request_id, Resolution(approved=True, allow_always=True, scope_to_args=True), ) - Grant globally (legacy behaviour)
Set
scope_to_agent=Falsefor the pre-#2622 semantic where one approval covers every agent.python from praisonai.gateway.exec_approval import Resolution manager.resolve( request_id, Resolution(approved=True, allow_always=True, scope_to_agent=False), )
How It Works
A matching grant short-circuits register — the future resolves immediately with approved=True and no prompt is shown.
Which Scope to Use
Pick the narrowest scope that still saves the operator from re-approving.
The Grant Key
Every grant is stored as (agent_id, tool_name, arg_signature).
| Key part | Type | Meaning | |
|---|---|---|---|
agent_id | str | Resolved agent name; sentinel "*" means “any agent” | |
tool_name | str | Registered tool name | |
arg_signature | str \ | None | First 32 chars of SHA-256 over json.dumps(args, sort_keys=True); None = argument-agnostic |
Resolver Options
Resolution carries the resolver’s decision and how broadly to persist it.
| Field | Type | Default | Description |
|---|---|---|---|
approved | bool | (required) | Whether the request is approved |
reason | str | "" | Human-readable reason |
allow_always | bool | False | Persist the grant; when False the resolution applies only to this one request |
scope_to_agent | bool | True | Grant is scoped to the requesting agent; set False for pre-#2622 global grants |
scope_to_args | bool | False | Also key the grant on the argument SHA — subsequent calls with different arguments will re-prompt |
resolver | Optional[str] | None | Identity of the principal resolving this request. Recorded on the audit trail and the allow-always grant. None keeps today’s anonymous "gateway" attribution. |
The gateway’s POST /approval/resolve handler derives resolver automatically from the authenticated request — see Operator identity attribution.
Per-request Reviewer Custody
Bind a single request to specific reviewers so only they can resolve it — a finer boundary than the coarse APPROVALS scope.
Pass authorized_reviewers on the ApprovalRequest; unauthorised resolvers are rejected and the request stays pending.
from praisonaiagents.approval import ApprovalRequest
request = ApprovalRequest(
tool_name="deploy",
arguments={"target": "prod"},
risk_level="high",
agent_name="deployer",
authorized_reviewers=["operator:alice", "operator:oncall"], # only these may resolve
)| Behaviour | Detail |
|---|---|
| Default | None — any resolver may resolve (legacy behaviour) |
| Unauthorised resolver | Returns False; the request is re-inserted into the pending map so an authorised reviewer can resolve it later |
| Rejection response | 404 with {"error": "Request not found, already resolved, or resolver not authorised"} — same status as “not found” so IDs cannot be enumerated |
| First-answer-wins | Preserved between authorised reviewers via the atomic pop |
| Durability | The binding survives a gateway restart (persisted and rehydrated by the SQLite store) |
Silent-Skip Warning
Storage Layout
Grants live in one SQLite file — safe to back up, rotate, or delete.
| Aspect | Value |
|---|---|
| Default path | ~/.praisonai/state/gateway/approvals.sqlite |
| Env override | PRAISONAI_HOME (whole state root moves) |
| Programmatic override | ExecApprovalManager(allowlist_path=...) |
| Journal mode | WAL |
| Default TTL | 90 days — an expired grant is refused on every allows() check, so the cap holds on a long-running gateway without a restart |
| TTL override | ScopedAllowlistStore(ttl_seconds=...) in Python — no gateway.yaml field |
| Safe to delete | Yes — next restart re-prompts on the next request |
HTTP Endpoint
GET/POST/DELETE /api/approval/allow-list manages grants over HTTP. POST and DELETE accept an optional agent_id; GET returns a grants view alongside the legacy allow_list.
GET
curl -H "Authorization: Bearer $TOKEN" \
http://localhost:8080/api/approval/allow-list{
"allow_list": ["read_file"],
"grants": [
{"agent_id": "shell-bot", "tool_name": "shell_exec", "arg_signature": null, "created_at": 1720000000.0, "approver": "operator:alice"},
{"agent_id": "*", "tool_name": "read_file", "arg_signature": null, "created_at": 1720000000.0, "approver": "gateway:operator"}
]
}POST
curl -X POST -H "Authorization: Bearer $TOKEN" \
-H "Content-Type: application/json" \
-d '{"tool_name": "shell_exec", "agent_id": "shell-bot"}' \
http://localhost:8080/api/approval/allow-listagent_id omitted → adds a legacy any-agent grant. agent_id supplied → adds a scoped grant with approver="gateway:operator". Empty/whitespace agent_id returns HTTP 400.
DELETE
curl -X DELETE -H "Authorization: Bearer $TOKEN" \
-H "Content-Type: application/json" \
-d '{"tool_name": "shell_exec", "agent_id": "shell-bot"}' \
http://localhost:8080/api/approval/allow-listReturns HTTP 404 if the specific (agent_id, tool_name) grant is not found.
Legacy Behaviour Preserved
I use allowlist.add('tool_name') directly
Still works. It creates an any-agent (agent_id="*") grant with an empty argument signature. "tool" in allowlist, allowlist.remove("tool"), and allowlist.list() also keep their original meaning.
I POST /api/approval/allow-list without agent_id
Still works. Omitting agent_id creates an any-agent grant, exactly as before.
I want the old cross-agent leaky behaviour
Set scope_to_agent=False on the Resolution. The grant then applies to every agent.
I need to purge everything
Delete the SQLite file, or call store.revoke_tool(name) to drop every grant for one tool across all agents.
Best Practices
Keep scope_to_agent=True
One agent’s approval should never authorise another. The default scoping prevents accidental cross-agent leakage.
Use scope_to_args=True for high-blast-radius tools
For shell_exec or apply_patch, add scope_to_args=True so the operator’s approval only covers the exact command they saw.
Rotate or shorten TTL for security-sensitive state
Rotate the SQLite file periodically, or shorten the window via ScopedAllowlistStore(ttl_seconds=...). A grant’s age is persisted, so restarting the gateway does not reset it.
Watch the silent-skip log line
“Skipping scoped allow-always grant” means a resolver is sending scope_to_agent=True on a request with no agent identity — the grant was not persisted.
Related
Gateway overview and deployment
Approval BackendsHow approval requests are routed
Gateway Operator ScopesAuthorisation for admin endpoints
Approval AttributionWho approved a request and per-request reviewer custody
Durable ApprovalsRestart-safe bot pending-approval queue
Server→Client Request ChannelReconnect-safe approval-kind requests to a gateway client