Model Armor & Safety Guardrail Plugin
Overview This skill turns "add safety guardrails to my agent" into a single runner-wide
BasePlugininstead of a callback bolted onto one agent. A Plugin registered on theRunnerapplies globally, in registration order, to every agent, sub-agent, model call, and tool call it manages — and plugin callbacks run before any agent/model/tool-level callback and can short-circuit them. That precedence is exactly what a coordinator-plus- sub-agents topology needs: one plugin instance, one policy, no per-agent copy-paste that silently misses a newly added sub-agent.
The plugin calls Google Cloud Model Armor's sanitizeUserPrompt and
sanitizeModelResponse REST methods from before_model_callback and
after_model_callback to screen for prompt injection/jailbreak, responsible-
AI content categories, and sensitive data (PII); it uses before_tool_callback
to catch exfiltration risk in configured outbound tool arguments (e.g., an
HTTP-call tool receiving a credential-shaped string); and it validates session state at
on_user_message_callback/before_agent_callback to resist session
poisoning from a prior malicious turn. Success looks like: a single
GuardrailPlugin class, registered once on the Runner, that blocks or
redacts on a documented verdict (filterMatchState: MATCH_FOUND) instead of
silently passing unsafe content through, plus a policy config file the user
can retune without touching plugin code.
Prerequisites
A GCP project with the Model Armor API enabled and IAM role
roles/modelarmor.useron the calling identity.Two Model Armor templates already created (or ready to create) at
projects/{PROJECT_ID}/locations/{LOCATION}/templates/{TEMPLATE_ID}— one for prompts, one for responses (see Step 1; do not reuse one template for both).The ADK Python
Runner/InMemoryRunnerconstruction site in the user's code, where aplugins=[...]list can be added.Tool boundary: this skill edits/adds plugin code and local config only. It never calls the live Model Armor API itself, never provisions IAM roles or templates via
gcloud, and never runs the user's agent. Usescripts/simulate_moderation_violation.py(a local stdlib-only mock) to validate logic before wiring in real credentials.Data classification: Confidential. Treat prompts, model responses, session state, and tool arguments as potentially containing customer PII or credentials. Do not log raw matched values; log only the filter category, verdict, and request correlation ID.
Workflow
Step 1: Decide the template split and enforcement mode Read
references/model_armor_api.mdfor the full request/response schema. Two decisions gate everything else:Decoupled templates. Configure one template scoped to input risks (prompt injection/jailbreak
HIGHorMEDIUM_AND_ABOVE, malicious uploads) and a separate one scoped to output risks (PII/SDP leakage, off-brand content, malicious URLs). Never point bothbefore_model_callbackandafter_model_callbackat the sameTEMPLATE_ID— their risk profiles differ and a shared template makes false-positive tuning impossible to reason about.Enforcement mode. Start every new integration in
Inspect only(log via Cloud Logging, never block) to baseline the false-positive rate against real traffic, then flip toInspect and blockonce theguardrail_policy_config.jsonthresholds are confirmed. Encode this as an explicitenforcement_modefield (see Step 6) — never hardcode "always block" in the plugin body.Step 2: Scaffold the
GuardrailPluginclass Extendgoogle.adk.plugins.base_plugin.BasePlugin, callingsuper().__init__(name="model_armor_guardrail"). Readreferences/safety_plugin_patterns.mdfor the exact signature of every relevant hook and the Observe/Intervene/Amend return-value contract before writing a single method — a plugin hook that returns a non-Nonevalue halts the run and skips every other plugin and the agent/model/tool-level callback, so getting the return type wrong either silently no-ops your guardrail or wrongly kills a clean request.Step 3: Implement the prompt gate (
before_model_callback) CallsanitizeUserPromptwith only the latest user turn inuserPromptData.text— never concatenate conversation history or include the system prompt (the API's own guidance; history dilutes the signal and inflates token cost). Inspectresponse.sanitizationResult.filterMatchState:"MATCH_FOUND"andenforcement_mode == "inspect_and_block": return a fabricatedLlmResponse(e.g. a fixed refusalContent) — this is the Intervene path, and it stops the request before it ever reaches the LLM."MATCH_FOUND"andenforcement_mode == "inspect_only": log the per-filterfilterResultsbreakdown (which category matched) and returnNoneto let the request proceed unmodified."NO_MATCH_FOUND": returnNone.sanitizationResult.invocationResult == "FAILURE": this is an availability failure, not a content verdict — apply the policy'sfail_mode(fail_openvsfail_closed, fromguardrail_policy_config.json) rather than silently treating it as a pass.Step 4: Implement the response gate (
after_model_callback) CallsanitizeModelResponsewith the model's output text inmodelResponseData.text(optionallyuserPromptfor context). OnMATCH_FOUNDin asdpfilter specifically, prefer redaction over a hard block when the policy allows it: replace only the matched span using thesdpFilterResultfinding'slocation, rather than discarding the whole response — this preserves agent usefulness while still stopping the PII leak. OnMATCH_FOUNDinrai(responsible-AI) categories, replace the wholeLlmResponsewith a policy-configured refusalContent.Step 5: Implement the exfiltration gate (
before_tool_callback) For every registered tool that makes an outbound call (HTTP fetch, email send, file upload, code execution with network access), re-run the relevant argument string through the samesanitizeUserPrompt-style scan (or a lighter regex/SDP-basic-infoType check fromguardrail_policy_config.json'sbasic_sdp_infotypeslist) before the tool executes. Return a replacement result dict to block a tool call whose arguments contain a credential- or PII-shaped string; returnNoneto let it proceed. Pair this with the ADK safety guidance on sandboxed code execution: any code-execution tool should already be hermetic (no network, full state cleanup between users) — the plugin gate is a second layer, not a substitute for that isolation.Step 6: Guard against session-state poisoning Implement
on_user_message_callback(orbefore_agent_callbackfor a specific sub-agent) to validate any developer-set policy flags in session state against a value the plugin itself controls — never trust a state value that could have been written by a prior model turn or tool output without re-validating it. ADK's docs do not use the term "session poisoning" verbatim; this pattern is built from the state-validation and in-tool-guardrail primitives documented inreferences/safety_plugin_patterns.md's Session State Integrity section — read it before assuming a specific API name exists for this.Step 7: Author
guardrail_policy_config.jsonand register the plugin Copy/editassets/guardrail_policy_config.json: set the twotemplate_ids from Step 1, per-category confidence thresholds,enforcement_mode, andfail_mode. Then register the plugin once, at theRunner/InMemoryRunnerconstruction site:
runner = Runner(agent=root_agent, app_name=app_name,
plugins=[GuardrailPlugin(config_path="guardrail_policy_config.json")])
Never register the same plugin instance on more than one Runner, and
never add it as a per-agent before_model_callback=... instead — that
loses the global, precedence-first coverage this skill exists to provide.
Step 8: Validate before wiring real credentials Run
scripts/simulate_moderation_violation.py --selftest. It starts a local stdlib-only mock Model Armor server and a mockGuardrailPluginharness (nogoogle-adkor GCP dependency required), then replays canned cases — benign text, prompt injection ("ignore previous instructions..."), a credit-card-shaped PII string, and a hate-speech-shaped string — asserting each gets the expectedMATCH_FOUND/NO_MATCH_FOUNDverdict and the correct block/redact/allow decision. All cases must pass before pointing the plugin at a realTEMPLATE_IDand regional endpoint.Examples
Example 1: New ADK coordinator with several sub-agents Input: "Add a safety guardrail plugin so nothing harmful reaches any of my sub-agents." Expected behavior: scaffold
GuardrailPluginper Steps 2-6, author two templates' worth of config per Step 7, register it once on the sharedRunner, and confirm via Step 8's selftest that a prompt-injection payload is blocked atbefore_model_callbackwithout reaching the model.Example 2: Existing agent, PII leaking in responses Input: "Our support bot sometimes echoes back a customer's card number." Expected behavior: focus on Step 4 — add/verify the
sdpoutput template, prefer the redact-the-span behavior over a full block, and confirm with a credit-card-shaped selftest case that the response is redacted, not just logged.Error Handling
sanitizationResult.invocationResult == "FAILURE"(all filters skipped/errored): apply the configuredfail_mode; never treat this the same asNO_MATCH_FOUND, and never silently default to fail-open without it being an explicit policy choice inguardrail_policy_config.json.A
before_tool_callbackreturns{}(empty dict) to mean "allow": this is wrong — ADK checks the return value withis None, so an empty dict still counts as an override that replaces the tool result. ReturnNoneexplicitly to allow the tool call to proceed.Both prompt and response gates point at the same
TEMPLATE_ID: stop and flag this during Step 1 — it defeats the decoupled-template tuning this skill depends on.The regional endpoint is missing or a global endpoint is used for a sanitize call: Model Armor's sanitize methods require a regional endpoint (
modelarmor.{LOCATION}.rep.googleapis.com), not the globalmodelarmor.googleapis.comhost used for template CRUD — calls to the wrong host will fail outright.scripts/simulate_moderation_violation.py --selftestreports a FAIL: do not proceed to wiring real credentials; the block/allow decision logic in the plugin (not the mock server) is almost always the bug — recheck thefilterMatchStatebranch from Steps 3-4.A referenced API or plugin-pattern document is unavailable: do not guess a hook signature, REST field, or endpoint. Stop the implementation and obtain the matching versioned ADK or Model Armor documentation before continuing.
Reference Files
references/model_armor_api.md: exact REST method names, paths, request/response JSON schema (
SanitizationResult,filterMatchState,filterResultsper category), confidence-level and enforcement-type tables, and IAM permissions — read before Step 1 or whenever a field name needs verifying.references/safety_plugin_patterns.md: the full
BasePluginhook table with exact async signatures, the Observe/Intervene/Amend return-value contract, the layered-defense model from ADK's safety guidance, and the session-state-integrity pattern — read before Step 2 and whenever a hook's behavior needs confirming.scripts/simulate_moderation_violation.py: stdlib-only mock Model Armor server plus a
GuardrailPlugindecision-logic harness; run--selftestin Step 8, or--payload "..." --mode prompt|responseto test one string ad hoc, before touching real credentials.assets/guardrail_policy_config.json: sample policy file — two
template_idplaceholders, per-category confidence thresholds,enforcement_mode,fail_mode, andbasic_sdp_infotypes— copy and edit in Step 7 rather than inventing a config shape ad hoc.Output Format Return, in order: (1) the
GuardrailPluginclass code with all implemented hooks, (2) the filled-inguardrail_policy_config.json, (3) the exactRunner(..., plugins=[...])registration snippet, and (4) thesimulate_moderation_violation.py --selftestoutput confirming every case passed. Never report the integration as complete without that selftest output, and never claim "safe in production" fromInspect onlymode alone — state explicitly which enforcement mode is active.