sic: remote commands without the SSH quoting hell

If you drive a fleet of machines over SSH — or you are an AI agent that does — you have hit this bug, probably today:

ssh host touch 'my file.txt'

That does not create one file called my file.txt. It creates two: my and file.txt. The quotes protected the argument on your local shell, then SSH discarded them and handed touch my file.txt to a remote shell, which split it again on the space.

SSH does not take an argument vector. It takes a string and runs it through sh -c on the far end. Every quote, $, backtick, glob and space you send is re-parsed by a shell whose quoting you did not write. One hop is annoying. Two hops —

ssh gateway 'ssh inner "grep $pattern *.log"'

— is a combinatorial mess of backslashes, and the moment a value contains a space, an apostrophe, or a curly quote pasted from somewhere, it breaks in a way that is tedious to debug and easy to get subtly wrong. Delete the wrong file this way once and you stop finding it funny.

The usual workarounds, and why they only move the pain

  • Escape harder. printf %q survives one hop. Two hops means escaping the escaping. It does not compose.
  • base64. Encode the command, decode on the far side, pipe to sh. It works, but every command is now an opaque blob and you are still running it through a shell.
  • Here-docs over stdin. Ties up stdin and still shell-parses the result.

All of these treat the symptom. The disease is that the argument vector — the thing the kernel actually wants, a list of byte strings — gets flattened into one string and re-split by a shell at every hop.

sic: keep the argv a vector

sic never flattens the argument vector. The client frames each argument as a netstring (<length>:<bytes>,), pipes the frame over SSH to a small daemon, sicd, which reads the frame and calls execvp() directly. No shell re-parses the arguments on the far end. What you passed is exactly what runs.

sic host touch 'my file.txt'      # one file, correctly named
sic host echo '$HOME'             # prints the literal string $HOME
sic host python3 -c 'print(1+1)'  # no quoting gymnastics

Because netstrings are length-prefixed, there is nothing to escape: a byte is a byte. Spaces, quotes, dollar signs, newlines and UTF-8 all survive untouched, however many hops deep you go.

When you actually want a shell

Sometimes the pipe, the glob or the redirect is the point. sic does not take that away — it just makes it explicit:

sic --sh host 'grep foo *.log | wc -l'

With --sh, sic hands the string to sh -c on purpose. Without it you get execvp and no surprises. The default is the safe one.

Why it exists

sic was built for AI agents driving machine fleets, where the quoting hell is constant and a wrong split can mean the wrong file removed. It is deliberately smaller than standing up a per-host MCP server: one static Go binary as the daemon, pinned to an SSH key with command="sicd", plus a client that is just argv-to-netstrings. If you already have SSH to a box, you already have the transport.

Security model: sicd execs whatever it is handed, so it must only ever be reachable over an authenticated SSH channel — never exposed on a TCP port — and is best pinned to a dedicated key.

Get it

git clone https://github.com/marfrit/sic
# or: git clone https://git.reauktion.de/marfrit/sic
cd sic
go build -o bin/sic  ./cmd/sic
go build -o bin/sicd ./cmd/sicd

Copy sicd to each target host (a static CGO_ENABLED=0 build runs anywhere, musl included), pin it to a key, and use sic <host> <command> [args…] wherever you used to write ssh <host> '…'. MIT licensed.

Repositories: github.com/marfrit/sic and git.reauktion.de/marfrit/sic.

One shameless ask: if sic just saved you an afternoon of backslash archaeology, star the repo on GitHub. It never phones home, so a star is the only way I find out anyone actually runs it.

Hinterlasse einen Kommentar.

Deine E-Mail-Adresse wird nicht veröffentlicht. Erforderliche Felder sind mit * markiert

Diese Website verwendet Akismet, um Spam zu reduzieren. Erfahre, wie deine Kommentardaten verarbeitet werden.