DocsPolicy & approvalsBrowse
Policy & approvals
Rules evaluate before an action executes — allow, refuse, or hold for a named human — and the decision lands on the same chain as the action it governed, because a verdict in a side table is not evidence.
The decision call
verdict = handle.decide({"action": "wire_transfer", "amount": 50_000,
"input": {"to": "acct 7"}}) # hashed, never sent
# → {"effect": "pending_approval", "policyId": "human-signoff-above-threshold", …}
# async hosts: await handle.adecide({...})The decision is itself evidence: every call chains a policy_decision event — allow, refuse, or hold — before anything happens, and a hold is exactly what the approval queue is built from. Everything the decision is checked against — spend totals, burn rate, halt state, prior approvals — is derived server-side from the chain and overwrites anything the caller claims. An agent under a budget it wants to exceed is the normal case, not the adversarial one. Unreachable fails open, loudly: the verdict and the record both say the check didn't run, so the trail shows an enforcement gap instead of a clean allow.
The six built-in rules
kill-switch tenant halt; forbid-wins over everything; the flip itself is chained human-signoff money moves above your threshold wait for a named human adverse-decision decisions against people must carry a structured reason tool-allowlist deny-by-default tool scope per agent burn-rate on pace to blow the daily ceiling inside the hour ⇒ stopped now cost-velocity the daily ceiling and the runaway-loop cap
Every rule ships with monitor mode, and the arming sequence is deliberate: record → warn → enforce. A compliance product that starts blocking production traffic on install does not get a second call.
In your framework: block → approve → resume
Capture is automatic; enforcement is one line more, because something has to stand in front of the tool. Both shims ask decide() before a guarded tool runs, chain the answer, and express a hold in the framework's own pause — so the run stops where it is and resumes where it stopped.
from auditant.langgraph import guard tools = guard(handle, [wire_transfer, lookup_balance], amount="amount") graph = builder.compile(checkpointer=InMemorySaver()) # interrupts need one out = graph.invoke(state, config) # held ⇒ out["__interrupt__"] carries the approval # …a named human approves in the dashboard or from Slack… out = graph.invoke(Command(resume=True), config) # the node re-runs, asks again, walks through
from auditant.openai_agents import guard, resolve
agent = Agent(name="underwriter", tools=guard(handle, [wire_transfer], amount="amount"))
result = await Runner.run(agent, "wire $50,000 to acct 7")
while result.interruptions: # something is held
answer = await resolve(handle, result) # answered from the chain, not by the caller
if not answer.ready: await asyncio.sleep(30); continue
result = await Runner.run(agent, answer.state)The resume value is never the approval. LangGraph re-executes the node on resume, so the gate asks the control plane again; resolve() re-asks with the same context the hold used. What lets the action through in either case is the human's answer on the chain, matched by session and action server-side — the thing being controlled cannot fill in who authorised it. A refusal comes back to the model in the policy's words (LangGraph: as the tool's result; Agents SDK: as the rejection message) rather than as a crash, and is chained as blocked. Monitor-mode rules and enforce=False record everything and stop nothing.
Approvals — dashboard and Slack
A held action appears on the dashboard queue, and — with a Slack incoming webhook configured — pings your channel with two answer paths: a dashboard link (named attribution, the approver's email on the chain) and HMAC-signed one-click links that expire in 24h and record the channel identity. Approve resumes the agent's next decide(); reject keeps it held, and the refusal is chained too.
AUDITANT_SLACK_WEBHOOK_URL= # an incoming webhook — no Slack app AUDITANT_LINK_SECRET= # signs the one-click links AUDITANT_PUBLIC_URL= # this control plane AUDITANT_DASHBOARD_URL= # for named approvals