commit 35a8a0715292ad9cbf3737efd3c0d55a85bd4c6a Author: marfrit Date: Sun Jul 19 12:10:51 2026 +0200 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 Claude-Session: https://claude.ai/code/session_01EWpfhDgYNA21tETDP9ueBE diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..43e9bc4 --- /dev/null +++ b/.gitignore @@ -0,0 +1,2 @@ +/gocache/ +*.o diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..cb4becc --- /dev/null +++ b/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2026 marfrit + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/README.md b/README.md new file mode 100644 index 0000000..ff97186 --- /dev/null +++ b/README.md @@ -0,0 +1,92 @@ +# sic + +Run a command on a remote host without a shell re-parsing its arguments. + +`ssh host touch 'a b'` creates two files: `ssh` joins its arguments with spaces +and hands the result to the remote login shell, which splits it again. `sic host +touch 'a b'` creates one file named `a b`. `sic` sends the argument vector to a +daemon on the host as length-prefixed fields, and the daemon calls `execvp` on +it. No shell parses the arguments. + +## Components + +- **`sic`** — client. Takes argv on the command line, frames it as netstrings, + pipes the frame over `ssh` to `sicd`. +- **`sicd`** — daemon on the target host. Reads netstrings from stdin and runs + the command. + +Both are single static Go binaries (`cmd/sic`, `cmd/sicd`). A Python reference +implementation of the daemon is in `gateway/sicd`. + +## Wire format + +Each field is a netstring: `:,`. A frame is a mode field, zero or +more argument fields, and an empty terminator: + +``` + * 0:, +``` + +`argv = ["touch", "a b"]` is `4:exec,5:touch,3:a b,0:,`. The length prefix means +no byte needs escaping and no delimiter can collide with the payload. + +| mode | selector | behaviour | +|------|----------|-----------| +| `exec` (default) | `4:exec,` | `execvp(argv)`, no shell. Arguments are passed through unchanged. | +| `sh` | `2:sh,` | one field passed to `sh -c`. Pipes, redirects, and globs work. | + +## Usage + +``` +sic [arg ...] +sic --sh '' +sic -- [arg ...] # explicit separator +``` + +``` +sic quotesu echo hello world +sic quotesu touch 'a b' # one file named "a b" +sic quotesu echo '$HOME' # literal, no expansion +sic --sh quotesu 'echo hi | wc -c' +``` + +## Build + +``` +go build -o bin/sic ./cmd/sic +go build -o bin/sicd ./cmd/sicd +``` + +## Install + +Client: + +``` +cp bin/sic ~/bin/sic +``` + +Daemon on each target host. `sicd` runs what it is sent, so it must only be +reachable over an authenticated transport. Pin it to a dedicated ssh key so the +target can run nothing else with that key: + +``` +scp bin/sicd host:/tmp/sicd +ssh host 'sudo install -m755 /tmp/sicd /usr/local/bin/sicd' +# ~/.ssh/authorized_keys on the target: +# command="sicd",no-port-forwarding,no-pty ssh-ed25519 AAAA... +``` + +Do not listen on a TCP or message-bus port. + +## Test + +``` +printf '4:exec,2:id,0:,' | ssh host sicd +sic host echo hello world +``` + +## See also + +- `docs/design.md` — wire format, exec modes, transport and security, prior art. +- `demos/quoting-hell.py` — six cases run both ways, plain `ssh` versus `sic`. +- `SKILL.md`, `SKILL-bg.md` — agent skill definitions (foreground and background). diff --git a/SKILL-bg.md b/SKILL-bg.md new file mode 100644 index 0000000..e43f2c3 --- /dev/null +++ b/SKILL-bg.md @@ -0,0 +1,67 @@ +--- +name: sic-bg +description: Run a long-running command on a remote host in the background via sic, capture its output to a log, and poll or follow it. +--- + +# sic-bg — background remote commands + +For commands that outlive a single `sic` call (builds, downloads, soak tests). +The command is launched detached on the host, its output goes to a log file, and +you poll or follow the log with later `sic` calls. Built on `sic --sh`; no extra +binary. `sic --sh` delivers the launch string to the host's `sh -c` unchanged, +so there is only one shell to reason about. + +Jobs live in `~/.sic-jobs/.{log,pid,rc}` on the target host. + +## Launch + +``` +sic --sh 'mkdir -p ~/.sic-jobs && setsid sh -c '\''; echo $? >~/.sic-jobs/.rc'\'' >~/.sic-jobs/.log 2>&1 pid=$!' +``` + +`` is a shell snippet (pipes and redirects allowed). `setsid` detaches +it from the ssh session, so it keeps running after `sic` returns. `` is any +label. Example: + +``` +sic --sh boltz 'mkdir -p ~/.sic-jobs && setsid sh -c '\''make -j8 2>&1; echo $? >~/.sic-jobs/build.rc'\'' >~/.sic-jobs/build.log 2>&1 'timeout 20 tail -f ~/.sic-jobs/.log' +``` + +## Snapshot the tail + +No shell needed: + +``` +sic tail -n 60 ~/.sic-jobs/.log +``` + +## Status and exit code + +``` +sic --sh 'p=$(cat ~/.sic-jobs/.pid 2>/dev/null); kill -0 "$p" 2>/dev/null && echo running || echo "done rc=$(cat ~/.sic-jobs/.rc 2>/dev/null)"' +``` + +The `.rc` file holds the command's exit code once it finishes. + +## Stop a job + +``` +sic --sh 'kill $(cat ~/.sic-jobs/.pid) 2>/dev/null; echo stopped' +``` + +## Notes + +- One `` per job. Reusing a name overwrites its log. +- `setsid ... [arg ...]` runs the command on ``. Arguments go to +`sicd` on the host as netstrings and are passed to `execvp` directly, so no +shell re-parses them. `ssh host touch 'a b'` makes two files; `sic host touch +'a b'` makes one file named `a b`. + +## Usage + +``` +sic [arg ...] # exec mode (default) +sic --sh '' # sh -c on the host +sic -- [arg ...] # explicit separator if host/command starts with -- +``` + +Pass each argument as a separate argument to `sic`. Do not construct netstrings; +`sic` frames them. + +``` +sic quotesu echo hello world +sic quotesu touch 'my file.txt' +sic quotesu echo '$HOME' # literal, no expansion +sic quotesu python3 -c 'import sys; print(sys.argv)' a 'b c' +sic --sh quotesu 'grep foo *.log | wc -l' +``` + +## exec vs --sh + +- Default (exec): a program with arguments. Quoting cannot break, but there are + no pipes, redirects, or globs. +- `--sh`: use only when you need a pipe, redirect, or glob. The single string is + parsed by `sh -c` on the host. + +## Deploy sicd to a new host + +``` +scp gateway/sicd :/tmp/sicd # Python reference; or bin/sicd (Go) +sic --sh 'sudo install -m755 /tmp/sicd /usr/local/bin/sicd' +sic id # verify +``` + +For long-running commands, use the background skill (`SKILL-bg.md`). diff --git a/bin/sic b/bin/sic new file mode 100755 index 0000000..148bea2 Binary files /dev/null and b/bin/sic differ diff --git a/bin/sicd b/bin/sicd new file mode 100755 index 0000000..3def10d Binary files /dev/null and b/bin/sicd differ diff --git a/cmd/sic/main.go b/cmd/sic/main.go new file mode 100644 index 0000000..535cc98 --- /dev/null +++ b/cmd/sic/main.go @@ -0,0 +1,107 @@ +// sic — frame argv as netstrings and pipe to sicd on a remote host. +// +// Each argument is framed as a netstring and written to sicd's stdin over ssh. +// The netstring framing is done here so callers pass argv on the command line +// and never construct frames by hand. +// +// Usage: +// +// sic [--sh] [ ...] +// sic [--sh] -- [ ...] (explicit separator) +// +// Examples: +// +// sic quotesu echo hello world +// sic quotesu touch 'a b' # ONE file, not two +// sic --sh quotesu 'echo hi | wc -c' +package main + +import ( + "fmt" + "os" + "os/exec" + "strconv" + "strings" +) + +// netstring encodes a UTF-8 string as a netstring (djb format). +func netstring(s string) string { + encoded := []byte(s) + return strconv.Itoa(len(encoded)) + ":" + string(encoded) + "," +} + +// frameArgv builds a single netstring command frame from args. +func frameArgv(args []string, mode string) []byte { + var sb strings.Builder + sb.WriteString(netstring(mode)) + switch mode { + case "exec": + for _, a := range args { + sb.WriteString(netstring(a)) + } + case "sh": + sb.WriteString(netstring(strings.Join(args, " "))) + } + sb.WriteString("0:,") + return []byte(sb.String()) +} + +func main() { + argv := os.Args[1:] + + // Parse flags + mode := "exec" + for len(argv) > 0 && strings.HasPrefix(argv[0], "--") { + switch argv[0] { + case "--sh": + mode = "sh" + case "--": + argv = argv[1:] + goto parseArgs + } + argv = argv[1:] + } + +parseArgs: + if len(argv) == 0 { + fmt.Fprintln(os.Stderr, "Usage: sic [--sh] [ ...]") + os.Exit(1) + } + + // Locate host and command + var host string + var command []string + + if idx := indexOf(argv, "--"); idx >= 0 { + host = argv[0] + command = argv[idx+1:] + } else if len(argv) >= 2 { + host = argv[0] + command = argv[1:] + } else { + fmt.Fprintln(os.Stderr, "sic: missing command arguments") + os.Exit(1) + } + + frameData := frameArgv(command, mode) + sshCmd := exec.Command("ssh", "-o", "StrictHostKeyChecking=no", host, "sicd") + sshCmd.Stdin = strings.NewReader(string(frameData)) + sshCmd.Stdout = os.Stdout + sshCmd.Stderr = os.Stderr + if err := sshCmd.Run(); err != nil { + if exitErr, ok := err.(*exec.ExitError); ok { + os.Exit(exitErr.ExitCode()) + } + fmt.Fprintf(os.Stderr, "sic: %v\n", err) + os.Exit(1) + } +} + +func indexOf(slice []string, s string) int { + for i, v := range slice { + if v == s { + return i + } + } + return -1 +} diff --git a/cmd/sicd/main.go b/cmd/sicd/main.go new file mode 100644 index 0000000..01e3977 --- /dev/null +++ b/cmd/sicd/main.go @@ -0,0 +1,125 @@ +// sicd — receive netstring-framed argv over stdin, execvp it directly. +// +// Deployed on target hosts. Reads netstrings from stdin, interprets the first +// field as a mode selector ("exec" or "sh"), and runs the command accordingly. +// +// Wire format (netstrings, djb): +// +// * +// +// Usage: +// +// ssh sicd < /some/netstring/frames +package main + +import ( + "bytes" + "fmt" + "os" + "os/exec" + "strconv" +) + +// readNetstring reads one netstring from buf. Returns (payload, rest, ok). +func readNetstring(buf []byte) ([]byte, []byte, bool) { + i := bytes.IndexByte(buf, ':') + if i < 0 { + return nil, nil, false + } + n, err := strconv.Atoi(string(buf[:i])) + if err != nil || n < 0 { + return nil, nil, false + } + start := i + 1 + end := start + n + if len(buf) < end+1 || buf[end] != ',' { + return nil, nil, false + } + return buf[start:end], buf[end+1:], true +} + +// runFrame executes a single command frame. +func runFrame(mode string, args [][]byte) int { + switch mode { + case "exec": + argv := make([]string, len(args)) + for i, a := range args { + argv[i] = string(a) + } + if len(argv) == 0 { + fmt.Fprintln(os.Stderr, "sicd: exec mode with no args") + return 1 + } + cmd := exec.Command(argv[0], argv[1:]...) + cmd.Stdin = os.Stdin + cmd.Stdout = os.Stdout + cmd.Stderr = os.Stderr + if err := cmd.Run(); err != nil { + if exitErr, ok := err.(*exec.ExitError); ok { + return exitErr.ExitCode() + } + fmt.Fprintf(os.Stderr, "sicd: %v\n", err) + return 1 + } + return 0 + case "sh": + if len(args) == 0 { + fmt.Fprintln(os.Stderr, "sicd: sh mode with no args") + return 1 + } + shellCmd := string(args[0]) + cmd := exec.Command("sh", "-c", shellCmd) + cmd.Stdin = os.Stdin + cmd.Stdout = os.Stdout + cmd.Stderr = os.Stderr + if err := cmd.Run(); err != nil { + if exitErr, ok := err.(*exec.ExitError); ok { + return exitErr.ExitCode() + } + fmt.Fprintf(os.Stderr, "sicd: %v\n", err) + return 1 + } + return 0 + default: + fmt.Fprintf(os.Stderr, "sicd: unknown mode %q\n", mode) + return 1 + } +} + +func main() { + buf, err := os.ReadFile("/dev/stdin") + if err != nil { + fmt.Fprintf(os.Stderr, "sicd: read stdin: %v\n", err) + os.Exit(1) + } + + var mode string + var fields [][]byte + framesProcessed := 0 + + for { + field, rest, ok := readNetstring(buf) + if !ok { + break + } + buf = rest + + if mode == "" { + mode = string(field) + } else if len(field) == 0 { + // Empty netstring = end of frame + if mode != "" { + runFrame(mode, fields) + framesProcessed++ + } + mode = "" + fields = fields[:0] + } else { + fields = append(fields, field) + } + } + + if framesProcessed == 0 { + os.Exit(1) + } +} diff --git a/demos/quoting-hell.py b/demos/quoting-hell.py new file mode 100755 index 0000000..2aba74b --- /dev/null +++ b/demos/quoting-hell.py @@ -0,0 +1,178 @@ +#!/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 tool — shows the fix + +Usage: + python3 demo-quoting-hell.py +""" +import subprocess +import sys +import shlex +import tempfile +import os + +HOST = sys.argv[1] if len(sys.argv) > 1 else "quotesu" + +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 tool (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("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("rs: rs quotesu touch 'my file.txt'") +r = sic("touch", "my file.txt") +r2 = sic("ls", "-la", "my file.txt") +result("rs", 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("SSH: ssh host 'echo \$HOME'") +r = ssh("echo $HOME") +result("SSH", r) +print(" → Remote shell expands $HOME — leaks local values") + +sub("SSH escaped: ssh host 'echo \\$HOME'") +r = ssh("echo \\$HOME") +result("SSH escaped", r) + +sub("rs: rs quotesu echo '\$HOME'") +r = sic("echo", "$HOME") +result("rs", r) +print(" → No shell involved — literal $HOME printed") + + +# ── 3. Backticks / command substitution ────────────────────────── +section("3. Backticks — unintended command execution") + +sub("SSH: ssh host 'echo `hostname`'") +r = ssh("echo `hostname`") +result("SSH", r) +print(" → Backticks execute on remote — prints quotesu") + +sub("rs: rs quotesu echo '`hostname`'") +r = sic("echo", "`hostname`") +result("rs", r) +print(" → Literal backticks — no execution") + + +# ── 4. Nested quotes ───────────────────────────────────────────── +section("4. Nested quotes — escaping hell") + +sub('SSH: ssh host "echo \\"hello world\\""') +r = ssh('echo "hello world"') +result("SSH", r) + +sub('rs: rs quotesu echo "hello world"') +r = sic("echo", "hello world") +result("rs", r) +print(" → No nested quoting needed — just pass the string") + + +# ── 5. Newlines in arguments ────────────────────────────────────── +section("5. Newlines in arguments") + +sub("SSH: ssh host 'echo \"line1\\nline2\"'") +r = ssh('echo "line1\nline2"') +result("SSH", r) +print(" → Newline in SSH command string is fragile") + +sub("rs: rs quotesu echo 'line1\\nline2'") +r = sic("echo", "line1\nline2") +result("rs", 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("rs: rs quotesu 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("rs", 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 + rs → 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. +""") diff --git a/docs/design.md b/docs/design.md new file mode 100644 index 0000000..58094d8 --- /dev/null +++ b/docs/design.md @@ -0,0 +1,110 @@ +# 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): `:,`. + +``` +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` — 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 ` `) 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. diff --git a/gateway/sicd b/gateway/sicd new file mode 100755 index 0000000..4f0f2f9 --- /dev/null +++ b/gateway/sicd @@ -0,0 +1,69 @@ +#!/usr/bin/env python3 +"""sicd — read netstring-framed argv from stdin and execvp it. Python reference. + +Wire format and exec modes: docs/design.md. +Usage: ssh sicd < +""" +import sys +import subprocess + + +def read_netstring(buf): + """Read one netstring from buffer. Returns (payload, rest) or None.""" + i = buf.find(b":") + if i < 0: + return None + try: + n = int(buf[:i]) + except ValueError: + return None + start = i + 1 + end = start + n + if len(buf) < end + 1 or buf[end : end + 1] != b",": + return None + return buf[start:end], buf[end + 1 :] + + +def run_frame(mode, args): + """Execute a single command frame.""" + if mode == b"exec": + decoded = [a.decode("utf-8", errors="replace") for a in args] + result = subprocess.run(decoded) + return result.returncode + elif mode == b"sh": + shell_cmd = args[0].decode("utf-8", errors="replace") + result = subprocess.run(shell_cmd, shell=True) + return result.returncode + else: + print(f"sicd: unknown mode {mode!r}", file=sys.stderr) + return 1 + + +def main(): + buf = sys.stdin.buffer.read() + mode = None + fields = [] + frames_processed = 0 + + while True: + item = read_netstring(buf) + if item is None: + break + field, buf = item + + if mode is None: + mode = field # first field = mode selector + elif field == b"": + if mode is not None: + run_frame(mode, fields) + frames_processed += 1 + mode = None + fields = [] + else: + fields.append(field) + + return 0 if frames_processed > 0 else 1 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..9204378 --- /dev/null +++ b/go.mod @@ -0,0 +1,3 @@ +module github.com/marfrit/sic + +go 1.26 diff --git a/quotesu/setup.md b/quotesu/setup.md new file mode 100644 index 0000000..6093a23 --- /dev/null +++ b/quotesu/setup.md @@ -0,0 +1,62 @@ +# quotesu — test target for sicd + +Incus container on `dcw2` (192.168.88.114), created as the target host for +sicd development and quoting-hell demonstrations. + +## Host facts + +| Property | Value | +|---|---| +| Host machine | `dcw2` (192.168.88.114, aarch64, Debian 13) | +| Container IP | 192.168.88.196/24 | +| Network | `br0` (unmanaged bridge) | +| Storage | `usb` pool (btrfs, /mnt/usb-noether) | +| OS | Debian 13 "trixie" (arm64) | +| Incus version | 6.0.4 | +| SSH user | `mfritsche` (key auth + passwordless sudo) | + +## Setup steps + +```sh +# 1. Create container +incus init debian-13 quotesu --storage usb --network br0 +incus start quotesu + +# 2. Install SSH + user +incus exec quotesu -- apt-get update -qq +incus exec quotesu -- apt-get install -y -qq openssh-server sudo python3 +incus exec quotesu -- adduser --disabled-password --gecos "" mfritsche +incus file push ~/.ssh/authorized_keys quotesu/home/mfritsche/.ssh/authorized_keys +incus exec quotesu -- chown -R mfritsche:mfritsche ~mfritsche/.ssh +incus exec quotesu -- chmod 700 ~mfritsche/.ssh +incus exec quotesu -- chmod 600 ~mfritsche/.ssh/authorized_keys +incus exec quotesu -- adduser mfritsche sudo +echo "mfritsche ALL=(ALL) NOPASSWD:ALL" | \ + incus exec quotesu -- tee -a /etc/sudoers.d/mfritsche +incus exec quotesu -- systemctl restart ssh + +# 3. Deploy sicd +incus exec quotesu -- mkdir -p /opt/sicd +# Copy ../gateway/sicd to the container, then: +incus exec quotesu -- chmod 755 /usr/local/bin/sicd +incus exec quotesu -- ln -sf /usr/local/bin/sicd /usr/local/bin/sicd +``` + +## Verification + +```sh +# Test the gateway +printf '4:exec,2:id,0:,' | ssh quotesu sicd +# → uid=1000(mfritsche) gid=1000(... +``` + +## DNS note + +dcw2 had a broken `/etc/resolv.conf` (NetworkManager header, no nameservers). +Fixed by setting: +``` +nameserver 1.1.1.1 +nameserver 192.168.88.1 +``` +This was needed for `incus image copy images:debian/13 local:...` to resolve +the image server hostname.