BrassCoders found command injection in two of the fifteen AI-generated Python files in its published benchmark 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:
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:
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:
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:
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
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.
Top comments (0)