Client (sic) frames argv as netstrings and pipes it over ssh to a daemon (sicd) that execvp's it, so no shell re-parses the arguments. exec mode by default; opt-in sh mode for pipes/redirects/globs. Go client + daemon, Python reference daemon, design doc, quoting-hell demo, and foreground/background agent skills. Renamed from the climcp prototype. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01EWpfhDgYNA21tETDP9ueBE
4.4 KiB
sic — design
A daemon (sicd) receives an argument vector over a stream and runs it, so the
caller does not pass a quoted string through a shell. A client (sic) frames
the argv and pipes it over ssh.
Problem
Composing shell → ssh → remote shell → program re-parses arguments at each
layer. ssh host touch 'a b' creates two files: ssh concatenates its argv with
spaces and hands the string to the remote login shell, which re-splits it.
Escaping tricks (base64, heredocs, printf %q, ${var@Q}) all exist to
survive that round-trip.
Approach: transmit argv, then execvp
A shell flattens argv into one string and re-parses it. sicd skips that: it
receives the argument vector as discrete framed fields and calls execvp(argv)
(subprocess.run(argv, shell=False)). No shell touches the arguments, so quotes,
$, backticks, newlines, and spaces are ordinary bytes in a field.
This is the mechanism behind find -print0 / xargs -0 / git -z: frame argv
unambiguously instead of serialising it as a shell string.
Wire format: netstrings
Each field is a netstring (djb): <length>:<bytes>,.
4:echo, bytes "echo", length 4
11:hello world, bytes "hello world", length 11
0:, empty netstring, used as a terminator
The length prefix means there is no delimiter to collide with and nothing to
escape. Netstrings are chosen over ASCII US/RS separators (0x1F/0x1E) because
those bytes can appear inside arbitrary payloads; netstrings are binary-safe and
need no escaping. The cost is a few header bytes per field.
Frame grammar
<mode-netstring> <arg-netstring>* <empty-netstring>
mode-netstring— first field,4:exec,or2:sh,.arg-netstring*— zero or more argument fields. Inexecmode these are the elements ofargv(first = program). Inshmode there is one field: the whole shell string.empty-netstring—0:,terminates the frame. The daemon runs it and resets. An empty terminator (not a separator) lets multiple frames pipeline in one connection.
Examples
| Intent | Wire bytes |
|---|---|
argv = ["echo", "hello world"] |
4:exec,4:echo,11:hello world,0:, |
argv = ["touch", "a b"] |
4:exec,5:touch,3:a b,0:, |
shell echo hi | wc -c |
2:sh,15:echo hi | wc -c,0:, |
| two frames | 4:exec,2:id,0:,4:exec,7:uptime,0:, |
Exec modes
| mode | selector | behaviour | when |
|---|---|---|---|
exec (default) |
4:exec, |
execvp(argv), shell=False. No pipes/redirects/globs. |
Default path. |
sh |
2:sh, |
single field to sh -c. Pipes, redirects, globs work; shell parsing is re-accepted for that one string. |
Only when a pipeline/redirect/glob is required. |
sh mode is a per-frame opt-in, so shell parsing is never entered by accident.
Transport and security
sicd runs what it is sent. That is remote code execution by design, so:
- Do not listen on an open TCP or message-bus port. An unauthenticated stream-to-exec endpoint is a backdoor.
- Run
sicdover ssh: as the remote command on ssh stdin, or pinned to a key withcommand="sicd"inauthorized_keys. This reuses ssh's authentication, encryption, and host identity;sicdadds only the framing. - For same-host fan-out, a Unix domain socket with
0600permissions is an option. Still not a TCP port.
The client invokes ssh host sicd with the frame on stdin, so the outer shell
sees a plain command with no special characters.
Response framing
sicd inherits the child's stdout, stderr, and exit status. Structured results
(for example a netstring triple <exit-code> <stdout> <stderr>) are not
implemented; add them the same way if needed.
Prior art
| Technique | Used for |
|---|---|
find -print0 / xargs -0 / git -z |
Framing argv without a shell re-split. |
| netstrings (djb) | Length-prefixed, escaping-free, binary-safe framing. |
execve / subprocess(shell=False) |
Bypassing the shell for arguments. |
the ssh argv gotcha |
The case this removes (ssh host touch 'a b' → two files). |
Implementation
cmd/sic— Go client.cmd/sicd— Go daemon (single static binary).gateway/sicd— Python reference daemon (~40 lines).
Reading all of stdin is sufficient for the ssh-per-call model. A long-lived
socket daemon needs incremental parsing; read_netstring already returns the
unconsumed remainder for that.