Where approval fits
Thebefore_tool hook on Governance runs on every tool call. It scores risk, evaluates policy, and only then consults the approver. A call reaches the approver when either condition holds:
- The policy attaches a
REQUIRE_APPROVALobligation to its decision. - The call’s risk tier is at or above
escalate_at(defaultRiskTier.HIGH).
FORBID decision is denied outright and never reaches approval.
escalate_at makes risk gate the decision, not just annotate it. Any permitted action at or above that tier is sent to the approver, so an unprofiled high-risk or side-effecting tool is denied by default unless a human approves. Set escalate_at=None to disable risk-driven escalation and rely on policy obligations alone.The Approver protocol
An approver is any object with a single method. Approver is a runtime_checkable Protocol, so you do not need to subclass anything: match the shape and it works.
review returns True to allow the call and False to reject it. It must answer immediately. If a human sign-off cannot happen inline, use a DurableApprover instead, which suspends the run rather than blocking a thread. See Durable approval.
ApprovalRequest
review receives one argument, a frozen ApprovalRequest, describing the exact action awaiting a decision.
reason is "policy/risk requires approval". Use these fields to render a prompt, post to a review channel, or key an audit record.
Built-in approvers
DenyAll
Rejects everything. The fail-closed default.
CallbackApprover
Delegates the decision to a callable you supply.
AutoApprove
Approves everything. Development and testing only.
DurableApprover
Suspends the run for out-of-band, hours-later sign-off.
DenyAll (the default)
Governance defaults its approver field to DenyAll. If you configure escalation but never wire an approver, every escalated call is rejected. This is deliberate: an unattended agent cannot approve its own high-risk actions.
CallbackApprover
Wrap any Callable[[ApprovalRequest], bool]: a console prompt, a Slack round-trip, a queue consumer. The return value is coerced with bool.
AutoApprove
Approves every request. Convenient for tests and local development where you want the escalation path exercised without a prompt.
How a decision is handled
Insidebefore_tool, the approver’s verdict is recorded and enforced:
1
Approver is consulted
approver.review(ApprovalRequest(...)) is called only when the call needs approval.2
Errors are treated as rejection
If
review raises, the call is audited as rejected and blocked with "Approval failed for <tool>". An approver that fails does not fail open.3
Verdict is audited
The decision (
approved or rejected) is written to the tamper-evident audit log along with the tool, tier, and reasons.4
Rejection blocks the tool
A
False verdict returns a blocking ToolDecision ("Approval rejected for <tool>"); a True verdict lets the tool run.ApprovalRequired. A DurableApprover raises it to signal “no decision yet, suspend the run.” before_tool lets it propagate rather than treating it as an error, so the run can persist and resume later.
Every approval path is audited, including rejections and approver errors. See Audit for how those records are hash-chained and verified.
When inline approval is not enough
Approver.review is synchronous: it must answer in the moment. Real human sign-off can take minutes or hours, which no thread should block on. For that, use a DurableApprover, which looks up a prior decision in an ApprovalStore and, when none exists, records the pending request and raises ApprovalRequired so the run suspends and resumes once a human decides.
Durable approval
Suspend a run at the approval point and resume it hours later, TOCTOU-safe.
Policy
How
REQUIRE_APPROVAL obligations and FORBID decisions are produced.