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.
21 KiB
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:
- Semantic invariants no example test explores adversarially. The
peek/disambiguation machinery (
peek_stream_typevsread_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 be0x00so aChunkReaderpeek distinguishes it from a chunk whose first byte is a server stream type ∈ {1, 2, 4} (wire.rs:189-213, ADR-052 §5). IfMAX_CHUNK_LENever changes or a stream type is added, that invariant breaks subtly. A fuzz target spanning both codecs is the cheapest enforcement that exists. - 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. - 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, standardfuzz/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. Thefuzz_target!binaries are thin wrappers. This makes corpus replay a plaincargo test --manifest-path fuzz/shared/Cargo.tomlon stable: it replays every committed seed through the identical invariant functions the fuzzer runs, and joins the release verification checklist. fuzz/rust-toolchain.tomlpinsnightlyfor 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=Nworkers 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:
- libFuzzer soft limits (always on):
-rss_limit_mb=2048-malloc_limit_mb=2048make libFuzzer exit cleanly (OOM-class artifact) before the kernel gets involved. Do not useulimit -vwith ASAN (the sanitizer reserves terabytes of virtual address space; the classic failure is instantMmapAllocdeath) — the ASAN-safe equivalent ismalloc_limit_mb. - Fork mode as blast-radius containment (default):
-fork=1runs 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. - Process detachment (mandatory for agent-run campaigns): never run
fuzzing as a foreground child of the session.
fuzz/run-detached.shwrapscargo fuzz runinsetsid+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_bytesoutput and every negotiation frame of length <MAX_CHUNK_LEN, the first byte is0x00— aChunkReaderpeek 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 byte0x01, colliding withSTREAM_STDOUT. The read paths admit it (length > MAX_CHUNK_LENrejects,==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 futureMAX_CHUNK_LENchange that widens the collision gets caught. (validate_header's doc comment atwire.rs:262-270claims the ≤ cap high byte is0x00; strictly the claim holds only for<the cap. Worth a one-word fix in the doc comment if we touchwire.rsfor 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-discoversfuzz/shared/into the main workspace and the MSRV toolchain tries to build nightly-consumed dev-deps."fuzz/"added to the publishexcludelist.
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
- Bump alkcall 0.8.0 → 0.8.1 (the duplicate adopt/open fix lands on
alktty's own path —
TtySession::open_via_channelsand the channels establisher run on the fixedChannelManager). Full verification set including wasm checks. Also fix AGENTS.md's stale "pin alkcall 0.6.0" line while touching it. Status: see §8. - Scaffold
fuzz/+ root workspace table + publish exclude. - Targets 1–3 invariant logic in
fuzz/shared/+ thin target bins + seeds + detached runner + README. - Corpus replay green on stable (
cargo test --manifest-path fuzz/shared/Cargo.toml); add it to the AGENTS.md verification checklist. - Detached campaigns (10 min per target via
run-detached.sh), triage anything found, record results in §8. - 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.tomlnightly pin, thin target bins, invariant logic infuzz/shared/(stable toolchain), 328 committed seeds viagen_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, not5..; (2) the negotiation framing layer is length-prefix-only —read_framereturns raw bytes and the adapter does the serde parse (adapter.rs:411), soNegotiationError::Jsonis unreachable from this reader (unlike alkcall's serde-backed envelope reader; theJsonarm 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/0on all ~28 fork jobs):chunk_frame380k execs (cov saturated at 710 edges within seconds — the 5-byte-parser ceiling is fully enumerated);negotiation_frame3.69M execs (cov 3157, still finding coverage at budget end — JSON structure exploration);control_json8.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-localFuzzBackend(the in-crateMockBackendis#[cfg(test)]-only) + the harness drivingdrive_session_pre_negotiatedover atokio::io::duplexpair, with a delivery model mirroring §4's invariants. 12 committed seeds via the deterministic generator (hand-encoded against thearbitrary1.4.x derive layout — variant tag(u32_le * 12) >> 32, fields LE zero-filled, keep-going byte gating eachVecelement; 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
ClientWriteafter 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 jobsoom/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.