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

the one synchronous 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.

langgraph — interrupt()
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
openai agents — needs_approval
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.

control plane env
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