35a8a07152
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
111 lines
4.4 KiB
Markdown
111 lines
4.4 KiB
Markdown
# 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-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 `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.
|