# Fuzzing alktty: Plan **Status:** adopted — implementation in progress (see §8 progress log) **Date:** 2026-09-28 **Research input:** `/workspace/@alkdev/alkcall/docs/research/fuzzing.md` (the alkcall fuzzing evaluation; the toolchain decision, corpus policy, detached runner, and no-CI policy below are adopted from it wholesale rather than re-derived). --- ## 1. Decision: fuzz this crate **Yes.** Alktty is the *named follow-up* in the alkcall research: §5 "Non-surfaces" explicitly defers the downstream parsers to "downstream crates' fuzz targets" — the channels layer carries opaque `Bytes` and the handler owns sub-stream framing (ADR-035). Alktty is that handler, and it owns four parse surfaces fed by arbitrary peers on both transports (direct ALPN and channels): | # | Surface | Location | Shape | |---|---|---|---| | 1 | 5-byte chunk codec | `src/wire.rs` (`ChunkReader`/`ChunkWriter`, `validate_header`) | async, `Cursor`-drivable; validate-then-alloc at `wire.rs:244` | | 2 | Negotiation framing | `src/negotiation.rs:185` (`NegotiationReader::read_frame`) | 4-byte BE prefix, bounds-check then `vec![0u8; length]` at `:199` — the same bounded-but-allocation-rate pattern alkcall flagged (§6.2-3) | | 3 | `NegotiateRequest` JSON | `src/negotiation.rs` (serde parse of up to 16 MiB bodies) | attacker-shaped JSON, typed struct | | 4 | `ControlMessage` JSON | `src/control.rs:105` (`from_slice`), `signal_from_name` | sync pure | Reasons this pays off here, beyond the generic alkcall argument: 1. **Semantic invariants no example test explores adversarially.** The peek/disambiguation machinery (`peek_stream_type` vs `read_chunk_after_peek` — both call orders must yield exactly one chunk) and the cross-codec invariant that makes it sound: an error-response frame's length-prefix high byte must be `0x00` so a `ChunkReader` peek distinguishes it from a chunk whose first byte is a server stream type ∈ {1, 2, 4} (`wire.rs:189-213`, ADR-052 §5). If `MAX_CHUNK_LEN` ever changes or a stream type is added, that invariant breaks subtly. A fuzz target spanning both codecs is the cheapest enforcement that exists. 2. **The alkcall bug class has a plausible analog here.** The duplicate adopt/open finding (alkcall §7.8) was *stateful*: an interleaving no unit test replays destroyed the live channel. Alktty's adapter has the same shape — take-once `Mutex>` slots (`adapter.rs:777-792`), session re-registration, handle replacement. A stateful op-sequence target against the session pump + mock backend is where a real finding would live. 3. **Cost is low and the playbook is proven.** Mirroring alkcall's as-built layout is a port, not a design exercise (§5 below). Zero parser-bug yield is a successful negative result — it converts "we think the codecs are robust" into a demonstrated property (the quinn lesson cuts both ways). **Scope excludes `src/local/`:** PTY/pipe output is local-OS-shaped, not wire-attacker-shaped. The wire-facing side is entirely the shared/producer/consumer modules (`wire`, `negotiation`, `control`, `backend`, `adapter`, `channels`, `session`). **The wasm invariant is unaffected:** `fuzz/` is host-only (nightly toolchain, `path` dep on the crate), excluded from publishing, and never compiled for `wasm32-unknown-unknown`. ## 2. Toolchain decision (adopted from alkcall §4/§7.7) - `cargo-fuzz` + `arbitrary`, standard `fuzz/` workspace with its own `[workspace]` table (nightly-only bins). - **The invariant logic lives in `fuzz/shared/` — a stable-toolchain library crate** — not inline in the target binaries. The `fuzz_target!` binaries are thin wrappers. This makes corpus replay a plain `cargo test --manifest-path fuzz/shared/Cargo.toml` on stable: it replays every committed seed through the identical invariant functions the fuzzer runs, and joins the release verification checklist. - `fuzz/rust-toolchain.toml` pins `nightly` for the subtree only; the crate stays stable at MSRV 1.88. - Committed hand-made seeds, gitignored grown corpora (quinn/h2 pattern); deterministic seed generator script committed next to them. - **No hosted CI** — this repo has the same no-CI policy as alkcall (§7.9 there): the gitea host serves git only; verification and campaigns are manual. Corpus replay is the standing fuzz gate; campaigns run via the detached runner before releases and after touching `src/wire.rs`, `src/negotiation.rs`, `src/control.rs`, the adapter, or the session pump. Do not add workflow files or "when CI exists" language. - AFL/bolero/libafl/OSS-Fuzz: not adopted, same reasoning as alkcall §4. ## 3. Operational isolation: campaigns must be detached from the agent session **This is mandatory, not a nice-to-have.** Agent sessions (opencode) run commands through a bash tool that spawns processes as children of the session's own process tree and cgroup. A fuzz campaign is exactly the workload shape that can kill its own host: - **OOM-killer shared fate.** The target's worst case *is* unbounded allocation. libFuzzer's RSS guard is a *soft* poll — a fast single huge allocation can beat it, and then the kernel OOM killer fires. It picks the largest-RSS process in the cgroup, which can be the opencode server hosting the session. Losing that server is recoverable (sessions are recorded in a db) but hugely derailing and entirely avoidable. An OOM in a fuzz target must cost the fuzzer, never the session. - **Fork bombs / CPU saturation:** `-fork=N` workers and unbounded task spawning in a buggy target can starve the session host. - **Deadlock hangs:** a wedged target must not hang the session's bash call. Three layers, all standard: 1. **libFuzzer soft limits (always on):** `-rss_limit_mb=2048` `-malloc_limit_mb=2048` make libFuzzer exit cleanly (OOM-class artifact) before the kernel gets involved. Do **not** use `ulimit -v` with ASAN (the sanitizer reserves terabytes of virtual address space; the classic failure is instant `MmapAlloc` death) — the ASAN-safe equivalent is `malloc_limit_mb`. 2. **Fork mode as blast-radius containment (default):** `-fork=1` runs each input in a short-lived child; a crash/timeout/OOM/leak kills only that child, the parent records the artifact and keeps going. Also turns deadlocks into per-input timeouts instead of hung sessions. 3. **Process detachment (mandatory for agent-run campaigns):** never run fuzzing as a foreground child of the session. `fuzz/run-detached.sh` wraps `cargo fuzz run` in `setsid` + `nohup` + redirection to a log file: removed from the session's controlling terminal and signal relation, survives the session ending, and gives a pollable log instead of a blocking call. Polling protocol (never `wait` on the detached process): ```bash tail -n 50 fuzz/artifacts/-*.log # campaign progress ls fuzz/artifacts/ # crash-* / oom-* / timeout-* artifacts pgrep -f "cargo fuzz run " # still running? ``` One session holds one detached campaign per target; log filenames carry UTC timestamps. `FUZZ_RUNTIME_SECS` overrides the per-campaign budget (default 600 s). ## 4. Targets In priority order. Targets 1–3 are the initial scope; target 4 is second wave, decided after campaigns on 1–3 come back clean (same sequencing as alkcall §7.4). | # | Target | Input style | Drives | Invariants | |---|---|---|---|---| | 1 | `chunk_frame` | raw `&[u8]` | `ChunkReader`/`ChunkWriter` over `Cursor` + `block_on` | no-panic; error-shape partition (`ConnectionClosed` only on truncation; `InvalidStreamType` iff `stream_type > 4`; `ChunkTooLarge` iff `length > MAX_CHUNK_LEN`; `Io` impossible on a Cursor); Ok iff header complete + payload complete; round-trip (`write_chunk` then `read_chunk` reproduces stream_type + bytes); 5-byte consumption accounting; peek interleave (`peek_stream_type` → `read_chunk_after_peek` == `read_chunk`, idempotent peek, peek state resets per chunk); never allocates for out-of-range headers | | 2 | `negotiation_frame` | raw `&[u8]` | `NegotiationReader`/`NegotiationWriter` + `NegotiateRequest` serde parse + `error_response_bytes` | no-panic; error-shape partition (`ConnectionClosed` only on truncation; `FrameTooLarge` iff `length > MAX_CHUNK_LEN`; `Json` only when the full body was present; `Io` impossible); consumption accounting (4 + length); negotiation JSON round-trip (`NegotiateRequest` → `to_json` → frame → `from_slice` → equal struct); **cross-codec disambiguation** (see §4.1) | | 3 | `control_json` | raw `&[u8]` | `ControlMessage::from_slice` / `to_json`, `signal_from_name` | no-panic on any bytes; clean serde errors (never silent); `to_json` → `from_slice` structural round-trip; `signal_from_name` is total (returns `Option`, never panics) | | 4 | `session_opseq` | `#[derive(Arbitrary)]` op sequence | the producer session pump (`drive_session_pre_negotiated`) + a fuzz-local mock backend (`fuzz/shared/src/session_opseq.rs`; the in-crate `MockBackend` is `#[cfg(test)]`-only) | no-panic (driver, pump tasks via the session `JoinError`, harness model); **kill-on-Drop (ADR-005)** — after teardown `kill_fired == !exit_resolved`, and with no exit op issued the kill MUST have fired; **exit chunk is last (ADR-004)** — at most one ctrl_out chunk, `type == "exit"`, `code` matches the first-issued exit op (`-1` on wait-failure or dropped exit sender), nothing observed after it, server never writes stream types 0/3; lossless per-stream FIFO of produced `(len, byte)` patterns with exactly one stdout sentinel (only after the stdout-EOF op) and no stderr sentinel; stdin prefix-losslessness (post-shutdown chunks never delivered); control dispatch never over-counts and dispatches promptly pre-exit; take-once allocation; teardown terminates within the bound | ### 4.1 Cross-codec disambiguation invariant (target 2, the subtle one) The consumer disambiguates an error-response frame from a raw chunk by peeking one byte (`wire.rs:189-213`): server stream types are {1, 2, 4}; a 4-byte BE length prefix ≤ 16 MiB − 1 starts with `0x00`, which is neither. Encoded as fuzz assertions: - For every `error_response_bytes` output and every negotiation frame of length < `MAX_CHUNK_LEN`, the first byte is `0x00` — a `ChunkReader` peek on `[frame bytes…]` never mistakes it for a chunk. - **Known boundary collision, encoded deliberately:** a frame of length exactly `MAX_CHUNK_LEN` (= `0x01000000`) has prefix `[0x01, …]` — first byte `0x01`, colliding with `STREAM_STDOUT`. The read paths admit it (`length > MAX_CHUNK_LEN` rejects, `==` passes). This is an inherent edge of the one-byte-peek design; producers only ever send tiny error frames, so it is accepted — but the assertion pins the boundary so any future `MAX_CHUNK_LEN` change that widens the collision gets caught. (`validate_header`'s doc comment at `wire.rs:262-270` claims the ≤ cap high byte is `0x00`; strictly the claim holds only for `<` the cap. Worth a one-word fix in the doc comment if we touch `wire.rs` for any other reason.) Also encoded (mirroring alkcall §7.7's trailing-byte probe): a valid negotiation frame with one extra JSON byte appended *and counted in the length prefix* must fail with a `Json` error, so a future switch to a tolerant streaming reader is caught. ## 5. Layout (mirrors alkcall §7.7 as-built) ``` fuzz/ ├── Cargo.toml alktty-fuzz (nightly-only bins; own [workspace]) ├── rust-toolchain.toml pins nightly for this subtree only ├── fuzz_targets/ │ ├── chunk_frame.rs thin fuzz_target! wrapper │ ├── negotiation_frame.rs thin wrapper │ ├── control_json.rs thin wrapper │ └── session_opseq.rs thin wrapper (typed SessionSequence input) ├── shared/ alktty-fuzz-shared — STABLE-toolchain library │ └── src/{chunk_frame,negotiation_frame,control_json,session_opseq}.rs ├── corpus// committed seeds ├── gen_fuzz_seeds.py deterministic seed generator ├── run-detached.sh §3 detached runner (CWD-independent) └── README.md ``` Root `Cargo.toml` changes (the alkcall §7.7 footguns, both required): - `[workspace]` table: `members = ["."]`, `exclude = ["fuzz"]` — without it cargo auto-discovers `fuzz/shared/` into the main workspace and the MSRV toolchain tries to build nightly-consumed dev-deps. - `"fuzz/"` added to the publish `exclude` list. No `#[cfg(fuzzing)]` exposure expected — `ChunkReader`/`ChunkWriter`, `NegotiationReader`/`NegotiationWriter`, `NegotiateRequest`, `ControlMessage`, `error_response_bytes`, `drive_session_pre_negotiated`, and the `TtyBackend`/`TtyHandle`/`TtyParams` shapes are all already `pub` (alkcall needed none of its five either). The one private constant, `MAX_CHUNK_LEN`, is wire-stable (ADR-001); the shared crate carries its own copy, and the shape invariants assert the rejection boundary so a drift is caught. The `session_opseq` harness is fuzz-local for the same reason `MockBackend`/`TestBackend` are `#[cfg(test)]`-only: a test backend in `src/` would either leak into the public API or force the whole mock apparatus behind `#[cfg(any(test, fuzzing))]`; the shared crate is the fuzz-side home for it (mirrors alkcall, whose `manager_routing` harness also lives in `fuzz/shared/`). ## 6. Corpus policy Commit hand-made seeds; gitignore grown corpora and artifacts (`fuzz/.gitignore` per alkcall's). Seeds per target: valid frames/headers for every stream type / every control variant, truncation at every prefix length, `length = 0`, `length = MAX`, `length = MAX + 1`, `length = u32::MAX`, invalid UTF-8 in JSON, deep-ish nesting, peek-interleave fixtures, cross-codec error-frame fixtures. The generator script is deterministic (no randomness) so seeds are reproducible. ## 7. Sequencing 1. **Bump alkcall 0.8.0 → 0.8.1** (the duplicate adopt/open fix lands on alktty's own path — `TtySession::open_via_channels` and the channels establisher run on the fixed `ChannelManager`). Full verification set including wasm checks. Also fix AGENTS.md's stale "pin alkcall 0.6.0" line while touching it. **Status: see §8.** 2. Scaffold `fuzz/` + root workspace table + publish exclude. 3. Targets 1–3 invariant logic in `fuzz/shared/` + thin target bins + seeds + detached runner + README. 4. Corpus replay green on stable (`cargo test --manifest-path fuzz/shared/Cargo.toml`); add it to the AGENTS.md verification checklist. 5. Detached campaigns (10 min per target via `run-detached.sh`), triage anything found, record results in §8. 6. Decide on target 4 (`session_opseq`) after 1–3 are clean. **Status: landed — see §8. All four targets are in scope and live.** ## 8. Progress log (append-only across sessions) - **2026-09-28** — Proposal discussed and adopted; this plan written. Alkcall dependency bump + fuzz scaffold started. - **2026-09-28 (later)** — **All of §7 steps 1–5 landed.** - alkcall 0.8.0 → 0.8.1 verified across the full checklist (113 + all-features 156 tests, clippy stable/wasm, fmt, doc). - AGENTS.md: stale "pin alkcall 0.6.0" corrected to 0.8.1; corpus replay added to the verification checklist; campaign guidance added. - `fuzz/` scaffolded exactly as §5: root `[workspace]` table + publish exclude, `fuzz/rust-toolchain.toml` nightly pin, thin target bins, invariant logic in `fuzz/shared/` (stable toolchain), 328 committed seeds via `gen_fuzz_seeds.py`, `run-detached.sh`, README, `.gitignore`. - Corpus replay green on stable (`cargo test --manifest-path fuzz/shared/Cargo.toml`). - Harness-model corrections the corpus replay caught *before* any campaign (the alkcall §7.7 pattern repeating): (1) a valid first chunk with trailing stream bytes is correct reader behavior — the payload slice is `5..5+len`, not `5..`; (2) the negotiation framing layer is length-prefix-only — `read_frame` returns raw bytes and the adapter does the serde parse (`adapter.rs:411`), so `NegotiationError::Json` is unreachable from this reader (unlike alkcall's serde-backed envelope reader; the `Json` arm now panics as "unreachable" and the trailing-byte probe asserts exact-body consumption instead of a Json error); (3) zero-length negotiation frames are admitted by this reader (the adapter's parse rejects them) — the shape assertion was relaxed accordingly. - **10-min detached campaigns, all three targets: every run exited 0 with an empty artifact directory — no crashes, hangs, OOMs, or leaks** (per-target `oom/timeout/crash: 0/0/0` on all ~28 fork jobs): `chunk_frame` 380k execs (cov saturated at 710 edges within seconds — the 5-byte-parser ceiling is fully enumerated); `negotiation_frame` 3.69M execs (cov 3157, still finding coverage at budget end — JSON structure exploration); `control_json` 8.6M execs (cov 2035, still growing). Grown corpus entries were deleted per the §6 policy; committed seeds unchanged. - §7 step 6 (target 4 `session_opseq`) remains open for a follow-up session. - **2026-09-28 (later still)** — **§7 step 6 landed: target 4 `session_opseq`. All four targets are now live.** - `fuzz/shared/src/session_opseq.rs`: `SessionOp`/`SessionSequence` (`#[derive(Arbitrary)]`, 12-op vocabulary — client writes incl. barrier probes, backend production/EOF, exit resolve/fail/drop, control messages, bounded reads, yields) + a fuzz-local `FuzzBackend` (the in-crate `MockBackend` is `#[cfg(test)]`-only) + the harness driving `drive_session_pre_negotiated` over a `tokio::io::duplex` pair, with a delivery model mirroring §4's invariants. 12 committed seeds via the deterministic generator (hand-encoded against the `arbitrary` 1.4.x derive layout — variant tag `(u32_le * 12) >> 32`, fields LE zero-filled, keep-going byte gating each `Vec` element; the encoding is pinned by a decode test asserting the exact ops each seed decodes to). - Invariants encoded: kill-on-Drop (ADR-005), exit-chunk-is-last (ADR-004), lossless per-stream FIFO + sentinel semantics, stdin prefix-losslessness (post-shutdown chunks never delivered), control dispatch bounds, take-once allocation, teardown termination. Harness bounds: 128 ops, 4 KiB/chunk, 32 KiB produce/write caps, unbounded backend stdin (control dispatch stays prompt). - **Harness-model correction before the campaign** (the alkcall §7.7 pattern repeating, third time): a 60s smoke run crashed on `ClientWrite` after the session had completed — a failed client write can mean the *server half of the duplex dropped* because the session finished (exit resolved → pumps join → exit chunk → drainer exits → duplex halves drop) while the write was in flight. The harness now treats any write/shutdown error as "the session closed": it drains the read side and requires the exit chunk (a close without it is a premature close — a real bug). No adapter code changed; the finding was a harness-model gap. - Corpus replay green on stable (9 tests, incl. the seed-decode pin). Full checklist green: 113 + 156 all-features tests, clippy stable/wasm + fuzz-shared clippy, fmt, doc, publish dry-run, wasm check. - Campaign: **10 min detached via `run-detached.sh`, exited 0 with an empty artifact directory — no crashes, hangs, OOMs, or leaks** (all 33 fork jobs `oom/timeout/crash: 0/0/0`). 42.3k execs at ~74/s per job (the op-seq target is stateful — each input runs a full tokio session with bounded reads/yields, so exec/s is inherently tens, not millions; the value is interleaving exploration). Cov grew 3934 → 4075 edges over the run, still finding coverage at budget end. Grown corpus excised per the §6 policy; 12 committed seeds unchanged; corpus replay green after. - §7 fuzzing plan complete: all four targets live, corpus replay is the standing gate, campaigns pre-release and on adapter/pump/wire changes. ## 9. Relationship to existing tests The existing suite covers valid-input round-trips and documented error paths (`wire.rs` codec tests, `negotiation.rs` and `tests/negotiation.rs` frame tests, `control.rs` message tests). Fuzzing adds: random malformed input exploration (the quinn CVE class), stateful interleavings (target 4), and adversarial exploration of the peek/disambiguation seam that example-based tests structurally cannot enumerate. Verification additions once landed: ```bash cargo test --manifest-path fuzz/shared/Cargo.toml # corpus replay — the standing fuzz gate ``` …added to the AGENTS.md checklist alongside the existing commands. The wasm checks (`cargo check --target wasm32-unknown-unknown`, `cargo clippy --target wasm32-unknown-unknown -- -D warnings`) remain untouched by fuzz work but must still pass after the root `Cargo.toml` gains its `[workspace]` table.