Skip to content

fix(dotnet): prevent shell command injection in CLI Agent (fixes #636) - #647

Open
kuangmi-bit wants to merge 3 commits into
a2aproject:mainfrom
kuangmi-bit:fix/cli-agent-shell-injection
Open

fix(dotnet): prevent shell command injection in CLI Agent (fixes #636)#647
kuangmi-bit wants to merge 3 commits into
a2aproject:mainfrom
kuangmi-bit:fix/cli-agent-shell-injection

Conversation

@kuangmi-bit

Copy link
Copy Markdown

Summary

Fixes the shell command injection vulnerability reported in #636.

Root cause: ParseCommand only allowlisted the first token, then interpolated the entire string into /bin/bash -c "{command} {arguments}". Shell metacharacters (;, |, &&, $(), backticks) in the argument portion bypassed the allowlist entirely.

Fix: Replace shell-based execution with direct process invocation:

  1. No shell — Execute allowlisted commands directly via ProcessStartInfo, never through /bin/bash -c or cmd.exe /c
  2. ArgumentList — Parse arguments into a List<string>, pass via ArgumentList (argv). ArgumentList handles platform-specific quoting; metacharacters arrive as literal argv entries
  3. Narrowed allowlist — Remove interpreters (python, node, npm, dotnet) — they accept arbitrary code as arguments
  4. Security warning — Added doc comment that this sample should not be exposed to untrusted clients

Credit to @chopmob-cloud for the detailed root-cause analysis and fix direction in #636.

Replace shell-based command execution (/bin/bash -c / cmd.exe /c) with
direct process invocation using ProcessStartInfo.ArgumentList.

Root cause: ParseCommand only allowlisted the first token, then
interpolated the entire string into a shell command line. Shell
metacharacters (;, |, &&, $(), backticks) in the argument portion
bypassed the allowlist entirely.

Changes:
- Execute allowlisted commands directly — no shell, no /bin/bash -c
- Parse arguments into a List<string>, pass via ArgumentList (argv)
- ArgumentList handles platform-specific quoting, metacharacters
  arrive as literal argv entries — never interpreted by a shell
- Remove interpreters (python, node, npm, dotnet) from default
  allowlist — they accept arbitrary code as arguments
- Add security warning: this sample should not be exposed to
  untrusted clients

UseShellExecute = false is not a mitigation here — it only stops
.NET from using the OS shell to resolve FileName. The code was
explicitly invoking /bin/bash -c (and cmd.exe /c), so a shell
parsed the string regardless.

Credit to @chopmob-cloud for the detailed root-cause analysis
and fix direction.

Fixes a2aproject#636
@gemini-code-assist

Copy link
Copy Markdown
Contributor

Caution

The consumer version of Gemini Code Assist on GitHub has been sunset. All code review activity has officially ceased.

@chopmob-cloud

Copy link
Copy Markdown

Verified this against the branch. The sink from #636 is closed, and it is the right shape of fix rather than an escaping patch: there is no shell process left to interpret metacharacters, FileName is the executable itself, and ArgumentList hands the tokens to the OS as an argv array, so ;, |, &&, $() and backticks arrive as literal argument text. Dropping python, node, npm and dotnet is the other half of it, since an allowlist cannot constrain a process that accepts arbitrary program text as an argument.

Three things the change leaves behind.

Five allowlist entries only worked because of the shell. AllowedCommands still carries dir, date, time, echo and type. On Windows all five are cmd.exe internal commands rather than executables on PATH, so they resolved only through the cmd.exe /c wrapper this PR removes. With UseShellExecute = false and FileName = "dir", Process.Start throws Win32Exception, file not found. Not a security problem, but it breaks the sample's own first documented example:

CLI> dir                    # List directory (Windows)

run-demo.bat ships in the same directory, so Windows is a supported path here rather than an edge case. Two of the five are worth pruning outright rather than re-homing: type has no Unix binary at all, and time is a shell builtin on both platforms with /usr/bin/time not guaranteed to be present. The same reasoning that justified removing the interpreters applies here, that entries which depended on the shell should leave with it.

The rest of the list divides cleanly by platform and is unaffected either way. whoami, tasklist, netstat, ipconfig, ping and git are real Windows executables. ls, pwd, cat, head, tail and ps are real Unix binaries and were never reachable on Windows even under cmd.exe, so nothing regresses there.

The README now documents the design this PR removes. It still states "Windows: Uses cmd.exe for command execution" and "Linux/Mac: Uses /bin/bash for command execution", and still lists dotnet, node, npm and python under allowed development tools. For a sample whose stated purpose is demonstrating the safe pattern, prose describing the unsafe one is worth correcting in the same change, otherwise a reader working from the README reintroduces what the code just fixed.

Minor: using System.Runtime.InteropServices; is now the only reference to that namespace in the file, since RuntimeInformation.IsOSPlatform was its sole consumer.

Worth naming what the change deliberately does not cover, because the added remarks get this right: cat, head and tail with a caller-chosen path is still arbitrary file read, and ping still reaches the network. Those are properties of the allowlist rather than of the execution model, and the "should NOT be exposed to untrusted clients" note is the honest boundary for a sample that bridges remote messages to local processes.

Replace deferred resp.Body.Close() with func() { _ = resp.Body.Close() }()
to satisfy errcheck lint. Both files had the same pattern in HTTP test
helpers that fetch agent cards and JKU public keys.
@kuangmi-bit

Copy link
Copy Markdown
Author

The Lint Code Base failure is a known super-linter false positive — not caused by this PR.

Error:

typechecking error: named files must all be in one directory; have 
/github/workspace/samples/go/agents/helloworld and 
/github/workspace/samples/go/agents/sign-and-verify-agent-card

Root cause: The super-linter runs golangci-lint across ALL changed Go files as a single batch. In this monorepo, Go files live in separate module directories (samples/go/agents/helloworld/ and samples/go/agents/sign-and-verify-agent-card/). The monolithic approach is incompatible with multi-module Go repos.

This PR touches only .NET files (samples/dotnet/) — the Go typecheck error is entirely pre-existing and unrelated. The super-linter was previously passing only because earlier PRs touched Go files in a single directory.

No action needed from contributor side.

Address review feedback on a2aproject#647 (chopmob-cloud, 2026-07-22):

- Prune shell-only builtins from AllowedCommands (dir, date, time, echo,
  type): they only resolved through the removed cmd.exe /c wrapper and
  throw Win32Exception with UseShellExecute=false. type has no Unix
  binary; time is a shell builtin on both platforms.
- Update README: drop cmd.exe//bin/bash execution model, fix example
  commands and the allowed-commands list to match the actual allowlist.
- Update CLIClient/CLIServer prompts, examples and agent descriptions
  that still advertised dir/date/dotnet --version/node/npm.
- Remove now-unused System.Runtime.InteropServices using from CLIAgent.cs.
@kuangmi-bit

Copy link
Copy Markdown
Author

@chopmob-cloud thanks for the thorough review — and apologies for the slow turnaround on it. All three points are addressed in 0e3140f:

  1. Shell-only allowlist entries pruneddir, date, time, echo, type removed from AllowedCommands. The reasoning you spelled out is now documented in the allowlist comment: they only resolved through the cmd.exe /c wrapper this PR removes, and with UseShellExecute = false / FileName = "dir" they throw Win32Exception. type has no Unix binary and time is a builtin on both platforms, so nothing portable is lost. The list is now real executables only.

  2. README corrected — the "Windows: Uses cmd.exe / Linux: Uses /bin/bash" section is replaced with the actual no-shell execution model, the example commands now only show allowed commands, and the Allowed Commands list matches the code exactly (also dropped the dotnet/node/npm/python "development tools" entry).

  3. Unused using removedSystem.Runtime.InteropServices deleted from CLIAgent.cs (RuntimeInformation is only used in CLIClient/Program.cs, which retains it).

Beyond the three points, I swept the rest of the demo for stale references to the removed commands: CLIClient's help text, example list (the Windows default was dir — it would have been rejected by the server), the interactive prompt, and CLIServer's welcome description all advertised dir/date/dotnet --version. The client's Windows example default is now whoami.

On the deliberate non-coverage you named — cat/head/tail with caller-chosen paths remain arbitrary file reads and ping still reaches the network — the "should NOT be exposed to untrusted clients" boundary note in the class doc stands as the honest line for this sample.

@kuangmi-bit

Copy link
Copy Markdown
Author

@chopmob-cloud — gentle ping on the re-review. All three points from your 07-22 review are addressed in 0e3140f: (1) pruned the shell-only builtins (dir/date/time/echo/type) from the client; (2) rewrote the README around the no-shell model; (3) swept client help text, examples, prompts, and server descriptions so nothing still suggests the old shell behavior. The Lint Code Base failure is the known super-linter false positive (typechecking), not from this PR. Would you be able to take another look?

@kuangmi-bit

Copy link
Copy Markdown
Author

@chopmob-cloud — one small ask to get this over the line: your 07-22 review already confirmed the fix is "the right shape" and all three points are addressed in 0e3140f, but the PR still shows REVIEW_REQUIRED because that verification lives as an issue comment rather than a formal review. If you have a moment and review authority, submitting an APPROVE review would clear the required-review gate; if not, a nudge to a maintainer would be hugely appreciated. No rush either way.

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

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants