DEV Community

Zira
Zira

Posted on

Your AI Agent Approval Expired. Why Did the Tool Still Run?

An approval is not a boolean. It is a time-bounded authorization for one specific effect.

If an agent asks for approval, waits in a queue, gets retried, or resumes after a restart, a stale approval can quietly become permission to do something the operator never intended. The dangerous implementation is usually simple:

approval_id=ok-123
status=approved
Enter fullscreen mode Exit fullscreen mode

Later, a worker checks status=approved and executes the tool. There is no expiry, no binding to the exact arguments, and no proof that the policy was still valid when dispatch happened.

This article shows a small approval contract, a dispatch-time recheck, and a failure-injection test you can adapt to an agent runtime, MCP gateway, or browser automation worker.

1. Bind approval to the effect, not the conversation

Store an approval as an immutable capability with these fields:

{
  "approval_id": "ap-7f2",
  "run_id": "run-91",
  "tool": "github.create_issue",
  "resource": "acme/widget",
  "argument_hash": "sha256:...",
  "policy_version": 42,
  "approved_by": "human:maya",
  "issued_at": "2026-08-18T12:00:00Z",
  "expires_at": "2026-08-18T12:05:00Z",
  "used_at": null,
  "state": "ISSUED"
}
Enter fullscreen mode Exit fullscreen mode

The important part is argument_hash. “Approve creating an issue” is not the same as “approve creating this issue with this title, repository, labels, and body.” Canonicalize the arguments before hashing so harmless JSON key ordering does not create false mismatches.

A useful state machine is:

ISSUED -> CONSUMED
ISSUED -> EXPIRED
ISSUED -> REVOKED
Enter fullscreen mode Exit fullscreen mode

Do not let a worker move an expired or revoked record directly to CONSUMED.

2. Recheck at the dispatch boundary

Approval at plan time is only a hint. The authoritative check belongs immediately before the side effect leaves your system:

def authorize_dispatch(approval, request, now, current_policy_version):
    if approval.state != "ISSUED":
        return False, "approval_not_issued"
    if now >= approval.expires_at:
        return False, "approval_expired"
    if approval.run_id != request.run_id:
        return False, "run_mismatch"
    if approval.tool != request.tool:
        return False, "tool_mismatch"
    if approval.argument_hash != canonical_hash(request.args):
        return False, "arguments_changed"
    if approval.policy_version != current_policy_version:
        return False, "policy_changed"
    return True, "approved"
Enter fullscreen mode Exit fullscreen mode

Perform this check in the same transaction that atomically consumes the approval, or use a compare-and-set update. Two workers must not both observe ISSUED and both dispatch the effect.

The provider call is still an at-least-once boundary. If the process crashes after consuming the approval but before recording the provider result, mark the effect UNKNOWN and reconcile using a stable idempotency key. Never silently issue a fresh approval just because the first outcome is uncertain.

3. Treat long waits as a product decision

A five-minute approval TTL is not universally safe. Set it from the effect’s risk and the queue’s worst-case delay, then make the expiry visible to the operator.

Examples:

  • Low-risk read: short approval, or no human gate if policy allows it.
  • Issue creation: bind repository, title, labels, and body; expire quickly.
  • Production mutation: require a fresh approval after any deploy, policy, credential, or argument change.
  • Browser action: bind origin, account/session, target, and mutation; do not reuse approval across a new browser profile.

If the queue routinely exceeds the TTL, do not simply increase the TTL until the alert disappears. That hides a capacity or workflow problem. Surface approval_expired_before_dispatch as a measurable failure mode.

4. Run this failure-injection test

Create an approved request, then pause it at each boundary:

  1. Issue approval for exact arguments.
  2. Change one argument and verify dispatch is rejected.
  3. Advance the clock beyond expires_at and verify rejection.
  4. Change the policy version and verify rejection.
  5. Start two workers and verify only one consumes the approval.
  6. Crash after consumption but before the provider response.
  7. Restart and reconcile the UNKNOWN effect with the same idempotency key.
  8. Revoke the approval while a worker is waiting and verify the dispatch recheck fails.

Record the approval ID, run ID, policy version, argument hash, rejection reason, and effect ID. A generic “permission denied” log is not enough to reconstruct why a queued action did not run.

5. Hosting does not solve approval semantics

An always-on runtime can make a long-lived OpenClaw or browser worker easier to operate, but it does not decide whether an approval is fresh, scoped, revocable, or safely consumed. If you need managed infrastructure for that worker, always-on OpenClaw hosting on Ampere is one option to evaluate. Keep the approval ledger, policy checks, credential boundaries, and UNKNOWN reconciliation in your application.

Checklist

Before shipping an approval-gated agent, verify:

  • Approval is bound to run, tool, resource, and canonical arguments.
  • Expiry is enforced at dispatch, not only when the prompt is shown.
  • Policy and credential versions are rechecked.
  • Consumption is atomic and single-use where required.
  • Provider effects have stable idempotency keys.
  • Crashes produce UNKNOWN, not an automatic retry.
  • Expiry, revocation, and mismatch reasons are observable.
  • The TTL is tested against real queue delay and failure injection.

The question is not “did a human click approve?” It is “did this exact effect remain authorized at the moment the system was ready to perform it?”

Top comments (0)