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:
marfrit
2026-07-19 12:10:51 +02:00
commit 35a8a07152
14 changed files with 883 additions and 0 deletions
+2
View File
@@ -0,0 +1,2 @@
/gocache/
*.o
+21
View File
@@ -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.
+92
View File
@@ -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
View File
@@ -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.
+47
View File
@@ -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`).
Executable
BIN
View File
Binary file not shown.
Executable
BIN
View File
Binary file not shown.
+107
View File
@@ -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
}
+125
View File
@@ -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)
}
}
+178
View File
@@ -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
View File
@@ -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
View File
@@ -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())
+3
View File
@@ -0,0 +1,3 @@
module github.com/marfrit/sic
go 1.26
+62
View File
@@ -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.