Inspect LLM responses and approve tool calls
Let the enclave inspect every inference response a governed agent receives, hold tool calls that cross your policy, and approve or deny them from the CLI while watching a live feed. Nothing a rule matches ever reaches the agent before a person decides.
This guide covers the response-inspection pipeline and the human-in-the-loop approval loop it feeds. Read it after Author and pull policy, which explains the scoping model (platform, org, group, agent) these keys reuse.
What is inspected, and where
The pipeline runs inside the enclave’s llmproxy, which already terminates every agent inference connection. It inspects responses: the model’s output that flows back to the agent, in both JSON bodies and streamed SSE, for the Anthropic Messages, OpenAI Chat Completions, and OpenAI Responses wire formats. It detects tool calls the model wants to make (read-only detection over the decoded content, never the stop reason).
Where inspection is active, llmproxy buffers the whole response before the agent sees a byte (bounded by the proxy’s max-body cap; an over-limit response fails closed with an error, never a truncated forward). Where it is not active, nothing changes: an agent with no inspection policy streams byte-for-byte as before, with no buffering and no audit noise.
Detection and judging stay inside the enclave. Tool-call arguments never leave the enclave, and the controlplane never sees them. An operator working the approval loop sees the tool name and arguments; an auditor sees verdicts and tool names, but never arguments, in the audit events.
The verdict model
Detectors find tool calls and emit findings; inspectors judge and return exactly one verdict per run. The built-in inspector is a deterministic, stateless rules inspector. The four verdicts:
- allow — forward the response to the agent unchanged (byte-identical, including on the approved path).
- redact — forward a replacement body instead of the original (v1 covers JSON bodies only; a redact on an SSE stream fails closed with an error rather than rewriting the stream mid-flight).
- reject — refuse the response outright; the agent receives an RFC 9457 problem it can react to.
- escalate — hold the response and wait for a human decision (the approval loop below). This is the human-in-the-loop verdict.
When several inspectors run, the pipeline resolves to the most severe verdict: reject beats escalate, escalate beats redact, redact beats allow. A reject in one inspector short-circuits the rest.
Authoring inspection policy
Inspection policy is the llm.inspection governance key. It resolves for each
agent through the same scope chain as every other LLM governance key, and an
absent key means the agent is ungoverned: the pipeline is inert.
The value is a JSON document listing ordered inspectors and each inspector’s
rules. A rule matches a tool-call finding by tool-name pattern (glob, e.g.
transfer_usd or mcp*) plus optional numeric-argument predicates, and
carries the verdict a match produces. The first matching rule wins:
{
"inspectors": [
{
"name": "payments",
"rules": [
{
"tool": "transfer_usd",
"arg_gt": { "amount": 1000 },
"verdict": "escalate",
"summary": "transfer over the $1,000 threshold needs operator approval"
}
]
}
]
}
An arg_gt predicate holds when the named argument is strictly greater
than the threshold: a transfer of exactly 1000 does not match a 1000
threshold. A rule with "verdict": "reject" refuses matched calls outright
instead of holding them. A malformed policy document fails closed: the
governed call is refused rather than a partial policy being applied.
Write the key with dvx policy llm inspection, which reads the JSON from
--file (- for stdin). Scope flags select the target exactly like the other
dvx policy commands: no flags is platform scope, --org, --group, and
--agent narrow it, and --lock pins the key so more-specific scopes cannot
override it:
dvx policy llm inspection \ --controlplane https://cp.example.com \ --org org_acme \ --file payments-inspection.jsonThe enclave picks the change up on its next policy snapshot pull, so a governed agent becomes governed (or the reverse) without restarting anything. See Author and pull policy for the scoping flags, locks, and the pull mechanics in full.
Operating the approval loop
When a rule escalates, llmproxy holds the response and registers a pending approval with the enclave broker. The held response waits there; the agent receives nothing while the hold is open.
Work the loop from the CLI. These commands talk to the broker’s operator API
(directly at the broker, or through the relay), authenticate with the shared
operator flags (--zitadel-key + --issuer, or --token-file, plus
--ca-cert), and take --broker-url:
dvx approvals list --broker-url https://relay.example.com:8452 ...b41a1f9e-... pending tool=transfer_usd agent=agent-a args: {"customer": "acme", "amount": 5000, "currency": "USD"}list shows pending and recently resolved approvals with the tool name,
arguments, and agent, which is what an operator needs to decide. get <id>
shows one approval. Decide with approve or deny:
dvx approvals approve b41a1f9e-... --broker-url ... dvx approvals deny b41a1f9e-... --broker-url ...Approve and the held response reaches the agent, which proceeds with the tool call exactly as if no hold had happened (the forwarded bytes are identical to what the model sent). Deny and the agent receives an error instead, described below. Deciding an already-decided or expired approval is a conflict error; the original decision stands.
For a web UI or dashboard, the broker exposes the same feed as server-sent
events at GET /api/v1/approvals/stream: each pending registration, decision,
and expiry arrives as it happens, in order, with a heartbeat on idle so the
connection’s liveness is observable. It is org-scoped bearer-authenticated
like every operator route, so it is safe to proxy to a browser client.
dvx approvals stream tails the same feed in a terminal until interrupted.
See Operator API for the authentication model these
routes share.
The disclosure toggle, timeout, and what the agent sees
Two operator-configured behaviors shape the agent-facing outcome of a denial.
The disclosure toggle (--inspection-disclosure on dvx serve llmproxy,
default detailed) controls how much of the reason the agent sees:
- detailed — the agent’s problem names the reason: “tool call denied by operator” on a deny, “approval timed out” on a timeout, or the rule’s own summary on a reject.
- opaque — the agent gets only “request denied by policy”, on every denial path (reject, deny, and timeout alike).
Operators always see the detail either way: the approval list, the SSE feed, and the audit events carry the full reason in both modes. The toggle governs only the agent-facing error.
The timeout (--escalation-timeout on dvx serve llmproxy, default
5 minutes) bounds how long a held response waits for a decision. A hold that
passes its deadline is treated as a deny, the approval row becomes expired,
and the agent receives the timeout error. Silence never resolves to an allow.
For each outcome, the agent sees:
- allow (or an approved escalation) — the model’s response, forwarded unchanged; the tool call proceeds normally.
- reject — a
403problem (detailed mode names the rule’s reason). - escalate, then deny — a
403problem (“tool call denied by operator” in detailed mode). - escalate, then timeout or expiry — a
504problem (“approval timed out” in detailed mode). - redact on a stream — a fail-closed error; v1 does not rewrite streams.
Every denial is a structured RFC 9457 problem, so a well-behaved agent can adjust its next step instead of hanging. The fail-closed rule is uniform: a deny, a timeout, a broker outage, or a policy error all refuse the tool call; nothing ever falls open.
Try it end to end
Pair this guide with Run an agent against a local enclave:
govern the agent with an inspection policy, give it a transfer tool, and watch
an over-threshold call surface as a pending approval in dvx approvals list
while an under-threshold call flows through with no approval step at all.