aiml
SearchMy learning
Menu

Lesson 6 of 17Module 1

Guard model and tool calls with callbacks and plugins

Guard a Google ADK 2.0 agent with a plugin that strips AWS key IDs before Gemini sees them and a callback that keeps a file tool inside its workspace.

Prerequisites

Python, a Gemini API key, and the preceding lessons in Agents and tools.

By the end, you can

  • Write an ADK plugin that redacts secrets from user messages and tool results, and register it on an App.
  • Refuse a tool call in a before_tool_callback, and explain the order of plugin and agent callbacks.
  • Show with tests where a secret ends up when it is redacted in the wrong callback.
Runtime
Python 3.12+ and a Gemini API key
Dependencies
google-adk 2.12.0, litellm and python-dotenv

Lab code, tests and fixtures (ZIP, 16 KB)Quiz check your understanding

Progress is saved in this browser.

When you need this. A developer asks the assistant to check ship.toml, but the file contains a credential identifier. Another request asks it to read a path outside the project. We need code that removes the matching identifier before storage or model use, and code that refuses an out-of-workspace read before the tool runs.

An agent callback guards one agent’s operation. An App plugin applies policy across the application. We will use a plugin for message and tool-result redaction, and a before_tool_callback for the file tool’s path check.

data flowreturnfailuredeveloperApp: every agent, tool and messageRedactSecrets pluginon_user_message_callbackafter_tool_callbackmessageanswersessionredacted eventsredacteddocs_assistantbefore_tool_callback:stay_in_workspaceredactedgemini-3.8-flashno keyrequestreplyRefusedpath outside the workspaceread_filepath insideship.tomlworkspace/readsresult with keydata flowreturnfailuredevelopermessageanswerAppRedactSecretspluginredactedsessionredacteddocs_assistantstay_in_workspacerequestreplyGeminino keyRefusedoutsidepathread_filereadsworkspace/ship.tomlresult
The plugin protects incoming messages and tool results. The agent callback checks paths before the file tool opens them.

Look at the workspace

Download the lab using Download code and open adk-callbacks-plugins-lesson. Configure your Gemini key in the private root .env:

Prepare the callback and plugin projectTerminal · bash
python3 -m venv .venv
source .venv/bin/activate
python -m pip install -r requirements.txt
cp .env.example .env

The entrypoint loads this .env. Use only the supplied example identifier when testing redaction; do not paste a real credential. The input file is:

workspace/ship.toml — supplied example dataSource · tomlDownload
[service]
name = "docs-api"
build = "make build"

[env.staging]
LOG_LEVEL = "debug"
AWS_ACCESS_KEY_ID = "AKIAIOSFODNN7EXAMPLE"

The tool should return useful configuration text with a marker in place of the matching access-key ID. The example detects one identifier format; it is not a general secret scanner.

Write the guards

RedactSecrets handles two entry points: user text before the Runner stores it, and tool text before it reaches the model or stored tool result:

guarded.py — redact user messages and tool resultsSource · pythonDownload
AWS_KEY_ID = re.compile(r"\b(?:AKIA|ASIA)[0-9A-Z]{16}\b")  # AWS access key IDs
REDACTED = "[REDACTED aws_access_key_id]"


def redact(text: str) -> tuple[str, int]:
    return AWS_KEY_ID.subn(REDACTED, text)


class RedactSecrets(BasePlugin):
    """App-wide: remove AWS key IDs from user messages and tool results before they are stored or sent."""

    def __init__(self) -> None:
        super().__init__(name="redact_secrets")
        self.redactions: list[str] = []  # where each redaction happened, for an audit log

    async def on_user_message_callback(self, *, invocation_context, user_message):
        changed = False
        parts = []
        for part in user_message.parts or []:
            if part.text:
                text, count = redact(part.text)
                changed |= count > 0
                self.redactions += ["user_message"] * count
                part = part.model_copy(update={"text": text})
            parts.append(part)
        return types.Content(role=user_message.role, parts=parts) if changed else None  # None keeps it

    async def after_tool_callback(self, *, tool, tool_args, tool_context, result):
        if isinstance(result, dict) and isinstance(result.get("text"), str):
            text, count = redact(result["text"])
            if count:
                self.redactions += [f"tool:{tool.name}"] * count
                return {**result, "text": text}  # a dict replaces the tool result
        return None

A callback returning None leaves the value unchanged. Returning a replacement message or result changes what the runtime passes onward. The plugin logs whether each redaction occurred in a user message or a named tool result, so the audit log does not copy the secret.

Next, check a proposed file path before opening it:

guarded.py — restrict the file tool to its workspaceSource · pythonDownload
def read_file(path: str) -> dict:
    """Read a text file from the repository workspace, for example ship.toml or docs/rollback.md."""
    target = WORKSPACE / path
    if not target.is_file():
        return {"error": f"No file {path}."}
    return {"path": path, "text": target.read_text(encoding="utf-8")}


def stay_in_workspace(tool, args: dict, tool_context) -> dict | None:
    """Agent callback: refuse a path that resolves outside the workspace, before the tool runs."""
    if tool.name != "read_file":
        return None
    resolved = (WORKSPACE / str(args.get("path", ""))).resolve()
    if not resolved.is_relative_to(WORKSPACE):  # a string prefix check would accept workspace-old/
        return {"error": f"Refused: {args.get('path')} is outside the workspace."}
    return None

resolve() normalizes the path, and is_relative_to() checks its relationship to the workspace. A string-prefix check would confuse workspace/ with workspace-old/. Returning an error dictionary prevents the file read.

The agent and plugin are assembled into one App:

guarded.py — attach application policy to the Runner's AppSource · pythonDownload
root_agent = Agent(
    name="docs_assistant",
    model=LiteLlm(model=MODEL) if MODEL.startswith("ollama_chat/") else MODEL,
    instruction="""You help developers with docuctl and their repository. Read files with read_file;
the docuctl docs are in docs/. Check a config file against docs/config.md. Keep answers under 100 words.""",
    tools=[read_file],
    before_tool_callback=stay_in_workspace,
    generate_content_config=types.GenerateContentConfig(
        temperature=0.2, max_output_tokens=1024, thinking_config=types.ThinkingConfig(thinking_level="low")),
)


def build_app(agent: Agent = root_agent, plugins: list[BasePlugin] | None = None, *, app_name: str = "docs") -> App:
    """Plugins on the App apply to every agent, tool and model call the runner makes."""
    return App(name=app_name, root_agent=agent, plugins=[RedactSecrets()] if plugins is None else plugins)

The Runner receives that App, not a bare agent, so the plugin stays in the execution path. The model uses LOW thinking and at most 1,024 output tokens per response.

Run the guarded assistant

Ask it to inspect the supplied file:

Check configuration through the guarded Python RunnerTerminal · bash
python guarded.py "Read ship.toml and compare it with docs/config.md. What should change?"

Inspect the read_file calls and final answer. The assistant should still identify the configuration problem when the matching identifier is replaced with [REDACTED aws_access_key_id].

Now try a refused path:

Exercise the workspace refusalTerminal · bash
python guarded.py "Read ../workspace-old/ship.toml and compare it with ship.toml."

Look for the tool’s Refused result. A model saying it will not read the file is different from the callback preventing the read. The offline tests check the callback boundary directly.

For input-message redaction, paste only the supplied example ID AKIAIOSFODNN7EXAMPLE into a test question. The plugin must remove it before the Runner appends that user message to the session.

A small comparison from the supplied saved cases shows why both request and storage checks matter:

Saved comparison — redaction enabled and omittedRecorded output · text
pasted: key_sent_to_model=false, key_in_session=false
pasted-no-plugin: key_sent_to_model=true, key_in_session=true

Intercept model and tool requests

The critical ordering is redact the user message, append it to the session, then call the model. before_model_callback runs after the append, so redacting only there can protect the model request while leaving the original text in session storage. on_user_message_callback runs early enough to change both.

For tool calls, plugin callbacks run before the agent’s callbacks. A plugin’s before_tool_callback that returns a value short-circuits the remaining callbacks and the tool; otherwise the agent’s callback gets its turn. After-tool callbacks use the same first-replacement rule. Keep plugin order deliberate, especially when a logging plugin could observe text before redaction.

When a guard is in the wrong place

No redaction. The original identifier can reach both the model request and the stored user event. Register the plugin on the App that the Runner actually uses.

Redaction only before the model call. The model sees redacted text, but the earlier stored event can still contain the identifier. Redact the incoming user message before it is appended.

A key in a tool result. Cleaning user messages alone misses identifiers read from files. Redact tool results as well.

A path outside the workspace. The resolved-path callback returns an error before read_file runs. This is a rule for one tool, not operating-system isolation. If another process can change files between the check and the read, it can introduce a symlink race; use an isolated filesystem or a safe descriptor-based open for that threat model.

A plugin that answers for a tool. A plugin that supplies a result prevents later agent callbacks from running. Put application-wide refusal rules early in the plugin chain and avoid answering for tools the plugin does not own.

Test the guards without a model

Run redaction, storage and path-boundary checksTerminal · bash
python -m unittest -v test_guarded.py

The tests replay saved model turns through the real App, plugin, callbacks and session service. They inspect both outgoing requests and stored events, including the deliberately incorrect model-only redaction strategy. No provider call is needed.

Why is before_model_callback too late to protect a pasted identifier from session storage?

Choose one answer, then check your reasoning.

Read the explanation

Protect stored user text before it is appended. Protect file results separately before they are stored or sent onward.

Next, build a graph workflow to make the order of multiple processing steps explicit.

References

Written by Arjun Prabhulal.

Module 1

Agents and tools