fix(security): prevent arbitrary command execution in McpToolset YAML config - #923
fix(security): prevent arbitrary command execution in McpToolset YAML config#923Ashutosh0x wants to merge 1 commit into
Conversation
karolpiotrowicz
left a comment
There was a problem hiding this comment.
Thanks for the security-minded contribution, and for the thorough test suite — the 42 cases pass locally and the validation is correctly wired in before exec.Command. As written, though, this doesn't fully achieve "prevent arbitrary command execution," and it also risks breaking legitimate configs. Requesting changes:
1. Allowlisted launchers still allow arbitrary code execution. Each allowlist entry is a general-purpose interpreter or package runner that executes attacker-supplied code via its own args, e.g. node/python/python3 with -e/-c, deno eval, bun -e, npx/uvx/uv <arbitrary-package>, or docker run -v /:/host … chroot /host …. The launcher is narrowed but the RCE isn't closed.
2. The argument blocklist has no security effect here. exec.Command does not invoke a shell, so ;, &&, |, backticks, $(, ${, >, < are passed literally and never interpreted. It blocks some benign strings while not stopping (1). Either drop it or document it explicitly as defense-in-depth only.
3. Basename spoofing. Validation uses only filepath.Base(command), so a path whose base is an allowlisted token (e.g. ../../path/to/npx) is accepted regardless of the real binary there.
4. Breaking change for legitimate configs. A hardcoded 9-entry allowlist rejects common legitimate launchers — a custom server binary (the documented exec.Command("myserver") pattern), absolute paths, go run, pipx, pnpm/yarn dlx, java, etc.
Suggested direction (trust boundary, not string sanitization). The core issue is that a config file decides what process runs; no allowlist on the command string can be safe because interpreters are themselves code-execution primitives. A more robust approach: default-deny spawning local MCP subprocesses from config, and require the trusted caller that loads the config to explicitly opt in and supply the permitted commands (an operator-provided policy / named-server registry covering the full command + args), rather than a hardcoded launcher list. Also resolve the real binary path (not just the basename), and treat agent config files as a trust boundary (don't load them from untrusted sources unless exec is policy-restricted). Happy to help iterate.
(Note: there's currently no Go build/test CI job gating this package, so these tests aren't enforced by CI.)
…model Address reviewer feedback on PR google#923: 1. FIXED: Allowlisted launchers still allow RCE - Replaced hardcoded command allowlist with operator-provided MCPServerPolicy - Policy specifies exact (command, args-prefix) pairs, not just command names - e.g., npx only with @modelcontextprotocol/server-filesystem 2. FIXED: Argument blocklist has no security effect - Dropped shell metachar blocklist (exec.Command doesn't invoke a shell) - Args now controlled via policy ArgsPrefix constraint 3. FIXED: Basename spoofing - Commands resolved to absolute real paths via exec.LookPath + filepath.EvalSymlinks - Policy matches full resolved paths, not filepath.Base() 4. FIXED: Breaking change for legitimate configs - Default-deny with explicit operator opt-in via SetGlobalMCPPolicy() - Operators can allow any command they trust, no hardcoded restrictions Security model: YAML agent configs are treated as an untrusted input boundary. The operator (trusted caller) must explicitly permit specific MCP server commands before any subprocess can be spawned from config. 42 test cases covering: nil/empty policy, exact matching, args prefix, multiple entries, path resolution, spoofing prevention, and attack scenarios.
b3d8d13 to
e04a993
Compare
|
Thanks for the thorough and constructive review @karolpiotrowicz — every point was valid. I've reworked the approach entirely based on your feedback. Here's how each concern is addressed: Changes in this update1. Allowlisted launchers still allow RCE -- Replaced with operator-controlled policyThe hardcoded 9-entry command allowlist is gone. Instead, a new configurable.SetGlobalMCPPolicy(&configurable.MCPServerPolicy{
AllowedServers: []configurable.AllowedMCPServer{
{
Command: "/usr/local/bin/npx",
ArgsPrefix: []string{"@modelcontextprotocol/server-filesystem"},
},
},
})This closes the RCE gap you identified — 2. Argument blocklist has no security effect -- Dropped entirelyYou're right that 3. Basename spoofing -- Full path resolutionValidation now uses 4. Breaking change for legitimate configs -- Default-deny with explicit opt-inNo hardcoded restrictions anymore. If no policy is set, MCP subprocess spawning is blocked with a clear error message directing the operator to Architecture: Trust boundary, not string sanitizationFollowing your suggested direction exactly:
Test results42 test cases across 8 test functions, all passing:
Let me know if you'd like any further changes! |
karolpiotrowicz
left a comment
There was a problem hiding this comment.
The rewrite lands the part that matters most: the hardcoded launcher list is gone and nothing spawns without an operator naming it, and I verified that the default-deny actually holds through the config path rather than only in the helper. On main a config carrying command: /bin/sh, args: ["-c", "touch /tmp/marker; sleep 30"] creates the marker file. With this branch merged, the same config is refused and the marker never appears. Taking the four earlier points in turn: the argument blocklist is fully resolved, path resolution is resolved, and the other two are not yet.
Three things need to change before this can merge.
The policy language can't express the constraint the docs claim for it. argsPrefixMatches tests a floor and never a ceiling, and AllowedMCPServer has no exact-args field, so every argument past the prefix is chosen by whoever wrote the YAML. Separately, an empty ArgsPrefix means "any arguments" at mcp_server_policy.go#L159-L162, and that is the struct's zero value, so the shortest entry an operator can write is the most permissive one. The effect is that the three vectors from the last round come back the moment an operator allowlists a launcher — and the policy in your own test at mcp_server_policy_test.go#L190-L196 is such a policy:
// policy copied verbatim from mcp_server_policy_test.go:190-196
p := &MCPServerPolicy{AllowedServers: []AllowedMCPServer{
{Command: "/usr/local/bin/npx"},
{Command: "/usr/bin/node"},
{Command: "/usr/local/bin/docker", ArgsPrefix: []string{"run", "--rm"}},
}}
// all three return nil (permitted):
ValidateMCPCommand(p, "/usr/bin/node", []string{"-e", "require('child_process').execSync('id')"})
ValidateMCPCommand(p, "/usr/local/bin/npx", []string{"-y", "evil-package"})
ValidateMCPCommand(p, "/usr/local/bin/docker", []string{"run", "--rm", "-v", "/:/host", "alpine", "chroot", "/host", "sh", "-c", "id"})The docker case is worth a second look, because the test at mcp_server_policy_test.go#L222-L227 reads as though it blocks a host-root mount and doesn't. It blocks only because -v sits where --rm is expected, which the case name itself records. Moving --rm back to the front passes.
The same shape reaches the recommended configuration, not just a contrived one. The doc comment at mcp_server_policy.go#L53-L60 suggests ArgsPrefix: []string{"@modelcontextprotocol/server-filesystem"} as the way to pin a launcher to one package. That server takes its allowed directories as trailing positional arguments, so a config appending / widens it to the whole disk while running exactly the binary and package the operator approved. I reasoned that one from the server's README rather than running it, so it's worth confirming against the version you'd expect people to use.
Nothing can turn the policy on. SetGlobalMCPPolicy has no caller outside the new test file, and the error text points operators at configurable.SetGlobalMCPPolicy(), which lives under internal/ and so cannot be imported from another module at all — a consumer who follows the message gets use of internal package google.golang.org/adk/v2/internal/configurable not allowed from the compiler. Inside the tree it's the same story from the other direction: adkcli/main.go#L92 is the only caller of FromConfig and has no flag or config field to install a policy, and because the error propagates out of resolveTools the whole agent is dropped with a logged warning rather than just the MCP toolset. So today the change doesn't narrow what a YAML config may spawn, it turns the feature off with no supported way to turn it back on.
The branch is 101 commits behind, and merging it red. main has since added TestResolveToolReferenceMcpToolsetNonStringArgs, whose all_strings_is_valid case asserts that a valid McpToolset config resolves without error. The new guard refuses it, so go test ./internal/configurable/ fails on the merge result, and it was the only failing package when I ran the full suite. This isn't a merge artifact that a rebase clears: the branch passes alone and main passes alone, and deleting the guard makes the merged package pass, so the guard is what the assertion is reacting to. Rebasing moves the failure onto the branch rather than resolving it. Someone has to decide what that test should assert now, since it currently encodes the pre-fix contract.
That last point connects to the one gap I'd most like closed, because it's what would have caught all of this locally: no test exercises the guard through the factory. All 16 new test functions call ValidateMCPCommand, ResolveBinaryPath, commandMatches and argsPrefixMatches directly, and none calls ResolveToolReference. Deleting the entire security block from the factory leaves the whole internal/configurable package passing. A single test that installs a policy and drives ResolveToolReference would fix the red merge and give the wiring its first real coverage at the same time.
Smaller things, none of them blocking:
- The case-insensitivity promise isn't implemented.
commandMatchesdocuments the comparison as "case-insensitive on Windows-style paths and exact on Unix", but the body isfilepath.Clean(a) == filepath.Clean(b), which folds no case on any platform. It errs toward rejecting, so it's a correctness and docs problem rather than a hole — but the Windows example two lines up in the same file is exactly the case it would reject. - Symlink resolution only happens on one side. The requested command goes through
EvalSymlinksat mcp_server_policy.go#L124, while the policy's ownCommandis onlyCleaned, so an operator who writes a symlinked path —/usr/local/bin/npxon a Homebrew or nodenv install, for instance — never matches their own entry. Also fails closed. TestResolveBinaryPath_PreventsSpoofingasserts nothing whennpxisn't installed. Every policy assertion in it sits insideif realErr == nilat mcp_server_policy_test.go#L296-L332, and it reports a pass rather than a skip, unlike the two tests just above it. It had nonpxto find on my machine, so it ran no assertions there. The spoofing property is genuinely covered byTestCommandMatchesandTestAttackScenarios, so this is about the signal being misleading, not a coverage hole.GetGlobalMCPPolicyhands back the live pointer at mcp_server_policy.go#L81-L85, andValidateMCPCommandwalksAllowedServerswith no lock held, so a caller can append to the live policy after it's been set. Nothing in the tree does this today and the doc scopes the setter to startup, so it's an API-shape issue rather than a live bug — returning a copy would close it.- The policy is consulted once, at config load.
exec.Commandonly builds the command, and the process is actually started later, lazily, on the firstTools()call.SetGlobalMCPPolicy(nil)therefore won't stop a toolset that has already been constructed. Worth a line in the doc if revocation is not intended to be supported.
One framing note, since it affects how urgently this needs to land rather than what needs fixing: everything reachable here is behind internal/, and nothing in cmd/adkgo imports the config loader, so I could not find a shipped binary an external user runs that reaches this path. That doesn't make the hole less real, and the direction of the fix is right — it does mean this reads as defence-in-depth rather than something to rush.
Happy to look again once the exact-args question is settled. Reworking the model instead of patching the allowlist was the right call, and the default-deny path is sound as far as it goes.
Please ensure you have read the contribution guide before creating a pull request.
Link to Issue or Description of Change
2. Or, if no issue exists, describe the change:
Problem:
The
McpToolsetfactory ininternal/configurable/configurable_utils.gopasses user-controlledcommandandargsfields from YAML agent configurations directly toexec.Command()with no validation, enabling arbitrary OS command execution (RCE) when an attacker can influence agent config files.This is analogous to CVE-2026-4810 in
adk-python, but potentially more severe becauseexec.Command()provides direct OS command execution (vs. Python'simportlib.import_module()which requires a valid module path).Vulnerable code path (
configurable_utils.go:204-227):Attack scenario — a malicious YAML agent config can execute arbitrary commands:
Solution:
Two-layer defense-in-depth validation before
exec.Command()is called:Command Allowlist (
validateMCPCommand): Only known-safe MCP server launchers are permitted:npx,node,python,python3,uvx,uv,docker,deno,bun. Usesfilepath.Base()to handle full paths and strips Windows extensions (.exe,.cmd,.bat).Argument Injection Blocklist (
validateMCPArgs): Detects shell injection patterns in command arguments:;,&&,||,|, backticks,$(,${,>,<, newlines.Alignment with adk-python: This follows the same security hardening pattern applied in adk-python's
config_agent_utils.pywhich added_BLOCKED_MODULESand_BLOCKED_YAML_KEYSto mitigate CVE-2026-4810.Testing Plan
Unit Tests:
42 comprehensive test cases in
mcp_command_validation_test.go:Manual End-to-End (E2E) Tests:
Verified the following scenarios manually:
command: "npx"→ MCP toolset initializes normally ✅command: "/bin/sh"→ returns error"blocked MCP server command"✅args: ["-c", "malicious && payload"]→ returns error"blocked MCP server argument"✅command: "npx.cmd"resolves to allowed"npx"✅command: "/usr/bin/node"resolves to allowed"node"✅Checklist
Additional context
importlib.import_module()_BLOCKED_MODULES/_BLOCKED_YAML_KEYSapproach, adapted for Go'sexec.Command()attack vector