subprocess.run(list, shell=True) Trap: The Arguments That Become Shell Positional Parameters2026-07-20
An SRE writes a disk-usage auditor for their weekly report. It "works" — no exceptions, plausible numbers — but the on-call engineer notices every mount point reports an identical size. This function is supposed to run du -sh <path> and return the size string.
import subprocess
def check_disk_usage(path):
"""Run `du -sh <path>` and return the size string, e.g. '1.2G'."""
result = subprocess.run(
["du", "-sh", path],
shell=True,
capture_output=True,
text=True,
check=True,
)
# stdout looks like "1.2G\t/var/log"
return result.stdout.split()[0]
for mount in ["/var/log", "/home", "/srv/data"]:
print(f"{mount}: {check_disk_usage(mount)}")
# Output:
# /var/log: 84K
# /home: 84K
# /srv/data: 84K
The developer added shell=True reflexively — perhaps because a colleague's snippet had it, or to "enable shell features." With a string argument, shell=True works as expected. With a list argument, it silently mutilates the call in a way that's almost never what you want.
On POSIX, subprocess.run(["du", "-sh", path], shell=True) executes:
/bin/sh -c "du" "-sh" "/var/log"
The first list element becomes the shell's -c command string. The remaining elements become positional parameters to the shell itself — $0, $1, $2 — not arguments to du. So the shell runs the literal command du, with no arguments. And du with no arguments summarizes the current working directory. Every call returns the same number because every call measures the same directory: whatever the auditor happens to be running from.
This is documented, but it's easy to miss: from the Python docs, "If args is a sequence, the first item specifies the command string, and any additional items will be treated as additional arguments to the shell itself." The trap is that nothing errors. du exits 0, stdout parses cleanly, and check=True is silent. The audit runs green for months.
Worse, the bug has a security cousin. Developers reach for shell=True when they need pipes or globs, then pass an unquoted f-string like f"du -sh {path}". A path of /tmp; rm -rf ~ is now a shell metacharacter injection.
Drop shell=True. You almost never need it, and the list form is safer and clearer:
result = subprocess.run(
["du", "-sh", path],
capture_output=True,
text=True,
check=True,
)
If you genuinely need shell features (pipes, redirections, globs), pass a single properly-quoted string and use shlex.quote on every interpolated value:
import shlex
cmd = f"du -sh {shlex.quote(path)} | tee -a /var/log/audit"
subprocess.run(cmd, shell=True, check=True)
As a rule of thumb: shell=True and a list argument together are almost always a bug. Some linters (e.g. bandit's B602/B604) will flag shell=True broadly — treat those warnings as an invitation to check whether you needed the shell at all.
shell=True, a list argument's first element is the shell command and the rest become the shell's positional parameters — not arguments to your program — so use a string with shell=True, or (better) a list without it.
