Skip to content

fix(parser,adapter,resolver): canonical-ABI correctness sweep — flat_byte_size + LS-P-6/-7/-8/-9/-10/-11/-12/-13/-14/-15/-16/-17/-18/-19#179

Merged
avrabe merged 14 commits into
mainfrom
fix/flat-byte-size-variant-join
May 24, 2026
Merged

fix(parser,adapter,resolver): canonical-ABI correctness sweep — flat_byte_size + LS-P-6/-7/-8/-9/-10/-11/-12/-13/-14/-15/-16/-17/-18/-19#179
avrabe merged 14 commits into
mainfrom
fix/flat-byte-size-variant-join

Conversation

@avrabe
Copy link
Copy Markdown
Contributor

@avrabe avrabe commented May 22, 2026

Summary

Five parser.rs canonical-ABI correctness fixes — all surfaced by the mythos-auto delta-pass scanning parser.rs. The auto-runner did its job: across successive scans it found four real, confirmed memory-safety / ABI-correctness bugs (LS-P-6 through LS-P-9) plus one hygiene issue.

Fix 1 — flat_byte_size element-wise variant JOIN (hygiene)

flat_byte_size computed result<T,E> / variant payload width as max(flat_byte_size(arm)) instead of the Component Model element-wise flatten_variant JOIN. Rewritten over a new flat_width_list helper. Disposition: OOB-write impact claim rejected on validation — flat_byte_size has no in-tree consumers. Hygiene fix, no LS-N entry.

Fix 2 — LS-P-6: area-size accumulators wrapped (confirmed)

params_area_byte_size / return_area_byte_size used a bare += against canonical_abi_size_unpadded which saturates to u32::MAX (LS-P-4) — once the accumulator saturated, the next field's += overflowed (debug panic, release wrap to ~3), under-sizing the params buffer and yielding an OOB write in cabi_realloc. Both sites are now saturating_add.

Fix 3 — LS-P-7: conditional-pointer CopyLayout computed for composite, not leaf (confirmed)

collect_conditional_pointers / collect_conditional_result_pointers computed CopyLayout once on the whole payload type. copy_layout only special-cases bare string/list; any composite payload fell to _ => Bulk { byte_multiplier: 1 }. A list<u64> leaf inside option<tuple<u32, list<u64>>> was tagged Bulk { 1 } instead of Bulk { 8 } — a 7/8 under-copy [H-4.1]; pointer-containing Elements leaves collapsed to flat Bulk, dropping inner pointer fixup [H-4.2]. New collect_pointer_positions_with_layout / collect_pointer_byte_offsets_with_layout carry each leaf's own layout.

Fix 4 — LS-P-8: record/tuple field-walk added unpadded child size (confirmed)

The Component Model spec lays out a record/tuple as s = 0; for f in fields: s = align_to(s, alignment(f)); s += size(f) where size(f) for an aggregate field is its full padded size. ~25 field-walk sites — Record/Tuple arms of canonical_abi_size_unpadded, collect_pointer_byte_offsets, the LS-P-7 _with_layout helpers, collect_conditional_result_pointers, collect_return_area_type_slots, collect_resource_byte_positions, element_inner_pointers, element_inner_resources, plus the top-level walks in params_area_byte_size, return_area_byte_size, pointer_pair_*_offsets/slots, resource_*_positions, and conditional_pointer_pair_result_positions — advanced offset/size by canonical_abi_size_unpadded(field) (no trailing pad) instead of canonical_abi_element_size(field). The per-field align_up doesn't re-absorb the preceding pad when the next field has smaller alignment.

Concretely tuple<record{u32,u8}, u8> now computes element_size = 12 (spec) instead of 8; a list<u32> following record{u32,u8} now sits at offset 8 instead of 5. The wrong offsets flowed into FACT adapter pointer-pair loads, list-copy byte lengths, and inner pointer-fixup walks.

Mythos process note: the auto-runner mis-located this finding as the option/variant/result payload contribution (which is actually spec-correct — those arms already use canonical_abi_element_size). Independent clean-room verification corrected the location to the Record/Tuple field accumulation.

Fix 5 — LS-P-9: total_flat_params used Iterator::sum::<u32>() instead of saturating fold (confirmed)

The canonical-ABI calling convention is picked from total_flat_params: <= MAX_FLAT_PARAMS (16) → flat; > → params-ptr. flat_count for a FixedSizeList is saturating (LS-P-4), so a nested FixedSizeList can yield flat_count = u32::MAX. A bare .sum() then panics in debug on u32::MAX + 1 and wraps to a small value in release; the wrapped total compares <= 16 and the adapter selects the flat convention for a function that genuinely needs params-ptr — call-site lowering and callee-side lifting disagree on the ABI slot. The sibling area-size accumulators already use saturating_add per LS-P-6; this calling-convention picker was missed. Replaces .sum() with .fold(0u32, u32::saturating_add).

Tests

  • flat_byte_size_result_uses_element_wise_join_not_max
  • ls_p_6_area_byte_size_saturates_across_fields
  • ls_p_7_conditional_pointer_layout_is_per_leaf_not_per_composite
  • ls_p_8_record_tuple_field_accumulation_uses_padded_field_size
  • ls_p_9_total_flat_params_saturates_across_params
  • Full meld-core lib suite green: 253 tests, 0 failures
  • LS-N verification gate: 23/23 (LS-P-6, LS-P-7, LS-P-8, LS-P-9 all detected with matching tests)

🤖 Generated with Claude Code

The mythos-auto delta-pass on PR #178 flagged that flat_byte_size
computes the payload width of result<T,E> and variant as
`max(flat_byte_size(arm))` rather than the Component Model's
element-wise flatten_variant JOIN.

`max` of arm byte totals underestimates whenever the arms flatten
to a different *number* of core values. result<u64, string>: the
ok arm u64 flattens to [i64] (8 B), the err arm string to [i32,i32]
(8 B). The old form gave 4 + max(8,8) = 12, but the joined payload
is [i64, i32] (12 B) and the true flat size is 4 + 12 = 16.

Fix: flat_byte_size is rewritten over a new private flat_width_list
helper that materialises each type's flat core-value width list
and JOINs variant/result arms element-wise. Non-variant types are
byte-for-byte unchanged. flat_width_list caps its length at
FLAT_WIDTH_CAP (256); a type whose flattening exceeds the cap
yields None and flat_byte_size returns u32::MAX, preserving the
LS-P-4 saturation contract and bounding the helper's Vec against
the LS-P-4 OOM class. The LS-P-4 regression test still passes.

Disposition of the mythos-auto finding: the discover step claimed
an OOB-write hazard. Rejected on validation — flat_byte_size has
zero consumers in meld-core/src/; retptr return areas are sized by
return_area_byte_size, a different function. No reachable hazard,
no possible PoC, NOT a confirmed finding, no LS-N entry. This
commit fixes the underlying arithmetic anyway, as correctness
hygiene on a pub fn a future consumer could inherit.

Regression test flat_byte_size_result_uses_element_wise_join_not_max
pins result<u64,string>=16, an unequal-arity variant=16, the
equal-arms result<u32,u32>=8, and non-variant record/u64 unchanged.

Refs: mythos-auto finding on PR #178.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
@github-actions
Copy link
Copy Markdown

github-actions Bot commented May 22, 2026

Mythos delta-pass required

This PR modifies one or more Tier-5 source files (per
scripts/mythos/rank.md):

meld-core/src/adapter/fact.rs
meld-core/src/parser.rs
meld-core/src/resolver.rs

Before merge, run the Mythos discover protocol on the
modified Tier-5 files:

  1. Follow scripts/mythos/discover.md
    — one fresh agent session per touched Tier-5 file.
  2. For each finding, the agent must produce both a Kani
    harness and a failing PoC test (per the protocol's
    "if you cannot produce both, do not report" rule).
  3. Attach a comment on this PR with either the findings
    (formatted per discover.md's output schema) or
    NO FINDINGS.
  4. Add the mythos-pass-done label to this PR.

Why this gate exists: LS-A-10
(CABI alignment padding in async-lift retptr writeback) was
found by the v0.8.0 pre-release Mythos pass — but it had
lived in the callback emitter since #128, across six
releases. A PR-time gate would have caught it at review
time instead of at the release boundary.

The gate check on this PR will pass once the label is
applied.

@github-actions
Copy link
Copy Markdown

github-actions Bot commented May 22, 2026

LS-N verification gate

33/33 approved LS entries verified

count
Passed (≥1 test, all green) 33
Failed (≥1 test failure) 0
Missing (no ls_*_NN_* test found) 0

Approved loss-scenarios.yaml entries are expected to have a
regression test named ls_<letter>_<num>_* (e.g. LS-A-11
ls_a_11_*). The gate runs each prefix via cargo test --lib --no-fail-fast and aggregates pass/fail/missing.

Failed LS entries

(none)

Missing regression tests

(none)

Updated automatically by tools/post_verification_comment.py.
Source of truth: safety/stpa/loss-scenarios.yaml.

@github-actions
Copy link
Copy Markdown

github-actions Bot commented May 22, 2026

Mythos delta-pass (auto)

3 finding(s) across 3 Tier-5 file(s)

File Verdict Hypothesis
meld-core/src/adapter/fact.rs ❌ FINDING The FixedSizeList arm of cabi_size_align computes total inline size as es * n with a plain u32 multiply. In release mode this silently wraps when es * n exceeds u32::MAX. emit_ptr_pair_result_writeback calls cabi_size_align on the FixedSizeList inner elem when the function's result type is list<FixedSizeList(T, N)>, receiving the wrapped small value as elem_size. It then uses that value as the byte multiplier (byte_count = list_len * elem_size), allocating and memory.copy-ing a tiny fraction of the callee's actual data. The fused caller receives severely truncated list elements with no trap; composed execution sees the full data. Silent semantic drift.
meld-core/src/parser.rs ❌ FINDING copy_layout calls panic! instead of returning an error when its list element type contains a pointer-bearing conditional (option/result/variant with string/list payload), causing process abort for valid Component Model types such as list<option<string>>.
meld-core/src/resolver.rs ❌ FINDING The last-resort fallback (lines 1346–1355) unconditionally picks the lowest-type-id [resource-rep] entry when a component has two distinct resources and only one has a canonical mapping, silently assigning resource X's rep function to a call site that holds a resource Y handle — causing the adapter to call the wrong resource table at runtime.

Auto-run via anthropics/claude-code-action@v1
(SHA-pinned) on the touched Tier-5 files, using the
maintainer's Max-plan OAuth token. See
.github/workflows/mythos-auto.yml and
scripts/mythos/discover.md.

A confirmed Mythos finding — surfaced by the mythos-auto delta-pass
when it re-scanned parser.rs on PR #179.

params_area_byte_size and return_area_byte_size accumulate a
component function's canonical-ABI memory size field by field with
a bare `size += canonical_abi_size_unpadded(ty)`.
canonical_abi_size_unpadded saturates to u32::MAX for a
pathologically large fixed-length-list (the LS-P-4 fix). But LS-P-4
did not reach these two cross-field accumulators: once a first
field saturates `size` to u32::MAX, the next field's `+=` overflows
— debug build panics, release build wraps u32::MAX down to a small
value. params for `(fixed-length-list<f64, 2^29>, u32)` wrap
params_area_byte_size from u32::MAX to ~3. The resolver stores that
as AdapterRequirements::params_area_byte_size; the FACT adapter
passes it to cabi_realloc, allocates a few-byte buffer, and copies
every parameter into it — an OOB write into callee linear memory.

The sibling Record/Tuple accumulators inside
canonical_abi_size_unpadded already use saturating_add — these two
area-size loops were missed by LS-P-4.

Fix: both `+=` sites become `size = size.saturating_add(...)`. A
saturated field keeps the area size near u32::MAX, an
un-allocatable value, so cabi_realloc fails safely instead of
under-allocating.

Mythos oracle: ls_p_6_area_byte_size_saturates_across_fields panics
today on the bare `+=` (debug-build overflow at parser.rs:1613) and
asserts a saturated result after the fix. Promoted to approved loss
scenario LS-P-6 (UCA-P-3, H-2/H-4/H-4.1); nearest primitive-layer
proof is LS-P-4's kani_fixed_size_list_size_no_overflow harness.

Second finding from the auto-runner's parser.rs scan; unlike the
flat_byte_size finding in the same PR (dead code, no reachable
hazard), LS-P-6's impact path is live and confirmed.

Refs: LS-P-6, LS-P-4, mythos-auto finding on PR #179.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
@avrabe avrabe changed the title fix(parser): flat_byte_size — element-wise variant JOIN, not max fix(parser): canonical-ABI size correctness — flat_byte_size JOIN + LS-P-6 area-size saturation May 22, 2026
@avrabe
Copy link
Copy Markdown
Contributor Author

avrabe commented May 22, 2026

mythos-auto finding #3 — reviewed (plausibly real, pre-existing, tracked separately)

The auto-runner's third scan of parser.rs flagged a new FINDING — collect_conditional_pointers assigning the wrong CopyLayout. Validated per validate.md:

Plausibly real. collect_conditional_pointers (parser.rs:2369) computes the conditional-pointer layout for option<T> / result<T,E> payloads. For each inner pointer position it does copy_layout_for_string_or_list_at(ok_ty) — passing the whole composite payload type, then assigning that one layout to every position from collect_pointer_positions. copy_layout only special-cases bare String / List; a composite (Record/Tuple/FixedSizeList) hits the _ => Bulk{1} fallback. So a list<u32> field inside result<record{ items: list<u32> }, E> gets Bulk{1} instead of Bulk{4} → the adapter copies len * 1 bytes, ¼ of the data → under-copy / truncation (H-4 class). The correct per-field pattern already exists in element_inner_pointers; collect_conditional_pointers uses the whole-composite layout instead.

Not a one-line fix — it needs collect_conditional_pointers restructured to compute the layout per pointer position (per field), not once for the composite. Tracked as a dedicated task for its own validate (PoC + Kani) + LS-N entry + PR.

This is pre-existing and unrelated to #179. #179's changes are flat_byte_size (variant JOIN) and LS-P-6 (area-size saturation) — neither touches collect_conditional_pointers. The auto-runner re-flags it because it scans the whole parser.rs, not the diff.

Process note — whole-file scan treadmill

mythos-auto scans the entire Tier-5 file, so every parser.rs PR is blocked until every latent finding in parser.rs is fixed. Across #178/#179 the auto-runner has surfaced three: flat_byte_size (dead code, not a hazard), LS-P-6 (real OOB, fixed in #179), and now finding #3. Each fix surfaces the next. Worth deciding whether the gate should scan the PR diff rather than the whole file.

#179 status

12 substantive checks green (Test, Clippy, Coverage, Bench, Format, all 4 fuzz, LS-N gate 20/20, Detect Tier-5, Mythos pass). #179 delivers two sound parser.rs canonical-ABI fixes, one a confirmed OOB bug (LS-P-6) with an approved LS-N entry. Ready to merge pending a maintainer call on dispositioning finding #3 as a separate follow-up.

collect_conditional_pointers and collect_conditional_result_pointers
emit one ConditionalPointerPair per pointer leaf inside an
option/result/variant payload, but computed the CopyLayout once on the
whole payload type. copy_layout only special-cases bare string/list, so
any composite payload (record/tuple/fixed-list) fell to its
`_ => Bulk { byte_multiplier: 1 }` fallback — a list<u64> leaf was
tagged Bulk{1} instead of Bulk{8} (7/8 silent under-copy), and a
pointer-containing list<string> leaf collapsed from Elements to flat
Bulk, dropping recursive inner-pointer fixup.

Add collect_pointer_positions_with_layout / _byte_offsets_with_layout,
which carry each String/List leaf's own CopyLayout alongside its
position; remove the now-dead copy_layout_for_string_or_list_at shim.

Confirmed Mythos finding from the mythos-auto delta-pass on PR #179;
promoted to approved loss scenario LS-P-7. Regression pinned by
ls_p_7_conditional_pointer_layout_is_per_leaf_not_per_composite,
exercising both the flat-param and retptr byte-offset paths.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
@avrabe avrabe changed the title fix(parser): canonical-ABI size correctness — flat_byte_size JOIN + LS-P-6 area-size saturation fix(parser): canonical-ABI correctness — flat_byte_size JOIN + LS-P-6/-7 May 22, 2026
@avrabe
Copy link
Copy Markdown
Contributor Author

avrabe commented May 22, 2026

mythos-auto finding #4 — clean-room verified (real bug, but auto-runner mis-described it)

The latest delta-pass on 0ec063e flagged a 4th FINDING. I ran it through an independent clean-room verification (a fresh agent given only the claims and the source, no access to the auto-runner's reasoning). Result:

The bug is real — but it is NOT where the auto-runner said. The finding claimed the option/variant/result arms of canonical_abi_size_unpadded use canonical_abi_element_size instead of size(). Verification shows those arms are correct — they already use the padded element size for payloads, which is exactly the spec's size().

The actual bug is in the Record and Tuple arms. They accumulate each field as size = align_up(size, field_align); size += canonical_abi_size_unpadded(field) — adding the field's unpadded size. The Component Model canonical ABI specifies s += size(f), where size(f) for an aggregate field includes that field's own trailing alignment padding. When a higher-aligned aggregate field is followed by a lower-aligned field, the omitted trailing pad is not re-absorbed by the next field's align_up, so the following field's offset — and the whole aggregate's size — comes out too small.

Worked exampletuple<record { a: u32, b: u8 }, u8>:

  • meld canonical_abi_size_unpadded6 (trailing u8 placed at offset 5)
  • Component Model spec size()12 (trailing u8 at offset 8)

The wrong offsets/sizes propagate to collect_pointer_byte_offsets, params_area_byte_size, return_area_byte_size, and the LS-P-7 _with_layout collectors — every record/tuple field walk that advances a byte offset by the unpadded child size.

Minimal fix: advance by canonical_abi_element_size(field) (padded) rather than canonical_abi_size_unpadded(field) at each record/tuple field-accumulation site (~8 sites). canonical_abi_size_unpadded itself still returns the outer type without its own trailing pad — that contract is unchanged; only the per-field contribution is corrected.

Disposition

Process — the whole-file-scan treadmill

This is the 4th finding the whole-file scan has surfaced across #178/#179: flat_byte_size (dead code, not a hazard), LS-P-6 (real, fixed), LS-P-7 (real, fixed), and now finding #4. Each fixed finding exposes the next, because mythos-auto scans the entire Tier-5 file rather than the PR diff. No parser.rs PR can go green through this gate until every latent parser.rs bug is fixed. This needs a maintainer decision — recommend either switching the gate to diff-scan, or dispositioning the pre-existing findings so a PR is judged on its own diff.

The Component Model canonical ABI lays out a record/tuple as:
  s = 0; for each field f:
      s = align_to(s, alignment(f))
      s += size(f)
where size(f) for an aggregate field is its full padded canonical
size. In this codebase that full padded size is
canonical_abi_element_size; canonical_abi_size_unpadded is the outer
type minus its own trailing align-up.

~25 field-walk sites — the Record/Tuple arms of
canonical_abi_size_unpadded, collect_pointer_byte_offsets,
collect_pointer_byte_offsets_with_layout (LS-P-7), the conditional
result/resource/slot/inner-pointer/inner-resource collectors, and the
top-level params/results walks in params_area_byte_size,
return_area_byte_size, pointer_pair_*_offsets/slots, and
resource_*_positions — advanced offset/size by
canonical_abi_size_unpadded(field) instead of
canonical_abi_element_size(field). The per-field align_up does NOT
re-absorb a preceding field's omitted trailing pad when the next
field's alignment is smaller, so a record/tuple containing a padded
aggregate followed by a lower-aligned field came out smaller than the
spec.

Concretely tuple<record{u32,u8}, u8> now computes element_size = 12
(spec) instead of 8; a list<u32> following record{u32,u8} now sits at
byte offset 8 instead of 5. The wrong offsets had been flowing into
the FACT adapter's pointer-pair loads, list-copy byte lengths, and
inner pointer-fixup walks; the area-size functions also under-sized
the cabi_realloc buffer (LS-P-6 hazard class via the per-field
primitive rather than the cross-field +=).

canonical_abi_size_unpadded itself still returns the outer size minus
its own trailing pad — that contract is unchanged; only the per-field
contribution is corrected.

Confirmed Mythos finding from the mythos-auto delta-pass on PR #179
(the auto-runner mis-located it as the option/variant/result payload
contribution, which is actually spec-correct — independent clean-room
verification corrected the location). Promoted to approved loss
scenario LS-P-8. Regression pinned by
ls_p_8_record_tuple_field_accumulation_uses_padded_field_size.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
@avrabe avrabe changed the title fix(parser): canonical-ABI correctness — flat_byte_size JOIN + LS-P-6/-7 fix(parser): canonical-ABI correctness — LS-P-6/-7/-8 + flat_byte_size JOIN May 23, 2026
total_flat_params picks the canonical-ABI calling convention from the
total flat param count: <= MAX_FLAT_PARAMS (16) → flat; > → params-ptr.
It summed per-param flat_count values with Iterator::sum::<u32>().
flat_count for a FixedSizeList is saturating (LS-P-4), so a nested
FixedSizeList can yield flat_count = u32::MAX; sum() then panics in
debug on u32::MAX + 1 and wraps to a small value in release. The
wrapped total compares <= 16 and the adapter selects the flat
convention for a function that genuinely needs params-ptr —
call-site lowering and callee-side lifting disagree on the ABI slot.

Sibling area-size accumulators (params_area_byte_size /
return_area_byte_size) already use saturating_add per LS-P-6 — this
calling-convention picker was simply missed.

Replaces .sum() with .fold(0u32, u32::saturating_add).

Confirmed Mythos finding from the mythos-auto delta-pass on PR #179.
Promoted to approved loss scenario LS-P-9. Regression pinned by
ls_p_9_total_flat_params_saturates_across_params.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
@avrabe avrabe changed the title fix(parser): canonical-ABI correctness — LS-P-6/-7/-8 + flat_byte_size JOIN fix(parser): canonical-ABI correctness — LS-P-6/-7/-8/-9 + flat_byte_size May 23, 2026
…s (LS-P-10)

A ConditionalPointerPair for a pointer leaf inside a nested
option/result/variant payload — e.g. result<option<string>, u32>,
variant { a(option<string>), b(u32) }, option<option<string>> —
previously carried only the INNERMOST discriminant guard. The FACT
adapter processed each pair independently with a single (load,
compare, branch). When the runtime value sat in a sibling arm
(e.g. Err(some_u32) of the result), the byte at the option's
discriminant slot held unrelated payload bytes; if those bytes
happened to read as the inner discriminant value (1 = Some), the
adapter sampled the adjacent slots as a (ptr, len) string pair and
ran cabi_realloc + memory.copy with attacker-controlled source
pointer and length — an arbitrary cross-component memory read,
plus a forged string pointer handed to the callee.

Surfaced by the mythos-auto delta-pass on PR #179. Clean-room
independently verified as a real, exploitable memory-safety hazard
(validator traced the four fact.rs consumer loops and confirmed
each treats every pair's guard independently — no implicit AND
with any enclosing conditional).

Fix:
  * Add DiscriminantGuard struct + outer_guards: Vec<DiscriminantGuard>
    field to ConditionalPointerPair (innermost guard stays in the
    existing discriminant_* fields for backward compatibility — empty
    outer_guards behaves identically to the old single-guard path).
  * Thread outer_guards through collect_conditional_pointers and
    collect_conditional_result_pointers recursion: at each
    option/result/variant arm, build the current guard and append it
    to the chain before recursing into the payload; stamp each
    emitted pair with the prefix chain seen so far.
  * Two new fact-adapter helpers (emit_conditional_guard_chain_flat /
    emit_conditional_guard_chain_byte) emit each guard's (load disc,
    I32Const value, I32Eq) and I32And them all together before the
    existing If/copy block.
  * Update the four consumer loops in fact.rs (flat-param,
    flat-result, retptr-param, retptr-result) to call the helpers.

Promoted to approved loss scenario LS-P-10 (UCA-P-3, H-2/H-4/H-4.2).
Regression pinned by
ls_p_10_nested_conditional_pointer_carries_outer_guard_chain.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
@avrabe avrabe changed the title fix(parser): canonical-ABI correctness — LS-P-6/-7/-8/-9 + flat_byte_size fix(parser,adapter): canonical-ABI correctness — LS-P-6 through LS-P-10 May 23, 2026
@avrabe
Copy link
Copy Markdown
Contributor Author

avrabe commented May 23, 2026

mythos-auto post-LS-P-10 results — 3 new findings across 3 files

The mythos-auto scan after the LS-P-10 commit (8ea01a6) surfaced three more findings — one in each of parser.rs, adapter/fact.rs, and resolver.rs (the three Tier-5 files I touched). Initial code-read verdicts (not yet full clean-room verified):

Finding #7adapter/fact.rs line 4366–4370 (likely real, design needed)

&& site.requirements.pointer_pair_positions.iter().any(|_| true)

.iter().any(|_| true) is semantically !is_empty(). The auto-runner is correct that this turns "is there any pointer pair anywhere in this function?" into "this consecutive (i32, i32) slot is a pointer pair," corrupting plain integer args in async adapters. Plausibly real but the right fix is not mechanical — the surrounding code (4338–4344) explicitly says "the resolver's positions are in callee order; the adapter's locals are in caller order; instead of using resolver positions, compute them from the caller's flat param types." Replacing the broken any(|_| true) with any(|&pos| pos == i as u32) would re-introduce the very caller/callee-order disagreement the surrounding code was working around. Needs design — probably walk caller_type component types and identify string/list params directly, rather than guessing from flat (i32, i32) shapes.

Finding #8parser.rs::element_inner_pointers _ => {} arm (looks real)

element_inner_pointers (around line 3100, returns Vec<(byte_offset, CopyLayout)> for each pointer leaf within a list/array element type) handles String, List, FixedSizeList, Record, Tuple, Type — and falls to _ => {} for Option / Result / Variant. So list<option<string>>, list<result<string, _>>, and list<variant-with-string> are classified as CopyLayout::Bulk (no inner pointer fixup) instead of CopyLayout::Elements with the inner pointer descriptor. The adapter then does a flat byte copy, leaving callee list elements with stale string pointers into source memory. Same hazard class as LS-R-2 / LS-P-7, on the list-element-of-conditional path. Contained fix (~similar to LS-P-7 in scope).

Finding #9resolver.rs same-name export HashMap collision (separate file)

The auto-runner claims: "When two core modules within the same component export a function with the same name, the flat-name resolver's HashMap-based export index silently overwrites the first module's entry with the second (last-writer wins), routing any import of that name to the wrong module with no error or warning." I have not pinned down the exact line yet — resolver.rs uses several HashMaps; needs targeted reading.

#179 status

Five canonical-ABI correctness fixes landed: flat_byte_size hygiene + LS-P-6/-7/-8/-9/-10. 13 substantive CI checks green; only the mythos gate + label gate + a transient fuzz_parse_component sanitizer-musl failure remain red. 252→254 lib tests, LS-N gate 24/24.

Treadmill — decision point

After five real fixes the auto-runner found three more in the same files. Each parser/adapter/resolver PR re-surfaces every latent canonical-ABI bug in those whole files because mythos-auto scans the file, not the diff. We are now 9 confirmed-or-likely findings deep. #179 cannot reach a clean mythos pass through this strategy in any bounded number of commits. The options remain:

  1. Switch the mythos-auto gate to diff-scan — a PR is judged on its own changes. fix(parser,adapter,resolver): canonical-ABI correctness sweep — flat_byte_size + LS-P-6/-7/-8/-9/-10/-11/-12/-13/-14/-15/-16/-17/-18/-19 #179 merges; findings ci: add release workflow #7/fix(ci): use macos-14 for x86_64-apple-darwin release build #8/Add Nix flake for reproducible development and build environment #9 (and any remainder) become their own PRs.
  2. Disposition + merge fix(parser,adapter,resolver): canonical-ABI correctness sweep — flat_byte_size + LS-P-6/-7/-8/-9/-10/-11/-12/-13/-14/-15/-16/-17/-18/-19 #179 — apply mythos-pass-done (the human-review escape), track the remaining findings as separate work.
  3. Keep grinding — fix fix(ci): use macos-14 for x86_64-apple-darwin release build #8 next (easiest), then Add Nix flake for reproducible development and build environment #9, then ci: add release workflow #7's design problem. Best estimate, several more commits before parser/adapter/resolver are fully clean.

I'm stopping the autonomous loop here pending a maintainer call. The five pushed fixes are independently valuable and tested — they're not held by which option you pick.

avrabe and others added 3 commits May 23, 2026 14:48
…P-11)

resolve_via_flat_names populated its export index with a blind
HashMap::insert(key, …) where key is the flat export name. When two
core modules within one component both exported the same name, the
second silently overwrote the first (last-writer wins), routing any
importer of that name to the wrong module with no error or warning —
the fused module wired wrong but type-clean.

The instance-graph resolver (taken whenever the component has an
InstanceSection, which wit-component / wasm-tools always emit for
multi-module components) is immune. The vulnerable path is
practically unreachable for production components: defensive
hardening for the synthetic-fixture and legacy single-module
fallback shapes that take the flat-name path.

Fix: replace the blind insert with an explicit collision check that
returns a new Error::DuplicateModuleExport { component_idx,
export_name, first_module_idx, second_module_idx }, mirroring the
existing DuplicateModuleInstantiation pattern (resolver.rs:2115).

Confirmed Mythos finding from the mythos-auto delta-pass on PR #179,
clean-room verified. Promoted to approved loss scenario LS-P-11
(priority low — defensive hardening, not a security emergency).
Regression pinned by
ls_p_11_duplicate_flat_name_export_is_rejected.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
… mitigation)

element_inner_pointers's match has no arms for Option, Result, or
Variant (line 3203, `_ => {} // scalars, options, results — no
pointer pairs`). For list<option<string>> (and the Result/Variant
analogues), the helper returns an empty vector even though the
element type DOES contain a pointer. copy_layout(List(inner)) then
classifies as CopyLayout::Bulk { byte_multiplier: element_size } —
which the FACT adapter handles with a flat memory.copy and no
per-element walk. Every option's (ptr, len) pair was copied
byte-for-byte into the callee, with `ptr` still referencing the
source component's memory. The callee then dereferenced a wild
pointer per Some(...) element — a cross-memory dangling reference /
arbitrary read on every list use [H-4 / H-4.2].

Conservative mitigation: copy_layout's List(inner) arm now panics
with a clearly-labelled LS-P-12 message whenever
`type_contains_pointers(inner)` AND element_inner_pointers returns
empty — converting silent cross-memory dangling-reference into a
loud refusal at adapter-generation time.

The full structural fix requires (a) Option/Result/Variant arms on
element_inner_pointers that recurse into the payload at the payload
byte offset, AND (b) per-element DiscriminantGuard chains on the
inner-pointer descriptor (extending CopyLayout::Elements'
inner_pointers field), AND (c) FACT-side per-element guard
evaluation before each inner copy. That is structurally analogous
to LS-P-10 but on the list-element axis rather than the top-level
conditional axis — tracked as follow-up.

Confirmed Mythos finding from the mythos-auto delta-pass on PR #179,
clean-room verified. Promoted to approved loss scenario LS-P-12
(priority high). Regression pinned by
ls_p_12_list_of_option_string_refuses_rather_than_silently_corrupts,
ls_p_12_list_of_result_string_refuses, and the positive sanity test
ls_p_12_pure_scalar_option_list_is_still_bulk.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
… heuristic (LS-P-13)

emit_param_copy_step (the P3-async lift adapter's parameter copy
step, called from generate_async_callback_adapter and
generate_async_stackful_adapter) walked caller_type.params looking
for adjacent (i32, i32) slots and gated each match on
pointer_pair_positions.iter().any(|_| true) — semantically
!is_empty(). Every adjacent integer-pair argument was therefore
rewritten via cabi_realloc + cross-memory memory.copy as if it were
a (ptr, len) string/list, with one integer used as the source
pointer and the other as the byte count.

For `fn f(a: i32, s: string, b: i32, c: i32)` lowered to flat
[I32, I32, I32, I32, I32] with resolver positions [1], the buggy
code emitted positions = [0, 2]. It then ran:
  - cabi_realloc(0, 0, 1, ptr_s) — string ptr used as length;
  - memory.copy(new_ptr, a, ptr_s) — reading from caller address a;
  - cabi_realloc(0, 0, 1, b) + memory.copy(new_ptr, len_s, b).
The real string at flat index 1 was never copied. The callee saw
mangled integers, the original string contents weren't transferred,
and the copy could trap on the overflow guard or perform a
cross-memory read at an attacker-influenced address.

The resolver's pointer_pair_param_positions returns flat indices
computed by walking the function's params with flat_count.
Canonical lowering preserves param order between caller and callee
component types, so those flat indices apply equally to both sides.
The previous comment block claiming a "callee order vs caller
order" mismatch was misleading.

Replaces the heuristic walk with
site.requirements.pointer_pair_positions.clone(); the resolver
already produces the correct positions.

Confirmed Mythos finding from the mythos-auto delta-pass on PR
#179, clean-room verified. Promoted to approved loss scenario
LS-P-13 (priority high). Regression pinned by
ls_p_13_pointer_pair_param_positions_is_flat_indices_not_just_nonempty,
which asserts the resolver returns [1] for (a: u32, s: string, b:
u32, c: u32) and [1, 4] for the mixed (a, s1, b, s2, c) signature.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
@avrabe
Copy link
Copy Markdown
Contributor Author

avrabe commented May 23, 2026

mythos-auto post-LS-P-13 — 3 new findings (one false positive)

After LS-P-13 (a6cd2f3) the mythos verdict surfaced three more findings — one in each Tier-5 file again. Quick triage (code-read only; full clean-room verification not done for #10/#12 yet):

Finding #10adapter/fact.rs inner nested-list copy missing emit_overflow_guard (plausibly real)

Claim: "the inner nested-list copy loop computes buf_len = callee_len × sub_elem_size via a bare i32.mul with no emit_overflow_guard call; a callee-controlled len that causes 32-bit overflow wraps buf_len to a small value, the subsequent i32.add-based bounds check also wraps and is bypassed, the adapter allocates/copies only the wrapped byte count while the caller's bulk-copied len field retains the original large value — truncated nested list, semantic drift."

fact.rs has many I32Mul sites (~9 by grep). Some are gated on emit_overflow_guard (lines 1553, 1696 — the outer copy paths), some are not (e.g. lines 362, 372, 403 in the inner element-fixup helpers). Plausibly real — needs targeted code-read + clean-room verification.

Finding #11parser.rs::canonical_abi_size_unpadded Record/Tuple field accumulation (FALSE POSITIVE)

Claim: "the Record and Tuple cases advance the size accumulator by canonical_abi_element_size(field) — the trailing-padded element stride — instead of canonical_abi_size_unpadded(field) — the raw spec size(t) — inflating computed record sizes…"

The auto-runner has the Component Model spec terminology inverted. From canonical-abi.py:

def record_size(fields):
  s = 0
  for f in fields:
    s = align_to(s, alignment(f.t))
    s += size(f.t)
  return align_to(s, alignment_record(fields))   # <-- final align_to

The spec's size(record) is align_to(running_s, alignment_record)WITH the trailing pad. In this codebase:

  • canonical_abi_size_unpadded(t) = running_s before the final align_to = spec size(t) minus trailing pad.
  • canonical_abi_element_size(t) = align_up(canonical_abi_size_unpadded(t), align(t)) = spec size(t).

The per-field accumulator s += size(f.t) should therefore add canonical_abi_element_size(f.t) — exactly what LS-P-8 fixed. The auto-runner's claim that _unpadded is "the raw spec size(t)" directly contradicts the spec. Reverting LS-P-8 would re-introduce a real bug.

The LS-P-8 PoC (ls_p_8_record_tuple_field_accumulation_uses_padded_field_size) pins spec-derived values: element_size(tuple<record{u32,u8}, u8>) == 12, params_area_byte_size([record{u32,u8}, tuple<R_in,u8>]) == 20, list<u32> after record{u32,u8} at offset 8. Those numbers are unambiguous spec outputs.

Disposition: dispute. No fix. Recommend either retraining the auto-runner's spec knowledge on this point, or adding a regression LS-N test the auto-runner can examine.

Finding #12resolver.rs::unwrap_or(true) resource-type bounds fallback (plausibly real, low impact)

Claim: "When pos.resource_type_id as usize exceeds component.component_type_defs.len(), the unwrap_or(true) fallback (line 1376) silently classifies the resource as callee-defined, causing wrong [resource-rep] / [resource-new] swap."

The code at 1373-1376:

component.component_type_defs
    .get(pos.resource_type_id as usize)
    .map(|def| !matches!(def, crate::parser::ComponentTypeDef::Import(_)))
    .unwrap_or(true)

Plausibly real — an out-of-bounds resource_type_id (parser bug, stale index, or adversarial input) does fall through to true ("callee-defined"). Reachability and severity need clean-room verification. Likely a defensive hardening like LS-P-11 rather than a memory-safety emergency.

Treadmill state

Nine fixes pushed (flat_byte_size + LS-P-6 through LS-P-13). 14 substantive CI checks green: Test, Clippy, Coverage, Bench, Format, all 4 fuzz, LS-N verification (27/27), Detect Tier-5, all 3 per-file Mythos passes. Two reds remain: the mythos aggregate verdict + the label-only gate. The auto-runner has now produced a demonstrable false positive (#11), which changes the calculus — the gate cannot be cleared by "fix what the auto-runner says" alone if it produces spec-contradicting findings.

I'm stopping the autonomous loop here. The three options stand:

  1. Switch the mythos-auto gate to diff-scan — addresses both the whole-file-scan treadmill and reduces false-positive surface area per PR.
  2. Disposition + merge fix(parser,adapter,resolver): canonical-ABI correctness sweep — flat_byte_size + LS-P-6/-7/-8/-9/-10/-11/-12/-13/-14/-15/-16/-17/-18/-19 #179 — apply mythos-pass-done (the human-review escape), dispute Rocq proofs for CopyLayout and recursive pointer fixup adapters #11 as wrong, track Add wit-bindgen fixtures for remaining type families #10 and CI: auto-generate wit-bindgen fixtures instead of checking in binaries #12 as separate work.
  3. Keep grinding — verify and fix Add wit-bindgen fixtures for remaining type families #10 and CI: auto-generate wit-bindgen fixtures instead of checking in binaries #12 (and any feat(safety): STPA analysis, safety requirements, and 30 new tests #13 the next scan produces), dispute Rocq proofs for CopyLayout and recursive pointer fixup adapters #11.

I lean toward option 2 in the short term plus option 1 as the structural change, but the call is yours.

avrabe and others added 4 commits May 24, 2026 06:26
… (LS-P-14)

emit_patch_nested_indirections computes the inner-list buf_len for
each per-element cabi_realloc + memory.copy by loading the
callee-supplied len and multiplying by sub_elem_size with a bare
i32.mul. i32.mul is modulo 2^32, so a callee-controlled len near
u32::MAX / sub_elem_size wrapped buf_len to a small value. The
subsequent old_ptr + buf_len > mem_bytes bounds check used i32.add
(also wrapping) and was bypassed. The adapter allocated/copied
only the wrapped byte count while the caller-side bulk copy of the
outer (ptr, len) retained the original large len — silent
truncation of the inner list contents, plus OOB read/write into
adjacent caller-allocated memory on every dereference past the
truncated edge.

The emit_overflow_guard helper (added as the LS-A-7 leg (a) fix for
the outer copy paths) was never retrofitted to the inner copy. Fix
stashes the loaded len into the existing l_buf_len scratch local,
calls emit_overflow_guard(body, l_buf_len, sub_elem_size) which
traps via `unreachable` when the multiplication would wrap, then
re-fetches the local for the multiplication. The guard is a no-op
when sub_elem_size == 1.

Confirmed Mythos finding from the mythos-auto delta-pass on PR
#179. Promoted to approved loss scenario LS-P-14 (priority high).
Regression pinned by
ls_p_14_nested_list_inner_copy_emits_overflow_guard, which emits
a synthetic patch loop for `record { items: list<u32> }`
(sub_elem_size = 4) and asserts the encoded function body contains
an Unreachable opcode — the only place that opcode is emitted
along this path is inside emit_overflow_guard.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
…S-P-15)

resolve_resource_positions decided callee-vs-caller resource
ownership via:

    component.component_type_defs
        .get(pos.resource_type_id as usize)
        .map(|def| !matches!(def, ComponentTypeDef::Import(_)))
        .unwrap_or(true)

When `resource_type_id` exceeded `component_type_defs.len()` —
stale id, alias remap past the local table, malformed input —
`.get(...) → None` and `unwrap_or(true)` silently classified the
resource as callee-defined. The adapter then emitted a
[resource-rep] call where [resource-new] was correct (or vice
versa), swapping the two handle-conversion sides on every fused
cross-component call passing that handle. The handle type-checks
on both sides (both i32-shaped), so the validator doesn't catch
it; the error only surfaces when the handle is dereferenced at
runtime.

Reachability is bounded — the instance-graph path keys
resource_type_id through validated parser-produced indices, so
this is defensive hardening rather than a memory-safety emergency.

Fix replaces the unwrap_or(true) with an explicit match on
.get(...): Some(def) classifies by type; None emits a log::warn!
and `continue`, dropping the position. The downstream adapter
either finds no work for the unused slot or surfaces a loud
missing-fixup error at adapter generation — never silently swaps.

Confirmed Mythos finding from the mythos-auto delta-pass on PR
#179. Promoted to approved loss scenario LS-P-15 (priority low).
Regression pinned by
ls_p_15_out_of_bounds_resource_type_id_is_dropped_not_misclassified,
which builds a synthetic ResourceImportMap whose lookup succeeds
at resource_type_id 999, calls resolve_resource_positions against
an empty component_type_defs, and asserts the returned Vec is
empty (pre-fix: 1 mis-classified entry; post-fix: dropped).

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
…e (LS-P-16)

emit_utf16_to_utf8_transcode's surrogate-pair If arm
unconditionally emitted a second I32Load16U at
mem16[ptr + (src_idx + 1) * 2] whenever the first code unit fell
into [0xD800, 0xDC00) (high surrogate). The loop's only bounds
check guarded the first code unit per iteration. For input whose
last code unit was a lone high surrogate, the second load read
2 bytes past the caller-supplied UTF-16 buffer; those bytes were
treated as the low surrogate (without validating they actually
were a low surrogate) and packed into a 4-byte UTF-8 sequence
written to callee memory — silent cross-memory leak of
attacker-adjacent caller bytes into the callee's transcoded
output per UTF-16→UTF-8 string transfer.

Reachable for any cross-memory UTF-16-caller / UTF-8-callee
fusion (line 614, 2999-3001) whose UTF-16 string ends on a high
surrogate, including malformed input from JS or trunctated
strings.

This is the conservative mitigation: inject a `src_idx + 1 >=
input_len` check inside the surrogate-pair If arm and
unreachable-trap on failure. The Canonical-ABI-correct behaviour
replaces the lone surrogate with U+FFFD (3-byte UTF-8 EF BF BD)
and continues; tracked as a structural follow-up.

Confirmed Mythos finding from the mythos-auto delta-pass on PR
#179, independently clean-room verified. Promoted to approved
loss scenario LS-P-16 (priority high). Regression pinned by
ls_p_16_utf16_lone_high_surrogate_oob_guard_emitted, a
structural test that requires the LS-P-16 marker AND an
Unreachable + I32GeU opcode pair to live inside the
surrogate-pair If arm before the second I32Load16U.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Two structurally-identical caller-encoding lookup loops (primary
~2877-2910, fallback ~3175-3225) filtered from_component.imports
on ComponentTypeRef::Func(_) to find a caller's Lower options for
each resolved interface. WIT interface imports lower to
ComponentTypeRef::Instance(_), so the loops never matched for
typical wit-component / wasm-tools output and fell through to a
heuristic min_by_key over caller_lower_map — picking the
lowest-indexed Lower's encoding for every interface.

Single-encoding callers (the common case) get the right answer by
coincidence. Mixed-encoding callers (e.g. a JS/.NET host
component lowering UTF-16 to one import alongside Rust UTF-8 to
another) get string_transcoding silently miscalibrated for one or
more interfaces, producing scrambled strings at the call
boundary.

This is the conservative mitigation: detect mixed-encoding
callers (values() not all identical) before the heuristic fires
and emit a log::warn! with the LS-P-17 marker and interface name.
Single-encoding callers see no behavioural change. The full
structural per-interface attribution (resolve caller component
func index from the core import via a caller_core_import_to_comp_func
map, OR extend the filter to walk ComponentTypeRef::Instance
aliases) is tracked as follow-up.

Confirmed Mythos finding from the mythos-auto delta-pass on PR
#179, independently clean-room verified. Promoted to approved
loss scenario LS-P-17 (priority low — latent for single-encoding
callers). Regression pinned by
ls_p_17_mixed_caller_encoding_warns_before_heuristic_fallback,
a structural test that requires the LS-P-17 marker at both
sites and the warn-on-mixed pattern (all_same uniformity check
+ log::warn!) to be present.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
@github-actions github-actions Bot added the mythos-pass-done Mythos delta-pass completed on Tier-5 file changes; findings (or NO FINDINGS) attached to PR label May 24, 2026
LS-P-18 — `copy_layout(List(inner))` LS-P-12 mitigation was
bypassed when a record mixed a covered pointer (bare string/list)
with a hidden conditional pointer (option<string>): the covered
field made `element_inner_pointers` non-empty, the emptiness-based
LS-P-12 panic didn't fire, and `CopyLayout::Elements.inner_pointers`
silently omitted the option-payload pointer. Adapter never fixed
up the conditional payload across memories — callee elements
retained source-memory string pointers per Some(_).

Replaces the emptiness check with deep recursive
`has_pointer_bearing_conditional(inner)` that walks Option /
Result / Variant arms through Records, Tuples, FixedSizeLists,
and Type aliases. Any pointer-bearing conditional anywhere in the
element layout now triggers a panic with the LS-P-18 marker
(message intentionally includes "LS-P-12" via the follow-up
phrasing so the existing should_panic(expected="LS-P-12") tests
continue to pass).

LS-P-19 — `emit_utf8_to_utf16_transcode` (mirror of LS-P-16, UTF-8
direction): the outer-loop bounds-check guarded only the lead
byte; each multi-byte branch (2/3/4-byte) unconditionally read
continuation bytes at src_idx + 1/+2/+3 via I32Load8U. A UTF-8
string ending on a truncated multi-byte lead caused the adapter
to read 1–3 bytes of attacker-adjacent caller memory and fold
them into a synthesized code point emitted as UTF-16 in the
callee. Conservative mitigation prepends an
`src_idx + N >= input_len` unreachable trap to each multi-byte
branch (N = 1, 2, 3).

Both confirmed Mythos findings from the mythos-auto delta-pass on
PR #179. Clean-room-verified disposition: LS-P-12 refinement
gap (real), UTF-8 OOB (real, mirror of LS-P-16). LS-P-18 promoted
to priority high; LS-P-19 promoted to priority high. Regression
pinned by:
  - ls_p_18_mixed_record_with_option_string_bypasses_p12_then_refuses
  - ls_p_18_pure_bare_pointer_record_still_works (positive sanity)
  - ls_p_19_utf8_to_utf16_continuation_byte_oob_guard_emitted

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
@avrabe avrabe changed the title fix(parser,adapter): canonical-ABI correctness — LS-P-6 through LS-P-10 fix(parser,adapter,resolver): canonical-ABI correctness sweep — flat_byte_size + LS-P-6/-7/-8/-9/-10/-11/-12/-13/-14/-15/-16/-17/-18/-19 May 24, 2026
@avrabe avrabe merged commit ecbe407 into main May 24, 2026
15 of 16 checks passed
@avrabe avrabe deleted the fix/flat-byte-size-variant-join branch May 24, 2026 10:54
@avrabe avrabe mentioned this pull request May 24, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

mythos-pass-done Mythos delta-pass completed on Tier-5 file changes; findings (or NO FINDINGS) attached to PR

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant