Skip to content

FunctionCall tool-span instrumentation for Agno leaks the full Agent/Team object (incl. model.api_key) into span metadata #733

Description

Summary

setup_agno()'s Agno FunctionCall instrumentation logs the full Agent/Team
instance — including agent.model.api_key — into the metadata of every
tool-call span, whenever the tool's entrypoint declares an
agent/team/_agno_agent/_agno_team-typed (or named) parameter. This
ships the LLM provider's real API key to Braintrust in plaintext on every
matching tool call.

This is not a hypothetical: it reproduces with Agno's own built-in
MCPTools toolkit, whose generated entrypoint always declares
_agno_agent/_agno_team (see "Root cause" below), so every MCP tool
call
made by an instrumented agent leaks its provider API key. We caught
this live in our own Braintrust project logs — a real OpenAI key sitting in
metadata._agno_agent.model.api_key on a tool span.

Environment

  • braintrust (Python SDK): 0.37.0 (also present in 0.34.0, so not a
    regression — checked via a source diff between the two)
  • agno: 3.0.5
  • Integration: braintrust.wrappers.agno.setup_agno() (re-exports
    braintrust.integrations.agno.setup_agno)

Reproduction

Minimal repro against FunctionCall.aexecute, using Braintrust's own
in-memory test logger:

import asyncio
from typing import Optional
from agno.agent import Agent
from agno.models.openai import OpenAIChat
from agno.run.base import RunContext
from agno.team.team import Team
from agno.tools.function import FunctionCall, Function
from braintrust import logger
from braintrust.integrations.agno import setup_agno
from braintrust.test_helpers import init_test_logger

init_test_logger("repro")
setup_agno(project_name="repro")

# This is exactly the signature agno.utils.mcp.get_entrypoint_for_tool
# generates for every MCP tool (agno/utils/mcp.py):
async def call_tool(
    _agno_run_context: Optional[RunContext] = None,
    _agno_agent: Optional[Agent] = None,
    _agno_team: Optional[Team] = None,
    **kwargs,
):
    return "ok"

model = OpenAIChat(id="gpt-4o-mini", api_key="sk-super-secret-leak-me-not")
agent = Agent(name="reasoning", model=model)

fn = Function(name="search_deal", entrypoint=call_tool, skip_entrypoint_processing=True)
fc = FunctionCall(function=fn, arguments={"q": "acme"})
fc.function._agent = agent

with logger._internal_with_memory_background_logger() as bgl:
    from braintrust.integrations.agno.tracing import _function_call_aexecute_wrapper
    asyncio.run(_function_call_aexecute_wrapper(
        lambda *a, **k: asyncio.sleep(0, result="ok"), fc, (), {}
    ))
    events = bgl.pop()

print("sk-super-secret-leak-me-not" in str(events))  # -> True

Output: True — the span's metadata["_agno_agent"]["model"]["api_key"]
contains the plaintext key.

Root cause

braintrust/integrations/agno/tracing.py::_function_call_metadata:

def _function_call_metadata(instance: Any) -> dict[str, Any]:
    metadata: dict[str, Any] = {}
    ...
    try:
        entrypoint_args = instance._build_entrypoint_args()
        if entrypoint_args:
            metadata.update(entrypoint_args)   # <-- unfiltered
    except Exception:
        pass
    return metadata

FunctionCall._build_entrypoint_args() (agno's own code,
agno/tools/function.py) injects the live framework objects an entrypoint's
signature asks for by name (agent, team, run_context, _agno_agent,
_agno_team, _agno_run_context) or by type annotation (anything
Agent/Team/RunContext-typed). metadata.update(entrypoint_args) copies
those objects — not a summary of them — straight into the span metadata,
which Braintrust then serializes in full, including nested attributes like
agent.model.api_key.

Crucially, this isn't limited to user-defined tools that happen to ask for
agent. Agno's own MCPTools toolkit generates every tool's entrypoint
with exactly this signature
(agno/utils/mcp.py::get_entrypoint_for_tool):

async def call_tool(
    _agno_run_context: Optional["RunContext"] = None,
    _agno_agent: Optional["Agent"] = None,
    _agno_team: Optional["Team"] = None,
    **kwargs,
) -> ToolResult:
    ...

So any agent instrumented with setup_agno() that uses MCPTools (a very
common setup) leaks its model's API key on every single MCP tool call,
with no opt-in from the user and no way to know about it short of reading a
tool span's raw metadata in the UI.

Suggested fix

_function_call_metadata should not blindly forward whatever
_build_entrypoint_args() returns. At minimum:

  • Recognize Agent/Team instances among the injected values (by
    isinstance against agno.agent.Agent / agno.team.team.Team, imported
    lazily to avoid a hard agno dependency) and replace them with a small safe
    summary (e.g. {"name": ..., "id": ...}) instead of the full object.
  • RunContext doesn't carry the same risk (no reference back to the
    agent/model in current agno), so it seems fine to leave that one as-is —
    but worth double-checking against future agno versions.

Temporary workaround

We're patching this ourselves, downstream, right after setup_agno()
succeeds, by wrapping _function_call_metadata to redact Agent/Team
values before they're merged into span metadata:

def _redact_agno_identity(value):
    from agno.agent import Agent
    from agno.team import Team

    if isinstance(value, Agent):
        return {"name": value.name, "id": value.id}
    if isinstance(value, Team):
        return {"name": value.name, "id": value.id}
    return value


def patch_agno_tool_metadata_redaction():
    from braintrust.integrations.agno import tracing as agno_tracing

    original = agno_tracing._function_call_metadata

    def _redacted_function_call_metadata(instance):
        metadata = original(instance)
        return {key: _redact_agno_identity(value) for key, value in metadata.items()}

    agno_tracing._function_call_metadata = _redacted_function_call_metadata

Called once, right after setup_agno(...) returns True. Happy to turn
this into a PR against _function_call_metadata directly if that's a
welcome contribution — let me know the preferred shape (redact in place vs.
an opt-out list vs. something else) and I'll send one.

Activity

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions