AFP engineering series · Chapter 6 of 25

Place checks around model and tool execution, return explicit denials, and test that rejected ADK calls have no side effects.

Begin with a tool that can cause harm

Northstar's support agent has a tool that prepares refunds. The user request can be perfectly ordinary while the generated arguments refer to the wrong order. An input-content filter cannot validate arguments that do not yet exist. The guardrail must inspect the proposed action before execution, and the action service must enforce its own authorization regardless of how the call arrived.

ADK exposes before- and after-model and tool callback points. In Python, a before-tool callback can return a result dictionary to skip execution; returning None continues. That distinction is the essential API behavior used here. The surrounding policy service, identity binding, and tests are an original reference design rather than a complete ADK application.

Choose the boundary from the failure mode

BoundaryUseful checkCannot undo
Before modelRequest size, allowed workflow, input screeningEarlier uploads or retrieval
Before toolArguments, identity, policy, approvalA previous tool side effect
After toolReturned-data minimization and labelingThe tool action already performed
After modelCandidate content and output structurePreviously emitted stream chunks

Do not implement every check at every boundary. Repeating a content classifier can add latency without improving action authorization. Instead, place each check where the required evidence first exists and before the protected effect. An order-ownership check needs a resolved order and authenticated principal; a final-answer privacy check needs the generated answer.

Document whether a callback mutates data, denies an operation, or only observes it. A callback that logs suspicious arguments is not an enforcement control. A callback that replaces a tool response after a write is a presentation control, not a rollback mechanism.

Keep the adapter thin

Illustrative python; adapt the persistence and identity adapters to your service.

def before_tool(tool, args, tool_context):
    # These adapters are application services, not ADK built-ins.
    principal = authenticated_principal(tool_context)
    verdict = policy_service.evaluate(
        principal=principal, tool=tool.name, arguments=args)
    if verdict.outcome != "allow":
        return {"status": "blocked", "reason": verdict.public_reason}
    return None

# Agent(..., before_tool_callback=before_tool)
# The tool endpoint repeats authorization before committing an effect.

The principal adapter must obtain identity from server-controlled invocation context or a protected session binding. It must not read a tenant or role supplied in the model's arguments and treat that as authentication. Likewise, a policy result should come from a typed service response with an explicit unavailable outcome, not from free-form model text.

Use exact callback parameter names required by the installed SDK and keep synchronous versus asynchronous behavior consistent with your adapter. Pin and test the ADK version in the application. The series checked current documentation and package metadata on October 15, 2026; a conceptual example is not evidence that an integration has run against your deployment.

Make denial unambiguous to the agent and the user

Return a stable status and a safe reason code. A string such as “there might be a problem” can be interpreted as a recoverable tool failure and trigger repeated attempts. Distinguish denied, review required, unavailable, and invalid arguments. The orchestration layer should know which outcomes may be retried and which terminate the action.

The final user interface should derive action status from the service record. If a refund was blocked, an enthusiastic generated answer must not display a success badge. Include the operation identifier in permitted results so the UI can retrieve authoritative status, while avoiding disclosure of internal policy details that are unnecessary for the user.

Handle callback failures deliberately

A guardrail can time out, raise an exception, or return malformed data. Test these as normal reliability cases. For a payment tool, an unavailable policy service should prevent execution and preserve the proposal for later review. For a public read-only answer, the product may offer a reduced mode with a documented limitation. One global fail-open flag is too coarse.

Apply a bounded timeout to remote checks and avoid retry amplification inside both the callback and the client. If the callback retries twice and the parent retries three times, the same policy service may receive six requests. Share a deadline and propagate a request identifier so operators can distinguish one logical action from repeated transport attempts.

Verify the absence of effects

A unit test that asserts the callback returned “blocked” is necessary but incomplete. Use a fake tool with an execution counter and invoke it through the actual runner. When the callback denies the request, assert that the counter remains zero. Also assert that the agent receives the blocked result and that no success state is written to the application database.

Run cases for allowed arguments, another tenant's order, expired approval, unknown tool name, policy timeout, and malformed policy output. Repeat with parallel tool proposals and with a subagent. If a path can call the action service without the callback, the service-side authorization should still reject it. That test proves the deeper boundary rather than merely the framework wiring.

Worked runner test: denial must stop the tool body

Create a sandbox tool that increments an execution counter and returns a synthetic order summary. Attach a before-tool callback that denies calls for a tenant B order when the authenticated fixture principal belongs to tenant A. Drive the call through the actual ADK runner using a controlled model response or a fake model adapter. Calling the callback directly would not verify the runner's skip behavior.

The test records callback entry, policy outcome, tool-body entry, and final service state. For the denied case, callback entry and denial should appear, while tool-body entry and committed effect should be absent. The agent should receive a stable blocked result. The user interface should not display a successful order lookup merely because the model produces reassuring prose afterward.

Run the allowed case next. It should execute exactly once and return the expected synthetic summary. This control matters: a callback wired incorrectly might block everything, making the denied case look successful. Then make the policy adapter unavailable and verify the chosen hold behavior. A raised exception must not cause a fallback branch to invoke the tool without enforcement.

Add a second tool call in the same turn. If one is denied and one allowed, inspect how the runtime represents both results and ensure that the permitted call does not inherit state from the denied one. Repeat with parallel proposals. The authenticated principal should be stable per invocation, and mutable callback state should not leak across requests.

Finally, bypass the runner and call the resource service directly with the same unauthorized principal. The service must deny independently. This establishes defense in depth with different failure modes: the callback improves orchestration behavior and early feedback, while the resource service protects the actual data or side effect.

Store the observed lifecycle trace as regression evidence for the pinned ADK version. After an SDK upgrade, rerun the test before enabling the new runtime. The expected contract is specific enough to detect changed callback semantics, missing hooks, accidental retries, and misleading UI status without relying on a real model to generate a particular adversarial phrase.

Observe and maintain the contract

Record callback name, policy version, outcome, duration, and action identifier in a restricted trace. Keep raw arguments out of default logs when they contain personal data. Compare attempted actions, denied actions, and committed actions; a rising gap can indicate either attacks or a broken integration.

Before upgrading ADK, run lifecycle tests that record callback order and return-value behavior. Include a callback that substitutes a result and one that raises an exception. The companion ordering lab makes these distinctions inspectable with deterministic fixtures. It does not execute ADK, so use it to understand the contract and the runner tests to verify the real integration.

Sources and implementation scope

Primary references checked October 15, 2026. Framework details follow the linked documentation; the Northstar cases, policy contracts, and test designs are original illustrative designs. Python snippets labeled illustrative are not complete production integrations. ADK package metadata checked for this series reported 2.12.0; pin and test the version used by your application.

Protect the action boundary with verified evidence, and test both containment and legitimate use.