Approvals bound to the call¶
Since 0.10.29 an approval binds to the exact call it approves: the arguments, once, and
for a limited time. Until then an approver saw only the escalation's reason and rule, and
an authorize_once grant admitted the next call of its tool whatever its arguments.
What the approver sees¶
An escalated call (SecurityPolicy.escalate_tools) asks policy.approver with an
OversightRequest that now carries:
| Field | What it is |
|---|---|
args_digest |
sha256 of the call's bound arguments in canonical JSON (agent_airlock.approvals.args_digest) |
args_preview |
the arguments by name, masked like an audit preview: a value under a secret-sounding name is [REDACTED], personal data and keys are masked |
agent_id, session_id |
the call's identity, when it carries one |
policy_digest |
the digest of the policy that escalated, frozen or not, so a stored decision can be checked against the policy in force on resume |
expires_at |
requested_at plus escalation_timeout_seconds |
The fields have defaults, so an approver written for 0.10.28 keeps working.
To bind its decision, the approver echoes the digest:
from agent_airlock.oversight import OversightRequest, OversightResponse, OversightVerdict
def approver(request: OversightRequest) -> OversightResponse:
ok = ask_a_human(request.tool_name, request.args_preview)
return OversightResponse(
request_id=request.request_id,
verdict=OversightVerdict.GRANT if ok else OversightVerdict.DENY,
args_digest=request.args_digest,
)
A response that echoes another digest is refused as policy_indeterminate. A response
with no args_digest is read as before.
Two more refusals close the gap between the decision and the call:
- If the call's arguments change while the approver decides (a shared dict another task
edits), the call is refused as
policy_indeterminate: the digest is worked out again just before the tool runs. - If the arguments cannot be read (nested deeper than 32 levels, or containing
themselves), the call is refused as
policy_indeterminatewithout asking anyone, since no approval can be bound to them.
@requires_human_oversight fills the same fields, and raises ValueError on a decision
about other arguments or arguments changed after the approval was asked.
One-shot grants¶
AirlockContext.authorize_once, which re-opens the reauthorization guard and a tripped
action-contradiction gate, can name the call it admits and a time to live:
from agent_airlock.approvals import args_digest
run.authorize_once("pay", args_digest=args_digest({"to": "bob", "amount": 10}))
run.authorize_once("pay", ttl_seconds=60)
A bound grant admits only a call whose arguments hash to it, positional or keyword. A
grant is spent under a lock, so two concurrent calls never both spend it, and a grant is
expired at now >= expires_at. with_metadata() clones share the run's grants, so a
grant spent through a clone is spent. A grant naming no digest admits one call of its
tool, as before.
Approvals recorded ahead of the call¶
SecurityPolicy(approval_ledger=...) takes approvals recorded before the call, for
example by a human approving in a dashboard, and lets exactly one call spend each one:
import time
from agent_airlock import SecurityPolicy
from agent_airlock.approvals import SQLiteApprovalLedger, args_digest
ledger = SQLiteApprovalLedger("approvals.db")
ledger.grant("pay", args_digest({"to": "bob", "amount": 10}), expires_at=time.time() + 300)
policy = SecurityPolicy(
escalate_tools={"pay": "moves money"},
approver=approver,
approval_ledger=ledger,
)
An escalated call claims a matching approval before asking the approver. The claim is
atomic: SQLiteApprovalLedger runs it in a BEGIN IMMEDIATE transaction, so replicas
sharing the file never both run one approval. InMemoryApprovalLedger does the same
within one process. An approval is expired at now >= expires_at, and a ledger that
raises claims nothing, so the approver is asked.
Standing grants¶
An operator can approve one tool called with exact target arguments for good:
from agent_airlock import SecurityPolicy
from agent_airlock.approvals import StandingGrant
payroll = StandingGrant(tool="pay", arguments={"to": "payroll"})
policy = SecurityPolicy(
escalate_tools={"pay": "moves money"},
approver=approver,
standing_grants=[payroll],
)
- The tool name is exact (a glob is refused) and at least one target argument is named.
Values compare as the digest encodes them, so
1is notTrueor1.0. - Only host code makes one. An approver's
GRANTor anauthorize_oncenever turns into a standing grant. - The call's audit record names the grant (
standing_grant). policy.revoke_standing_grant(payroll.grant_id)stops it from the next call on.- Adding a grant after
freeze()moves the digest, so a frozen policy refuses every call. A revocation only narrows the policy, so it does not, andfreeze()keeps it. - A denied tool stays denied: escalation, and so a standing grant, is checked last.
What this does not do¶
- A ledger approval binds to the tool and the digest, not to an agent: any agent making that exact call can claim it.
- There is no reviewer step that auto-approves below a threshold.
- The digest reads a host object that model JSON cannot build (a framework's injected context) as its type alone, so two such objects of one type share a digest.