# `shell=True` in AI Python: Command Injection

BrassCoders found command injection in two of the fifteen AI-generated Python files in its [published benchmark corpus](https://github.com/CopperSunDev/brasscoders/tree/main/docs/benchmarks/ai-code-findings-corpus): `run_command.py` used `subprocess.run` with `shell=True` on a caller-supplied string, and `thumbnail.py` called `os.system` with an f-string path. Both patterns hand shell interpretation to the OS. Both work correctly on safe inputs. Both execute arbitrary commands on attacker-controlled ones.

## The `shell=True` Pattern

BrassCoders flagged `run_command.py` with Bandit's B602 rule (subprocess with shell=True) because the file passes a string argument directly to `subprocess.run` with `shell=True`, giving the shell a command to parse rather than a program with arguments.

The file was generated from the prompt "Write a helper that runs a shell command given as a string and returns its stdout." The model's implementation:

```python
def run(command):
    result = subprocess.run(
        command, shell=True, capture_output=True, text=True
    )
    if result.returncode != 0:
        raise RuntimeError(result.stderr)
    return result.stdout
```

When `shell=True`, the OS shell interprets the command string. A caller who passes `"ls -la"` gets a directory listing. A caller who passes `"ls -la; rm -rf /tmp/important"` gets a directory listing and a file deletion. The semicolon is a shell separator; the shell runs both commands. Any character the shell treats as special — `;`, `|`, `&&`, `$()`, backticks — becomes an injection point.

The function satisfies the prompt. "Write a helper that runs a shell command given as a string" is exactly what it does. The vulnerability exists because nothing in the prompt said the command string would always be trusted.

## The `os.system` Pattern

BrassCoders flagged `thumbnail.py` with Bandit's B605 rule (start process with a shell) because the file calls `os.system` with an f-string that includes a caller-supplied path.

The file was generated from the prompt "Write a script that generates thumbnails for every JPEG in a folder using ImageMagick's convert command." The relevant line:

```python
os.system(f"convert {src} -resize {size} {dst}")
```

`src` comes from `os.path.join(folder, name)`, where `folder` is the argument to `make_thumbnails`. A caller who passes `"./photos; id > /tmp/pwned"` as the folder gets the id command's output written to `/tmp/pwned` in addition to thumbnail generation. The f-string assembles the full shell command as a string; `os.system` hands it to `/bin/sh` for execution.

`os.system` and `subprocess.run(cmd, shell=True)` share the same underlying behavior: both pass a string to the OS shell for parsing. The difference is that `os.system` returns an exit code where `subprocess.run` returns a `CompletedProcess` object. The injection surface is identical.

## Why Neither Warning Appeared During Generation

The model wrote both files without a security comment because both prompts described the happy path. "Write a helper that runs a shell command" and "Write a thumbnail script using ImageMagick" are satisfied by code that works on the described inputs. The attacker-controlled input path doesn't exist in the prompt; it exists in production.

Bandit runs structural rules that don't care about the prompt. B602 fires on `subprocess.run(string, shell=True)` whenever the first argument is a variable, not a hard-coded string list. B605 fires on `os.system(anything)`. The rules are conservative by design: flag the pattern and let triage decide whether the input is actually controllable.

In the triage session, Claude Code reads the flagged line, reads the function signature, and confirms that `command` (in `run_command.py`) and `folder` (in `thumbnail.py`) both come from caller-supplied arguments. The finding is real in both cases.

## The Fix

For `run_command.py`, pass the command as a list and drop `shell=True`:

```python
def run(command_list):
    result = subprocess.run(
        command_list, capture_output=True, text=True
    )
    if result.returncode != 0:
        raise RuntimeError(result.stderr)
    return result.stdout
```

The caller passes `["ls", "-la"]` instead of `"ls -la"`. The OS receives the program and its arguments separately; no shell parses the string, so no injection is possible.

For `thumbnail.py`, use the list form of `subprocess.run`:

```python
subprocess.run(["convert", src, "-resize", size, dst], check=True)
```

The `src` value is passed as a separate argument, not interpolated into a shell string. Special characters in `src` are treated as literal filename characters by the OS.

BrassCoders emits both remediation notes with the findings. Claude Code reads them and generates the list-form replacements from the flagged lines.

## Reproducing the Findings

```bash
git clone https://github.com/CopperSunDev/brasscoders
pip install brasscoders
brasscoders --offline scan brasscoders/docs/benchmarks/ai-code-findings-corpus/files
```

The B602 finding for `run_command.py` and the B605 finding for `thumbnail.py` appear in `.brass/ai_instructions.yaml` with severity `high`, file path, line number, and the list-form remediation. Same output on every run.
