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.

Diagram

allow_always ✓

🤖 Agent

🚪 Gateway

🛡️ Approve?

💾 SQLite

✅ Future calls auto-approve

Quick Start

  1. 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
  2. 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.
  3. Scope even tighter to arguments

    Add scope_to_args=True so 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),
    )
  4. Grant globally (legacy behaviour)

    Set scope_to_agent=False for 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

Diagram
ScopedAllowlistStoreUIPermissionAllowlistExecApprovalManagerAgentScopedAllowlistStoreUIPermissionAllowlistExecApprovalManagerAgentpersisted to SQLite (WAL)register(tool, args, agent_name)allows(agent_id, tool, arg_sig)?missprompt for approvalresolve(Resolution(allow_always=True))add(agent_id, tool, arg_sig)register(same call)allows(…)? → Trueimmediate approve

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.

Diagram

No

Yes

Yes

No

Does the request
carry an agent identity?

scope_to_agent=False
(global grant)

High-blast-radius tool?
shell_exec, apply_patch

scope_to_args=True
(per-agent + per-argument)

default
(per-agent, argument-agnostic)


The Grant Key

Every grant is stored as (agent_id, tool_name, arg_signature).

Key partTypeMeaning
agent_idstrResolved agent name; sentinel "*" means “any agent”
tool_namestrRegistered tool name
arg_signaturestr \NoneFirst 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.

FieldTypeDefaultDescription
approvedbool(required)Whether the request is approved
reasonstr""Human-readable reason
allow_alwaysboolFalsePersist the grant; when False the resolution applies only to this one request
scope_to_agentboolTrueGrant is scoped to the requesting agent; set False for pre-#2622 global grants
scope_to_argsboolFalseAlso key the grant on the argument SHA — subsequent calls with different arguments will re-prompt
resolverOptional[str]NoneIdentity 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.

python
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
)
Diagram
🛡️ ExecApprovalManager🚪 Gateway📱 Operator UI🛡️ ExecApprovalManager🚪 Gateway📱 Operator UIalt[resolver not in authorized_reviewers][authorised (or unbound)]resolve (resolver=”operator:bob”)resolve(rid, Resolution(resolver=”operator:bob”))False + re-insert pending404 resolver not authorisedTrue200 OK
BehaviourDetail
DefaultNone — any resolver may resolve (legacy behaviour)
Unauthorised resolverReturns False; the request is re-inserted into the pending map so an authorised reviewer can resolve it later
Rejection response404 with {"error": "Request not found, already resolved, or resolver not authorised"} — same status as “not found” so IDs cannot be enumerated
First-answer-winsPreserved between authorised reviewers via the atomic pop
DurabilityThe 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.

AspectValue
Default path~/.praisonai/state/gateway/approvals.sqlite
Env overridePRAISONAI_HOME (whole state root moves)
Programmatic overrideExecApprovalManager(allowlist_path=...)
Journal modeWAL
Default TTL90 days — an expired grant is refused on every allows() check, so the cap holds on a long-running gateway without a restart
TTL overrideScopedAllowlistStore(ttl_seconds=...) in Python — no gateway.yaml field
Safe to deleteYes — 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

bash
curl -H "Authorization: Bearer $TOKEN" \
  http://localhost:8080/api/approval/allow-list
json
{
  "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

bash
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-list

agent_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

bash
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-list

Returns 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