Shell and installer arguments¶
Added in 0.10.28. ToolArgSinkGuard reads a call's string arguments for shell syntax, and
its installer arguments for anything but a bare version. @Airlock runs it as step 2.75,
right after the deserialization guard. It is off until a policy sets it, and a policy
without it behaves exactly as before:
| Field | Default | What it does |
|---|---|---|
shell_args |
frozenset() |
argument names screened for shell syntax |
screen_all_str_args |
False |
screen every argument, except free_text_args |
free_text_args |
frozenset() |
arguments screen_all_str_args leaves alone: messages, prompts, file bodies |
installer_args |
frozenset() |
arguments that must be a bare version |
extra_metachars |
frozenset() |
tokens refused on top of the default set |
mode |
"deny" |
"deny" refuses a finding, "warn" logs it and runs the call |
dispatch_args |
{} |
arguments that name a function the tool calls, each with the names it may hold: see Dispatch arguments |
Each name collection takes a set, list or tuple of strings and is stored as a frozenset.
A config that could not mean what it says raises when the guard is built: a lone string
(shell_args="repo" would otherwise be split into letters), a mode other than "deny" or
"warn", an argument named both free text and screened, free_text_args without
screen_all_str_args, or a guard that screens nothing.
The CVEs¶
Nine MCP server CVEs published on 27 and 28 October 2026 share one shape: a string
argument of an ordinary tool, written by the model, reaches a shell or a package installer
on the server. A prompt-injected agent only has to write x; id or file:../evil into it.
| CVE | Server | Argument | Reaches | CVSS 3.1 |
|---|---|---|---|---|
| CVE-2026-117558 | ai-sdlc plugin MCP server through 0.31.2 | repo |
gh issue view / gh pr view through execSync |
7.8 |
| CVE-2026-117559 | DroidMind through 0.4.0 | command, path |
Device.run_shell(), past its allowlist with $(...) or backticks |
7.8 |
| CVE-2026-117560 | ATXP Gemini CLI extension through 1.0.0 | message_id, subject, query |
runAtxp() |
7.8 |
| CVE-2026-117561 | sf-mcp through 1.3.2 | Salesforce CLI flag values | execSync in executeSfCommand |
7.8 |
| CVE-2026-117567 | esp-mcp through 28afddf | target, port, project_path, project_name |
asyncio.create_subprocess_shell |
7.8 |
| CVE-2026-117568 | mcp-ffmpeg through 1.0.5 | videoPath, outputDir |
child_process.exec |
7.0 |
| CVE-2026-117569 | Kali-GPT through a608c74 | target, ports, domain, wordlist, url |
subprocess.run(shell=True) |
7.8 |
| CVE-2026-117573 | hagent through 761eec6 | args |
arduino-cli and idf.py under subprocess.run(shell=True) |
7.8 |
| CVE-2026-59176 | functype-mcp-server before 1.4.4 | version |
pnpm add functype@<version>, then imported (CWE-829) |
7.8 |
NVD names no argument for DroidMind or sf-mcp; the names above are the servers' own
(android-shell's command, list_directory's path; sf-mcp's tools take the CLI's flag
names, such as target-org and query, and build --<flag>=<value>).
Until 0.10.28 the call path screened shell syntax only in a stdio spawn's argv
(StdioCommandInjectionGuard) and in a SafeURL argument, so a guarded tool whose
repo: str received x; id passed every gate. Any tool @Airlock guards is now screened
when its policy carries the guard, whether the tool builds the command line itself or
forwards its arguments to an MCP server that does.
Shell arguments¶
Name the arguments a server splices into a command line:
from agent_airlock import Airlock, SecurityPolicy, ToolArgSinkGuard
policy = SecurityPolicy(tool_arg_sink_guard=ToolArgSinkGuard(shell_args={"repo"}))
@Airlock(policy=policy)
def check_issue(repo: str, number: int) -> str:
return f"checked {repo}#{number}"
check_issue(repo="owner/name", number=7) # runs
check_issue(repo="owner/name; id", number=7) # refused: block_reason "tool_arg_sink"
Shell syntax is the stdio guard's own default set, DEFAULT_SHELL_METACHARS (the guard
imports it, it does not keep a copy): ;, &, &&, ||, |, a newline, a carriage
return, a backtick, $(, <( and >(. Bare < and > are left out because
pkg>=1.0 is benign; add them, or quotes, through extra_metachars.
What is read¶
- Names are the tool's parameter names, or
**kwargskeys. A positional argument is read under its parameter's name, and*argsitems under the*argsname. A positional argument the call cannot bind to a parameter is screened whatever the names say, since which parameter it is cannot be known. - Everything an argument holds is screened with it: strings nested in lists, tuples,
sets and mappings, mapping keys included, and in the tool's own pydantic models and
dataclasses, through the walk the other value gates use. A value counts by the argument
it sits in, not by the nearest key: with
shell_args={"repo"}, arepokey inside another argument is that argument's content. - Text in any shape:
str(a subclass is read as the characters it holds, whatever its__str__or__contains__says), bytes and memoryviews, aUserString, and path and URL objects. A number, a bool orNonecarries no text and passes. An object the host passed (a framework's run context) is not walked, as at the other value gates. - Input nested deeper than 32 levels, or a container that contains itself, is refused
as
deny_uninspectablerather than skipped.
Every string argument¶
screen_all_str_args=True screens every argument and opts free text out by name:
from agent_airlock import Airlock, SecurityPolicy, ToolArgSinkGuard
guard = ToolArgSinkGuard(screen_all_str_args=True, free_text_args={"body"})
@Airlock(policy=SecurityPolicy(tool_arg_sink_guard=guard))
def comment_on_issue(repo: str, body: str) -> str:
return f"commented on {repo}"
comment_on_issue(repo="owner/name", body="Looks good; merging.") # runs
In this mode a **kwargs key is screened too: a server that turns keyword arguments into
--name=value flags puts the name on the command line (sf-mcp builds --${key}=...).
The refusal does not repeat the name.
Host-injected arguments are arguments like any other: a LangGraph state or a run config
passed to the tool is screened unless you name it in free_text_args.
Installer arguments¶
An installer argument must be a bare version. Two patterns decide it, matched against the whole value, ASCII only:
- PEP 440: the regular expression of PEP 440's Appendix B (
packaging'sVERSION_PATTERN), case-insensitive.1.4.4,2.0.0rc1,1!2.0.post1,1.0+local.7. No specifier operator:>=1.0is a range, and>is redirection to a shell. - npm semver: the expression semver.org suggests for SemVer 2.0.0, after an optional
^,~or=and an optionalv.^1.30.0,~2.1.0-beta.1. No</>ranges,xwildcards or dist-tags (latest): resolve those on the host.
None passes, since no version reaches the installer. Anything else is refused, and the
refusal says which kind of thing it found:
| Value | Refused as |
|---|---|
1.4.4; touch x |
shell syntax (;) |
https://files.example.test/pkg-1.0-py3-none-any.whl |
a URL or package protocol (https:) |
git+https://github.com/example/pkg |
a VCS reference (git+) |
file:../evil, npm:evil-pkg@1.0.0 |
a URL or package protocol (file:, npm:) |
../p, C:\pkgs\evil |
a path |
@scope/evil |
a package@version or scoped package name (@) |
1.0 other |
whitespace, which separates a second argument or package |
pkg[extra] |
package extras ([) |
evil-pkg, latest |
a package name or tag, not a version |
2 |
not a string |
from agent_airlock import Airlock, SecurityPolicy, ToolArgSinkGuard
guard = ToolArgSinkGuard(installer_args={"version"})
@Airlock(policy=SecurityPolicy(tool_arg_sink_guard=guard))
def set_functype_version(version: str) -> str:
return f"installing functype@{version}"
set_functype_version(version="1.4.4") # runs
set_functype_version(version="file:../evil") # refused: not a bare version
Modes and the refusal¶
In deny mode a finding is the usual blocked response: block_reason "tool_arg_sink",
an error, fix_hints saying what to send instead, and metadata with the argument
("repo", "repo[1]", "target.repo"), the token that tripped it and the verdict
(deny_shell_metachar, deny_not_a_version or deny_uninspectable). It names the
argument and the token, never the whole value; a mapping key is reported as "a mapping
key in argument 'repo'", since its path would spell it out. on_blocked hears it with
violation_type "tool_arg_sink", and the log has airlock_tool_arg_sink_block.
In warn mode the finding is logged as airlock_tool_arg_sink_warn_only (function,
argument, token, verdict) and the call runs.
Preset¶
policy_presets.tool_arg_sink_defaults() screens every string argument in warn mode,
so adopting it cannot break a host on day one:
from agent_airlock import Airlock
from agent_airlock.policy_presets import tool_arg_sink_defaults
preset = tool_arg_sink_defaults(free_text_args={"body"}, installer_args={"version"})
@Airlock(policy=preset["policy"])
def comment_on_issue(repo: str, body: str) -> str:
return f"commented on {repo}"
Read the log, name the free-text arguments it flags, then pass mode="deny". To keep a
policy of your own, pass preset["guard"] as its tool_arg_sink_guard. The preset lists
the nine CVEs under "cves" and maps to OWASP MCP05, CWE-78 and CWE-829.
Honest scope¶
- It screens text, not how a server quotes it. A server that passes argv without a
shell is not exposed to these characters, and a value refused there was harmless. That
is the reason for
warnfirst and forfree_text_args. - The default set leaves out quotes, bare
<and>,$VARexpansion and globs. A quote that only closes the server's own quoting injects arguments rather than commands: mcp-ffmpeg (CVE-2026-117568) took a closing double quote as well as$()and backticks, and the guard refuses those two by default but the quote only withextra_metachars={'"'}. - Option injection passes. A value starting with
-(CWE-88) is not shell syntax. - Only text is read. A number or an object the host passed carries no text the model wrote.
Judging the command itself¶
This guard refuses shell syntax in an argument. To decide which commands an agent's shell
tool may run at all (allow git status, ask before git push, deny rm -rf, wrappers
followed), see Shell commands.