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
This commit is contained in:
@@ -0,0 +1,2 @@
|
||||
/gocache/
|
||||
*.o
|
||||
@@ -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.
|
||||
@@ -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: `<length>:<bytes>,`. A frame is a mode field, zero or
|
||||
more argument fields, and an empty terminator:
|
||||
|
||||
```
|
||||
<mode> <arg>* 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 <host> <command> [arg ...]
|
||||
sic --sh <host> '<shell string>'
|
||||
sic <host> -- <command> [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).
|
||||
+67
@@ -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/<name>.{log,pid,rc}` on the target host.
|
||||
|
||||
## Launch
|
||||
|
||||
```
|
||||
sic --sh <host> 'mkdir -p ~/.sic-jobs && setsid sh -c '\''<command>; echo $? >~/.sic-jobs/<name>.rc'\'' >~/.sic-jobs/<name>.log 2>&1 </dev/null & echo launched <name> pid=$!'
|
||||
```
|
||||
|
||||
`<command>` is a shell snippet (pipes and redirects allowed). `setsid` detaches
|
||||
it from the ssh session, so it keeps running after `sic` returns. `<name>` 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 </dev/null & echo launched build pid=$!'
|
||||
```
|
||||
|
||||
## Follow for a bounded time
|
||||
|
||||
`timeout` caps how long the follow runs, then returns. Use this to watch
|
||||
progress without blocking:
|
||||
|
||||
```
|
||||
sic --sh <host> 'timeout 20 tail -f ~/.sic-jobs/<name>.log'
|
||||
```
|
||||
|
||||
## Snapshot the tail
|
||||
|
||||
No shell needed:
|
||||
|
||||
```
|
||||
sic <host> tail -n 60 ~/.sic-jobs/<name>.log
|
||||
```
|
||||
|
||||
## Status and exit code
|
||||
|
||||
```
|
||||
sic --sh <host> 'p=$(cat ~/.sic-jobs/<name>.pid 2>/dev/null); kill -0 "$p" 2>/dev/null && echo running || echo "done rc=$(cat ~/.sic-jobs/<name>.rc 2>/dev/null)"'
|
||||
```
|
||||
|
||||
The `.rc` file holds the command's exit code once it finishes.
|
||||
|
||||
## Stop a job
|
||||
|
||||
```
|
||||
sic --sh <host> 'kill $(cat ~/.sic-jobs/<name>.pid) 2>/dev/null; echo stopped'
|
||||
```
|
||||
|
||||
## Notes
|
||||
|
||||
- One `<name>` per job. Reusing a name overwrites its log.
|
||||
- `setsid ... </dev/null` is required: without it the job is killed when the ssh
|
||||
session closes.
|
||||
- Poll with the bounded `tail -f` or a snapshot; do not hold a `sic` call open
|
||||
for the whole job.
|
||||
@@ -0,0 +1,47 @@
|
||||
---
|
||||
name: sic
|
||||
description: Run a command on a remote host without shell quoting bugs. Lighter than a per-host MCP server.
|
||||
---
|
||||
|
||||
# sic — remote command execution
|
||||
|
||||
`sic <host> <command> [arg ...]` runs the command on `<host>`. 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 <host> <command> [arg ...] # exec mode (default)
|
||||
sic --sh <host> '<shell string>' # sh -c on the host
|
||||
sic <host> -- <command> [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 <host>:/tmp/sicd # Python reference; or bin/sicd (Go)
|
||||
sic --sh <host> 'sudo install -m755 /tmp/sicd /usr/local/bin/sicd'
|
||||
sic <host> id # verify
|
||||
```
|
||||
|
||||
For long-running commands, use the background skill (`SKILL-bg.md`).
|
||||
+107
@@ -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] <host> <command> [<arg> ...]
|
||||
// sic [--sh] <host> -- <command> [<arg> ...] (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] <host> <command> [<arg> ...]")
|
||||
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
|
||||
}
|
||||
@@ -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):
|
||||
//
|
||||
// <mode-netstring> <arg-netstring>* <empty-netstring>
|
||||
//
|
||||
// Usage:
|
||||
//
|
||||
// ssh <host> 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)
|
||||
}
|
||||
}
|
||||
Executable
+178
@@ -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 <host>
|
||||
"""
|
||||
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.
|
||||
""")
|
||||
+110
@@ -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): `<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.
|
||||
Executable
+69
@@ -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 <host> sicd < <netstring-frames>
|
||||
"""
|
||||
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())
|
||||
@@ -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.
|
||||
Reference in New Issue
Block a user