Commit Graph

16 Commits

Author SHA1 Message Date
Claude (noether) b71ea84670 sic v2 client: rework frame builders to the argv-netstring wire (match daemon bf82a80)
v2Frame now takes explicit argv [][]byte (not a space-joined command string) and builds
content = argv-netstrings + 0:, + payload, so the client preserves the users argument boundaries
end-to-end. wrapHop space-splits the hop VERB (space-safe) + appends sicd; buildChain folds
verbs[0] outermost. Consolidated the three WP tests into frame_test.go with an independent
reference builder. Addressing layer (parseTarget/verbFor/resolveVerbs/parseHostsConfig) unchanged.
2026-07-23 11:40:05 +02:00
Claude (noether) bf82a80869 sicd: v2 wire preserves argv boundaries (content = argv netstrings + terminator + payload)
The v2 command field was a single string the daemon SPACE-SPLIT into argv — which silently
broke sic's founding guarantee (`ssh host touch 'a b'` makes TWO files; `sic host touch 'a b'`
must make ONE). The composition of the v2 client surfaced this: every argument with a space
would be re-split.

Fix (markus chose it): v2 content is now a run of djb netstrings (the argv), terminated by the
empty netstring 0:,, then the payload — mirroring the boundary-safe v1 exec encoding. Each
argument is length-framed, so spaces, NUL, 0xFF all ride untouched; readNetstringStream already
bounds each element + its length token, so a hostile content cannot OOM here. runV2 uses the new
parseV2Content; the test frame() helper space-splits test-side only (the wire never does).

Proof: TestV2ArgvBoundaryPreserved — argv {script, "a b"} makes the script see 1 arg "a b", not
2. All existing v2 + v1 dual-read tests stay green; go vet clean.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-23 11:37:54 +02:00
Claude (noether) fe799b98f9 sic v2 client WP7: parseHostsConfig — minimal hosts.toml subset parser (stdlib only) 2026-07-23 11:26:42 +02:00
Claude (noether) 234b057c41 sic v2 client WP6: resolveVerbs — map hops to verbs by nest depth, fail-loud on overflow 2026-07-23 11:23:53 +02:00
Claude (noether) ca54f83601 sic v2 client WP5: verbFor — runtime type + name -> the runtime verb (incus/docker/pct) 2026-07-23 11:22:01 +02:00
Claude (noether) 209ef8da83 sic v2 client WP4: parseTarget — split host/hop1/hop2 into host + ordered hops 2026-07-23 11:20:19 +02:00
Claude (noether) f72c77e2a7 sic v2 client WP3: buildChain — fold wrapHop into the nested onion
buildChain(verbs, cmd, payload): innermost v2Frame(cmd,payload), wrap outward so verbs[0] is the
outermost layer; zero verbs = a single v2 frame (bare host). @godev/deepseek.
2026-07-23 11:18:20 +02:00
Claude (noether) f412d84738 sic v2 client WP2: wrapHop — wrap an inner frame for one chain hop
wrapHop(verb, inner) = v2Frame(verb+" sicd", inner): the hop command is the runtime verb with
sicd appended (the inner-hop peeler), the inner frame is the payload. @godev/deepseek, one grind.
2026-07-23 11:12:33 +02:00
Claude (noether) d2b9eb4870 sic v2 client WP1: v2Frame — one v2 wire frame (leaf function)
First function-level package of Piece 2. v2Frame(command, payload) builds one v2 frame:
0x00 + be32(len(netstring)) + netstring(command 0x00 payload). Byte-exact tests (cat/hi, empty
payload nil==[]byte{}, binary payload riding untouched). @testdesigner proved satisfiability in
scratch + caught a be32 arithmetic error in the spec; @godev (deepseek default) implemented it —
the small-package decomposition carrying on the room grinder where the monolithic parser could not.
2026-07-23 11:07:34 +02:00
Claude (noether) 189bb31737 sicd: bound the v1 length token and frame size (reviewer 925 #1 HIGH, #2 MED)
#1 [HIGH, was a real DoS]: readNetstringStream read the length token via bufio.ReadString(':'),
which buffers the whole pre-':' run UNBOUNDED — the 1<<24 cap only bounded the body, applied
AFTER the token was parsed. A protocol-confined caller sending an endless digit run with no ':'
drove sicd to ~1 GB RSS (measured by @reviewer). Now the token is read byte-by-byte with an
8-digit hard cap (a valid <=1<<24 length is 8 digits), so it rejects after <=9 bytes regardless
of input size — memory is bounded by construction, before the value is trusted.

#2 [MED]: nothing bounded the NUMBER of netstrings per frame (2M tiny ones ~140 MB before exec).
runV1 now caps element count (4096) and total argv bytes (1<<20).

#3 [MED, documented]: an empty netstring 0:, is always the terminator, so a legit empty ""
argument collides with it (silent argv truncation + stdin bleed). Inherent to the v1 wire, not
introduced here — noted in a comment; a fix would need a wire change.

Regression tests: overlong length token (100k digits) -> exit 1; 9-digit length -> exit 1;
frame over the element cap -> exit 1. go test ./... ok, go vet clean. Found by @reviewer's
probe (reproduced 1 GB RSS), not a re-read — exactly why the outside look isn't skippable.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-23 10:50:50 +02:00
Claude (noether) 5d2ee5cdc9 sicd: implement v1/v2 dual-read (migration) — first-byte dispatch + v1-legacy parser
The v2-only daemon rejected v1 frames, so deploying it would brick every v1 client in the
field. main() now dispatches on the FIRST byte: 0x00 = v2 (path unchanged); an ASCII digit =
a new STREAMING v1-legacy parser (that digit is the first byte of the first netstring length,
pushed back via io.MultiReader before parsing); anything else = exit 1 with a diagnostic.

v1 wire (what the deployed cmd/sic sends, no preamble): netstring(mode "exec"|"sh") +
argv/command netstrings + the empty-netstring terminator 0:, ; everything after the terminator
is the child's stdin. exec runs argv directly (each netstring is ONE argv element, never
space-split); sh runs via sh -c. Reuses pumpStdin so EPIPE / reap / exit-status semantics
match the v2 path exactly.

Removed the sicd_test.go "old-v1-frame-without-preamble expects exit 1" subtest: it pinned the
v2-only REJECTION of v1, which dual-read deliberately reverses. v1 acceptance is now covered by
dualread_test.go (9 cases: exec/sh, argv boundaries, trailing stdin, exit status, and the four
reject paths).

Implemented by noether (rich agent) after the room hit a genuine wall — DeepSeek couldn't build
the parser in 10 attempts, Kimi-k2.6 soliloquy-stalled past the 300s per-call timeout. All green:
go test ./... ok, 27/27 Python reference.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-23 10:42:32 +02:00
Claude (noether) b28f9d3722 sicd: cover the read-error branch with a unit test; correct the skip rationale; drop vestigial rest
Acting on @reviewer's fourth-pass verdict (all three points accepted):

1. Refactored the pump into pumpStdin(dst, src, reap, errOut) int and covered it with unit
   tests using a mock erroring io.Reader — which IS the Python reference's mechanism
   (monkeypatching os.read to raise OSError), translated. Three cases: a genuine read error
   reaps + exits 1 + diagnoses; EPIPE is head-semantics (return child status, no diagnostic);
   clean EOF returns child status. The waitForChild added in the prior commit now has coverage.

2. Corrected the pty test's skip string. My earlier rationale was wrong twice, as @reviewer's
   raw syscall.Read probe showed: it is NOT a Go-vs-CPython poller difference — the KERNEL
   returns a clean EOF on the slave side in BOTH languages (EIO-after-hangup is master-side);
   and the Python reference never used a pty (it mocks os.read). The skip now states the
   corrected facts and points at TestPumpStdin*.

3. Dropped readNetstring's vestigial third return (rest was provably always empty).

Go ok (incl. 3 new unit tests), 27/27 Python, go vet clean.
2026-07-23 07:42:14 +02:00
Claude (noether) 3982706ebb sicd: reject malformed netstrings (security), and retire an unsatisfiable read-error test
FINDING 1 (security) — the Go daemon accepted netstrings the Python reference REJECTS and
then EXECUTED them, so a malformed frame that fails closed on a Python hop ran on a Go hop.
parse_netstring now matches gateway/sicd exactly: reject non-digit lengths (incl. a leading
'+'/'-'), reject lengths > 1<<24, reject trailing bytes after the closing comma. The three
cases now exit 1 with a diagnostic instead of exec'ing. Both suites pin them (the Python
suite didn't either, which is how this slipped through green tests).

FINDING 2 (read-error path) — TestStdinReadErrorDiagnosedNotSwallowed is SKIPPED, with the
rationale in the skip string, because its premise is false in Go. It injected a fault by
closing a pty master mid-pump, assuming the slave read returns EIO — true for CPython's
os.read(), but Go's runtime poller delivers a clean EOF instead (probed directly: slave.Read
-> io.EOF, io.Copy -> nil; a socketpair peer close is EOF too — SO_LINGER/RST is TCP-only).
No fd mechanism available to the test produces a distinguishable READ error on sicd's stdin.
That is not a gap in main.go: the trailing stdin is UNFRAMED by design, so a truncated source
and a clean end are BOTH EOF and cannot be told apart — the pump treats EOF as a legitimate
end and already reaps-then-exits-nonzero on a genuine non-EPIPE error (kept in main.go), which
just doesn't arise via pty/socket close in Go.

Two local models burned 20+ grind attempts trying to satisfy this test before the premise was
probed — a textbook "a failure that looks like incapability is instrumentation." Proper
coverage for the reap-on-real-error branch = refactor the pump into a unit-testable func fed by
an io.Reader that returns a non-EOF error; left as a follow-up.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-23 07:22:08 +02:00
Claude (noether) 940c3525ef sicd: port the wire protocol to the Go daemon (cmd/sicd)
The Go daemon is what actually runs on 30+ fleet hosts; until now it had none of the
wire-protocol work that went into the Python reference (gateway/sicd) — which is why sic
still ate a bare -- and silently wrote zero-byte files in production. This ports it: preamble
+ nested netstring peeling (one layer per hop), payload -> child stdin then remaining stdin
pumped through (the zero-byte-file fix), bare -- survives as opaque payload, malformed frame
-> exit 1, no deadlock above the 64 KiB pipe buffer, short writes looped, EPIPE handled,
signal-killed child -> exit 128+WTERMSIG. Built by the bullpen room (@testdesigner -> @godev
-> @reviewer), the first Go work the room could grade.

CHECKPOINT commit, green but NOT final: 28/28 Go, 24/24 Python, go vet clean. @reviewer's
third pass found four divergences from the reference that a fix round is about to address —
recorded here so the checkpoint is honest:
  1. (must-fix, security) the Go netstring parser accepts frames the reference REJECTS and
     executes them: trailing data after the comma, a +-prefixed length, and no 1<<24 cap. A
     malformed frame that fails closed on a Python hop currently runs on a Go hop. The Python
     suite does not pin these either — both suites need the malformed cases added.
  2. (must-fix) the read-error path os.Exit(1)s without reaping the child, leaving it detached
     and still writing to inherited stdout after sicd reports its status.
  3. (test) an EPIPE test's premise is wrong in Go (LookPath fails at Start, so the payload
     write never happens); the path is really covered by the signal test.
  4. (test) the pty EIO test is synchronized only by a 300 ms sleep.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-23 05:37:53 +02:00
Markus Fritsche 99568666d3 Drop quotesu/ setup doc; make the demo take any sicd host as an argument
The quotesu setup notes exposed internal host details and aren't useful to
readers, so remove the directory. The quoting-hell demo now requires the
target host as an argument (any host running sicd) instead of defaulting to
an internal name, and its labels say sic rather than the old rs. Example
hostnames in the README/SKILL/main.go are now the neutral placeholder host1.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EWpfhDgYNA21tETDP9ueBE
2026-07-19 16:03:42 +02:00
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