Files
sic/docs/design.md
T
marfrit 35a8a07152 sic — quoting-proof remote command execution
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
2026-07-19 12:10:51 +02:00

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, or 2:sh,.
  • arg-netstring* — zero or more argument fields. In exec mode these are the elements of argv (first = program). In sh mode there is one field: the whole shell string.
  • empty-netstring0:, 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 sicd over ssh: as the remote command on ssh stdin, or pinned to a key with command="sicd" in authorized_keys. This reuses ssh's authentication, encryption, and host identity; sicd adds only the framing.
  • For same-host fan-out, a Unix domain socket with 0600 permissions 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.