An agent tool accepts a document name. Its adapter builds a Python argument list, sets shell=False, and calls a fixed executable. The value --delete arrives as one argument, exactly as supplied. The child recognises it as an option and enables its delete mode.

No shell was involved. The adapter preserved the string but failed to preserve its intended role as a document name.

The downloadable example below reproduces that parsing failure with a harmless child process. It prints a Boolean and a list; it never opens or deletes a file. This is a synthetic adapter example, not a reported vulnerability in Claude Code or a particular MCP implementation.

I would reject a tool review that stops at “we use shell=False”. The review also needs evidence that model-controlled values reach the called program as permitted operands. That evidence belongs in a test of the actual adapter and command version.

Run the harmless counterexample

Download the Python fixture, inspect it, and run:

python3 agent-option-injection.py
python3 agent-option-injection.py --test
python3 -O agent-option-injection.py --test

The fixture uses the standard library. It launches the same Python file as a child, with a private entry point that parses arguments and prints JSON. It makes no network calls, invokes no model and performs no document operations. Local execution on Python 3.9.6 produced:

naive: {"delete": true, "documents": []}
separated: {"delete": false, "documents": ["--delete"]}
validated: document identifier rejected

The first call passes the supplied value directly after the adapter’s fixed arguments. The second inserts an option terminator before it. The third applies a deliberately narrow document-identifier policy before launching the child.

The child’s relevant parser definition is:

parser = argparse.ArgumentParser(allow_abbrev=False)
parser.add_argument('--delete', action='store_true')
parser.add_argument('documents', nargs='*')

Allowing zero documents makes the failure easy to see: the parser accepts --delete, sets the flag and leaves the document list empty. A different command might fail because it requires an operand, or might accept a dangerous option with its own value. The effect depends on that command’s grammar. This fixture establishes only the behaviour shown above.

Argument separation leaves another parser in the path

Python’s subprocess security documentation explains that the library does not implicitly invoke a system shell. For this direct Python-child invocation, a semicolon inside an argument remains a semicolon. It does not start a second command.

The child still parses its arguments. Shell quoting and option parsing happen at different boundaries. An option-looking string can therefore change the child’s behaviour without escaping its argument or adding another process.

OWASP’s command-injection defence guidance identifies argument injection separately and recommends parameterisation together with input validation. Its preferred defence is to avoid an OS command when a suitable library API exists. A direct API call removes the command-line option grammar, although the application must still validate and authorise the API arguments.

Do not apply shlex.quote() to each item of an argument list as an extra precaution. In this fixture there is no shell to remove the resulting quote characters. You would change the document name passed to the child rather than prove that it is safe.

There are platform limits to the general advice. Python explicitly warns that Windows batch files may be launched through a system shell even when the caller did not request one. The fixture uses a Python executable, not a .bat or .cmd file. Test the executable type and platform you deploy; do not extrapolate this run into a Windows batch-file guarantee.

Put the operand boundary in the adapter

For a command that supports the convention, -- ends option parsing. The repaired fixture constructs its final arguments as:

command += [str(Path(__file__).resolve()), '--child']
command.append('--')
command.append(value)
result = subprocess.run(
    command, shell=False, check=True,
    capture_output=True, text=True, timeout=5,
)

This excerpt assumes the download’s trusted interpreter prefix. The child receives --delete after the terminator, so it records that string in documents and leaves delete false. A name containing spaces remains one operand because the adapter appends one string rather than splitting it.

Keep fixed options before the terminator and model-controlled operands after it. Do not expose a general extra_args list merely to save writing a typed adapter. If the agent needs a choice of output formats, map a small enum to adapter-owned arguments. The agent should not supply the executable path, subcommand or arbitrary flags through a field labelled “document”.

The terminator is command-specific. Some programs use a different convention, parse their own subcommands, or interpret operand syntax such as response-file references and URLs. Confirm the target’s documented behaviour and run a test against the pinned executable. A generic wrapper cannot establish every program’s grammar by inserting two hyphens.

Even with a working terminator, the separated example accepts the document name --delete. That demonstrates data preservation, not permission to access such a document. Whether that name is legal is a separate application decision.

Validate a business identifier before constructing arguments

The fixture’s stricter entry point accepts only identifiers shaped like doc-alpha.txt:

if type(value) is not str or re.fullmatch(
    r'doc-[a-z0-9]{1,32}\.txt', value
) is None:
    raise ValueError('document identifier rejected')
return invoke(value, separated=True)

This is an illustrative policy for a tool whose inputs are simple identifiers. It rejects paths, option names, embedded whitespace and oversized identifiers. It would be an inappropriate policy for a tool whose legitimate purpose is to handle arbitrary filenames. Define the business contract first, then choose validation that preserves it.

An identifier matching the pattern is not automatically authorised. A production adapter must resolve it within the caller’s permitted collection, check the requested operation and enforce tenant membership. Avoid resolving an identifier into a shared global path and assuming that a valid filename belongs to the current user.

Path handling has its own controls. If your application eventually opens a file, a permitted path string can still resolve through a symlink outside the allowed directory. Ending option parsing does not address that problem. Keep the command grammar check and the filesystem authority check visible as separate tests.

Test the child interpretation, not only the outgoing list

A test asserting that the adapter created a list instead of a string would pass for the vulnerable example. So would a test checking that shell is false. Both assertions describe useful construction choices, but neither observes the child’s interpretation.

The fixture’s twelve test methods launch the child where needed and inspect its JSON. They cover the vulnerable flag, the repaired operand, ordinary identifiers, spaces and shell punctuation. They also test a literal -- operand, unknown-option failure, identifier length and invalid types. The same suite passes under python3 -O; the parent propagates optimisation to the child, and validation does not depend on removable assert statements.

These are local component tests. They do not prove that a deployed MCP server uses this adapter or that the service account has appropriate permissions. For a real integration, substitute a harmless inspection mode or an isolated test resource and verify the command’s resulting interpretation. Keep credentials, production destinations and destructive modes out of the test.

The fixture captures the child’s output because the child is trusted and prints a small record. capture_output=True is not an output-size limit. An adapter calling an untrusted or verbose program needs bounded collection and a resource policy; the related tool-result output-budget example shows why limits must apply at the transformation that can expand data.

Record the adapter contract before enabling the tool

For each command-backed tool, record the fixed executable and version, permitted operation, operand grammar and identity used to authorise the resource. Keep a negative test that supplies an option-looking value through every model-controlled string field. A parser upgrade can change behaviour even when the Python wrapper has not changed.

Prefer a structured log of the operation and validation outcome over a shell-like command string. Rendered command strings can obscure argument boundaries and may expose sensitive values. If you need argument evidence for an incident, preserve a redacted structured list under the appropriate retention policy.

For the wider review and operating workflow, see my Claude Code book, available as a Kindle edition on Amazon UK. The downloadable fixture is a separate teaching example.

Before approving the next command-backed tool, pass an option-looking document name through its real adapter. Verify that the program treats it as a permitted operand or that the adapter rejects it before process launch.