Files
alktty/docs/plans/fuzzing.md
T
glm-5.3-flash 49f70d9efb feat(fuzz): cargo-fuzz workspace, 3 wire-surface targets; bump alkcall 0.8.1
- Bump alkcall 0.8.0 -> 0.8.1 (duplicate adopt/open channel-state
  destruction fix from alkcall's fuzz campaign; AGENTS.md stale-pin fix).
- Adopt fuzzing per docs/plans/fuzzing.md (mirrors alkcall's
  docs/research/fuzzing.md as-built layout): fuzz/ workspace with own
  [workspace] table, nightly pinned for the subtree only, invariant
  logic in a stable-toolchain shared crate.
- Targets: chunk_frame (5-byte chunk codec), negotiation_frame
  (negotiation framing + NegotiateRequest + error_response_bytes +
  the cross-codec peek-disambiguation seam), control_json
  (ControlMessage JSON + signal_from_name). src/local/ out of scope
  (not wire-attacker-shaped).
- 328 committed seeds (deterministic generator); grown corpora
  gitignored. Corpus replay on stable is the standing fuzz gate
  (cargo test --manifest-path fuzz/shared/Cargo.toml).
- Detached-runner rule (fuzz/run-detached.sh): campaigns never run in
  the foreground of an agent session - OOM in a target must cost the
  fuzzer, never the session host.
- Root Cargo.toml gained [workspace] members/exclude (MSRV vs fuzz
  nightly dev-deps footgun) and fuzz/ in the publish exclude.

Verification: cargo test (113), cargo test --all-features (156),
clippy stable + wasm32-unknown-unknown, fmt, doc, publish dry-run,
corpus replay 328 seeds - all green. 10-min detached campaigns on all
three targets: 0 crashes/hangs/OOMs/leaks (chunk_frame cov-saturated
at 710 edges; negotiation_frame 3.69M execs cov 3157; control_json
8.6M execs cov 2035). Corpus replay caught three harness-model
mismatches pre-campaign (payload-slice shape; the negotiation framing
layer is length-prefix-only - Json unreachable from read_frame;
zero-length frames admitted by the reader, rejected by the adapter's
parse). Campaigns recorded in docs/plans/fuzzing.md section 8.
2026-09-28 08:07:30 +00:00

16 KiB
Raw Blame History

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<Option<tx>> 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):

tail -n 50 fuzz/artifacts/<target>-*.log      # campaign progress
ls fuzz/artifacts/                            # crash-* / oom-* / timeout-* artifacts
pgrep -f "cargo fuzz run <target>"            # 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 (second wave) #[derive(Arbitrary)] op sequence the producer session pump + mock backend (src/testing.rs, drive_session) no-panic; take-once slot semantics; duplicate/replayed session ops leave the live session intact — the alkcall-bug-class target

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
├── shared/               alktty-fuzz-shared — STABLE-toolchain library
│   └── src/{chunk_frame,negotiation_frame,control_json}.rs
├── corpus/<target>/      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, and error_response_bytes 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.

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.

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.

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:

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.