Files
alktty/docs/plans/fuzzing.md
T
glm-5.3-flash fb7cfe3851 feat(fuzz): session_opseq target - stateful op-sequence campaign against the session pump
Target 4 from the fuzzing plan (the alkcall-bug-class hunt): a
#[derive(Arbitrary)] op sequence drives the producer session pump
(drive_session_pre_negotiated) over a duplex pair against a fuzz-local
mock backend.

- fuzz/shared/src/session_opseq.rs: op vocabulary (client writes incl.
  invalid-type/oversize-length barrier probes, backend production/EOF,
  exit resolve/fail/drop, control messages, bounded reads, yields),
  kill-guard exit future (ADR-005 observability), delivery model, and
  the invariants: kill-on-Drop (kill_fired == !exit_resolved), exit
  chunk is last with first-issued code -1 on wait-failure (ADR-004),
  lossless per-stream FIFO + single stdout sentinel, stdin
  prefix-losslessness (post-shutdown chunks never delivered), control
  dispatch bounds, take-once allocation, teardown termination.
- Harness-model correction caught by a 60s smoke run before the
  campaign (the alkcall §7.7 pattern, third time): a failed client
  write can mean the session completed and dropped the server duplex
  half while the write was in flight - the harness now drains and
  requires the exit chunk on any write/shutdown error (premature close
  without it is a finding). No adapter code changed.
- 12 committed seeds via the deterministic generator, hand-encoded
  against the arbitrary 1.4.x derive layout; the encoding is pinned by
  a seed-decode test. Existing chunk_frame/negotiation_frame/
  control_json seed corpora are byte-identical to before.
- fuzz/.gitignore: the per-target seed negations never matched (corpus/*
  excludes the parent dir; git cannot re-include beneath an excluded
  dir). Fixed with the !corpus/*/ + corpus/*/* + !corpus/*/seed-*
  recipe - new seed files were silently ignored until now.
- Campaign: 10 min detached via run-detached.sh, exited 0, artifact
  dir empty - 42.3k execs across 33 fork jobs, 0 crash/oom/timeout,
  cov 3934 -> 4075 edges. Grown corpus excised per the corpus policy.

Verification: corpus replay green on stable (9 tests), cargo test 113
+ --all-features 156, clippy stable/wasm/fuzz-shared, fmt, doc,
publish dry-run, wasm check - all pass.
2026-09-28 10:06:00 +00:00

21 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 #[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/<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, 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:

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.