Agent incidents are rarely model failures. They are an email agent that obeys an instruction hidden in a message it was summarising, a support agent that refunds outside policy, a CI agent with credentials to delete a repository. Each one is a missing control at the moment a tool with side effects was called. Governance is the architecture that puts a decision in front of every such call, records it, and lets people change the rules without redeploying the agent.

This article builds that architecture on ADK for Java. It uses ADK's plugin interface as the enforcement point, an external policy engine as the decision point, ADK's tool confirmation for human approval, and an append-only audit trail. The ADK signatures below were checked against the adk-java main branch source in October 2026; the API is still moving, so confirm them against the version in your build. For the general callback model see ADK Java callbacks.

Every side effect passes the same decision pathUser messageonUserMessage hookLlmAgentbeforeModel: spend capGovernancePluginbeforeToolCallbackTool runsafterTool: audittool callallowPolicy engineOPA, local sidecarApprovalrequestConfirmationinputhigh riskTool registryrisk band in customMetadataAudit store, append-onlydecision, input hash, policy versionSpend ledgermodel calls and tool calls per invocationevery decisionDeny by default: an unknown tool, a policy error or a timeout is a denyPlugins run before agent callbacks; a non-empty return short-circuits the toolIncidents become new rules, shipped as a new policy version
The runner-level plugin intercepts every tool call, asks the policy engine, pauses high-risk calls for approval and records every decision.

Enforcement points and decision points

Governance borrows two terms from access control. A policy enforcement point (PEP) is code on the execution path that cannot be skipped and asks for a decision. A policy decision point (PDP) answers allow, deny or needs approval from rules and facts. Keeping them separate is what lets a security team change a rule without a code release, and lets you test rules on their own.

Four principles follow. Every side effect is a tool call, so the tool boundary is where enforcement goes; an agent that can open its own HTTP connections is ungovernable. Deny by default: an unregistered tool, an unreachable policy engine or a timeout is a deny. Decide on structured input: the user, the tool, its risk band and its arguments, never the model's explanation of why it wants to act, because that text is exactly what an injection controls. And record every decision with the policy version that made it, so an incident can be replayed.

Content filters that classify prompts and responses are a separate, complementary layer, covered in ADK Java guardrails. They reduce how often a bad action is proposed; governance decides whether a proposed action runs.

Enforcement points and decision points

Governance borrows two terms from access control. A policy enforcement point (PEP) is code on the execution path that cannot be skipped and asks for a decision. A policy decision point (PDP) answers allow, deny or needs approval from rules and facts. Keeping them separate is what lets a security team change a rule without a code release, and lets you test rules on their own.

Four principles follow. Every side effect is a tool call, so the tool boundary is where enforcement goes; an agent that can open its own HTTP connections is ungovernable. Deny by default: an unregistered tool, an unreachable policy engine or a timeout is a deny. Decide on structured input: the user, the tool, its risk band and its arguments, never the model's explanation of why it wants to act, because that text is exactly what an injection controls. And record every decision with the policy version that made it, so an incident can be replayed.

Content filters that classify prompts and responses are a separate, complementary layer, covered in ADK Java guardrails. They reduce how often a bad action is proposed; governance decides whether a proposed action runs.

The hooks ADK Java gives you

ADK Java exposes the hooks through the Plugin interface, with BasePlugin as the class to extend. Plugins are registered on the runner and apply to every agent it runs, which is what you want for controls that must not depend on each agent author remembering them. Plugin callbacks run before agent-level callbacks, and a non-empty return short-circuits: the agent callback and the tool or model call are skipped and your value is used instead.

Hook (Plugin interface)ReturnsGovernance use
onUserMessageCallback(InvocationContext, Content)Maybe<Content>redact or tag input before the run
beforeModelCallback(CallbackContext, LlmRequest.Builder)Maybe<LlmResponse>spend caps, stop a run
beforeToolCallback(BaseTool, Map<String,Object>, ToolContext)Maybe<Map<String,Object>>the main enforcement point
afterToolCallback(..., Map<String,Object> result)Maybe<Map<String,Object>>audit outcome, redact results
onToolErrorCallback(..., Throwable)Maybe<Map<String,Object>>record failures, return a safe error
onEventCallback(InvocationContext, Event)Maybe<Event>stream every event to the audit store

Maybe.empty() means proceed. Returning a map from beforeToolCallback means the tool does not run and the model sees your map as the tool's result, which is how a denial reaches the model: as data it can explain to the user, not as an exception that kills the run.

A tool registry with risk bands

The policy engine needs to know what each tool is. Keep that in a registry, not in the policy: owner, risk band, data classes touched, and whether it is reversible. ADK's BaseTool carries a custom metadata map, which is a convenient place to attach the band at registration time, and FunctionTool.create has an overload with a requireConfirmation flag for tools that should always need a human.

FunctionTool readLedger = FunctionTool.create(FinanceTools.class, "readLedger");
readLedger.setCustomMetadata("risk", "low");

FunctionTool postEntry = FunctionTool.create(FinanceTools.class, "postJournalEntry");
// No static flag: the tool asks for approval itself above a threshold (see below).
// FunctionTool.create(cls, name, /* requireConfirmation= */ true) gates every call.
postEntry.setCustomMetadata("risk", "high");

FunctionTool sendEmail = FunctionTool.create(FinanceTools.class, "sendEmail");
sendEmail.setCustomMetadata("risk", "medium");

Make the registry the only way tools reach an agent: build agents from a factory that pulls tools from it, and fail startup if any tool lacks a band. A tool with no band is denied at runtime anyway, but failing at startup turns a production incident into a build error.

The governance plugin

public final class GovernancePlugin extends BasePlugin {
  private final PolicyClient policy;   // calls OPA over HTTP, 50 ms timeout
  private final AuditSink audit;       // append-only store

  public GovernancePlugin(PolicyClient policy, AuditSink audit) {
    super("governance");
    this.policy = policy;
    this.audit = audit;
  }

  @Override
  public Maybe<Map<String, Object>> beforeToolCallback(
      BaseTool tool, Map<String, Object> args, ToolContext ctx) {
    Object risk = tool.customMetadata().get("risk");
    if (risk == null) {
      return Maybe.just(deny("tool " + tool.name() + " is not registered with a risk band"));
    }
    PolicyInput input = new PolicyInput(
        ctx.userId(), ctx.agentName(), tool.name(), risk.toString(), args);
    return policy.decide(input)                                  // Single<Decision>
        .doOnSuccess(d -> audit.record(ctx.invocationId(),
            ctx.functionCallId().orElse(""), input, d))
        .flatMapMaybe(d -> d.allow()
            ? Maybe.<Map<String, Object>>empty()                 // run the tool
            : Maybe.just(deny(d.reason())))
        .onErrorReturn(e -> deny("policy engine unavailable")); // fail closed
  }

  private static Map<String, Object> deny(String reason) {
    return Map.of("status", "denied", "reason", reason);
  }
}

Three details matter. The input is built from the tool registry and the invocation context (userId(), agentName()) plus the raw arguments; nothing comes from the model's prose. The audit record is written before the decision takes effect, so even an allow that later crashes is recorded. And the error path returns a deny, so a policy outage stops side effects instead of silently allowing them. Register the plugin on the runner:

LlmAgent agent = LlmAgent.builder()
    .name("finance_ops")
    .model("gemini-2.5-flash")
    .instruction("Reconcile accounts. Explain any denied action to the user.")
    .tools(readLedger, postEntry, sendEmail)
    .build();

Runner runner = new InMemoryRunner(agent, "finance-app",
    List.of(new GovernancePlugin(policyClient, auditSink), new SpendCapPlugin(ledger)));

InMemoryRunner keeps the sketch short; a production deployment uses a runner with persistent session storage, and the plugin list is passed the same way.

Policy as code with OPA

Open Policy Agent evaluates rules written in Rego and exposes them over a REST API: the plugin POSTs {"input": {...}} to /v1/data/agents/tools/decision and reads the result field. Run OPA as a sidecar or local daemon so a decision costs about a millisecond on loopback rather than a network round trip, and load policy bundles from a versioned store. Cedar is a reasonable alternative with a stricter, analysable language; the plugin does not care which engine answers.

package agents.tools

# OPA 1.x syntax.
default decision := {"allow": false, "reason": "no rule allows this call"}

decision := {"allow": true, "reason": "low risk"} if {
    input.risk == "low"
}

decision := {"allow": true, "reason": "medium risk, internal recipients only"} if {
    input.risk == "medium"
    input.tool == "sendEmail"
    endswith(input.args.to, "@example.com")
}

decision := {"allow": true, "reason": "controller, within limit"} if {
    input.risk == "high"
    input.tool == "postJournalEntry"
    "finance_controller" in data.roles[input.user]
    input.args.amount <= 10000
}

Rules are mutually exclusive by risk band, which avoids Rego's conflict error when two complete rules produce different values. Test policies like code with opa test: a table of inputs and expected decisions, run in CI, including the dangerous cases such as an external recipient and an amount just over the limit. Tie decisions to identity carefully; authorization at the agent boundary covers propagating the end user's identity instead of the agent's service account.

Human approval with tool confirmation

Some actions should run only after a person agrees. ADK Java has built-in support: a FunctionTool created with requireConfirmation set to true asks for confirmation before running, and a tool can request it conditionally through ToolContext, which offers requestConfirmation(hint), requestConfirmation(hint, payload) and toolConfirmation(), an Optional<ToolConfirmation> whose confirmed() says what the person decided. The run surfaces a confirmation request to your client; the client shows it, collects the decision and sends it back, and the tool call proceeds or is refused. Use the static flag for tools that always need a human and the conditional form, as below, when only some calls do. The exact event shape of that round trip differs between releases, so build the client against the version you deploy.

public static Map<String, Object> postJournalEntry(
    String account, double amount, String memo, ToolContext ctx) {
  Optional<ToolConfirmation> decision = ctx.toolConfirmation();
  if (decision.isEmpty() && amount > 2_000) {
    ctx.requestConfirmation("Post " + amount + " to " + account + "?");
    return Map.of("status", "awaiting_approval");
  }
  if (decision.isPresent() && !decision.get().confirmed()) {
    return Map.of("status", "rejected_by_approver");
  }
  return ledgerService.post(account, amount, memo);
}

Operational rules for approvals: show the approver the structured arguments, not the model's summary; expire requests (an unanswered approval is a reject); forbid self-approval by checking the approver is not the requesting user; and watch the approval rate, because a queue where everyone clicks yes is a rubber stamp. More on designing the human step in human in the loop.

Spend and rate caps

Runaway loops cost money before they cause damage. A second plugin enforces a per-invocation cap in beforeModelCallback: if the ledger is exhausted it returns a final response, the model call is skipped, and the run ends with an explanation.

@Override
public Maybe<LlmResponse> beforeModelCallback(CallbackContext ctx, LlmRequest.Builder request) {
  if (ledger.tryCharge(ctx.invocationId(), 1)) {
    return Maybe.empty();
  }
  return Maybe.just(LlmResponse.builder()
      .content(Content.fromParts(Part.fromText(
          "Stopped: this request reached its model-call budget. Nothing further was changed.")))
      .build());
}

Keep counters outside the process (Redis or the database) so they hold across replicas, and add per-user and per-tool daily caps with the same pattern in beforeToolCallback.

Audit trail and a worked example

An audit record per decision should hold: timestamp, invocation and function-call IDs, user, agent, tool, a hash of the arguments plus the arguments with sensitive fields redacted, the decision and reason, the policy bundle version, the approver if any, and the outcome from afterToolCallback or onToolErrorCallback. Write to append-only storage and stream to your SIEM. The ADK Java audit log article covers storage and retention.

Worked example. A controller asks the agent to reconcile Q2. The agent calls readLedger: band low, OPA allows, one record. It proposes postJournalEntry for 8,400: OPA allows because the user holds the controller role and the amount is within limit, and the tool's threshold triggers a confirmation; a second controller approves and the entry posts. It then tries sendEmail to an external auditor's address: OPA denies, the model receives the denial map and tells the user the email needs to go through the approved channel. In the review a week later, the team notices an entry approved without the matching invoice, adds a rule requiring an invoice ID argument for high-risk entries, and ships it as policy version 14 without touching the agent.

Failure modes

  • Fail-open error handling. An exception in the plugin that propagates or returns empty lets the tool run. Map every error to a deny and test it by stopping OPA in staging.
  • Agent callbacks instead of plugins. Controls attached per agent are forgotten on the next new agent. Put mandatory controls in runner-level plugins.
  • Deciding on model text. Any rule that reads the model's reasoning can be steered by injected content. Decide on identity, tool metadata and arguments.
  • Latency creep. A remote policy call on every tool adds up. Use a local engine and keep rules free of slow external lookups.
  • Approval fatigue. Too many low-value approvals train people to click yes. Raise thresholds and let policy allow routine cases.

What to do next

  1. Inventory every tool and give it an owner, a risk band and a reversibility flag.
  2. Write the governance plugin with deny-by-default and fail-closed error handling, and register it on every runner.
  3. Run OPA locally, version the policy bundle, and add opa test cases to CI.
  4. Gate irreversible tools with requireConfirmation (always) or requestConfirmation (above a threshold), and build the approval view on structured arguments.
  5. Add the spend-cap plugin with counters shared across replicas.
  6. Stream audit records to your SIEM and schedule a monthly replay of incidents against the current policy.
Key takeaway: Govern agents at the tool boundary. In ADK Java, a runner-level plugin's beforeToolCallback is the enforcement point; it builds structured input from the tool registry and invocation context, asks a local policy engine, denies by default and on error, and records every decision with its policy version. Use tool confirmation for irreversible actions, cap spend in beforeModelCallback, and turn every incident into a tested policy change.