Files
sic/docs/design-nested.md
Claude (noether) 20a6608f0e docs+examples: de-identify — generic hosts, no personal/model/internal names
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.
2026-07-23 13:17:06 +02:00

5.5 KiB

sic — nested (multi-hop) design & chain-resolution decision

Status: Accepted — 2026-07-23. This records a decision and its rejected alternatives for the audit trail — a future reader should see that a shared-store router was considered and killed for cause, not overlooked.

Goal

Make sic host/hop1/hop2 … argv real: run a command inside a container reached through a chain of hops (incus exec, docker exec, pct exec, …) without a shell re-parsing argv at any layer. The v2 wire-protocol daemon (cmd/sicd, gateway/sicd) already implements one-layer-per-hop peeling; this covers the client and the rollout.

Model (confirmed)

The client builds the whole onion up front: wrap the innermost command in a frame for hop N, wrap that for hop N-1, … outermost for hop 1, and ship the nested blob to hop 1. Each sicd peels exactly one layer (its own), runs its runtime verb with sicd itself as the command (incus exec <c> -- sicd), and that inner sicd reads the next frame from stdin and repeats. There is no separate sic-run binary — see Decision 3. Construction is recursive and up-front; unrolling is iterative and distributed.

One frame = preamble (MAGIC 0x00, then 4-byte big-endian len32) + a djb netstring whose content is netstring(argc) + argc argv netstrings + payload; the payload of a non-terminal frame is itself a full frame for the next hop.

Decision 1 — chain resolution: static per-client config

/etc/sic/hosts.toml declares, per host, the ordered runtime verbs for its hops (nest = ["incus", "docker"], etc.). The client reads it to turn host/hop1/hop2 into the concrete verb + address at each layer. Fail loud on any mismatch (unknown host, more hops than the config declares) — never guess.

Rejected: a shared memory store as the chain router

Resolving chains by querying a shared, mutable fleet-memory store was proposed and rejected. Even with the bootstrap circularity removed (the store's own address is a flat local setting, so a lookup is one direct call, not a nested one), it loses on four counts:

  • Trust (dispositive). Such a store is mutable and shared. Any writer poisons one chain entry and every subsequent sic call through that chain execs the attacker's verb on the target host — a supply-chain RCE vector. Static config is immutable-after-deploy and auditable once.
  • Staleness fails the wrong way. A moved/renamed container makes the stored chain lie, and the client silently execs into the wrong container. Static config fails loud (connection refused); a mutable store turns a hard error into quiet wrong-target execution.
  • Availability. The store may live on a host that sleeps; resolution would die with it, and a local cache to cover that is just a slower, staler config file.
  • No net benefit. The client needs a local store address regardless, so the store only adds a fragile network hop + cache to do what a flat file read already does.

A shared memory store may still be useful for memory — this decision is narrowly about routing, nothing else.

Decision 2 — migration: daemon dual-read, daemon-first

The v2 daemon hard-requires the 0x00 magic; the deployed v1 client sends a digit first, so a naive v2 deploy breaks every existing call on the many live hosts already running v1. Instead the daemon sniffs the first byte0x00 → v2 parser, digit → v1-legacy parser — a once-per-connection branch, ~20 lines, zero perf cost. Ship the v2 daemon everywhere first; roll clients independently, no flag day.

An earlier draft proposed a separate sic-run helper as the per-hop peeler. It is not built: 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 (a deployment-simplicity decision). Verified empirically, not assumedsicd invoking sicd was run against a two-layer nested frame (build sicd; frame("sicd", frame("cat", payload)) | sicd): the payload reached the inner hop, exit status propagated, stderr clean. sicd calling sicd peels correctly, so nothing about a nested hop needs behaviour sicd lacks. The only prerequisite is that sicd is present in every container a chain traverses (a packaging/rollout item, not new code).

Backward compat & the two original bugs

  • A bare -- must survive into the innermost argv untouched (the payload is opaque bytes; no hop interprets it) — closes the double-dash bug (#1).
  • stdin must be forwarded into/through the frame — closes the silent zero-byte-file bug.
  • A bare host with no / stays a single v1-style hop (backward compatible).

Escape hatch (documented, not built)

If redeploying clients on chain changes ever costs more than the trust of a live store, distribute signed manifests out-of-band — deterministic like static config, updatable like a live store, without the mutable-store poison vector. Future work; explicitly not today's problem.

Consequences

  • Chain changes require redeploying clients (accepted: moves are infrequent, rollouts controlled).
  • No network dependency and no mutable store in the routing path — routing is deterministic and auditable.
  • Next: implement cmd/sic (config read + recursive frame build + --/stdin fixes) and the daemon dual-read; ensure sicd is deployed into target containers (no sic-run binary); both are Go.