Skip to content

0.20.0: reduced unsafe pitfalls. - #48

Merged
MathisWellmann merged 9 commits into
mainfrom
miri
Jul 22, 2026
Merged

0.20.0: reduced unsafe pitfalls.#48
MathisWellmann merged 9 commits into
mainfrom
miri

Conversation

@MathisWellmann

@MathisWellmann MathisWellmann commented Jul 22, 2026

Copy link
Copy Markdown
Owner

Changes include:

  • breaking: Every evolvable function must return a type with Default implemented. Previously mem::zeroed() was used after function panic was caught which was UB for some types.
  • breaking: forbid using unsafe in LLM generated code and backpressure the LLM to only use safe rust. This is done during the AST validation phase and is very cheap.
  • Add infrastructure to run miri on this codebase to detect undefined behaviour in the unsafe code (panic-buffer, protocol, fn-pointer transmutes).

More LLM code is being denied:

Extends the validation-stage policy scan beyond unsafe code. Rejected
unconditionally, because they break the harness's own contracts:

  • static items and thread_local! dylib-local state silently resets
    on every evolution and every retained revision gets its own copy;
    state must be host-owned (the CAVEATS design rule and system-prompt
    instruction are now actually enforced)
  • macro_rules! definitions: macro bodies would otherwise be a blind
    spot for every other rule
  • #[global_allocator], #[panic_handler], #[alloc_error_handler],
    #[no_main]: hijack the allocator/panic/entry contract between host
    and dylib
  • std::panic::{set_hook, take_hook, update_hook} -- replacing the hook
    breaks the panic-buffer reporting protocol

Rejected by default, host-configurable via DylibConfig
(with_denied_path / with_allowed_path):

  • std::process (exit/abort kill the host, bypassing panic capture)
  • std::thread (spawned threads escape the feedback-loop contract)
  • std::fs
  • std::net
  • std::env
  • std::os
  • std::io::stdin

…est the protocol under Miri

The preamble that ships inside every generated dylib now lives in a real
source file (pulled into PANIC_PREAMBLE via include_str!), so the exact
same unsafe panic-buffer code can be include!-ed into the test binary
and executed by Miri. New tests cover both sides of the protocol: the
dylib-side buffer writes (store, fallback, clamping to small caller
buffers, 512-byte truncation) and the host-side read_panic_buffer
decode (fn-pointer transmute, uninitialized buffer, raw-parts slice).
- Fall back to std::time::Instant under Miri: minstant's #[ctor] probes
  the TSC via rdtsc, which Miri cannot interpret. All timed operations
  are LLM calls, compilation, and evolution (ms-to-s scale), so the
  std clock is more than sufficient there.
- Ignore integration tests under Miri: they compile dylibs with cargo
  and load them via dlopen, neither of which Miri supports.
- Ignore the two DebuggingRecorder observability tests under Miri:
  crossbeam-epoch (via metrics-util) violates Stacked Borrows, a known
  third-party false positive.
- flake.nix: add the miri rustup component to the nightly toolchain.
- CI: new miri job running the symbiont lib tests under
  MIRIFLAGS=-Zmiri-disable-isolation, covering the unsafe panic-buffer
  protocol and fn-pointer transmutes.
- CAVEATS.md: document what Miri covers and what stays out of reach
  (the dlopen boundary, cross-dylib calls, and the mem::zeroed()
  placeholder return in the catch_unwind wrapper).
@MathisWellmann MathisWellmann self-assigned this Jul 22, 2026
@MathisWellmann MathisWellmann changed the title Miri support Miri infra Jul 22, 2026
Preparation for enforcing a Default bound on evolvable return types:
decide() returns Action, and on a caught panic the harness substitutes
the return type's default value. Hold is the natural placeholder -- a
crashed strategy should do nothing this candle. Previously a panicking
decide() produced a mem::zeroed() Action, which is UB for this enum.
…olvable panics

The catch_unwind wrapper injected into every generated dylib used
unsafe { mem::zeroed() } as the placeholder return value after a caught
panic. All-zero bytes are undefined behaviour for return types like
String, Vec, references, or niche-optimized enums, so a panicking
evolvable with such a return type was instant UB.

The wrapper now substitutes ::core::default::Default::default() -- safe
for every type -- and the evolvable! macro enforces the required
Default bound with a compile error at the declaration site (spanned to
the offending return type), so generated dylibs always compile. Hosts
keep detecting panics via Runtime::take_panic and discard the
placeholder. A compile_fail doctest on the evolvable re-export pins the
enforcement.
Generated code is now scanned at the AST level (validation stage,
before any cargo round-trip) and rejected if it contains any unsafe
construct:

- unsafe { .. } blocks
- unsafe fn (free, impl, trait, and foreign)
- unsafe impl / unsafe trait
- extern blocks
- unsafe attributes such as #[unsafe(export_name = ..)] -- except the
  exact #[unsafe(no_mangle)] export attribute the harness manages
- unsafe tokens smuggled through macro definitions or invocations

A crate-level #![forbid(unsafe_code)] in the dylib cannot do this job:
the injected panic preamble is legitimately unsafe, forbid permits no
local allow escape for it, and in edition 2024 the unsafe_code lint
fires on the #[unsafe(no_mangle)] attribute validation injects into
every evolvable function. The AST scan is also cheaper (rejects before
compiling), pinpoints the offending construct for the backpressure
prompt, and cannot be evaded with #[allow(unsafe_code)].

The rejection feeds the self-healing loop as Error::UnsafeCode with a
new 'unsafe' failure kind (metrics + EvolveFailure records), the retry
prompt names the offending construct, and the system prompt tells the
agent up front that unsafe is forbidden. Covered by unit tests for
every construct and a backpressure integration test.
@MathisWellmann MathisWellmann changed the title Miri infra 0.20.0: reduced unsafe pitfalls. Jul 22, 2026
…rated code

Extends the validation-stage policy scan beyond unsafe code. Rejected
unconditionally, because they break the harness's own contracts:

- static items and thread_local! -- dylib-local state silently resets
  on every evolution and every retained revision gets its own copy;
  state must be host-owned (the CAVEATS design rule and system-prompt
  instruction are now actually enforced)
- macro_rules! definitions -- macro bodies would otherwise be a blind
  spot for every other rule
- #[global_allocator], #[panic_handler], #[alloc_error_handler],
  #[no_main] -- hijack the allocator/panic/entry contract between host
  and dylib
- std::panic::{set_hook, take_hook, update_hook} -- replacing the hook
  breaks the panic-buffer reporting protocol

Rejected by default, host-configurable via DylibConfig
(with_denied_path / with_allowed_path): references to std::process
(exit/abort kill the host, bypassing panic capture), std::thread
(spawned threads escape the feedback-loop contract), std::fs,
std::net, std::env, std::os, and std::io::stdin.

Denied paths are matched after resolving the file's use aliases
(use std as s; use std::process::exit as quit; use std::io; ...),
inside macro tokens, and glob imports of denied modules are rejected
outright. Violations surface as Error::ForbiddenConstruct with a new
'forbidden' failure kind, a dedicated retry-prompt nudge naming the
construct and reason, and an up-front system-prompt rule. This bounds
what evolvable code can name; it is guidance for the evolution loop,
not a security sandbox (documented in CAVEATS.md).
@MathisWellmann
MathisWellmann merged commit 907a8c9 into main Jul 22, 2026
5 checks passed
@MathisWellmann
MathisWellmann deleted the miri branch July 22, 2026 21:40
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