AFP engineering series · Chapter 7 of 25
Centralize cross-agent checks while testing plugin precedence, cached responses, and per-request isolation.
Local callbacks become hard to audit at scale
A growing assistant can contain a support agent, a search agent, and a specialist that prepares account changes. Copying a guardrail callback into each definition creates configuration drift: one agent receives the new policy, another keeps an old version, and a newly added tool path has no check at all. A shared policy integration reduces that maintenance burden, but only if its execution order is understood.
ADK plugins provide cross-cutting hooks. Current Python documentation registers them on App through its plugins list. Plugin hooks precede corresponding object callbacks, and a non-None result can stop the remaining callback chain. Those documented semantics make order a security concern, especially when a cache or fallback also returns a result.
A safe result can still bypass a required check
Suppose the first plugin returns a cached tool response. A later authorization plugin may never run. The cached content might be harmless for one principal and private for another. The bug is not that caching is inherently unsafe; it is that the cache hit changed the control path before the current access decision.
Separate permission checks from result substitution. Place non-bypassable authorization inside the action or data service, and design cache keys around the security context of the result. In the framework chain, order policy checks before optional substitutions where the lifecycle permits it. Test the actual short-circuit behavior rather than relying on the order in a diagram.
Keep plugin state request-scoped
A plugin instance may serve many invocations. Storing current_user, current_tenant, or current_budget as mutable instance attributes can mix concurrent requests. Use immutable configuration on the plugin and keep per-invocation state in a context keyed by a trusted request identifier. A process-global counter is suitable for rough diagnostics, not for enforcing tenant-specific spending limits across replicas.
Make policy clients concurrency-safe and define cleanup behavior for cancellation. If a request reserves budget and is cancelled before completion, the reservation needs a bounded lifetime or an explicit reconciliation process. A finally block in one worker is not enough when the worker itself can crash.
Register an explicit policy module
Illustrative python; adapt the persistence and identity adapters to your service.
from google.adk.apps import App
from google.adk.plugins.base_plugin import BasePlugin
class ActionPolicyPlugin(BasePlugin):
def __init__(self, policy_client):
super().__init__(name="action_policy")
self.policy_client = policy_client # concurrency-safe adapter
async def before_tool_callback(self, *, tool, tool_args, tool_context):
verdict = await self.policy_client.check(
tool.name, tool_args, tool_context)
if verdict.outcome != "allow":
return {"status": "blocked", "reason": verdict.public_reason}
return None
# app = App(name="support", root_agent=root_agent,
# plugins=[ActionPolicyPlugin(policy_client)])The policy_client is an application adapter, not an ADK class. Its contract must specify how it resolves authenticated identity and handles unavailable services. Do not pass the whole tool context to a remote policy engine by blindly serializing it; extract the minimal verified attributes required for the decision.
Keep plugin names stable and unique so traces can identify which policy executed. Version the policy independently from the plugin code. A deployment may use the same adapter with a newer rule bundle, and incident reconstruction needs both versions.
Test composition as a matrix
| First component | Later component | Risk to test |
|---|---|---|
| Cache returns a result | Authorization | Current permission check skipped |
| Policy denies | Metrics observer | Denial missing from metrics |
| Output redactor | Response cache | Unredacted data stored elsewhere |
| Fallback handler | Policy error | Unavailable converted to allow |
Build small runner fixtures with two plugins and one object callback. Record a trace entry at each hook and return a substitute result from one component at a time. Assert which hooks execute, which do not, and whether the tool runs. Repeat after changing registration order. This is more informative than a test that merely instantiates the plugin class.
An observer that must see every decision should not depend solely on a hook that can be skipped. Emit the enforcement decision at the policy boundary itself, then correlate it with runner events. Avoid making audit completeness depend on the success path of the component being audited.
Roll out shared policy without surprising every agent
Start with a capability inventory and a shadow decision mode that cannot authorize new actions. Compare proposed policy outcomes with existing decisions, investigate disagreements, and then enforce on a small population. High-impact actions still require existing authorization while shadowing; shadow mode is observation, not a temporary exemption.
Publish a compatibility contract for tool names, argument schemas, and reason codes. A plugin that recognizes refund_order but silently allows a newly renamed issue_credit tool is unsafe. Unknown side-effecting tools should fail according to an explicit policy, and deployment tests should enumerate the registered tool inventory.
Worked ordering review: cache hit before authorization
A team introduces a response cache plugin to reduce latency for order summaries. In the first implementation, the cache key is tool name plus order identifier. The plugin returns a cached response before a later policy plugin runs. Tenant A requests an order previously read by tenant B, and the cache supplies B's summary. The database was correctly protected during the original read, but the cache created a new unauthorized data path.
Fixing registration order helps only if every cache hit still reaches an appropriate access check. Add principal and tenant scope to the cache contract, and revalidate access before releasing personalized data. Consider whether entitlement changes require invalidation or a fresh check on every hit. A cache of public product descriptions can have simpler rules than a cache of customer records.
Build a two-plugin test with trace markers. In cache-first mode, make the cache return a synthetic result and observe whether the policy hook executes. In policy-first mode, deny access and verify that neither cache data nor a tool result reaches the client. These are tests of documented short-circuit behavior, not assertions that the framework is defective.
Add an audit observer after both plugins. If a denial short-circuits it, the missing audit record reveals another composition assumption. Move authoritative decision logging into the policy adapter or use an observation path whose coverage is independently verified. Do not reorder logging ahead of redaction in a way that exposes sensitive payloads merely to recover metrics.
Now test concurrent invocations. The cache and policy clients should be safe to share, but per-request identity must remain in the invocation context. A mutable plugin field holding the most recently seen tenant can produce intermittent leakage that a sequential test never sees. Synchronize two requests around the policy check to make that race observable.
The review ends with an explicit order contract, cache-key specification, invalidation rule, and trace test. The backend retains authorization for uncached reads. The cache path gains its own current-access enforcement. This is the level of detail needed when a performance feature changes which security components execute.
Know what centralization does not solve
A plugin does not protect a service endpoint called outside its runner, repair an overprivileged database account, or validate a user's consent by itself. It is one integration point in a larger control system. Keep core authorization and transaction invariants in the services that own the protected resources.
Use the ordering lab to inspect how substitution and denial differ, then reproduce those cases in your pinned SDK. The release evidence should show that each agent path reaches enforcement, each denied action has zero side effects, and each decision remains attributable under concurrent requests. Centralization is valuable when it makes those properties easier to prove.
Practice and continue
Open the interactive companion lab to inspect the assumptions and outcomes with synthetic data. Then return to the 25-chapter AFP learning path.
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.