Files
sic/demos/quoting-hell.py
T
Markus Fritsche 99568666d3 Drop quotesu/ setup doc; make the demo take any sicd host as an argument
The quotesu setup notes exposed internal host details and aren't useful to
readers, so remove the directory. The quoting-hell demo now requires the
target host as an argument (any host running sicd) instead of defaulting to
an internal name, and its labels say sic rather than the old rs. Example
hostnames in the README/SKILL/main.go are now the neutral placeholder host1.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EWpfhDgYNA21tETDP9ueBE
2026-07-19 16:03:42 +02:00

181 lines
5.8 KiB
Python
Executable File

#!/usr/bin/env python3
"""Demonstrate quoting hell scenarios with plain SSH and how sicd fixes them.
Each scenario is run both ways:
A) Plain SSH — shows the breakage
B) sic — shows the fix
Point it at any host that runs sicd (reachable over SSH with your key):
python3 quoting-hell.py <host>
"""
import subprocess
import sys
import os
if len(sys.argv) < 2:
sys.exit("usage: python3 quoting-hell.py <host> # any host running sicd")
HOST = sys.argv[1]
SIC = os.path.expanduser("~/bin/sic")
SSH = ["/usr/bin/ssh", "-o", "StrictHostKeyChecking=no", "-o", "ConnectTimeout=5"]
def ssh(cmd_str):
"""Run a command string via plain SSH."""
return subprocess.run(
SSH + [HOST, cmd_str],
capture_output=True, text=True, timeout=10
)
def sic(*args):
"""Run argv via sic (which frames netstrings for sicd)."""
return subprocess.run(
[SIC, HOST] + list(args),
capture_output=True, text=True, timeout=10
)
def section(title):
print(f"\n{'='*60}")
print(f" {title}")
print(f"{'='*60}")
def sub(test_name):
print(f"\n{test_name}")
def result(label, r):
stdout = r.stdout.strip() if r.stdout else ""
stderr = r.stderr.strip() if r.stderr else ""
status = "OK" if r.returncode == 0 else f"FAIL (rc={r.returncode})"
print(f" {label}: {status}")
for line in stdout.split("\n"):
if line:
print(f"{line}")
for line in stderr.split("\n"):
if line:
print(f" ! {line}")
# ── 1. Space in filename ──────────────────────────────────────────
section("1. Space in filename — the classic")
sub(f"SSH: ssh {HOST} touch 'my file.txt'")
# SSH concatenates args with spaces, remote shell re-splits
r = ssh("touch 'my file.txt'")
r2 = ssh("ls my file.txt 2>&1; echo ---; ls 'my file.txt' 2>&1")
result("SSH", r2)
print("'touch' sees TWO args: 'my' and 'file.txt' — two files created")
# Clean up
ssh("rm -f my file.txt 'my file.txt'")
sub(f"sic: sic {HOST} touch 'my file.txt'")
r = sic("touch", "my file.txt")
r2 = sic("ls", "-la", "my file.txt")
result("sic", r2)
print("'touch' sees ONE arg: 'my file.txt' — one file created")
sic("rm", "-f", "my file.txt")
# ── 2. Dollar sign / variable expansion ───────────────────────────
section("2. Dollar sign — unintended variable expansion")
sub(f"SSH: ssh {HOST} 'echo \$HOME'")
r = ssh("echo $HOME")
result("SSH", r)
print(" → Remote shell expands $HOME — leaks local values")
sub(f"SSH escaped: ssh {HOST} 'echo \\$HOME'")
r = ssh("echo \\$HOME")
result("SSH escaped", r)
sub(f"sic: sic {HOST} echo '\$HOME'")
r = sic("echo", "$HOME")
result("sic", r)
print(" → No shell involved — literal $HOME printed")
# ── 3. Backticks / command substitution ──────────────────────────
section("3. Backticks — unintended command execution")
sub(f"SSH: ssh {HOST} 'echo `hostname`'")
r = ssh("echo `hostname`")
result("SSH", r)
print(" → Backticks execute on remote — prints the remote hostname")
sub(f"sic: sic {HOST} echo '`hostname`'")
r = sic("echo", "`hostname`")
result("sic", r)
print(" → Literal backticks — no execution")
# ── 4. Nested quotes ─────────────────────────────────────────────
section("4. Nested quotes — escaping hell")
sub(f'SSH: ssh {HOST} "echo \\"hello world\\""')
r = ssh('echo "hello world"')
result("SSH", r)
sub(f'sic: sic {HOST} echo "hello world"')
r = sic("echo", "hello world")
result("sic", r)
print(" → No nested quoting needed — just pass the string")
# ── 5. Newlines in arguments ──────────────────────────────────────
section("5. Newlines in arguments")
sub(f"SSH: ssh {HOST} 'echo \"line1\\nline2\"'")
r = ssh('echo "line1\nline2"')
result("SSH", r)
print(" → Newline in SSH command string is fragile")
sub(f"sic: sic {HOST} echo 'line1\\nline2'")
r = sic("echo", "line1\nline2")
result("sic", r)
print(" → Newline is just a byte in a netstring field")
# ── 6. Complex python script via ssh ─────────────────────────────
section("6. Python one-liner — where quoting really hurts")
script = """python3 -c "import sys; [print(i, repr(a)) for i,a in enumerate(sys.argv)]" -- "first arg" "second arg" "arg with $HOME" "arg with \`ls\`" "nested \\"quotes\\"" """
sub("SSH: the monstrosity")
r = ssh(script)
result("SSH", r)
print(" → Try to get all those quoting levels right...")
sub("sic: sic <host> python3 -c ... with clean args")
r = sic(
"python3", "-c",
"import sys; [print(i, repr(a)) for i,a in enumerate(sys.argv)]",
"demo", # sys.argv[0]
"first arg",
"second arg",
"arg with $HOME",
"arg with `ls`",
'nested "quotes"'
)
result("sic", r)
print(" → Each argument is a separate field — no escaping needed")
# ── Summary ──────────────────────────────────────────────────────
section("SUMMARY")
print("""
The pattern in every case:
SSH → flattens argv to one string, shell re-parses → QUOTING HELL
sic → sends argv as framed fields, execvp directly → NO ESCAPING
Netstrings (length:bytes,) are the key: the length tells the parser
exactly where each field ends, so NO delimiter needs escaping.
This is the same insight as git -z / find -print0 / xargs -0,
but generalized to arbitrary remote command execution.
""")