Dispatch arguments¶
A tool that calls a function by a name it was given hands the model a choice of code.
ToolArgSinkGuard(dispatch_args=...) reads that name before the tool runs. It is part of the
guard described in Shell and installer arguments: @Airlock
runs it at step 2.75, and it is off until a policy sets it.
| Field | Default | What it does |
|---|---|---|
dispatch_args |
{} |
maps each argument that names a function to the names it may hold. The argument must hold one name its allowlist admits or, with an empty allowlist, one that DEFAULT_DISPATCH_DENY does not match |
The CVE¶
CVE-2026-119050 (CVSS 3.1 8.1, CVSS 4.0
9.2, CWE-470): Agno through 3.1.11 ships PandasTools with two such tools.
create_pandas_dataframe passes the model's create_using_function to
getattr(pandas, ...), and run_dataframe_operation passes operation to
getattr(dataframe, ...). A prompt-injected agent can have the first call
pandas.read_pickle on a path it chooses, which unpickles the file and so runs code, or another
reader to read local files. There was no fixed release when this page was written
(agno-agi/agno#8699).
The dangerous value is a plain identifier, read_pickle. It holds no shell syntax and no
eval( text, so neither the shell screen nor the eval guards see it. dispatch_args answers
the shape, not Agno: any tool that turns an argument into a function call, whether by
getattr, a lookup in a table the caller indexes, or importlib.
Allowlist¶
List the names each tool supports:
import pandas as pd
from agent_airlock import Airlock, SecurityPolicy, ToolArgSinkGuard
guard = ToolArgSinkGuard(
dispatch_args={
"create_using_function": ("read_csv",),
"operation": ("head", "tail", "describe", "info"),
}
)
frames: dict[str, pd.DataFrame] = {}
@Airlock(policy=SecurityPolicy(tool_arg_sink_guard=guard))
def create_pandas_dataframe(
dataframe_name: str, create_using_function: str, function_parameters: dict
) -> str:
frames[dataframe_name] = getattr(pd, create_using_function)(**function_parameters)
return dataframe_name
@Airlock(policy=SecurityPolicy(tool_arg_sink_guard=guard))
def run_dataframe_operation(
dataframe_name: str, operation: str, operation_parameters: dict
) -> str:
return str(getattr(frames[dataframe_name], operation)(**operation_parameters))
create_using_function="read_pickle" and operation="to_pickle" are refused before getattr
runs; read_csv and describe run. A tool's call is read only for the arguments it takes, so
one guard can serve both tools.
An allowed reader still reads whatever path it is handed. Pair the guard with a
FilesystemPolicy: step 3 reads paths nested in arguments too, so an absolute or traversing
path such as /etc/passwd or ../../etc/passwd under function_parameters is refused unless
it lies inside your roots.
Default denylist¶
With an empty allowlist, dispatch_args={"operation": ()}, any identifier passes unless
DEFAULT_DISPATCH_DENY (in agent_airlock.tool_arg_sink_guard) matches it. The patterns are
shell-style and matched against the name case-folded:
_* (private and dunder names), *pickle*, read_*, to_*, load, loads, load_*,
eval, exec, compile, system, popen, spawn*, open, import_module,
__import__, getattr, setattr, delattr, globals, locals, vars, breakpoint.
read_* and to_* cover pandas' file readers and writers, and also harmless converters such
as to_dict. A tool that needs one lists it in an allowlist, which replaces the denylist for
that argument.
What is read¶
- The argument by its parameter name, or its
**kwargskey. A positional argument is read under its parameter's name. An argument the call does not pass is not read, so a parameter's default never is. - One name, as a
str.None, a number, bytes or a list is refused. The name must be one ASCII identifier (letters, digits and underscores, not starting with a digit), matched whole, sohead\n,head()andx; idare refused. - A dotted path (
pandas.read_csv) passes only when the argument's allowlist names it exactly. - An allowlist matches exactly and by case:
Headis nothead. The denylist is case-folded:READ_PICKLEis refused. - A
strsubclass is read as the characters it holds, whatever its__eq__or__hash__claims.
The refusal¶
In deny mode a refused name is the usual blocked response: block_reason
"tool_arg_sink", and metadata with the argument, the verdict deny_dispatch_name, and
the token: the refused name, cut at 64 characters, or for a value that is no name the
character that shows it. The error names the argument and the name, never another value.
With an allowlist, fix_hints list the names it accepts and say how to add a name that is
meant; without one, they say so for the denylist. The pattern that matched is never quoted. In
warn mode the finding is logged as airlock_tool_arg_sink_warn_only and the call runs.
Preset¶
policy_presets.reflection_sink_defaults() turns the check on in warn mode, so adopting it
cannot break a host on day one:
from agent_airlock import Airlock
from agent_airlock.policy_presets import reflection_sink_defaults
preset = reflection_sink_defaults(dispatch_args={"operation": ("head", "describe")})
@Airlock(policy=preset["policy"])
def run_dataframe_operation(dataframe_name: str, operation: str) -> str:
return f"ran {operation} on {dataframe_name}"
Read the log, then pass mode="deny". To keep a policy of your own, pass preset["guard"]
as its tool_arg_sink_guard. The preset lists CVE-2026-119050 under "cves" and maps to
OWASP MCP05 and CWE-470.
Honest scope¶
- Top-level arguments only. A name inside another argument, a model's field or a mapping's value, is that argument's content and is not read. Give the name its own parameter.
- One name per argument. A list of names is refused, not read item by item.
- The denylist is a backstop. It cannot see what an admitted method does with its
parameters:
DataFrame.query, which it admits, evaluates an expression string. An allowlist of the operations a tool supports is the control. - It reads names, not functions. A tool that resolves names through a table of its own safe functions does not need it.