Skip to content

FEAT-043 (v2.6): weight shadow-stack bound by resolved call_indirect targets#79

Merged
avrabe merged 5 commits into
mainfrom
feat-043-indirect-stack-weight
Jun 27, 2026
Merged

FEAT-043 (v2.6): weight shadow-stack bound by resolved call_indirect targets#79
avrabe merged 5 commits into
mainfrom
feat-043-indirect-stack-weight

Conversation

@avrabe

@avrabe avrabe commented Jun 27, 2026

Copy link
Copy Markdown
Contributor

Final feature of v2.6.0 (DD-016 slice-3). Tightens the worst-case shadow-stack bound.

What

  • The stack longest-path weighted every call_indirect by the whole table. Now it uses the resolved target set (FEAT-006 index resolution) — a sound superset, tighter when the index is constrained.
  • resolved_stack_callees: for a gap-free function (FEAT-040), the union of its call_graph edges' resolved_targets; a function with a gap (degraded / havocked → calls maybe unrecorded) falls back to the conservative whole-table. Recursion + topo stay conservative.
  • Enabler: model global.get (push ⊤) / global.set (pop) instead of degrading. Stack prologues read the SP global, so frame-bearing functions used to fully degrade (losing index resolution); now they stay analyzed — a general precision win too.

Soundness

resolved_targets ⊇ concrete (SCRY-001); the gap-gate never drops a callee; recursion stays conservative. The bound never under-counts. Globals modeled as untracked ⊤ (sound); locals/stack unaffected.

Test: feat043_… — entry (8) → const-index call_indirect → table[0]=$small(16) ⇒ 24, not the whole-table 72. 46 core tests (all 6 FEAT-021 stack tests unchanged). Traces DD-016. release: v2.6.0.

🤖 Generated with Claude Code

avrabe and others added 3 commits June 27, 2026 12:46
…ect targets

DD-016 slice-3. The worst-case shadow-stack longest-path previously weighted
EVERY call_indirect by the WHOLE table (build_static_call_graph). Now it uses the
RESOLVED target set (FEAT-006 index-interval resolution) — a sound superset,
tighter when the index is constrained (a constant index → one table entry).

- resolved_stack_callees: per gap-free function, the union of its call_graph
  edges' resolved_targets (sound superset, SCRY-001); a function WITH a FEAT-040
  gap (degraded / control-region havoc → possibly unrecorded calls) falls back
  to the conservative whole-table static_callees. Recursion + topo stay on
  static_callees (conservative). Sound: resolved_targets ⊇ concrete and the
  gap-gate never drops a callee.
- Enabler: model global.get (push ⊤) / global.set (pop) in the interpreter
  instead of degrading the function. Stack prologues read the SP global, so
  every frame-bearing function previously fully degraded (losing its
  call_indirect index resolution); now they stay analyzed. Sound (globals are
  untracked ⊤; locals/stack unaffected by global ops). Also a general precision
  win — prologue functions keep their interval/relational facts.

Test feat043_indirect_stack_weighted_by_resolved_target: entry (frame 8) with a
const-index call_indirect to table[0]=$small(16) is bounded 8+16=24, not the
whole-table 8+64=72. 46 core tests (incl. all 6 FEAT-021 stack tests unchanged);
clippy + fmt clean. FEAT-043 accepted (release v2.6.0).

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

Clean-room found a real UNDER-COUNT: a void call (empty write-set) inside an
`if` region is havocked, so its edge is never recorded in call_graph — yet the
function had no gap (the gap was gated on write-set non-empty), so
resolved_stack_callees trusted the incomplete call_graph and DROPPED the
callee's frame (reported 256 vs true 264). The downstream memory reservation
(synth#383) would have been too small.

Fix: havoc_region records a gap when the region contains ANY call op
(Call/CallIndirect/ReturnCall/ReturnCallIndirect), not only when it widens a
local — restoring the invariant "a gap-free function's call_graph is complete"
that resolved_stack_callees (and the FEAT-040 gap report) rely on. The gapped
function falls back to the conservative whole-table static_callees → sound 264.

Also documented the pre-existing FEAT-036-class limitation (passive/declared
elem segments under-report indirect targets in BOTH static_callees and
resolved_targets — FEAT-043 is no less sound than the prior whole-table
weighting on that case; tracked separately).

Regression test feat043_call_in_havocked_if_not_dropped (264, not 256). 47 core
tests; clippy + fmt clean.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…oom concern #1)

A function dispatching an arbitrary funcref (call_ref / return_call_ref,
function-references proposal) calls a target neither the resolved nor the
whole-table graph enumerates, so its stack contribution is genuinely UNKNOWN —
report StackBound::Unknown rather than a false finite bound that omits the
funcref-dispatched subtree. Unknown propagates soundly through sb_add/sb_max
(and Unbounded still dominates). Closes the remaining funcref-dispatch
under-count flagged alongside the return_call finding; the passive/declared-elem
table under-report stays documented as a pre-existing FEAT-036-class limitation.

49 core tests; clippy + fmt clean.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
@github-actions

github-actions Bot commented Jun 27, 2026

Copy link
Copy Markdown

📐 rivet artifact delta

PR: #79 Base SHA: c99e8fb0

Validation

head — `rivet validate` result
  SYS-4 (system-req, status: accepted) — missing: sys-integration-verification
  SR-9 (sw-req, status: accepted) — missing: unit-verification, sw-integration-verification
  SR-6 (sw-req, status: accepted) — missing: unit-verification, sw-integration-verification
  SR-13 (sw-req, status: accepted) — missing: unit-verification, sw-integration-verification
  SR-7 (sw-req, status: accepted) — missing: unit-verification, sw-integration-verification
  SR-5 (sw-req, status: accepted) — missing: unit-verification, sw-integration-verification
  SYS-1 (system-req, status: accepted) — missing: sys-integration-verification
  SR-12 (sw-req, status: accepted) — missing: unit-verification, sw-integration-verification
  SR-8 (sw-req, status: accepted) — missing: unit-verification, sw-integration-verification
  SR-1 (sw-req, status: accepted) — missing: unit-verification, sw-integration-verification
  SYS-3 (system-req, status: accepted) — missing: sys-integration-verification
  SR-2 (sw-req, status: accepted) — missing: unit-verification, sw-integration-verification
  SR-3 (sw-req, status: accepted) — missing: unit-verification, sw-integration-verification
  SR-11 (sw-req, status: accepted) — missing: unit-verification, sw-integration-verification
  SR-4 (sw-req, status: accepted) — missing: unit-verification, sw-integration-verification
  SYS-5 (system-req, status: accepted) — missing: sys-integration-verification
  → run `rivet validate --explain SR-10` to see which link type and source types satisfy a gap

Result: PASS (74 warnings)
Schemas: common@0.3.0 (embedded), dev@0.2.0 (embedded), research@0.1.0 (embedded), research-ext@0.1.0 (on-disk), safety-case@0.1.0 (embedded), aspice@0.2.0 (embedded)
base — `rivet validate` result (for comparison)
  SYS-4 (system-req, status: accepted) — missing: sys-integration-verification
  SR-9 (sw-req, status: accepted) — missing: unit-verification, sw-integration-verification
  SYS-5 (system-req, status: accepted) — missing: sys-integration-verification
  SR-1 (sw-req, status: accepted) — missing: unit-verification, sw-integration-verification
  SR-2 (sw-req, status: accepted) — missing: unit-verification, sw-integration-verification
  SR-13 (sw-req, status: accepted) — missing: unit-verification, sw-integration-verification
  SR-8 (sw-req, status: accepted) — missing: unit-verification, sw-integration-verification
  SR-4 (sw-req, status: accepted) — missing: unit-verification, sw-integration-verification
  SR-11 (sw-req, status: accepted) — missing: unit-verification, sw-integration-verification
  SYS-3 (system-req, status: accepted) — missing: sys-integration-verification
  SR-6 (sw-req, status: accepted) — missing: unit-verification, sw-integration-verification
  SR-7 (sw-req, status: accepted) — missing: unit-verification, sw-integration-verification
  SYS-1 (system-req, status: accepted) — missing: sys-integration-verification
  SYS-2 (system-req, status: accepted) — missing: sys-integration-verification
  SR-10 (sw-req, status: accepted) — missing: unit-verification, sw-integration-verification
  SR-3 (sw-req, status: accepted) — missing: unit-verification, sw-integration-verification
  → run `rivet validate --explain SR-12` to see which link type and source types satisfy a gap

Result: PASS (74 warnings)
Schemas: common@0.3.0 (embedded), dev@0.2.0 (embedded), research@0.1.0 (embedded), research-ext@0.1.0 (on-disk), safety-case@0.1.0 (embedded), aspice@0.2.0 (embedded)

Artifact stats

base head
Total artifacts 203 203
full stats — head
Artifact summary:
  academic-reference               24
  competitive-analysis             11
  design-decision                  17
  feature                          58
  market-finding                    7
  requirement                      17
  safety-context                    3
  safety-goal                       5
  safety-justification              3
  safety-solution                   6
  safety-strategy                   1
  stakeholder-req                   3
  sw-req                           13
  sw-verification                  13
  sys-verification                  5
  system-req                        5
  technology-evaluation            12
  TOTAL                           203

Orphan artifacts (no links): 11
  CA-001
  CA-002
  CA-003
  CA-004
  CA-005
  CA-006
  CA-007
  CA-008
  CA-009
  CA-010
  CA-011

Diagnostics: 0 error(s), 74 warning(s), 16 info(s)

Diff (base → head)

~ FEAT-043
  status: - proposed -> + accepted

0 added, 0 removed, 1 modified, 202 unchanged

AADL model — head

spar/scry.aadl: OK

Posted by the rivet-delta workflow. Informational only — does not gate the PR.

avrabe and others added 2 commits June 27, 2026 13:16
A call_indirect/return_call_indirect against a table scry cannot fully
enumerate must yield StackBound::Unknown, not a finite under-count that
silently drops the dispatched callee's frame. Unenumerable = a non-zero
table index, or a table-0 whose contents are not fully known (growable,
or populated by a passive/non-const elem segment).

compute_stack_usage now takes table0_contents_known and the
has_unresolvable_call guard covers CallRef|ReturnCallRef and
CallIndirect|ReturnCallIndirect{table_index!=0 || !contents_known}.

Regression: feat043_unenumerable_indirect_is_unknown (table-1 dispatch ->
Unknown). feat043_indirect_stack_weighted_by_resolved_target switched to a
bounded (non-growable) table so it still exercises the finite resolved-
weighting path.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…an-room #4)

The element-segment shape gives the table's INITIAL contents, but a Wasm
table is mutable module-global state. If any defined function can write
table 0 (table.set/fill/grow on it, or it is the dst of table.copy/init),
an active-segment-resolved slot may be overwritten at runtime with a
deeper-framed callee — so resolving call_indirect to the declared target
under-counts the stack.

Fix: after the body-collection pass, scan for any table-0 mutator and, if
present, clear func_table.contents_known. This makes every downstream
consumer (resolved stack weighting, interpret-time resolution, and the
compute_stack_usage Unknown guard) fall back to the conservative whole-
table over-approximation / StackBound::Unknown.

Regression: feat043_runtime_table_mutation_is_unknown (table.set overwrites
slot0 with a 4096-byte-frame callee -> Unknown, not Bytes under-count).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
@avrabe avrabe merged commit e75ce06 into main Jun 27, 2026
10 checks passed
@avrabe avrabe deleted the feat-043-indirect-stack-weight branch June 27, 2026 18:43
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant