- 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.
16 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 (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_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
├── 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-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, 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
- 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.
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.
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.