Subprocess ssh arg quoting via shlex
Skill Ed3Design/ed3design-skill-bundles/code-quality/skills/subprocess-ssh-arg-quoting-via-shlex
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.From its SKILL.md
npx -y skills add Ed3Design/ed3design-skill-bundles --skill subprocess-ssh-arg-quoting-via-shlexAssembled from the repository path, not quoted from the project. Check it against their README if it does not work.
One thing to look at
- 0 stars0 stars. Stars are a popularity signal and not a quality one, but at this level it is likely that nobody has read this closely except its author, and you would be relying on your own review.
SKILL.md
10.3 KB, ~2.5k tokens by cl100k_base, as published. Nobody here has run it
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:
Reduces subsequent calls to ~5-20ms.Host your-server ControlMaster auto ControlPath ~/.ssh/cm-%r@%h:%p ControlPersist 600
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-Ffollowed 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-pipingdb-telemetry-primary-docker-logs-secondary(GA): typical caller buildingssh + docker exec psqlchainsmcp-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 + stdinfor 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
descriptionfrontmatter (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:shlexis 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
- Polish-1: anti-pattern "f-string construction" extended with concrete code example with
Cycle-2-Backlog (Polish, non-blocking)
- Encoding hint —
text=Trueuses 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).
What ships with it
Read from the repository
Just SKILL.md. No reference files, no scripts.