Subprocess SSH Arg Quoting via shlex
✅ PROMOTED via TDD cycle (RED+GREEN subagent pair). RED subagent wrote naive list form and RED recognized on its own while writing "my code is probably broken for the -F '|' part" — skill prevents exactly this silent shell interpretation. GREEN subagent additionally used Step 4 (SQL via stdin instead of -c) and verified 10 edge cases.
Overview
subprocess.run(["ssh", "user@host", "remote", "cmd", "arg1", "arg2", ...]) does NOT pass args atomically. SSH joins all post-host args with spaces into one string, sends that string to the remote sshd, which spawns the user's login shell to execute it — meaning the remote shell interprets metacharacters.
The Python args-list mechanism (subprocess.run([...])) protects you from local shell. SSH undoes that protection. The fix is to quote per-remote-arg BEFORE handing it to SSH.
The Pattern
❌ Naive (broken for any arg with metacharacters)
subprocess.run(
["ssh", "user@host",
"docker", "exec", "container",
"psql", "-c", "SELECT * FROM t WHERE x = '2026-06-01'",
"-F", "|"],
...
)
# SSH joins: docker exec container psql -c SELECT * FROM t WHERE x = '2026-06-01' -F |
# ^
# remote shell sees `|` as PIPE, breaks
✅ Correct (shlex.quote per arg + join)
import shlex
import subprocess
remote_argv = [
"docker", "exec", "container",
"psql", "-U", "user", "-d", "db",
"--no-psqlrc", "-A", "-F", "|",
]
remote_cmd = " ".join(shlex.quote(a) for a in remote_argv)
# remote_cmd = "docker exec container psql -U user -d db --no-psqlrc -A -F '|'"
# ^^^
# pipe is now properly quoted
subprocess.run(
["ssh", "-o", "BatchMode=yes", "-o", "ConnectTimeout=5",
"user@host", remote_cmd],
input=sql_payload, capture_output=True, text=True, timeout=30,
)
Steps to apply
Step 1: Identify the remote-arg-array
Separate Python-local args (ssh, options, host) from remote-command args.
# Local-only (SSH client args):
ssh_prefix = ["ssh", "-o", "BatchMode=yes", "-o", "ConnectTimeout=5", host]
# Remote command + its args:
remote_argv = ["docker", "exec", "-i", container, "psql", "-U", user, "-d", db, ...]
Step 2: shlex.quote per remote-arg + join
import shlex
remote_cmd = " ".join(shlex.quote(a) for a in remote_argv)
Step 3: Pass remote_cmd as ONE arg to SSH
result = subprocess.run(
ssh_prefix + [remote_cmd],
...,
)
Step 4: For SQL/script payloads, prefer stdin over -c
If your remote tool accepts stdin (psql, bash -c via heredoc, python -c, etc.), pass the payload via input=... rather than embedding in the arg-list. Avoids ALL quoting concerns for the payload itself.
# Best: SQL via stdin
result = subprocess.run(
ssh_prefix + [remote_cmd_with_psql_flags_but_no_minus_c],
input=sql_text, capture_output=True, text=True,
)
Step 5: Verify with a metachar-heavy test case
Test cases that catch broken quoting:
"SELECT * FROM t WHERE x = 'literal-quoted'" (single quotes in SQL)
"COUNT(*)" (parentheses)
"-F", "|" (pipe as separator)
"file with spaces.txt" (whitespace in arg)
"key=val;DROP TABLE" (semicolon)
"$HOME" (dollar-sign — would expand on remote)
Anti-Patterns
- ❌ Manual escaping with backslashes:
arg.replace("'", "'") — fragile, misses edge cases, doesn't handle nested quoting
- ❌ shlex.quote on ssh_prefix args: those go through Python's execve (no shell), already safe
- ❌ Using f-strings to build remote command — easy to forget escaping for one variable, hard to audit. Concrete example:
# ❌ BROKEN: what if symbol='CL=F' contains, or e.g. 'O'Reilly'?
symbol = "CL=F"
cmd = ["ssh", host, f"docker exec db psql -c \"SELECT * FROM t WHERE s='{symbol}'\""]
# Audit question: must check each f-string argument individually for shell-safety
# ✅ CORRECT: shlex.quote per argument, then join
remote_argv = ["docker", "exec", "db", "psql", "-c", f"SELECT * FROM t WHERE s='{symbol}'"]
remote_cmd = " ".join(shlex.quote(a) for a in remote_argv)
cmd = ["ssh", host, remote_cmd]
# OR better: SQL via stdin (Step 4)
- ❌ shell=True locally: that's a different escaping problem, also dangerous for injection
- ❌ Forgetting BatchMode=yes: SSH will try interactive password/passphrase prompts and hang silently in subprocess
- ❌ No SSH ControlMaster/ControlPersist for high-frequency callers: each call does a new TCP+TLS+Auth roundtrip (
200-500ms). For MCP servers with frequent queries, set `/.ssh/config`:Host your-server
ControlMaster auto
ControlPath ~/.ssh/cm-%r@%h:%p
ControlPersist 600
Reduces subsequent calls to ~5-20ms.
Why pipe | and tab \t are particularly insidious
- Pipe
|: silently interpreted as shell pipeline, the rest of args become input to a (often non-existent) next command. Symptom: weird "command not found" for what looks like a normal arg.
- Tab
\t: gets eaten as whitespace by SSH joining (one or more whitespaces between args are merged). Python sends "-F", "\t"; SSH joins into "-F " (extra space, no tab); remote shell tokenizes back into -F followed by nothing → "option requires an argument".
Both are silent — no Python-side error, no traceback, just wrong remote behavior. The shlex.quote pattern fixes BOTH in one move.
Connection to other skills
- A remote-deploy iteration workflow (rsync → build → health-check over SSH): every SSH-based deploy step is a candidate for this skill if it has metacharacter args
remote-script-scp-over-ssh-heredoc: heredoc is one valid alternative to stdin-piping
db-telemetry-primary-docker-logs-secondary (GA): typical caller building ssh + docker exec psql chains
mcp-server-stdio-to-http-migration (GA): MCP servers often have this exact issue at the SSH boundary
Cost-of-Skipping
A real MCP-server refactor session:
- Iteration 1:
-c "SQL" → quotes/parens broken → 5 min
- Iteration 2:
-i + stdin for SQL, but -F "\t" still broken → 8 min (tab whitespace-eaten)
- Iteration 3: switch to
-F "|" for tab-replacement → 7 min (pipe = remote shell pipeline)
- Iteration 4:
shlex.quote + join → 5 min, finally works
- Total cost: ~30 min + cognitive overhead for one bug-class that 5 min of applying the skill would have prevented
At 3-4 SSH-bridge projects per year × 30 min = 1.5-2h annual savings + frustration reduction.
Source triggers
- MCP-server refactor session: 4 iterations to correct quoting
- Pain: 30 min token consumption for a bug class that was structurally avoidable
- Brain-dump item "critical analysis of token usage" — such iteration loops are direct token pain
Background: TDD progression (Bulletproofing log)
Cycle 1 — PASS
RED subagent (without skill, scenario: MCP-server function query_trading_db(sql) for a remote DB via SSH+docker exec+psql with -F '|' and test query with single quotes): wrote naive list form ["ssh", host, "docker", "exec", ..., "psql", ..., "-F", "|", "-c", sql]. While writing, RED recognized on its own: "my code is probably broken for the -F '|' part. The SQL might get through because it's already quoted, but that's luck, not design." Identified the correct pattern (shlex.quote + join) only as an option, not as spontaneous default. Classic anti-pattern: known but not applied without skill trigger.
GREEN subagent (with skill via Read tool): first read description frontmatter (trigger check), then "The Pattern" (visual ✅/❌ comparison), then "Steps to apply" as a checklist. Additionally used Step 4 (SQL via stdin instead of -c) — the most powerful lever because it removes the SQL payload completely from the quoting game. Wrote 10 edge-case tests (pipe, single-quote, parens, semicolon, dollar-sign, tab, glob, syntax-error, timeout, BatchMode). Caller-context check: shlex is Python stdlib, available out-of-the-box.
Refactor applied before PROMOTE:
- Polish-1: anti-pattern "f-string construction" extended with concrete code example with
symbol="CL=F" — previously only one line mentioned, now with ❌/✅ comparison
- Polish-2: anti-pattern "No ControlMaster/ControlPersist" added — for MCP-server callers (high frequency) reduces setup overhead from 200-500ms to 5-20ms per call
Cycle-2-Backlog (Polish, non-blocking)
- Encoding hint —
text=True uses locale default. For mixed-locale setups more robust: encoding="utf-8", errors="replace"
- psql
-t (tuples only) for machine parsing — out-of-scope from the skill but practically relevant for MCP tools
- Cross-skill with
read-only-sql-via-regex-validator — skill solves only shell quoting, not SQL-injection safety. For MCP server: validator layer separately
- SSH stderr loss with
check=True + capture_output=True — psql notices get lost, possibly return them
Created in a post-session skill review.
Promoted after TDD Cycle 1 PASS via skill-tdd-promotion-workflow (RED+GREEN subagent pair, 2 polish items pre-PROMOTE incorporated).
1---2name: subprocess-ssh-arg-quoting-via-shlex3description: Use BEFORE writing or debugging Python `subprocess.run(["ssh", host, "cmd", "arg1", "arg2"])` calls where remote args contain shell-metacharacters — pipes `|`, parens `()`, semicolons `;`, dollar-signs `$`, backticks, quotes, glob `*?`, tabs `\t`, or empty strings. The naive list passed after host is silently joined by SSH with spaces and re-evaluated by the REMOTE shell. Symptoms: 'syntax error: unexpected end of file', 'option requires an argument', 'command not found', or silently-wrong arg parsing. STOP and use `shlex.quote()` on EACH remote-side arg, then `' '.join(quoted_args)` to produce ONE string passed as a SINGLE post-host arg. Trigger on phrases like "ssh + docker exec doesn't work", "psql via SSH gives syntax error", "MCP server SSH tunnel", "Bash syntax error from remote", "tab character disappears in SSH". Do NOT use for `shell=True` calls, SSH config aliases without metacharacter-args, pure tunnel/SFTP, or pure-local subprocess.4---56# Subprocess SSH Arg Quoting via shlex78> ✅ **PROMOTED** via TDD cycle (RED+GREEN subagent pair). RED subagent wrote naive list form and RED recognized on its own while writing "my code is probably broken for the `-F '|'` part" — skill prevents exactly this silent shell interpretation. GREEN subagent additionally used Step 4 (SQL via stdin instead of `-c`) and verified 10 edge cases.910## Overview1112`subprocess.run(["ssh", "user@host", "remote", "cmd", "arg1", "arg2", ...])` does NOT pass args atomically. SSH joins all post-host args with spaces into one string, sends that string to the remote sshd, which spawns the user's login shell to execute it — meaning the **remote shell** interprets metacharacters.1314The Python args-list mechanism (`subprocess.run([...])`) protects you from local shell. SSH undoes that protection. The fix is to quote per-remote-arg BEFORE handing it to SSH.1516## The Pattern1718### ❌ Naive (broken for any arg with metacharacters)1920```python21subprocess.run(22 ["ssh", "user@host",23 "docker", "exec", "container",24 "psql", "-c", "SELECT * FROM t WHERE x = '2026-06-01'",25 "-F", "|"],26 ...27)28# SSH joins: docker exec container psql -c SELECT * FROM t WHERE x = '2026-06-01' -F |29# ^30# remote shell sees `|` as PIPE, breaks31```3233### ✅ Correct (shlex.quote per arg + join)3435```python36import shlex37import subprocess3839remote_argv = [40 "docker", "exec", "container",41 "psql", "-U", "user", "-d", "db",42 "--no-psqlrc", "-A", "-F", "|",43]44remote_cmd = " ".join(shlex.quote(a) for a in remote_argv)45# remote_cmd = "docker exec container psql -U user -d db --no-psqlrc -A -F '|'"46# ^^^47# pipe is now properly quoted4849subprocess.run(50 ["ssh", "-o", "BatchMode=yes", "-o", "ConnectTimeout=5",51 "user@host", remote_cmd],52 input=sql_payload, capture_output=True, text=True, timeout=30,53)54```5556## Steps to apply5758### Step 1: Identify the remote-arg-array5960Separate Python-local args (`ssh`, options, host) from remote-command args.6162```python63# Local-only (SSH client args):64ssh_prefix = ["ssh", "-o", "BatchMode=yes", "-o", "ConnectTimeout=5", host]6566# Remote command + its args:67remote_argv = ["docker", "exec", "-i", container, "psql", "-U", user, "-d", db, ...]68```6970### Step 2: shlex.quote per remote-arg + join7172```python73import shlex74remote_cmd = " ".join(shlex.quote(a) for a in remote_argv)75```7677### Step 3: Pass remote_cmd as ONE arg to SSH7879```python80result = subprocess.run(81 ssh_prefix + [remote_cmd],82 ...,83)84```8586### Step 4: For SQL/script payloads, prefer stdin over -c8788If your remote tool accepts stdin (psql, bash -c via heredoc, python -c, etc.), pass the payload via `input=...` rather than embedding in the arg-list. Avoids ALL quoting concerns for the payload itself.8990```python91# Best: SQL via stdin92result = subprocess.run(93 ssh_prefix + [remote_cmd_with_psql_flags_but_no_minus_c],94 input=sql_text, capture_output=True, text=True,95)96```9798### Step 5: Verify with a metachar-heavy test case99100Test cases that catch broken quoting:101- `"SELECT * FROM t WHERE x = 'literal-quoted'"` (single quotes in SQL)102- `"COUNT(*)"` (parentheses)103- `"-F"`, `"|"` (pipe as separator)104- `"file with spaces.txt"` (whitespace in arg)105- `"key=val;DROP TABLE"` (semicolon)106- `"$HOME"` (dollar-sign — would expand on remote)107108## Anti-Patterns109110- ❌ **Manual escaping with backslashes**: `arg.replace("'", "'")` — fragile, misses edge cases, doesn't handle nested quoting111- ❌ **shlex.quote on ssh_prefix args**: those go through Python's execve (no shell), already safe112- ❌ **Using f-strings to build remote command** — easy to forget escaping for one variable, hard to audit. Concrete example:113 ```python114 # ❌ BROKEN: what if symbol='CL=F' contains, or e.g. 'O'Reilly'?115 symbol = "CL=F"116 cmd = ["ssh", host, f"docker exec db psql -c \"SELECT * FROM t WHERE s='{symbol}'\""]117 # Audit question: must check each f-string argument individually for shell-safety118 ```119 ```python120 # ✅ CORRECT: shlex.quote per argument, then join121 remote_argv = ["docker", "exec", "db", "psql", "-c", f"SELECT * FROM t WHERE s='{symbol}'"]122 remote_cmd = " ".join(shlex.quote(a) for a in remote_argv)123 cmd = ["ssh", host, remote_cmd]124 # OR better: SQL via stdin (Step 4)125 ```126- ❌ **shell=True locally**: that's a different escaping problem, also dangerous for injection127- ❌ **Forgetting BatchMode=yes**: SSH will try interactive password/passphrase prompts and hang silently in subprocess128- ❌ **No SSH ControlMaster/ControlPersist for high-frequency callers**: each call does a new TCP+TLS+Auth roundtrip (~200-500ms). For MCP servers with frequent queries, set `~/.ssh/config`:129 ```130 Host your-server131 ControlMaster auto132 ControlPath ~/.ssh/cm-%r@%h:%p133 ControlPersist 600134 ```135 Reduces subsequent calls to ~5-20ms.136137## Why pipe `|` and tab `\t` are particularly insidious138139- **Pipe `|`**: silently interpreted as shell pipeline, the rest of args become input to a (often non-existent) next command. Symptom: weird "command not found" for what looks like a normal arg.140- **Tab `\t`**: gets eaten as whitespace by SSH joining (one or more whitespaces between args are merged). Python sends `"-F", "\t"`; SSH joins into `"-F "` (extra space, no tab); remote shell tokenizes back into `-F` followed by nothing → "option requires an argument".141142Both are silent — no Python-side error, no traceback, just wrong remote behavior. The shlex.quote pattern fixes BOTH in one move.143144## Connection to other skills145146- A remote-deploy iteration workflow (rsync → build → health-check over SSH): every SSH-based deploy step is a candidate for this skill if it has metacharacter args147- `remote-script-scp-over-ssh-heredoc`: heredoc is one valid alternative to stdin-piping148- `db-telemetry-primary-docker-logs-secondary` (GA): typical caller building `ssh + docker exec psql` chains149- `mcp-server-stdio-to-http-migration` (GA): MCP servers often have this exact issue at the SSH boundary150151## Cost-of-Skipping152153A real MCP-server refactor session:154- Iteration 1: `-c "SQL"` → quotes/parens broken → 5 min155- Iteration 2: `-i + stdin` for SQL, but `-F "\t"` still broken → 8 min (tab whitespace-eaten)156- Iteration 3: switch to `-F "|"` for tab-replacement → 7 min (pipe = remote shell pipeline)157- Iteration 4: `shlex.quote + join` → 5 min, finally works158- **Total cost: ~30 min + cognitive overhead** for one bug-class that 5 min of applying the skill would have prevented159160At 3-4 SSH-bridge projects per year × 30 min = 1.5-2h annual savings + frustration reduction.161162## Source triggers163164- MCP-server refactor session: 4 iterations to correct quoting165- Pain: 30 min token consumption for a bug class that was structurally avoidable166- Brain-dump item "critical analysis of token usage" — such iteration loops are direct token pain167168---169170## Background: TDD progression (Bulletproofing log)171172### Cycle 1 — PASS173174- **RED subagent** (without skill, scenario: MCP-server function `query_trading_db(sql)` for a remote DB via SSH+docker exec+psql with `-F '|'` and test query with single quotes): wrote naive list form `["ssh", host, "docker", "exec", ..., "psql", ..., "-F", "|", "-c", sql]`. **While writing**, RED recognized on its own: "my code is probably broken for the `-F '|'` part. The SQL might get through because it's already quoted, but that's luck, not design." Identified the correct pattern (`shlex.quote + join`) **only as an option**, not as spontaneous default. Classic anti-pattern: known but not applied without skill trigger.175176- **GREEN subagent** (with skill via Read tool): first read `description` frontmatter (trigger check), then "The Pattern" (visual ✅/❌ comparison), then "Steps to apply" as a checklist. Additionally used Step 4 (SQL via stdin instead of `-c`) — the most powerful lever because it removes the SQL payload completely from the quoting game. Wrote 10 edge-case tests (pipe, single-quote, parens, semicolon, dollar-sign, tab, glob, syntax-error, timeout, BatchMode). Caller-context check: `shlex` is Python stdlib, available out-of-the-box.177178- **Refactor applied before PROMOTE**:179 - **Polish-1**: anti-pattern "f-string construction" extended with concrete code example with `symbol="CL=F"` — previously only one line mentioned, now with ❌/✅ comparison180 - **Polish-2**: anti-pattern "No ControlMaster/ControlPersist" added — for MCP-server callers (high frequency) reduces setup overhead from 200-500ms to 5-20ms per call181182### Cycle-2-Backlog (Polish, non-blocking)1831841. **Encoding hint** — `text=True` uses locale default. For mixed-locale setups more robust: `encoding="utf-8", errors="replace"`1852. **psql `-t` (tuples only) for machine parsing** — out-of-scope from the skill but practically relevant for MCP tools1863. **Cross-skill with `read-only-sql-via-regex-validator`** — skill solves only shell quoting, not SQL-injection safety. For MCP server: validator layer separately1874. **SSH stderr loss with `check=True` + `capture_output=True`** — psql notices get lost, possibly return them188189---190191_Created in a post-session skill review. 192Promoted after TDD Cycle 1 PASS via `skill-tdd-promotion-workflow` (RED+GREEN subagent pair, 2 polish items pre-PROMOTE incorporated)._