Put the payload in a file, pass the path
Each layer of interpretation — your shell, ssh, the remote shell, -c, an
interpreter's own string parser — reparses the text and consumes one level of
escaping. Two layers is the practical limit for a human or a model to get right.
One is safe. So do not nest; write a file and reference it.
# instead of this
ssh host-a "python3 -c \"print('a\\tb')\""
# do this
scp script.py host-a:/tmp/script.py
ssh host-a 'python3 /tmp/script.py'The second form has exactly one layer of quoting and it is single quotes, which interpret nothing.
The three quoting rules that cover almost everything
| Form | $var |
backtick / $( ) |
backslash | Use for |
|---|---|---|---|---|
'single' |
literal | literal | literal | Anything you want sent verbatim |
"double" |
expands | expands | escape char | Only when you want expansion |
<<'EOF' |
literal | literal | literal | Multi-line payloads |
<<EOF |
expands | expands | escape char | Templates, deliberately |
The quoted heredoc delimiter is the one people forget. <<EOF will happily
expand a $PATH or execute a backticked command inside what you thought was
inert text:
cat <<'EOF' > /srv/app/notes.md # safe: nothing inside is interpreted
Costs $100 and uses `backticks` and ${braces}.
EOFFor a heredoc inside an ssh, quote both the outer command and the delimiter:
ssh host-a 'bash -s' <<'EOF'
set -euo pipefail
printf '%s\n' 'literal $HOME stays literal'
EOFThe payload goes over stdin rather than through the remote shell's argument parser, so it is never reparsed. This is the single most useful trick here.
When it truly must be inline: base64
Binary-safe, quote-free, survives any number of layers:
b64=$(base64 -w0 < payload.txt) # -w0: no line wrapping (GNU)
ssh host-a "printf %s '$b64' | base64 -d > /srv/app/payload.txt"Only base64 characters cross the boundary, so no shell, on either side, can
find anything to interpret. On macOS/BSD base64 wraps by default — use
base64 | tr -d '\n'.
Never build source code with a shell one-liner
sed -i, perl -pe, node -e "…fs.writeFileSync…" and python -c on a file
that itself contains quotes, backticks or $ fail in two ways, both quiet:
- Match nothing, report success. The pattern got mangled by a layer of
quoting, matched zero lines, and
sedexited0. Nothing changed and nothing said so. - Match too much. A greedy pattern ate a function body. The file still parses. The tests still pass, because the eaten part was not covered. This one ships.
Use a real editor/writer for source files. If a scripted edit is genuinely unavoidable, it is not finished until you have run both of these:
node --check file.js # or: bash -n f.sh, python -m py_compile f.py,
# jq . f.json, yamllint f.yml
sed -n '40,60p' file.js # and actually read the region you changed"The script printed true" is not evidence. See
skills/verifying-a-claim — an edit tool that cannot report zero matches is
a check that cannot fail.
Layers you may not have counted
- Your agent harness. A tool call encodes the command as JSON;
\nand\"are consumed there before the shell ever sees them. bash -lcon the far side of a tool, adding a shell you did not write.make, which eats$— write$$for a shell variable in a recipe.- CI YAML, which parses the string before the shell does. A
:or a leading*in an unquoted YAML scalar changes the meaning entirely. docker exec sh -c '…'andkubectl exec -- sh -c '…', which are two more shells stacked on yours.
Count the layers out loud before writing the command. If the answer is more than two, switch to a file.
PowerShell is a different language, not a dialect
If the outer shell is PowerShell, the bash instincts are wrong:
- The escape character is a backtick, not a backslash.
"double"interpolates$var;'single'does not. Same idea, different parser.- A single-quoted here-string is
@' … '@, and the closing'@must be at column zero. Indent it and you get a parse error. - Arguments beginning with
-or@may be parsed as operators before reaching the program;--%stops PowerShell parsing the rest of the line. - Redirection and
Set-Contentwrite UTF-16 or add a BOM depending on version, and add CRLF. That is how a.shfile stops working — see skills/line-endings-and-encodings.
A quick self-test
If you can answer these without running anything, the command is probably right:
[ ] How many parsers see this string before the bytes are used?
[ ] Which of them expands $ and backticks?
[ ] If the payload contained a single quote, would it still work?
[ ] If the pattern matched zero times, would I find out?See also skills/atomic-file-writes and machinery/what-does-not-render.