Skip to content

Commit df27b1b

Browse files
committed
feat(runtime): Stage 9 exception-unwind surface + doc 08/15 §9 coverage
Stage 9 lands the minimum 1.0 reserved ExceptionUnwindContract verifier plus the native-boundary fail-closed guard doc 09 requires, and folds in the remaining doc 08 §9 / doc 15 §9 spec tests that were still missing from Stages 5-8 (Commit 4 of the shortcut clean-up pass). Verifier surface - eh_guard module parses correctness_legality_contract.exception _unwind_contract from CanonicalMetadataBytesV1 and rejects any profile that: upgrades executable_eh_status above reserved_ disabled_v1, exposes non-empty handler / cleanup tables, permits cross-protected-frame unwind, flips native_boundary_unwind_ behavior away from translate_to_trap_or_fail_closed, declares unknown critical extensions, or advertises a family_specific_ unwind_surface_ref that does not match the bound family. - accept_unit_entry calls the verifier after the Stage 7 payload/ profile binding checks and maps failures to a new block of UnitAcceptError codes (ExceptionUnwindContractMalformed, ExecutableEhStatusNotReservedDisabled, HandlerTable/Cleanup TableNotReservedEmpty, CrossProtectedFrameUnwindPermitted, NativeBoundaryUnwindBehaviorNotFailClosed, UnknownEhCritical Extension, FamilySpecificUnwindSurfaceMismatch). Native-boundary guard - guarded_platform_call wraps the platform_call trampoline with a structured handler so that C++ throws / SEH (Windows) and the SIGSEGV / SIGBUS / SIGILL / SIGFPE family (POSIX) all collapse into DiagnosticCode::NativeBoundaryUnwindTrapped instead of propagating across a protected VM frame. - eh_guard.cpp opts in to /EHa so catch(...) can intercept both C++ and Windows structured exceptions in the same TU; the rest of the runtime stays on /EHsc. - HandlerTraits<NATIVE_CALL>::dispatch now routes through guarded_platform_call and surfaces the diagnostic unchanged. doc 08 §9 / doc 15 §9 tests (Commit 4) - AcceptConfig.minimum_policy_floor plus the Debug<Standard<HighSec ordering enforced in accept_unit_entry (§9 #1, §9 #4, §9 #5). - Profile↔UBR family / policy / profile_id cross-check added to accept_unit_entry with its own error codes (§9 #2). - vm_stub_entry now cross-checks the inner blob's BLOB_FLAG_DEBUG against UBR.requested_policy_id and rejects on mismatch (§9 #6). - test_spec_compliance exercises the above plus the tier-neutral public failure surface (§9 #7) and the section_table_shape_class invariant across debug / standard / highsec artifacts (§9 #10), including a negative control that confirms the shape-class comparison actually fires when a producer drifts. Fixture builders - ResolvedFamilyProfileBuilder now emits the correctness_legality_ contract map by default with a reserved ExceptionUnwindContract; negative tests inject malformed specs via ExceptionUnwindContract Spec so every new error code has a concrete trigger. - sha256_of helper and registry build_canonical_bytes() variant let tests reuse the production Ed25519-wrapped registry path without leaking VMPilot_crypto.hpp namespaces into the runtime surface. Test coverage - test_spec_compliance: 8 cases covering §9 #1, #2, #4, #5, #6, #7, #10 (happy + drift). - test_eh_guard: contract-level suite (8 cases) plus runtime native- boundary tests (Windows C++ throw / SEH raise, POSIX signal). The Windows runtime cases opt out under MSVC /fsanitize=address because ASAN corrupts SEH unwind through the ASM platform_call trampoline; non-ASAN builds and POSIX exercise the full path. All 116 previously-green runtime tests still pass; the new 16 Stage-9/Commit-4 cases pass under dev-win.
1 parent 484e1d6 commit df27b1b

15 files changed

Lines changed: 1749 additions & 58 deletions

common/include/diagnostic.hpp

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -83,6 +83,7 @@ enum class DiagnosticCode : uint32_t {
8383
EpochResyncFailed = 0x0006'0011,
8484
ShadowStackOverflow = 0x0006'0012,
8585
InvalidOpcodeAlias = 0x0006'0013,
86+
NativeBoundaryUnwindTrapped= 0x0006'0014,
8687

8788
// --- 0x0007: Loader ---
8889
PatchInputInvalid = 0x0007'0001,

common/src/diagnostic_collector.cpp

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -83,6 +83,8 @@ const char* to_string(DiagnosticCode code) noexcept {
8383
case DiagnosticCode::EpochResyncFailed: return "RT:epoch_resync_failed";
8484
case DiagnosticCode::ShadowStackOverflow: return "RT:shadow_stack_overflow";
8585
case DiagnosticCode::InvalidOpcodeAlias: return "RT:invalid_opcode_alias";
86+
case DiagnosticCode::NativeBoundaryUnwindTrapped:
87+
return "RT:native_boundary_unwind_trapped";
8688
// 0x0007: Loader
8789
case DiagnosticCode::PatchInputInvalid: return "LDR:input_invalid";
8890
case DiagnosticCode::PatchOutputFailed: return "LDR:output_failed";

runtime/CMakeLists.txt

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -59,6 +59,7 @@ add_library(VMPilot_Runtime STATIC
5959
src/blob_builder.cpp
6060
src/program_builder.cpp
6161
src/handler_detail.cpp
62+
src/eh_guard.cpp
6263
src/trust_root_embed.cpp
6364
src/envelope/outer.cpp
6465
src/binding/package.cpp
@@ -69,6 +70,16 @@ add_library(VMPilot_Runtime STATIC
6970
${PLATFORM_ASM_SOURCES}
7071
)
7172

73+
# eh_guard.cpp must catch structured exceptions raised by native code
74+
# (C++ throws, RaiseException, signals) that cross the platform_call
75+
# trampoline. MSVC /EHsc does not let catch(...) intercept SEH; /EHa
76+
# removes that restriction. Only eh_guard.cpp needs this — the rest of
77+
# the runtime keeps /EHsc for the smaller codegen.
78+
if(MSVC)
79+
set_source_files_properties(src/eh_guard.cpp
80+
PROPERTIES COMPILE_OPTIONS "/EHa")
81+
endif()
82+
7283
include(${CMAKE_SOURCE_DIR}/cmake/TrustRoot.cmake)
7384
vmpilot_configure_trust_root(VMPilot_Runtime)
7485

@@ -228,6 +239,11 @@ if(ENABLE_TESTS)
228239
EXTRA_INCLUDES ${CMAKE_CURRENT_SOURCE_DIR}/include
229240
WITH_FIXTURES)
230241

242+
vmpilot_add_runtime_test(test_spec_compliance
243+
SOURCES test/fixtures/test_spec_compliance.cpp
244+
EXTRA_INCLUDES ${CMAKE_CURRENT_SOURCE_DIR}/include
245+
WITH_FIXTURES)
246+
231247
# ── security/ — Doc 15-19 cryptographic invariants ──
232248

233249
foreach(_t IN ITEMS
@@ -241,6 +257,11 @@ if(ENABLE_TESTS)
241257
vmpilot_add_runtime_test(${_t} SOURCES test/security/${_t}.cpp)
242258
endforeach()
243259

260+
vmpilot_add_runtime_test(test_eh_guard
261+
SOURCES test/security/test_eh_guard.cpp
262+
EXTRA_INCLUDES ${CMAKE_CURRENT_SOURCE_DIR}/include
263+
WITH_FIXTURES)
264+
244265
# ── robustness/ — error paths + boundary values ──
245266

246267
foreach(_t IN ITEMS

runtime/include/binding/package.hpp

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@
1111

1212
#include "envelope/outer.hpp"
1313
#include "trust_root.hpp"
14+
#include "vm/family_policy.hpp"
1415

1516
// PackageBindingRecord acceptance.
1617
//
@@ -42,6 +43,15 @@ struct AcceptConfig {
4243
std::vector<std::string> supported_schema_versions;
4344
std::vector<std::string> supported_canonical_encodings;
4445
RuntimeEpochState epoch;
46+
47+
// Per-unit policy floor (doc 15 §9 #4-#5). Enforced in
48+
// accept_unit_entry: UBR.requested_policy_id must be numerically
49+
// >= this floor in the {Debug, Standard, HighSec} ordering.
50+
// Default is Debug — i.e. no floor. Highsec runtimes raise this to
51+
// HighSec so a standard-tier package silently loaded on a highsec
52+
// runtime cannot unlock execution.
53+
VMPilot::DomainLabels::PolicyId minimum_policy_floor{
54+
VMPilot::DomainLabels::PolicyId::Debug};
4555
};
4656

4757
// What survives acceptance. Retain enough to let Stages 6/7 verify unit

runtime/include/binding/unit.hpp

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -112,6 +112,24 @@ enum class UnitAcceptError : std::uint8_t {
112112
// Epoch gates.
113113
AntiDowngradeEpochTooOld, // UBR vs runtime.minimum_accepted_epoch
114114
PackageEpochBelowUnitEpoch, // PBR.anti_downgrade_epoch < UBR's (doc 06 §10)
115+
116+
// Tier floor (doc 15 §9 #4).
117+
PolicyBelowRuntimeFloor, // UBR.policy < config.minimum_policy_floor
118+
119+
// Profile ↔ UBR cross-field mismatches (doc 08 §9 #2).
120+
ProfileFamilyIdMismatch, // profile.family_id != ubr.family_id
121+
ProfilePolicyIdMismatch, // profile.policy_id != ubr.policy_id
122+
ProfileIdMismatch, // profile.profile_id != ubr.profile_id
123+
124+
// Stage 9 — reserved exception / unwind surface.
125+
ExceptionUnwindContractMalformed,
126+
ExecutableEhStatusNotReservedDisabled,
127+
HandlerTableNotReservedEmpty,
128+
CleanupTableNotReservedEmpty,
129+
CrossProtectedFrameUnwindPermitted,
130+
NativeBoundaryUnwindBehaviorNotFailClosed,
131+
UnknownEhCriticalExtension,
132+
FamilySpecificUnwindSurfaceMismatch,
115133
};
116134

117135
tl::expected<AcceptedUnit, UnitAcceptError>

runtime/include/eh_guard.hpp

Lines changed: 110 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,110 @@
1+
#ifndef VMPILOT_RUNTIME_EH_GUARD_HPP
2+
#define VMPILOT_RUNTIME_EH_GUARD_HPP
3+
4+
#include <cstddef>
5+
#include <cstdint>
6+
#include <string>
7+
#include <string_view>
8+
#include <vector>
9+
10+
#include <tl/expected.hpp>
11+
12+
#include "diagnostic.hpp"
13+
#include "platform_call.hpp"
14+
#include "vm/family_policy.hpp"
15+
16+
namespace VMPilot::Runtime::EH {
17+
18+
enum class ExecutableEhStatus : std::uint8_t {
19+
ReservedDisabledV1 = 1,
20+
ExecutableV1_1,
21+
};
22+
23+
enum class CrossProtectedFrameUnwind : std::uint8_t {
24+
Forbidden = 1,
25+
PermittedByProfile,
26+
};
27+
28+
enum class NativeBoundaryUnwindBehavior : std::uint8_t {
29+
TranslateToTrapOrFailClosed = 1,
30+
ProfileUpgraded,
31+
};
32+
33+
enum class ReservedTableStatus : std::uint8_t {
34+
ReservedEmpty = 1,
35+
ProfileSpecific,
36+
};
37+
38+
struct ExceptionUnwindContract {
39+
std::string semantic_contract_version;
40+
std::string eh_contract_version;
41+
ExecutableEhStatus executable_eh_status;
42+
std::string planned_executable_eh_epoch;
43+
CrossProtectedFrameUnwind cross_protected_frame_unwind;
44+
NativeBoundaryUnwindBehavior native_boundary_unwind_behavior;
45+
ReservedTableStatus handler_table_status;
46+
ReservedTableStatus cleanup_table_status;
47+
std::string frame_contract_ref;
48+
std::string stackmap_contract_ref;
49+
std::string resume_contract_ref;
50+
std::string verifier_rules_ref;
51+
std::string family_specific_unwind_surface_ref;
52+
};
53+
54+
enum class ContractParseError : std::uint8_t {
55+
BadCbor = 1,
56+
NotAMap,
57+
MissingCorrectnessLegalityContract,
58+
MissingExceptionUnwindContract,
59+
MissingField,
60+
WrongFieldType,
61+
UnknownEnumValue,
62+
UnknownCriticalExtension,
63+
};
64+
65+
enum class ContractVerifyError : std::uint8_t {
66+
MalformedContract = 1,
67+
ExecutableEhStatusNotReservedDisabled,
68+
CrossProtectedFrameUnwindPermitted,
69+
NativeBoundaryBehaviorNotFailClosed,
70+
HandlerTableNotReservedEmpty,
71+
CleanupTableNotReservedEmpty,
72+
UnknownCriticalExtension,
73+
FamilySpecificUnwindSurfaceMismatch,
74+
};
75+
76+
tl::expected<ExceptionUnwindContract, ContractParseError>
77+
parse_exception_unwind_contract(const std::uint8_t* data,
78+
std::size_t size) noexcept;
79+
80+
inline tl::expected<ExceptionUnwindContract, ContractParseError>
81+
parse_exception_unwind_contract(
82+
const std::vector<std::uint8_t>& bytes) noexcept {
83+
return parse_exception_unwind_contract(bytes.data(), bytes.size());
84+
}
85+
86+
tl::expected<ExceptionUnwindContract, ContractVerifyError>
87+
verify_reserved_exception_unwind_contract(
88+
const std::uint8_t* data,
89+
std::size_t size,
90+
VMPilot::DomainLabels::FamilyId expected_family) noexcept;
91+
92+
inline tl::expected<ExceptionUnwindContract, ContractVerifyError>
93+
verify_reserved_exception_unwind_contract(
94+
const std::vector<std::uint8_t>& bytes,
95+
VMPilot::DomainLabels::FamilyId expected_family) noexcept {
96+
return verify_reserved_exception_unwind_contract(
97+
bytes.data(), bytes.size(), expected_family);
98+
}
99+
100+
std::string_view expected_family_specific_unwind_surface_ref(
101+
VMPilot::DomainLabels::FamilyId family_id) noexcept;
102+
103+
tl::expected<std::uint64_t, VMPilot::Common::DiagnosticCode>
104+
guarded_platform_call(const VMPilot::Runtime::PlatformCallDesc* desc,
105+
bool returns_struct,
106+
void* struct_return_ptr) noexcept;
107+
108+
} // namespace VMPilot::Runtime::EH
109+
110+
#endif // VMPILOT_RUNTIME_EH_GUARD_HPP

runtime/include/handler_impls.hpp

Lines changed: 8 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -37,6 +37,7 @@
3737
/// LOAD/POP: MemVal from guest/ORAM -> im.mem.decode_lut() -> plaintext
3838

3939
#include "handler_traits.hpp"
40+
#include "eh_guard.hpp"
4041
#include "platform_call.hpp"
4142

4243
#include <vm/encoded_value.hpp>
@@ -824,18 +825,18 @@ struct HandlerTraits<VmOpcode::NATIVE_CALL, P> {
824825
is_variadic ? desc.fp_count : static_cast<uint8_t>(0), returns_fp);
825826

826827
// Call via platform-specific ASM trampoline
827-
uint64_t result;
828-
if (returns_struct && abi == CallABI::AAPCS64) {
829-
result = platform_call_struct(&desc, struct_ptr);
830-
} else {
831-
result = platform_call(&desc);
832-
}
828+
auto result_or = EH::guarded_platform_call(
829+
&desc, returns_struct && abi == CallABI::AAPCS64, struct_ptr);
833830

834831
// Zero plaintext args from stack (forward secrecy)
835832
secure_zero(raw_args, sizeof(raw_args));
836833

834+
if (!result_or) {
835+
return tl::make_unexpected(result_or.error());
836+
}
837+
837838
// Store plaintext result; pipeline will FPE-encode reg 0
838-
e.regs[0] = RegVal(result);
839+
e.regs[0] = RegVal(*result_or);
839840
e.native_call_nonce++;
840841
return {};
841842
}

runtime/src/binding/unit.cpp

Lines changed: 69 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,9 @@
55

66
#include "VMPilot_crypto.hpp"
77
#include "binding/inner_partition.hpp"
8+
#include "binding/resolved_profile.hpp"
89
#include "cbor/strict.hpp"
10+
#include "eh_guard.hpp"
911
#include "vm/domain_labels.hpp"
1012
#include "vm/family_policy.hpp"
1113

@@ -78,6 +80,31 @@ bool hash_equals(const std::array<std::uint8_t, 32>& a,
7880
return std::memcmp(a.data(), b.data(), 32) == 0;
7981
}
8082

83+
UnitAcceptError map_eh_contract_error(
84+
VMPilot::Runtime::EH::ContractVerifyError error) noexcept {
85+
using VMPilot::Runtime::EH::ContractVerifyError;
86+
87+
switch (error) {
88+
case ContractVerifyError::MalformedContract:
89+
return UnitAcceptError::ExceptionUnwindContractMalformed;
90+
case ContractVerifyError::ExecutableEhStatusNotReservedDisabled:
91+
return UnitAcceptError::ExecutableEhStatusNotReservedDisabled;
92+
case ContractVerifyError::CrossProtectedFrameUnwindPermitted:
93+
return UnitAcceptError::CrossProtectedFrameUnwindPermitted;
94+
case ContractVerifyError::NativeBoundaryBehaviorNotFailClosed:
95+
return UnitAcceptError::NativeBoundaryUnwindBehaviorNotFailClosed;
96+
case ContractVerifyError::HandlerTableNotReservedEmpty:
97+
return UnitAcceptError::HandlerTableNotReservedEmpty;
98+
case ContractVerifyError::CleanupTableNotReservedEmpty:
99+
return UnitAcceptError::CleanupTableNotReservedEmpty;
100+
case ContractVerifyError::UnknownCriticalExtension:
101+
return UnitAcceptError::UnknownEhCriticalExtension;
102+
case ContractVerifyError::FamilySpecificUnwindSurfaceMismatch:
103+
return UnitAcceptError::FamilySpecificUnwindSurfaceMismatch;
104+
}
105+
return UnitAcceptError::ExceptionUnwindContractMalformed;
106+
}
107+
81108
// ─── Field extraction helpers ───────────────────────────────────────────
82109

83110
struct RequireCtx {
@@ -475,6 +502,39 @@ accept_unit_entry(const std::uint8_t* artifact_data,
475502
return err(UnitAcceptError::ResolvedProfileContentHashMismatch);
476503
}
477504

505+
// 6b. Profile ↔ UBR cross-check (doc 08 §9 #2). The profile's own
506+
// embedded (family_id / policy_id / profile_id) must agree with
507+
// the UBR that referenced it. Otherwise a producer could slide a
508+
// debug-policy profile in under a highsec-policy UBR: every
509+
// hash still matches (UBR committed to this exact profile), but
510+
// the profile's declared policy contradicts the UBR's claim and
511+
// the runtime would dispatch on the wrong tier.
512+
auto profile_header = parse_resolved_family_profile_header(profile_bytes);
513+
if (!profile_header) {
514+
return err(UnitAcceptError::ResolvedProfileTableMalformed);
515+
}
516+
if (profile_header->family_id != ubr.family_id) {
517+
return err(UnitAcceptError::ProfileFamilyIdMismatch);
518+
}
519+
if (profile_header->requested_policy_id != ubr.requested_policy_id) {
520+
return err(UnitAcceptError::ProfilePolicyIdMismatch);
521+
}
522+
if (profile_header->profile_id != ubr.resolved_family_profile_id) {
523+
return err(UnitAcceptError::ProfileIdMismatch);
524+
}
525+
526+
// 6c. Stage 9 — 1.0 reserved exception/unwind surface verifier.
527+
// The profile must carry the typed contract, but 1.0 runtime only
528+
// accepts the strictly reserved posture: executable EH disabled,
529+
// empty handler/cleanup tables, cross-frame unwind forbidden, and
530+
// native boundary unwind translated to trap/fail-closed.
531+
auto eh_contract =
532+
VMPilot::Runtime::EH::verify_reserved_exception_unwind_contract(
533+
profile_bytes, ubr.family_id);
534+
if (!eh_contract) {
535+
return err(map_eh_contract_error(eh_contract.error()));
536+
}
537+
478538
// 7. Payload identity: single-unit packaging for now — the whole
479539
// payload partition belongs to this unit. Multi-unit payload
480540
// slicing is a future stage.
@@ -510,6 +570,15 @@ accept_unit_entry(const std::uint8_t* artifact_data,
510570
return err(UnitAcceptError::PackageEpochBelowUnitEpoch);
511571
}
512572

573+
// 8c. Runtime policy floor (doc 15 §9 #4). The enum ordering is
574+
// Debug(1) < Standard(2) < HighSec(3) — numerically
575+
// comparable. A standard-tier package cannot unlock a
576+
// runtime that requires highsec.
577+
if (static_cast<std::uint8_t>(ubr.requested_policy_id) <
578+
static_cast<std::uint8_t>(config.minimum_policy_floor)) {
579+
return err(UnitAcceptError::PolicyBelowRuntimeFloor);
580+
}
581+
513582
AcceptedUnit out;
514583
out.descriptor = std::move(desc);
515584
out.ubr = std::move(ubr);

0 commit comments

Comments
 (0)