The README covered only single-host use; the differentiator from a shell helper
— reaching into containers several hops deep with the argv frame preserved at
every layer — was undocumented. Add a Nesting section (host/guest/svc syntax,
/etc/sic/hosts.toml nest stanzas, and the quoting-hell contrast with the ssh
equivalent), surface it in the intro line, and link docs/design-nested.md.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EWpfhDgYNA21tETDP9ueBE
The v2 client (0.2.0) forwards its own stdin and only closes the ssh stdin channel when that
stdin hits EOF. When sic is invoked with an inherited stdin that never closes (a pipe held open
by the caller -- exactly how non-interactive tooling runs it), the channel stays open, and
pumpStdin's `io.Copy(child.stdin, ssh-stdin)` stayed BLOCKED on the read long after the remote
command had already exited. sicd never reaped, ssh never returned, and the whole call hung until
stdin EOF or a timeout. Measured: `sic host true </dev/null` = 3.4s (== raw ssh), bare = 20s hang.
Fix: race the stdin copy against the child's exit (what ssh itself does).
* child exits first -> it did not need the rest of stdin; stop forwarding, abandon the read,
return the child's status. (the hang fix)
* copy ends first (EOF/EPIPE/error) -> its outcome is meaningful; a genuine read error that
truncated the transfer to a still-live child is still a non-zero failure.
* near-simultaneous -> a bounded 50ms window still catches a real truncation, but a genuinely
blocked stdin can no longer re-introduce the hang.
Daemon-only change; the client is untouched, and v1/v2 dual-read is unaffected. New regression
TestPumpStdinReturnsOnChildExitDespiteBlockedStdin (never-EOFing stdin + exited child -> prompt
child status, not a hang). Existing pump tests (read-error->1, EPIPE head-semantics, clean EOF)
unchanged and green. Verified live: bare `sic host true` 20s -> 3.9s; piped/redirected stdin
still forward.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Public repo hygiene: scrub local identifiers from the design doc and the usage
examples/tests. docs/design-nested.md loses the hostname, the maintainer name, a
model name, and internal system/agent names (the rejected shared-store router is
now described generically, not by product name); its stale wire line is corrected
to netstring(argc) while there. Usage comments and test fixtures switch real fleet
hostnames/container names to host1/host2/app/db. No behaviour change; all tests green.
@reviewer re-probed the argc path (18 hand-built frames, all rejection paths clean) and cleared
it as deployable. Three non-blocking follow-ups, all closed here:
#1 doc rot -- the comments ARE the spec on a hand-rolled wire, and three still described removed
formats: readNetstringStream (claimed v1-only; it now serves both paths), parseV2Content doc
(empty-netstring terminator), runV2 doc (the two-generations-old command/NUL/payload split),
and the test frame() helper. All rewritten to netstring(argc) + argc argv netstrings + payload.
#2 canonical argc -- strconv.Atoi accepted "+1" and "001" (one value, two encodings). Reject
leading +/-, leading zeros, and empty before Atoi, mirroring readNetstringStream length-token
validation. New regression TestV2NonCanonicalArgcRejected (01/+1/-1 etc. exit 1, no exec).
#3 argc-low reclassification -- documented where payload is defined: a low argc turns the
sender's own trailing netstrings into child stdin; only ever the sender frame, no trust
boundary. By design, now stated.
Also fixed TestV2FrameElementCapRejected, which under the argc wire was tripping on a bad argc
token rather than the count cap it claims -- now sends argc=5000 to hit maxArgs honestly.
Build/vet clean, full daemon + client suites green. Daemon is deploy-ready at this commit.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
WP8, the last code before deploy. Rewrites the client entrypoint on the v2 wire:
* target parsing: host[/hop1/hop2...]; hops resolved to incus/docker/pct verbs via
/etc/sic/hosts.toml (loaded only when hops are present), folded into the frame onion
by buildChain — verbs[0] is the outermost layer the host sicd enters first.
* argv is carried as [][]byte end to end and NEVER space-joined; --sh is the sole
exception (user asked for a shell line -> [sh -c <joined>]).
* stdin is forwarded AFTER the frame (the v1 client set Stdin to the frame alone, so
`sic host cat >f <local` wrote a zero-byte file). The copy runs in an abandoned
goroutine so a stdin-ignoring command on a tty never blocks on a read that never EOFs.
* ssh -T: the frame is binary; a pty would mangle it.
* exit code inherited from the far end.
Dead v1 framing (netstring/frameArgv/indexOf) removed — frame.go owns the wire now.
Also corrected the stale runV2 doc comment in sicd (still described the removed 0:,
terminator; it is argc-prefixed since e1f93de).
E2e on boltzmann (ssh localhost + real sicd), all green: touch "a b" -> ONE file;
echo A "" B -> "A B"; pipe+redirect stdin both forward; exit 7 propagates; a
stdin-ignoring echo does not hang; --sh; and netstring-looking args (3:x, a,b:c)
survive as single arguments.
Ship-blocker #1: the empty-netstring terminator collided with a legit empty "" argument —
parseV2Content broke on the first len(ns)==0, so echo A "" B ran as echo A and B bled into
child stdin (wrong command executed, not rejected). Replaced the terminator with an explicit
argc prefix: content = netstring(argc) + argv netstrings + payload; the daemon reads EXACTLY
argc elements, so "" is a normal arg. Fixes the collision AND the element-count bound in one.
Daemon parseV2Content, client v2Frame, both test helpers, and the reference builder all updated.
Proof: TestV2EmptyArgPreserved (echo A "" B -> "A B") + TestV2FrameEmptyArgRepresentable.
All v2/v1 tests green, vet clean. Daemon now clears reviewer 964 #1/#2/#3 — deployable.
#3 [DoS]: runV2 allocated make([]byte, nsLen) from a raw uint32 (up to 4 GiB) BEFORE
readNetstring capped it — 5 bytes committed ~4 GB. Now reject nsLen>1<<24 immediately.
#2 [DoS]: parseV2Content had no element-count/byte cap (16 MiB content = 4.2M argv, ~388 MB).
Now caps maxArgs=4096 / maxFrameBytes=1<<20, mirroring runV1. Regression tests for both.
#1 (empty-arg terminator collision) is a SEPARATE ship-blocker, deferred (needs a wire-design
call: explicit argc vs client-side reject) — do NOT deploy the daemon until #1 lands.
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.
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>
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.
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.
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.
#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>
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>
Decision 3 (Markus, deployment simplicity): drop the proposed sic-run helper. sicd is already
a pure v2 frame-peeler, so the inner-hop command is just sicd (incus exec <c> -- sicd) and one
binary deploys instead of two. Verified empirically, not assumed: sicd invoking sicd against a
two-layer nested frame delivered the payload to the inner hop with correct exit status and clean
stderr. Prerequisite is only that sicd is present in every container a chain traverses.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Records the 2026-07-23 bullpen design session (room msgs 865-877): the v2 client model
(client builds the whole onion, servers peel one hop each), and two decisions with their
rejected alternatives so a future reader sees the reasoning, not just the outcome.
Decision 1 — chain resolution = static per-client hosts.toml, fail-loud, no mutable store in
the routing path. mneme-as-router was proposed and REJECTED: mutable shared store = a
supply-chain RCE vector (poison one chain entry, reroute a hop's exec); staleness fails
silently into the wrong container vs a loud connection-refused; dies when boltzmann sleeps;
and adds nothing over a flat file since the client needs a local mneme address regardless.
mneme stays the fleet MEMORY store — this is narrowly about routing.
Decision 2 — migration = daemon dual-read (sniff first byte: 0x00=v2, digit=v1-legacy),
daemon-first, no flag day.
Escape hatch (documented, not built): signed manifests out-of-band if redeploy friction ever
exceeds the trust cost.
Scribed by noether from the room debate; @architect/@skeptic (deepseek) argued it, Markus
initiated and made the calls. Not code — no review gate.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
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.
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>
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>
CPython sets SIGPIPE to SIG_IGN so EPIPE surfaces as BrokenPipeError.
A Go port must replicate this explicitly or the EPIPE path silently
becomes a SIGPIPE death.
24/24 green.
write_all() loops until the full buffer is transferred — os.write's return value was
ignored at the payload write and in the pump loop, so a short write silently truncated
the stream. EPIPE at the payload write (child already dead) now reaps the child and
inherits its status instead of dying with a BrokenPipeError traceback; EPIPE in the pump
loop falls through to the same waitpid, which is standard `cmd | head` semantics.
Tests use deterministic fault injection rather than racing signals: a fork wrapper that
waits with waitid(WNOWAIT) so the child is provably dead but still reapable before the
parent writes, and an os.write cap of 7 bytes per call as the stand-in for a
signal-interrupted partial write.
Reviewed three times. Known gaps, tracked, NOT fixed here: signal-killed children still
collapse to exit 1 (no 128+WTERMSIG), and the pump loop's `except OSError: pass` is
broader than it should be.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
- Pump loop after fork: parent streams stdin to child through pipe
- netstring: enforce .isdigit() on length field
- netstring: require exact consumption (no trailing data)
- argv split: use .split space not .split
- Non-ASCII command: fail loudly
- Child exec failure: os._exit(1)
17/17 pytest green
- Read preamble: magic byte \x00 + 4-byte BE length + netstring
- Split netstring at first NUL: command\0<payload>
- Exec command with payload + trailing stdin forwarded
- Nested payloads are netstrings for the next hop (one layer peeled per sicd)
- Bare -- survives as opaque data
- Malformed input: exit 1 with stderr diagnostic
- 14/14 pytest green
Agents (esp. small models) keep calling `fetch_url https://...` positionally
instead of `fetch_url url="https://..."`. The key=value parser turned a bare
URL into {"<url>":""} with no `url` key, so the server rejected it with the
misleading "url must start with http:// or https://". Now a lone http(s)://
positional is mapped to url=, killing this recurring pi-agent failure. Explicit
key=value and raw-JSON forms are unchanged.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EWpfhDgYNA21tETDP9ueBE
lmcp-tool drives a host's lmcp (MCP) tools over a stateless tools/call via sic —
list for discovery, key=value args so a model never embeds JSON in a shell (apostrophe-safe).
sicwrite/sicedit pass file content as argv since sicd consumes stdin for the frame.
References the lmcp server project: git.reauktion.de/marfrit/lmcp
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EWpfhDgYNA21tETDP9ueBE
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
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