Commit Graph

29 Commits

Author SHA1 Message Date
Claude (noether) ebccfa0467 sic: v2 main() — nested targets, boundary-preserved argv, real stdin forwarding
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.
2026-07-23 11:59:26 +02:00
Claude (noether) e1f93dea7c sicd+client: explicit argc in v2 content — empty args representable (reviewer 964 #1)
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.
2026-07-23 11:50:35 +02:00
Claude (noether) 1c43b6d9c0 sicd: bound outer netstring length before alloc + argv element count (reviewer 964 #2/#3)
#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.
2026-07-23 11:45:10 +02:00
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) 3ce50a8ba3 docs: chain link is sicd itself, no sic-run binary (verified 2-hop peel)
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>
2026-07-23 09:26:07 +02:00
Claude (noether) 77c58beb93 docs: nested-sic client design + chain-resolution decision (audit trail)
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>
2026-07-23 08:30:58 +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 a0f93c16cf sicd: add SIGPIPE/SIG_IGN porting note at EPIPE catch site
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.
2026-07-23 01:05:39 +02:00
Markus Fritsche 833a0432b8 sicd: harden pump loop, EPIPE/diagnostic handling, signal fidelity
Review round 3 fixes:
- write_all helper: handle partial writes from signals
- EPIPE on write side: catch BrokenPipeError (child dead), not all OSError
- Read-side OSError: diagnose on stderr + exit 1 (not silent truncation)
- parse_netstring: sanity cap 1<<24, return content only (not tuple)
- 128+WTERMSIG for signal-killed children (shell convention)
- 24/24 pytest green
2026-07-23 01:01:59 +02:00
Claude (noether) 7f7f0c2ecb sicd: handle EPIPE and short writes at both write sites
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>
2026-07-23 00:39:58 +02:00
Markus Fritsche 70103792df sicd: fix pipe-buffer deadlock and review findings
- 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
2026-07-23 00:00:55 +02:00
Markus Fritsche 6584181f5c tests: add large-payload and nonexistent-command tests (2 red)
Adds tests that expose the pipe-buffer deadlock:
- 65,792 bytes (64 KiB + 1) hangs instead of completing
- 1 MiB body hangs
- Nonexistent command exits nonzero

15 passed, 2 failed (expected — deadlock not yet fixed)
2026-07-22 23:55:44 +02:00
Markus Fritsche b5e7e2b632 sicd: implement wire protocol (preamble + nested netstring peeling)
- 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
2026-07-22 23:44:00 +02:00
Markus Fritsche 4a6c1bae5c lmcp-tool: auto-map a bare http(s):// positional arg to url=
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
2026-07-20 06:16:02 +02:00
Markus Fritsche dd987e4c1e contrib: sic companion helpers (lmcp-tool, sicwrite, sicedit)
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
2026-07-19 20:36:05 +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
v0.1.0
2026-07-19 12:10:51 +02:00