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.
Summary
setup_agno()'s AgnoFunctionCallinstrumentation logs the fullAgent/Teaminstance — including
agent.model.api_key— into themetadataof everytool-call span, whenever the tool's entrypoint declares an
agent/team/_agno_agent/_agno_team-typed (or named) parameter. Thisships 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
MCPToolstoolkit, whose generated entrypoint always declares_agno_agent/_agno_team(see "Root cause" below), so every MCP toolcall 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_keyon atoolspan.Environment
braintrust(Python SDK): 0.37.0 (also present in 0.34.0, so not aregression — checked via a source diff between the two)
agno: 3.0.5braintrust.wrappers.agno.setup_agno()(re-exportsbraintrust.integrations.agno.setup_agno)Reproduction
Minimal repro against
FunctionCall.aexecute, using Braintrust's ownin-memory test logger:
Output:
True— the span'smetadata["_agno_agent"]["model"]["api_key"]contains the plaintext key.
Root cause
braintrust/integrations/agno/tracing.py::_function_call_metadata:FunctionCall._build_entrypoint_args()(agno's own code,agno/tools/function.py) injects the live framework objects an entrypoint'ssignature asks for by name (
agent,team,run_context,_agno_agent,_agno_team,_agno_run_context) or by type annotation (anythingAgent/Team/RunContext-typed).metadata.update(entrypoint_args)copiesthose 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 ownMCPToolstoolkit generates every tool's entrypointwith exactly this signature (
agno/utils/mcp.py::get_entrypoint_for_tool):So any agent instrumented with
setup_agno()that usesMCPTools(a verycommon 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_metadatashould not blindly forward whatever_build_entrypoint_args()returns. At minimum:Agent/Teaminstances among the injected values (byisinstanceagainstagno.agent.Agent/agno.team.team.Team, importedlazily to avoid a hard agno dependency) and replace them with a small safe
summary (e.g.
{"name": ..., "id": ...}) instead of the full object.RunContextdoesn't carry the same risk (no reference back to theagent/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_metadatato redactAgent/Teamvalues before they're merged into span metadata:
Called once, right after
setup_agno(...)returnsTrue. Happy to turnthis into a PR against
_function_call_metadatadirectly if that's awelcome contribution — let me know the preferred shape (redact in place vs.
an opt-out list vs. something else) and I'll send one.