Skip to content

chunk: exhaustive OpCode operand tables close the verifier drift (#737) - #804

Merged
InauguralPhysicist merged 1 commit into
mainfrom
fix-737-opcode-tables-exhaustive
Aug 2, 2026
Merged

chunk: exhaustive OpCode operand tables close the verifier drift (#737)#804
InauguralPhysicist merged 1 commit into
mainfrom
fix-737-opcode-tables-exhaustive

Conversation

@InauguralPhysicist

Copy link
Copy Markdown
Collaborator

Closes #737.

The fix: silent-wrong → build error

The three opcode operand-layout tables (op_name, op_verify_operands, op_stack_effect) all switched on uint8_t op with a default: arm, so -Werror=switch — already in CFLAGS — structurally could not apply. Each is now an exhaustive switch ((OpCode)op) with no default: arm: a new opcode is a compile error at every site, using enforcement the Makefile already pays for. This is the same instinct as the #630 operand-width lesson — make the drift impossible, not just fixed.

The three drifts it closed

  1. The verifier hole (security-relevant). op_verify_operands was missing OP_TRAJECTORY_SLOT while its NAME sibling was present, so the untrusted-chunk verifier walked that 3-byte instruction as 1 byte and marked its two operand bytes as valid instruction boundaries — a crafted jump could land mid-instruction and pass pass 2. This is the exact #721 surface, reopened through the drifted table. Now verified.
  2. op_name printed 4 opcodes as "???" (REPORT_SLOT, REPORT_NAME, OBSERVE_VALUE_SLOT, OBSERVE_VALUE_NAME). The names are now derived from the enum spellings (#op + 3), so a name cannot drift from its opcode again.
  3. The disassembler desynced on 15 opcodes. Its separate op_has_u16 boolean had drifted on 8 single-operand opcodes and structurally couldn't express the multi-operand superinstructions. op_has_u16 is deleted; chunk_disassemble now steps off the verifier's operand table — one layout source, not two.

Red-then-green

test_vm_run_bytecode.eigs gains a planted-fault gate for the verifier hole: a chunk whose JUMP lands inside OP_TRAJECTORY_SLOT's operand is refused (returns null). I verified it bites by reintroducing the default: arm and removing the TRAJECTORY_SLOT case — the chunk then passed verification and ran to 7. (Note the exhaustive switch means the pre-fix state can only be reconstructed by also re-adding a default: arm — removing just the case is now a build error, which is the point.) A positive control (jump one instruction past the operand → accepted, returns 7) proves the fix doesn't blanket-reject trajectory-bearing chunks.

Deliberately not in this PR (from the issue's "also noted")

  • Collapsing the tables into one X-macro row (name + widths + stack effect). A larger refactor; the exhaustive-switch change already achieves the drift-proofing goal, and the tables now fail the build in lockstep. Worth doing, but separately.
  • The three skill paths in CLAUDE.md (eigenscript-extend-vm, eigenscript-jit, aot-differential). These are not missing — they exist as hq roles loaded via the Skill tool (I used two of them while writing this fix), just not as files under the repo's .claude/skills/. Accurate for the maintainer environment; a contributor-facing clarification is a docs question for a separate change.
  • _Static_assert coverage. The #630 operand-width break is already gated by test_vm_run_bytecode.eigs (it pins OP_LINE's 4-byte operand); broader static-assert hardening is its own hardening pass.

Validation

  • Builds clean under -Werror=switch (the whole mechanism); make jit-smoke green.
  • Release suite 3364/3364; ASan+UBSan detect_leaks=1 suite 3368/3368, leak tally 0.
  • Planted fault confirmed: the verifier assertion fails (got 7, expected null) against a reintroduced default: arm.

🤖 Generated with Claude Code

op_name, op_verify_operands, and op_stack_effect each switched on
uint8_t with a default: arm, so -Werror=switch (already in CFLAGS)
could not enforce completeness — and three had already drifted:

- op_verify_operands was missing OP_TRAJECTORY_SLOT while its NAME
  sibling was present. The untrusted-chunk verifier then walked that
  3-byte instruction as 1 byte and marked its two operand bytes as
  valid instruction boundaries, so a crafted jump could land
  mid-instruction and still pass pass 2 — the #721 surface, reopened.
- op_name printed 4 opcodes (REPORT_SLOT/REPORT_NAME/
  OBSERVE_VALUE_SLOT/OBSERVE_VALUE_NAME) as "???".
- the disassembler's separate op_has_u16 boolean had drifted on 8
  opcodes and could not express the multi-operand superinstructions,
  desyncing on 15.

All three now switch on OpCode with NO default arm — a new opcode is a
build error at each site, using enforcement the Makefile already pays
for. op_name's strings are derived from the enum spellings (#o + 3), so
a name cannot drift from its opcode. The disassembler is driven off the
verifier's operand table (op_has_u16 deleted) — one layout source, not
two.

Red-then-green: test_vm_run_bytecode.eigs asserts a jump into
OP_TRAJECTORY_SLOT's operand is refused; on the drifted table (verified
by reintroducing the default arm) that chunk ran to 7.

Not done here (tracked in #737): collapsing the tables into one X-macro
row, and the CLAUDE.md skill-path note (the three cited skills exist as
hq roles, loaded via the Skill tool, not files under .claude/skills).

Closes #737

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Copilot AI review requested due to automatic review settings August 2, 2026 07:47

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

This PR hardens the bytecode verifier/disassembler metadata against opcode-table drift by making operand/name/stack-effect mappings compile-time enforced (exhaustive switch over OpCode with no default:), closing a verifier hole that could allow jumps into operand bytes.

Changes:

  • Make op_name, op_verify_operands, and op_stack_effect exhaustive switch ((OpCode)...) implementations so missing opcodes become -Werror=switch build failures.
  • Remove the disassembler’s separate operand-width logic and drive chunk_disassemble stepping from the verifier’s operand-layout table (single source of truth).
  • Add a planted-fault regression test ensuring jumps into OP_TRAJECTORY_SLOT operand bytes are rejected, and document the fix in the changelog.

Reviewed changes

Copilot reviewed 4 out of 4 changed files in this pull request and generated no comments.

File Description
tests/test_vm_run_bytecode.eigs Adds a regression test that rejects a jump landing inside OP_TRAJECTORY_SLOT’s operand bytes (the drift-enabled verifier hole).
src/compiler.c Makes op_stack_effect an exhaustive OpCode switch (no default:) to prevent silent stack-depth drift.
src/chunk.c Makes op_name and op_verify_operands exhaustive and drives the disassembler operand stepping from the verifier operand table.
CHANGELOG.md Documents the drift-proofing change and the verifier-hole fix under Unreleased.

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

@InauguralPhysicist
InauguralPhysicist merged commit 7ec442f into main Aug 2, 2026
19 checks passed
@InauguralPhysicist
InauguralPhysicist deleted the fix-737-opcode-tables-exhaustive branch August 2, 2026 08:10
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

2 participants