From fce08845780bc07944c43b83cf2bbb3153cdea65 Mon Sep 17 00:00:00 2001 From: MathisWellmann Date: Wed, 22 Jul 2026 13:58:27 +0100 Subject: [PATCH 1/9] unwind: extract the dylib panic preamble into panic_preamble.rs and test 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). --- symbiont/src/panic_preamble.rs | 117 ++++++++++++++++ symbiont/src/unwind.rs | 243 +++++++++++++++++++-------------- 2 files changed, 254 insertions(+), 106 deletions(-) create mode 100644 symbiont/src/panic_preamble.rs diff --git a/symbiont/src/panic_preamble.rs b/symbiont/src/panic_preamble.rs new file mode 100644 index 0000000..c0423ea --- /dev/null +++ b/symbiont/src/panic_preamble.rs @@ -0,0 +1,117 @@ +// SPDX-License-Identifier: MPL-2.0 +// Preamble injected verbatim into every generated dylib (see +// `crate::unwind::PANIC_PREAMBLE`, which pulls this file in via +// `include_str!`). +// +// This is a standalone source file rather than a string literal so the +// exact code that ships inside generated dylibs can also be compiled into +// the test binary (via `include!`) and executed under Miri, giving UB +// coverage for the unsafe panic-buffer protocol below. +// +// NOTE: keep this file free of inner attributes and `//!` doc comments; +// `include!` does not accept them. + +use std::sync::Mutex; +use std::sync::atomic::{AtomicBool, Ordering}; + +/// Lock-free "a panic message is stored" flag. +/// +/// `__symbiont_take_panic` reads it as a fast path so the hot no-panic case +/// never touches the Mutex: hosts poll for panics after every evolvable +/// call, potentially from many threads at once, +/// and a shared Mutex lock per call serializes all of them on one cache +/// line. A read-mostly atomic flag scales instead. +static __SYMBIONT_PANICKED: AtomicBool = AtomicBool::new(false); + +/// Fixed-size buffer for the last panic message (512 bytes max). +/// Layout: (panicked: bool, message: [u8; 512], length: usize) +static __SYMBIONT_PANIC: Mutex<(bool, [u8; 512], usize)> = + Mutex::new((false, [0u8; 512], 0)); + +pub(crate) fn __symbiont_store_panic(msg: &str) { + if let Ok(mut guard) = __SYMBIONT_PANIC.lock() { + let len = msg.len().min(512); + guard.0 = true; + guard.1[..len].copy_from_slice(&msg.as_bytes()[..len]); + guard.2 = len; + __SYMBIONT_PANICKED.store(true, Ordering::Release); + } +} + +/// Store `msg` only when no message is currently stored. +/// +/// Fallback used by the `catch_unwind` wrapper: the panic hook has already +/// recorded the message together with its source location, which the +/// location-less `catch_unwind` payload must not overwrite. +pub(crate) fn __symbiont_store_panic_fallback(msg: &str) { + if let Ok(mut guard) = __SYMBIONT_PANIC.lock() { + if guard.0 { + return; + } + let len = msg.len().min(512); + guard.0 = true; + guard.1[..len].copy_from_slice(&msg.as_bytes()[..len]); + guard.2 = len; + __SYMBIONT_PANICKED.store(true, Ordering::Release); + } +} + +/// Ensures the location-capturing panic hook is installed exactly once. +static __SYMBIONT_HOOK: std::sync::Once = std::sync::Once::new(); + +/// Install a panic hook that records the panic message together with its +/// source location, then delegates to the previously installed hook. +/// +/// The hook runs at panic time, before unwinding reaches `catch_unwind`; +/// it is the only point where `std::panic::Location` is available. The +/// dylib links its own copy of `std`, so this hook only observes panics +/// raised by code compiled into this dylib, never panics of the host. +pub(crate) fn __symbiont_install_panic_hook() { + __SYMBIONT_HOOK.call_once(|| { + let previous = std::panic::take_hook(); + std::panic::set_hook(Box::new(move |info| { + let msg = if let Some(s) = info.payload().downcast_ref::<&str>() { + *s + } else if let Some(s) = info.payload().downcast_ref::() { + s.as_str() + } else { + "unknown panic" + }; + match info.location() { + Some(location) => __symbiont_store_panic(&format!("{msg} at {location}")), + None => __symbiont_store_panic(msg), + } + previous(info); + })); + }); +} + +/// Copy the last panic message into `buf` and return its length. +/// Returns 0 if no panic occurred. Clears the stored message. +/// +/// The no-panic case is a single atomic load; the Mutex is only locked when +/// a panic message is actually stored, so concurrent hot-loop polling from +/// many threads does not contend. +/// +/// # Safety +/// +/// `buf` must point to at least `buf_len` writable bytes. +#[unsafe(no_mangle)] +pub unsafe fn __symbiont_take_panic(buf: *mut u8, buf_len: usize) -> usize { + if !__SYMBIONT_PANICKED.load(Ordering::Acquire) { + return 0; + } + if let Ok(mut guard) = __SYMBIONT_PANIC.lock() { + if !guard.0 { + return 0; + } + let len = guard.2.min(buf_len); + unsafe { core::ptr::copy_nonoverlapping(guard.1.as_ptr(), buf, len) }; + guard.0 = false; + guard.2 = 0; + __SYMBIONT_PANICKED.store(false, Ordering::Release); + len + } else { + 0 + } +} diff --git a/symbiont/src/unwind.rs b/symbiont/src/unwind.rs index d567e0a..2704df6 100644 --- a/symbiont/src/unwind.rs +++ b/symbiont/src/unwind.rs @@ -66,113 +66,11 @@ pub(crate) fn wrap_bodies_in_catch_unwind(file: &mut syn::File) { /// Provides a fixed-size panic buffer and an exported `__symbiont_take_panic` /// symbol so the host can retrieve panic messages without heap allocation /// crossing the dylib boundary. -#[allow(clippy::needless_raw_strings, reason = "contains #[unsafe(no_mangle)]")] -pub(crate) const PANIC_PREAMBLE: &str = r#" -use std::sync::Mutex; -use std::sync::atomic::{AtomicBool, Ordering}; - -/// Lock-free "a panic message is stored" flag. -/// -/// `__symbiont_take_panic` reads it as a fast path so the hot no-panic case -/// never touches the Mutex: hosts poll for panics after every evolvable -/// call, potentially from many threads at once, -/// and a shared Mutex lock per call serializes all of them on one cache -/// line. A read-mostly atomic flag scales instead. -static __SYMBIONT_PANICKED: AtomicBool = AtomicBool::new(false); - -/// Fixed-size buffer for the last panic message (512 bytes max). -/// Layout: (panicked: bool, message: [u8; 512], length: usize) -static __SYMBIONT_PANIC: Mutex<(bool, [u8; 512], usize)> = - Mutex::new((false, [0u8; 512], 0)); - -fn __symbiont_store_panic(msg: &str) { - if let Ok(mut guard) = __SYMBIONT_PANIC.lock() { - let len = msg.len().min(512); - guard.0 = true; - guard.1[..len].copy_from_slice(&msg.as_bytes()[..len]); - guard.2 = len; - __SYMBIONT_PANICKED.store(true, Ordering::Release); - } -} - -/// Store `msg` only when no message is currently stored. -/// -/// Fallback used by the `catch_unwind` wrapper: the panic hook has already -/// recorded the message together with its source location, which the -/// location-less `catch_unwind` payload must not overwrite. -fn __symbiont_store_panic_fallback(msg: &str) { - if let Ok(mut guard) = __SYMBIONT_PANIC.lock() { - if guard.0 { - return; - } - let len = msg.len().min(512); - guard.0 = true; - guard.1[..len].copy_from_slice(&msg.as_bytes()[..len]); - guard.2 = len; - __SYMBIONT_PANICKED.store(true, Ordering::Release); - } -} - -/// Ensures the location-capturing panic hook is installed exactly once. -static __SYMBIONT_HOOK: std::sync::Once = std::sync::Once::new(); - -/// Install a panic hook that records the panic message together with its -/// source location, then delegates to the previously installed hook. -/// -/// The hook runs at panic time, before unwinding reaches `catch_unwind`; -/// it is the only point where `std::panic::Location` is available. The -/// dylib links its own copy of `std`, so this hook only observes panics -/// raised by code compiled into this dylib, never panics of the host. -fn __symbiont_install_panic_hook() { - __SYMBIONT_HOOK.call_once(|| { - let previous = std::panic::take_hook(); - std::panic::set_hook(Box::new(move |info| { - let msg = if let Some(s) = info.payload().downcast_ref::<&str>() { - *s - } else if let Some(s) = info.payload().downcast_ref::() { - s.as_str() - } else { - "unknown panic" - }; - match info.location() { - Some(location) => __symbiont_store_panic(&format!("{msg} at {location}")), - None => __symbiont_store_panic(msg), - } - previous(info); - })); - }); -} - -/// Copy the last panic message into `buf` and return its length. -/// Returns 0 if no panic occurred. Clears the stored message. /// -/// The no-panic case is a single atomic load; the Mutex is only locked when -/// a panic message is actually stored, so concurrent hot-loop polling from -/// many threads does not contend. -/// -/// # Safety -/// -/// `buf` must point to at least `buf_len` writable bytes. -#[unsafe(no_mangle)] -pub unsafe fn __symbiont_take_panic(buf: *mut u8, buf_len: usize) -> usize { - if !__SYMBIONT_PANICKED.load(Ordering::Acquire) { - return 0; - } - if let Ok(mut guard) = __SYMBIONT_PANIC.lock() { - if !guard.0 { - return 0; - } - let len = guard.2.min(buf_len); - unsafe { core::ptr::copy_nonoverlapping(guard.1.as_ptr(), buf, len) }; - guard.0 = false; - guard.2 = 0; - __SYMBIONT_PANICKED.store(false, Ordering::Release); - len - } else { - 0 - } -} -"#; +/// The source lives in `panic_preamble.rs` (as a file rather than a string +/// literal) so the tests below can `include!` the exact same code and run it +/// under Miri to check the unsafe buffer protocol for undefined behaviour. +pub(crate) const PANIC_PREAMBLE: &str = include_str!("panic_preamble.rs"); #[cfg(test)] mod tests { @@ -211,4 +109,137 @@ mod tests { "# ); } + + /// The exact preamble source that ships inside every generated dylib, + /// compiled into this test binary so the unsafe panic-buffer protocol + /// can be executed directly — in particular under Miri, which flags + /// undefined behaviour in it. + #[allow( + unused, + unreachable_pub, + reason = "the preamble is compiled verbatim; in a dylib crate root its `pub` items are reachable" + )] + mod preamble { + include!("panic_preamble.rs"); + } + + /// Serializes the protocol tests: the preamble's panic buffer and + /// "panicked" flag are process-global statics shared by all tests in + /// this binary. + static PROTOCOL_LOCK: std::sync::Mutex<()> = std::sync::Mutex::new(()); + + fn protocol_lock() -> std::sync::MutexGuard<'static, ()> { + PROTOCOL_LOCK.lock().unwrap_or_else(|e| e.into_inner()) + } + + /// Install a silent base hook (so intentional panics don't spam test + /// output), then the preamble's location-capturing hook on top of it. + fn install_hooks_once() { + static ONCE: std::sync::Once = std::sync::Once::new(); + ONCE.call_once(|| { + std::panic::set_hook(Box::new(|_| {})); + preamble::__symbiont_install_panic_hook(); + }); + } + + /// Clear any message left behind by another test. + fn drain_panic_buffer() { + let mut buf = [0u8; 512]; + // SAFETY: `buf` is 512 writable bytes, matching `buf_len`. + unsafe { preamble::__symbiont_take_panic(buf.as_mut_ptr(), buf.len()) }; + } + + /// `__symbiont_take_panic` cast to the erased pointer type the host + /// stores in the dispatch atomics and passes to `read_panic_buffer`. + fn take_panic_ptr() -> *const () { + preamble::__symbiont_take_panic as unsafe fn(*mut u8, usize) -> usize as *const () + } + + #[test] + fn take_panic_returns_zero_without_panic() { + let _guard = protocol_lock(); + drain_panic_buffer(); + let mut buf = [0u8; 64]; + // SAFETY: `buf` is 64 writable bytes, matching `buf_len`. + let len = unsafe { preamble::__symbiont_take_panic(buf.as_mut_ptr(), buf.len()) }; + assert_eq!(len, 0); + } + + #[test] + fn panic_message_roundtrips_through_host_protocol() { + let _guard = protocol_lock(); + install_hooks_once(); + drain_panic_buffer(); + + let _ = std::panic::catch_unwind(|| panic!("boom {}", 42)); + + // Decode through the host-side path, exercising the fn-pointer + // transmute, the uninitialized buffer, and the raw-parts slice. + // SAFETY: the pointer refers to a function with the exported + // protocol signature. + let msg = unsafe { crate::revision::read_panic_buffer(take_panic_ptr()) } + .expect("panic message must be stored by the hook"); + assert!(msg.contains("boom 42"), "message: {msg}"); + assert!(msg.contains("unwind.rs"), "location missing: {msg}"); + + // Taking the message clears the buffer. + // SAFETY: same as above. + assert!(unsafe { crate::revision::read_panic_buffer(take_panic_ptr()) }.is_none()); + } + + #[test] + fn long_panic_messages_truncate_at_buffer_size() { + let _guard = protocol_lock(); + install_hooks_once(); + drain_panic_buffer(); + + let long = "x".repeat(600); + let _ = std::panic::catch_unwind(|| std::panic::panic_any(long)); + + // SAFETY: the pointer refers to a function with the exported + // protocol signature. + let msg = unsafe { crate::revision::read_panic_buffer(take_panic_ptr()) } + .expect("panic message must be stored by the hook"); + assert_eq!(msg.len(), 512); + assert!(msg.bytes().all(|b| b == b'x')); + } + + #[test] + fn take_panic_clamps_to_small_caller_buffer() { + let _guard = protocol_lock(); + drain_panic_buffer(); + + preamble::__symbiont_store_panic("this is a longer message"); + let mut buf = [0u8; 8]; + // SAFETY: `buf` is 8 writable bytes, matching `buf_len`; Miri + // verifies no out-of-bounds write occurs. + let len = unsafe { preamble::__symbiont_take_panic(buf.as_mut_ptr(), buf.len()) }; + assert_eq!(len, 8); + assert_eq!(&buf, b"this is "); + } + + #[test] + fn fallback_does_not_overwrite_hook_message() { + let _guard = protocol_lock(); + drain_panic_buffer(); + + preamble::__symbiont_store_panic("primary"); + preamble::__symbiont_store_panic_fallback("secondary"); + let mut buf = [0u8; 512]; + // SAFETY: `buf` is 512 writable bytes, matching `buf_len`. + let len = unsafe { preamble::__symbiont_take_panic(buf.as_mut_ptr(), buf.len()) }; + assert_eq!(&buf[..len], b"primary"); + + // With the buffer empty, the fallback does store. + preamble::__symbiont_store_panic_fallback("secondary"); + // SAFETY: same as above. + let len = unsafe { preamble::__symbiont_take_panic(buf.as_mut_ptr(), buf.len()) }; + assert_eq!(&buf[..len], b"secondary"); + } + + #[test] + fn read_panic_buffer_null_ptr_is_none() { + // SAFETY: `read_panic_buffer` explicitly permits a null pointer. + assert!(unsafe { crate::revision::read_panic_buffer(std::ptr::null()) }.is_none()); + } } From 7eeea626b041688279cdd37160d07b0a35521a1c Mon Sep 17 00:00:00 2001 From: MathisWellmann Date: Wed, 22 Jul 2026 13:58:27 +0100 Subject: [PATCH 2/9] Make the test suite Miri-compatible - 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. --- symbiont/src/compiler.rs | 3 +++ symbiont/src/lib.rs | 5 +++++ symbiont/src/observability.rs | 8 ++++++++ symbiont/src/runtime.rs | 3 +++ symbiont/tests/backpressure_compile.rs | 4 ++++ symbiont/tests/backpressure_context_size.rs | 4 ++++ symbiont/tests/backpressure_fatal.rs | 4 ++++ symbiont/tests/backpressure_max_retries.rs | 4 ++++ symbiont/tests/backpressure_max_turns.rs | 4 ++++ symbiont/tests/backpressure_panic.rs | 4 ++++ symbiont/tests/backpressure_parse.rs | 4 ++++ symbiont/tests/backpressure_signature.rs | 4 ++++ symbiont/tests/backpressure_signature_modifier.rs | 4 ++++ symbiont/tests/backpressure_transient.rs | 4 ++++ symbiont/tests/metrics.rs | 4 ++++ symbiont/tests/revisions.rs | 4 ++++ symbiont/tests/runtime.rs | 4 ++++ 17 files changed, 71 insertions(+) diff --git a/symbiont/src/compiler.rs b/symbiont/src/compiler.rs index 1ca3bfa..0def0fe 100644 --- a/symbiont/src/compiler.rs +++ b/symbiont/src/compiler.rs @@ -1,9 +1,12 @@ // SPDX-License-Identifier: MPL-2.0 +#[cfg(miri)] +use std::time::Instant; use std::{ path::Path, process::Command, }; +#[cfg(not(miri))] use minstant::Instant; use prettyplease::unparse; use tracing::info; diff --git a/symbiont/src/lib.rs b/symbiont/src/lib.rs index 9ab6c85..001d32d 100644 --- a/symbiont/src/lib.rs +++ b/symbiont/src/lib.rs @@ -3,6 +3,11 @@ html_logo_url = "https://raw.githubusercontent.com/MathisWellmann/symbiont/main/assets/logo.svg" )] #![doc = include_str!("../README.md")] +// Under Miri the timing code falls back to `std::time::Instant`, because +// minstant's `#[ctor]` probes the TSC via `rdtsc`, which Miri cannot +// interpret. Leaving the crate unused (and thus unlinked) keeps its ctor +// out of the interpreted binary. +#![cfg_attr(miri, allow(unused_crate_dependencies))] mod compiler; #[cfg(debug_assertions)] diff --git a/symbiont/src/observability.rs b/symbiont/src/observability.rs index d72c0e6..c4569e4 100644 --- a/symbiont/src/observability.rs +++ b/symbiont/src/observability.rs @@ -330,6 +330,10 @@ mod tests { } #[test] + #[cfg_attr( + miri, + ignore = "crossbeam-epoch (via metrics-util) violates Stacked Borrows; known third-party false positive" + )] fn emissions_reach_recorder_with_labels() { let recorder = DebuggingRecorder::new(); let snapshotter = recorder.snapshotter(); @@ -363,6 +367,10 @@ mod tests { } #[test] + #[cfg_attr( + miri, + ignore = "crossbeam-epoch (via metrics-util) violates Stacked Borrows; known third-party false positive" + )] fn describe_metrics_registers_units_and_descriptions() { let recorder = DebuggingRecorder::new(); let snapshotter = recorder.snapshotter(); diff --git a/symbiont/src/runtime.rs b/symbiont/src/runtime.rs index 2c55b69..95305e1 100644 --- a/symbiont/src/runtime.rs +++ b/symbiont/src/runtime.rs @@ -3,6 +3,8 @@ //! managing the lifecycle of the temporary dylib crate: creation, compilation, //! loading, and hot-reloading. +#[cfg(miri)] +use std::time::Instant; use std::{ collections::hash_map::DefaultHasher, fmt::Write, @@ -33,6 +35,7 @@ use metrics::{ gauge, histogram, }; +#[cfg(not(miri))] use minstant::Instant; use owo_colors::OwoColorize; use prettyplease::unparse; diff --git a/symbiont/tests/backpressure_compile.rs b/symbiont/tests/backpressure_compile.rs index a3345da..c632705 100644 --- a/symbiont/tests/backpressure_compile.rs +++ b/symbiont/tests/backpressure_compile.rs @@ -23,6 +23,10 @@ use symbiont::{ const BASE_PROMPT: &str = "Implement the function. Code only."; #[tokio::test] +#[cfg_attr( + miri, + ignore = "compiles and dlopens dylibs, which Miri does not support" +)] #[tracing_test::traced_test] async fn compile_failure_feeds_compiler_diagnostics_back() { symbiont::evolvable! { diff --git a/symbiont/tests/backpressure_context_size.rs b/symbiont/tests/backpressure_context_size.rs index 7311aba..ec231c3 100644 --- a/symbiont/tests/backpressure_context_size.rs +++ b/symbiont/tests/backpressure_context_size.rs @@ -39,6 +39,10 @@ fn context_size_error() -> PromptError { } #[tokio::test] +#[cfg_attr( + miri, + ignore = "compiles and dlopens dylibs, which Miri does not support" +)] #[tracing_test::traced_test] async fn context_size_overflow_restarts_from_the_base_prompt() { symbiont::evolvable! { diff --git a/symbiont/tests/backpressure_fatal.rs b/symbiont/tests/backpressure_fatal.rs index a7df26b..5d89f19 100644 --- a/symbiont/tests/backpressure_fatal.rs +++ b/symbiont/tests/backpressure_fatal.rs @@ -27,6 +27,10 @@ use symbiont::{ const BASE_PROMPT: &str = "Implement the function. Code only."; #[tokio::test] +#[cfg_attr( + miri, + ignore = "compiles and dlopens dylibs, which Miri does not support" +)] #[tracing_test::traced_test] async fn fatal_agent_error_propagates_without_retry() { symbiont::evolvable! { diff --git a/symbiont/tests/backpressure_max_retries.rs b/symbiont/tests/backpressure_max_retries.rs index 158a242..5297d9c 100644 --- a/symbiont/tests/backpressure_max_retries.rs +++ b/symbiont/tests/backpressure_max_retries.rs @@ -25,6 +25,10 @@ use symbiont::{ const BASE_PROMPT: &str = "Implement the function. Code only."; #[tokio::test] +#[cfg_attr( + miri, + ignore = "compiles and dlopens dylibs, which Miri does not support" +)] #[tracing_test::traced_test] async fn retry_budget_is_bounded_and_nudges_do_not_accumulate() { symbiont::evolvable! { diff --git a/symbiont/tests/backpressure_max_turns.rs b/symbiont/tests/backpressure_max_turns.rs index 9e9f2fe..bacdd42 100644 --- a/symbiont/tests/backpressure_max_turns.rs +++ b/symbiont/tests/backpressure_max_turns.rs @@ -27,6 +27,10 @@ use symbiont::{ const BASE_PROMPT: &str = "Implement the function. Code only."; #[tokio::test] +#[cfg_attr( + miri, + ignore = "compiles and dlopens dylibs, which Miri does not support" +)] #[tracing_test::traced_test] async fn max_turns_error_is_nudged_and_recovered_from() { symbiont::evolvable! { diff --git a/symbiont/tests/backpressure_panic.rs b/symbiont/tests/backpressure_panic.rs index e89666e..b605b6d 100644 --- a/symbiont/tests/backpressure_panic.rs +++ b/symbiont/tests/backpressure_panic.rs @@ -23,6 +23,10 @@ use symbiont::{ const BASE_PROMPT: &str = "Implement the function. Code only."; #[tokio::test] +#[cfg_attr( + miri, + ignore = "compiles and dlopens dylibs, which Miri does not support" +)] #[tracing_test::traced_test] async fn panicking_evolved_code_is_caught_and_message_retrievable() { symbiont::evolvable! { diff --git a/symbiont/tests/backpressure_parse.rs b/symbiont/tests/backpressure_parse.rs index 817450d..f47ea31 100644 --- a/symbiont/tests/backpressure_parse.rs +++ b/symbiont/tests/backpressure_parse.rs @@ -22,6 +22,10 @@ use symbiont::{ const BASE_PROMPT: &str = "Implement the function. Code only."; #[tokio::test] +#[cfg_attr( + miri, + ignore = "compiles and dlopens dylibs, which Miri does not support" +)] #[tracing_test::traced_test] async fn parse_failure_is_fed_back_and_recovered_from() { symbiont::evolvable! { diff --git a/symbiont/tests/backpressure_signature.rs b/symbiont/tests/backpressure_signature.rs index fde65f6..a849a35 100644 --- a/symbiont/tests/backpressure_signature.rs +++ b/symbiont/tests/backpressure_signature.rs @@ -23,6 +23,10 @@ use symbiont::{ const BASE_PROMPT: &str = "Implement the function. Code only."; #[tokio::test] +#[cfg_attr( + miri, + ignore = "compiles and dlopens dylibs, which Miri does not support" +)] #[tracing_test::traced_test] async fn signature_mismatch_is_fed_back_and_recovered_from() { symbiont::evolvable! { diff --git a/symbiont/tests/backpressure_signature_modifier.rs b/symbiont/tests/backpressure_signature_modifier.rs index 21d404b..0809496 100644 --- a/symbiont/tests/backpressure_signature_modifier.rs +++ b/symbiont/tests/backpressure_signature_modifier.rs @@ -22,6 +22,10 @@ use symbiont::{ const BASE_PROMPT: &str = "Implement the function. Code only."; #[tokio::test] +#[cfg_attr( + miri, + ignore = "compiles and dlopens dylibs, which Miri does not support" +)] #[tracing_test::traced_test] async fn signature_modifier_is_named_and_recovered_from() { symbiont::evolvable! { diff --git a/symbiont/tests/backpressure_transient.rs b/symbiont/tests/backpressure_transient.rs index ff46d9c..ecc946e 100644 --- a/symbiont/tests/backpressure_transient.rs +++ b/symbiont/tests/backpressure_transient.rs @@ -27,6 +27,10 @@ use symbiont::{ const BASE_PROMPT: &str = "Implement the function. Code only."; #[tokio::test] +#[cfg_attr( + miri, + ignore = "compiles and dlopens dylibs, which Miri does not support" +)] #[tracing_test::traced_test] async fn transient_http_error_is_retried_with_unmodified_prompt() { symbiont::evolvable! { diff --git a/symbiont/tests/metrics.rs b/symbiont/tests/metrics.rs index d63dc62..07054f8 100644 --- a/symbiont/tests/metrics.rs +++ b/symbiont/tests/metrics.rs @@ -47,6 +47,10 @@ fn has_label(key: &CompositeKey, k: &str, v: &str) -> bool { } #[tokio::test(flavor = "current_thread")] +#[cfg_attr( + miri, + ignore = "compiles and dlopens dylibs, which Miri does not support" +)] async fn evolution_emits_metrics() { symbiont::evolvable! { fn metrics_step(counter: &mut usize) { diff --git a/symbiont/tests/revisions.rs b/symbiont/tests/revisions.rs index 4c92b1d..f5b926c 100644 --- a/symbiont/tests/revisions.rs +++ b/symbiont/tests/revisions.rs @@ -21,6 +21,10 @@ use symbiont::{ /// registered as a revision, its dylib stays loaded, and any revision can be /// re-activated later without parsing or compiling anything. #[tokio::test] +#[cfg_attr( + miri, + ignore = "compiles and dlopens dylibs, which Miri does not support" +)] #[tracing_test::traced_test] #[expect( clippy::too_many_lines, diff --git a/symbiont/tests/runtime.rs b/symbiont/tests/runtime.rs index c610704..38a8d02 100644 --- a/symbiont/tests/runtime.rs +++ b/symbiont/tests/runtime.rs @@ -21,6 +21,10 @@ use symbiont::{ }; #[tokio::test] +#[cfg_attr( + miri, + ignore = "compiles and dlopens dylibs, which Miri does not support" +)] #[tracing_test::traced_test] async fn runtime() { symbiont::evolvable! { From 444d867c66baf77fb2b8140ddc4611ecc55168a9 Mon Sep 17 00:00:00 2001 From: MathisWellmann Date: Wed, 22 Jul 2026 13:58:27 +0100 Subject: [PATCH 3/9] Add Miri to the dev shell and CI to detect undefined behaviour - 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). --- .github/workflows/ci.yml | 18 ++++++++++++++++++ CAVEATS.md | 25 +++++++++++++++++++++++++ flake.nix | 1 + 3 files changed, 44 insertions(+) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 19a2771..b89b569 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -57,6 +57,24 @@ jobs: run: nix develop --command taplo fmt --check - name: cargo-doc run: nix develop --command cargo doc + miri: + # Target self-hosted runner by label + runs-on: [nixos] + needs: [rust-checks] + # SECURITY: Require manual approval for external PRs + if: ${{ github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name == github.repository }} + steps: + - name: checkout-code + uses: actions/checkout@v4 + - name: cargo-miri + # Detect undefined behaviour in the unsafe code (panic-buffer + # protocol, fn-pointer transmutes). Tests that compile or dlopen + # dylibs are `#[cfg_attr(miri, ignore)]`d since Miri cannot spawn + # processes or load libraries; the dylib preamble itself is covered + # by compiling it into the test binary (see symbiont/src/unwind.rs). + env: + MIRIFLAGS: "-Zmiri-disable-isolation" + run: nix develop --command bash -c "cargo miri test -p symbiont --lib" integration-tests: # Target self-hosted runner by label runs-on: [nixos] diff --git a/CAVEATS.md b/CAVEATS.md index ac09431..0dc1477 100644 --- a/CAVEATS.md +++ b/CAVEATS.md @@ -138,3 +138,28 @@ a `RevisionFn` handle land in that handle's revision — read them with `RevisionFn::take_panic`. A buffer holds only the most recent message, so concurrent panicking calls into the same revision overwrite each other. + +## Undefined behaviour and Miri + +The pointer-swapping dispatch, the panic-buffer protocol, and the +fn-pointer transmutes are all `unsafe` code. The test suite runs +under [Miri](https://github.com/rust-lang/miri) to detect +undefined behaviour in them: + +```sh +MIRIFLAGS="-Zmiri-disable-isolation" cargo miri test -p symbiont --lib +``` + +Miri cannot spawn processes or `dlopen` libraries, so tests that +compile and load dylibs are `#[cfg_attr(miri, ignore)]`d. The +panic-buffer preamble that ships inside every generated dylib is +still covered: it lives in `symbiont/src/panic_preamble.rs` and is +compiled directly into the test binary (see the tests in +`symbiont/src/unwind.rs`), where Miri executes both sides of the +protocol — the dylib-side buffer writes and the host-side +`read_panic_buffer` decode. + +Miri cannot check what it cannot execute: the actual `dlopen` +boundary, cross-dylib calls through swapped pointers, and the +`mem::zeroed()` placeholder return value in the `catch_unwind` +wrapper remain outside its reach. diff --git a/flake.nix b/flake.nix index cb17124..f832213 100644 --- a/flake.nix +++ b/flake.nix @@ -35,6 +35,7 @@ extensions = [ "rust-src" "rust-analyzer" + "miri" ]; targets = ["x86_64-unknown-linux-gnu"]; } From e498f20c39887808bb17960dd7eaa29c2e76b746 Mon Sep 17 00:00:00 2001 From: MathisWellmann Date: Wed, 22 Jul 2026 14:43:48 +0100 Subject: [PATCH 4/9] evolving-trader: make Action implement Default with Hold as the default 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. --- examples/evolving-trader/src/lib.rs | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/examples/evolving-trader/src/lib.rs b/examples/evolving-trader/src/lib.rs index a702ce6..3d96fa8 100644 --- a/examples/evolving-trader/src/lib.rs +++ b/examples/evolving-trader/src/lib.rs @@ -54,9 +54,14 @@ pub struct AccountState { /// The trading decision returned by the evolved strategy. /// Only market orders are available; they fill immediately at the current /// bid/ask and pay taker fees. -#[derive(Debug, Clone, Copy, PartialEq)] +/// +/// `Hold` is the [`Default`]: if an evolved strategy panics, the harness +/// substitutes `Action::default()` for the call's return value, and the +/// safe reaction to a crashed strategy is to do nothing this candle. +#[derive(Debug, Clone, Copy, PartialEq, Default)] pub enum Action { /// Do nothing this candle. + #[default] Hold, /// Submit a market buy order for `qty` BTC. /// Increases long exposure or reduces/flips a short position. From b75b434bc4a283bff3ebc5516c835be72ce472f4 Mon Sep 17 00:00:00 2001 From: MathisWellmann Date: Wed, 22 Jul 2026 14:43:48 +0100 Subject: [PATCH 5/9] 0.20.0: Return Default::default() instead of mem::zeroed() when an evolvable 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. --- CAVEATS.md | 12 +++++++++--- Cargo.lock | 24 ++++++++++++------------ Cargo.toml | 2 +- symbiont-macros/src/lib.rs | 38 +++++++++++++++++++++++++++++++++++++- symbiont/Cargo.toml | 2 +- symbiont/src/lib.rs | 12 ++++++++++++ symbiont/src/unwind.rs | 13 ++++++++++--- 7 files changed, 82 insertions(+), 21 deletions(-) diff --git a/CAVEATS.md b/CAVEATS.md index 0dc1477..9fae5f0 100644 --- a/CAVEATS.md +++ b/CAVEATS.md @@ -132,6 +132,13 @@ exported symbol (`__symbiont_take_panic`). Use `Runtime::take_panic` to retrieve panic messages after each call. +When an implementation panics, the wrapped call returns +`Default::default()` as a safe placeholder value — check +`Runtime::take_panic` to distinguish it from a real result. +Every evolvable return type must therefore implement `Default`; +the `evolvable!` macro enforces this with a compile error at the +declaration site, so generated dylibs always compile. + Each revision has its own panic buffer. `Runtime::take_panic` reads the **active** revision's buffer; panics from calls through a `RevisionFn` handle land in that handle's revision — read them @@ -160,6 +167,5 @@ protocol — the dylib-side buffer writes and the host-side `read_panic_buffer` decode. Miri cannot check what it cannot execute: the actual `dlopen` -boundary, cross-dylib calls through swapped pointers, and the -`mem::zeroed()` placeholder return value in the `catch_unwind` -wrapper remain outside its reach. +boundary and cross-dylib calls through swapped pointers remain +outside its reach. diff --git a/Cargo.lock b/Cargo.lock index 963ff11..b83e6e9 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1334,7 +1334,7 @@ dependencies = [ [[package]] name = "counter-example" -version = "0.19.3" +version = "0.20.0" dependencies = [ "symbiont", "tokio", @@ -2100,7 +2100,7 @@ dependencies = [ [[package]] name = "evolving-trader-example" -version = "0.19.3" +version = "0.20.0" dependencies = [ "lfest", "plotters", @@ -2208,7 +2208,7 @@ dependencies = [ [[package]] name = "fizzbuzz-example" -version = "0.19.3" +version = "0.20.0" dependencies = [ "rig-core", "symbiont", @@ -2322,7 +2322,7 @@ dependencies = [ [[package]] name = "fractal-studio-example" -version = "0.19.3" +version = "0.20.0" dependencies = [ "eframe", "egui_extras", @@ -4817,7 +4817,7 @@ dependencies = [ [[package]] name = "quantize-example" -version = "0.19.3" +version = "0.20.0" dependencies = [ "colorgrad", "derive_more", @@ -5033,7 +5033,7 @@ dependencies = [ [[package]] name = "rastrigin-example" -version = "0.19.3" +version = "0.20.0" dependencies = [ "rig-core", "romu", @@ -5950,7 +5950,7 @@ dependencies = [ [[package]] name = "sort-example" -version = "0.19.3" +version = "0.20.0" dependencies = [ "rig-core", "romu", @@ -5994,7 +5994,7 @@ checksum = "73473c0e59e6d5812c5dfe2a064a6444949f089e20eec9a2e5506596494e4623" [[package]] name = "struct-support-example" -version = "0.19.3" +version = "0.20.0" dependencies = [ "symbiont", "tokio", @@ -6009,7 +6009,7 @@ checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292" [[package]] name = "symbiont" -version = "0.19.3" +version = "0.20.0" dependencies = [ "criterion", "getset", @@ -6037,7 +6037,7 @@ dependencies = [ [[package]] name = "symbiont-macros" -version = "0.19.3" +version = "0.20.0" dependencies = [ "prettyplease", "proc-macro2", @@ -6208,7 +6208,7 @@ dependencies = [ [[package]] name = "tictactoe-example" -version = "0.19.3" +version = "0.20.0" dependencies = [ "romu", "symbiont", @@ -6437,7 +6437,7 @@ dependencies = [ [[package]] name = "tool-calling-example" -version = "0.19.3" +version = "0.20.0" dependencies = [ "rig-core", "serde", diff --git a/Cargo.toml b/Cargo.toml index 32783df..4d5f3fa 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -17,7 +17,7 @@ members = [ resolver = "2" [workspace.package] -version = "0.19.3" +version = "0.20.0" [workspace.lints.rust] # checks for cases that are confusing between a negative literal and a negation that's not part of the literal. diff --git a/symbiont-macros/src/lib.rs b/symbiont-macros/src/lib.rs index 88dbd73..2762697 100644 --- a/symbiont-macros/src/lib.rs +++ b/symbiont-macros/src/lib.rs @@ -10,10 +10,14 @@ mod utils; use proc_macro::TokenStream; use proc_macro2::Span; -use quote::quote; +use quote::{ + quote, + quote_spanned, +}; use syn::{ FnArg, ReturnType, + spanned::Spanned, }; use crate::{ @@ -36,12 +40,25 @@ use crate::{ /// } /// ``` /// +/// # Return types +/// +/// Every return type must implement [`Default`]: when an evolved +/// implementation panics, the in-dylib `catch_unwind` wrapper substitutes +/// `Default::default()` as a safe placeholder return value (retrieve the +/// panic message with `Runtime::take_panic`). The bound is enforced with a +/// compile error at the declaration site, so generated dylibs always +/// compile. +/// /// This generates: /// - A `SYMBIONT_DECLS` constant with metadata for each function /// - Wrapper functions that dispatch calls through the loaded dylib /// - Per-function `_fn(revision)` accessors returning typed /// `RevisionFn` handles to any retained revision #[proc_macro] +#[expect( + clippy::too_many_lines, + reason = "One big macro, better be left undisturbed." +)] pub fn evolvable(input: TokenStream) -> TokenStream { let block = syn::parse_macro_input!(input as EvolvableBlock); @@ -96,6 +113,25 @@ pub fn evolvable(input: TokenStream) -> TokenStream { ReturnType::Type(_, ty) => quote! { #ty }, }; + // On panic inside the dylib, the `catch_unwind` wrapper substitutes + // `Default::default()` as the return value, so every evolvable + // return type must implement `Default`. Enforce this at declaration + // time so generated dylibs always compile. + let ret_span = match &sig.output { + ReturnType::Type(_, ty) => ty.span(), + ReturnType::Default => ident.span(), + }; + let assert_ident = syn::Ident::new( + &format!("__symbiont_return_type_of_{fn_name_str}_must_implement_default"), + ident.span(), + ); + wrapper_fns.push(quote_spanned! {ret_span=> + const _: fn() = || { + fn #assert_ident() {} + #assert_ident::<#ret_ty>(); + }; + }); + // Build the EvolvableDecl entry (with reference to the AtomicPtr static) decl_entries.push(quote! { ::symbiont::EvolvableDecl { diff --git a/symbiont/Cargo.toml b/symbiont/Cargo.toml index 1569137..b390cbd 100644 --- a/symbiont/Cargo.toml +++ b/symbiont/Cargo.toml @@ -16,7 +16,7 @@ website = "https://symbiont.rs" workspace = true [dependencies] -symbiont-macros = { version = "0.19.0", path = "../symbiont-macros" } +symbiont-macros = { version = "0.20.0", path = "../symbiont-macros" } rig-core.workspace = true tokio.workspace = true diff --git a/symbiont/src/lib.rs b/symbiont/src/lib.rs index 001d32d..d8c9f7b 100644 --- a/symbiont/src/lib.rs +++ b/symbiont/src/lib.rs @@ -61,6 +61,18 @@ pub use revision::{ }; use rig_core::providers::openrouter::CompletionModel; pub use runtime::Runtime; +/// Evolvable return types must implement [`Default`]: when an evolved +/// implementation panics, the in-dylib `catch_unwind` wrapper substitutes +/// `Default::default()` as a safe placeholder return value. The bound is +/// enforced at the declaration site: +/// +/// ```compile_fail +/// struct NoDefault; +/// +/// symbiont::evolvable! { +/// fn make() -> NoDefault; +/// } +/// ``` pub use symbiont_macros::evolvable; /// type alias for the return type of `init_agent` diff --git a/symbiont/src/unwind.rs b/symbiont/src/unwind.rs index 2704df6..9c94db1 100644 --- a/symbiont/src/unwind.rs +++ b/symbiont/src/unwind.rs @@ -18,12 +18,19 @@ /// else if let Some(s) = e.downcast_ref::() { s.as_str() } /// else { "unknown panic" }; /// __symbiont_store_panic_fallback(msg); -/// unsafe { core::mem::zeroed() } +/// Default::default() /// } /// } /// } /// ``` /// +/// The `Err` arm substitutes `Default::default()` as a placeholder return +/// value — safe for every type, unlike a zeroed value, which is undefined +/// behaviour for types like `String` or `&T`. The `evolvable!` macro +/// enforces at declaration time that every return type implements +/// [`Default`], so the wrapped code always compiles; hosts detect the +/// panic via `Runtime::take_panic` and discard the placeholder. +/// /// The panic *message with its source location* is recorded by the panic hook /// installed via `__symbiont_install_panic_hook` — the hook runs at panic /// time, which is the only point where `std::panic::Location` is available. @@ -52,7 +59,7 @@ pub(crate) fn wrap_bodies_in_catch_unwind(file: &mut syn::File) { "unknown panic" }; __symbiont_store_panic_fallback(__symbiont_msg); - unsafe { ::core::mem::zeroed() } + ::core::default::Default::default() } } }); @@ -102,7 +109,7 @@ mod tests { "unknown panic" }; __symbiont_store_panic_fallback(__symbiont_msg); - unsafe { ::core::mem::zeroed() } + ::core::default::Default::default() } } } From 8d5639221a8803ed1aa4170c12a4de33b6b4a2d6 Mon Sep 17 00:00:00 2001 From: MathisWellmann Date: Wed, 22 Jul 2026 15:32:30 +0100 Subject: [PATCH 6/9] Forbid unsafe code in LLM-generated evolvable code 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. --- CAVEATS.md | 10 ++ symbiont/Cargo.toml | 2 +- symbiont/src/error.rs | 3 + symbiont/src/evolve_failure.rs | 26 ++- symbiont/src/observability.rs | 9 + symbiont/src/runtime.rs | 7 + symbiont/src/system_prompt.rs | 2 + symbiont/src/validation.rs | 232 +++++++++++++++++++++++++- symbiont/tests/backpressure_unsafe.rs | 80 +++++++++ 9 files changed, 362 insertions(+), 9 deletions(-) create mode 100644 symbiont/tests/backpressure_unsafe.rs diff --git a/CAVEATS.md b/CAVEATS.md index 9fae5f0..cf26821 100644 --- a/CAVEATS.md +++ b/CAVEATS.md @@ -148,6 +148,16 @@ revision overwrite each other. ## Undefined behaviour and Miri +The generated code itself is barred from introducing new unsafety: +validation rejects any `unsafe` construct in LLM-generated code at +the AST level before compiling — `unsafe` blocks, `unsafe fn`, +`unsafe impl`/`trait`, `extern` blocks, unsafe attributes (except +the harness-managed `#[unsafe(no_mangle)]` export), and `unsafe` +tokens smuggled through macros. The offending construct is fed +back to the agent as backpressure. Note this bounds the UB +surface; it is *not* a security sandbox — safe Rust running in +the host process can still perform I/O or spawn processes. + The pointer-swapping dispatch, the panic-buffer protocol, and the fn-pointer transmutes are all `unsafe` code. The test suite runs under [Miri](https://github.com/rust-lang/miri) to detect diff --git a/symbiont/Cargo.toml b/symbiont/Cargo.toml index b390cbd..b2c89a6 100644 --- a/symbiont/Cargo.toml +++ b/symbiont/Cargo.toml @@ -32,7 +32,7 @@ proc-macro2 = { version = "1", features = ["span-locations"] } quote = "1" rustdoc-types = "0.57" serde_json = "1" -syn = { version = "2", features = ["full"] } +syn = { version = "2", features = ["full", "visit"] } thiserror = "2" tracing-subscriber = { version = "0.3", features = ["env-filter"] } diff --git a/symbiont/src/error.rs b/symbiont/src/error.rs index 7fce484..9ad2b7e 100644 --- a/symbiont/src/error.rs +++ b/symbiont/src/error.rs @@ -38,6 +38,9 @@ pub enum Error { got: String, }, + #[error("Unsafe code is forbidden in evolvable code: found {construct}")] + UnsafeCode { code: String, construct: String }, + #[error("Compilation failed:\n{err}")] CompilationFailed { code: String, err: String }, diff --git a/symbiont/src/evolve_failure.rs b/symbiont/src/evolve_failure.rs index 59fee96..17aa50f 100644 --- a/symbiont/src/evolve_failure.rs +++ b/symbiont/src/evolve_failure.rs @@ -16,7 +16,8 @@ use crate::{ /// /// Captures exactly the failures that are rendered back into the retry /// prompt as backpressure: missing code blocks, parse errors, exhausted -/// tool-call turn budgets, signature mismatches, and compilation failures. +/// tool-call turn budgets, signature mismatches, forbidden unsafe code, +/// and compilation failures. /// Hosts can drain these via [`crate::Runtime::take_evolve_failures`] and /// persist them for offline analysis of common failure patterns, e.g. to /// tune prompts or the documented API surface. @@ -27,7 +28,7 @@ pub struct EvolveFailure { attempt: usize, /// Failure kind label; the same values as the `kind` label of /// [`crate::observability::EVOLVE_FAILURES`]: one of `no_rust_code`, - /// `parse`, `max_turns`, `signature` or `compile`. + /// `parse`, `max_turns`, `signature`, `unsafe` or `compile`. #[getset(get_copy = "pub")] kind: &'static str, /// The generated source that failed. Empty when the agent produced no @@ -36,7 +37,8 @@ pub struct EvolveFailure { generated_code: String, /// The diagnostics fed back to the agent: rustc stderr for `compile`, /// the parse error for `parse`, the mismatch description for - /// `signature`, and the corrective nudge otherwise. + /// `signature`, the offending construct for `unsafe`, and the + /// corrective nudge otherwise. #[getset(get = "pub")] diagnostics: String, } @@ -60,6 +62,7 @@ impl EvolveFailure { code.clone(), format!("signature mismatch in `{got}`; expected `{expected}`"), ), + Error::UnsafeCode { code, construct } => (code.clone(), construct.clone()), Error::CompilationFailed { code, err } => (code.clone(), err.clone()), _ => return None, }; @@ -109,6 +112,23 @@ mod tests { assert!(failure.diagnostics().contains("fn g(x: u32)")); } + #[test] + fn unsafe_code_is_recorded() { + let failure = EvolveFailure::from_error( + &Error::UnsafeCode { + code: "pub fn f() { unsafe {} }".to_string(), + construct: "an `unsafe` block: `unsafe { }`".to_string(), + }, + 2, + ) + .expect("unsafe code feeds backpressure"); + + assert_eq!(failure.attempt(), 2); + assert_eq!(failure.kind(), "unsafe"); + assert_eq!(failure.generated_code(), "pub fn f() { unsafe {} }"); + assert!(failure.diagnostics().contains("an `unsafe` block")); + } + #[test] fn no_rust_code_is_recorded_without_source() { let failure = EvolveFailure::from_error(&Error::NoRustCode, 1) diff --git a/symbiont/src/observability.rs b/symbiont/src/observability.rs index c4569e4..6066227 100644 --- a/symbiont/src/observability.rs +++ b/symbiont/src/observability.rs @@ -110,6 +110,7 @@ pub const DYLIB_SOURCE_BYTES: &str = "symbiont_dylib_source_bytes"; pub(crate) mod failure_kind { pub(crate) const PARSE: &str = "parse"; pub(crate) const SIGNATURE: &str = "signature"; + pub(crate) const UNSAFE_CODE: &str = "unsafe"; pub(crate) const COMPILE: &str = "compile"; pub(crate) const NO_RUST_CODE: &str = "no_rust_code"; pub(crate) const MAX_TURNS: &str = "max_turns"; @@ -218,6 +219,7 @@ pub(crate) fn failure_kind_of(e: &crate::Error) -> &'static str { match e { CouldNotParseRust { .. } => failure_kind::PARSE, SignatureMismatch { .. } => failure_kind::SIGNATURE, + UnsafeCode { .. } => failure_kind::UNSAFE_CODE, CompilationFailed { .. } => failure_kind::COMPILE, NoRustCode => failure_kind::NO_RUST_CODE, RigPrompt(rig_core::completion::PromptError::MaxTurnsError { .. }) => { @@ -437,6 +439,13 @@ mod tests { }), failure_kind::SIGNATURE ); + assert_eq!( + failure_kind_of(&UnsafeCode { + code: String::new(), + construct: String::new() + }), + failure_kind::UNSAFE_CODE + ); assert_eq!( failure_kind_of(&CompilationFailed { code: String::new(), diff --git a/symbiont/src/runtime.rs b/symbiont/src/runtime.rs index 95305e1..69c6aa7 100644 --- a/symbiont/src/runtime.rs +++ b/symbiont/src/runtime.rs @@ -644,6 +644,13 @@ impl Runtime { } => write!(prompt, "Signature mismatch in {got}. Expected `{expected}`. Fix ONLY this function's signature (argument types and return type must match exactly; argument names may differ). Full code: ```{code}```", ).expect("Can write to prompt"), + UnsafeCode { code, construct } => write!(prompt, + "Your generated code contains {construct}, but unsafe code is forbidden in evolvable code. \ + Rewrite it in safe Rust only: no `unsafe` blocks, `unsafe fn`, `unsafe impl`, `unsafe trait`, \ + `extern` blocks, unsafe attributes, or `unsafe` tokens inside macros. \ + Keep the logic and the function signatures unchanged. Full code: ```{}```", + code.blue() + ).expect("Can write to prompt"), CompilationFailed{code, err} => write!(prompt, "Your generated code ```{}``` failed to compile. Compiler output:\n```\n{}\n```\n\ Fix the compilation errors while preserving the existing logic and behaviour. \ diff --git a/symbiont/src/system_prompt.rs b/symbiont/src/system_prompt.rs index dd25c75..8ff1515 100644 --- a/symbiont/src/system_prompt.rs +++ b/symbiont/src/system_prompt.rs @@ -45,6 +45,8 @@ Do not emit `main`, tests, Cargo metadata, modules, or unrelated items unless th Do not add `#[no_mangle]`, `#[unsafe(no_mangle)]`, or `extern` attributes. The harness handles dynamic-library exports. +Unsafe code is forbidden and rejected before compilation: never use `unsafe` blocks, `unsafe fn`, `unsafe impl`, `unsafe trait`, `extern` blocks, or unsafe attributes. + # Compilation environment The generated crate uses Rust edition 2024. diff --git a/symbiont/src/validation.rs b/symbiont/src/validation.rs index 5557119..d852a58 100644 --- a/symbiont/src/validation.rs +++ b/symbiont/src/validation.rs @@ -6,6 +6,10 @@ use syn::{ Signature, Type, Visibility, + visit::{ + self, + Visit, + }, }; use tracing::debug; @@ -19,6 +23,7 @@ use crate::{ }; /// Validate that a parsed AST enforces typed generation: +/// - No `unsafe` code anywhere (see [`reject_unsafe_code`]) /// - All functions are `pub` /// - All functions have `#[unsafe(no_mangle)]`) /// - All function signatures match the expected signatures from lib.rs. @@ -30,6 +35,8 @@ use crate::{ /// /// Returns `Err` with a descriptive message if any check fails. pub(crate) fn validate_generated_ast(file: &mut syn::File, expected_sigs: &[String]) -> Result<()> { + reject_unsafe_code(file)?; + if expected_sigs.is_empty() { return Ok(()); } @@ -94,6 +101,119 @@ pub(crate) fn validate_generated_ast(file: &mut syn::File, expected_sigs: &[Stri Ok(()) } +/// Reject any `unsafe` construct in LLM-generated code. +/// +/// Enforced on the parsed AST *before* compilation: the rejection is +/// cheap (no cargo round-trip), pinpoints the offending construct for the +/// backpressure prompt, and cannot be evaded with `#[allow(unsafe_code)]` +/// the way a compiler lint could. A crate-level `#![forbid(unsafe_code)]` +/// is not an option anyway: the injected panic preamble is legitimately +/// unsafe, and the `#[unsafe(no_mangle)]` export attribute on every +/// evolvable function trips the `unsafe_code` lint in edition 2024. +/// +/// Rejected constructs: +/// - `unsafe { .. }` blocks +/// - `unsafe fn` (free, impl, trait, and foreign) +/// - `unsafe impl` and `unsafe trait` +/// - `extern` blocks +/// - unsafe attributes such as `#[unsafe(export_name = ..)]` — except the +/// exact `#[unsafe(no_mangle)]` export attribute the harness itself +/// manages +/// - an `unsafe` token anywhere inside a macro definition or invocation, +/// which would otherwise smuggle unsafe code past the AST scan +pub(crate) fn reject_unsafe_code(file: &syn::File) -> Result<()> { + let mut scan = UnsafeScan { finding: None }; + scan.visit_file(file); + match scan.finding { + Some(construct) => Err(Error::UnsafeCode { + code: unparse(file), + construct, + }), + None => Ok(()), + } +} + +/// AST visitor recording the first forbidden `unsafe` construct. +struct UnsafeScan { + finding: Option, +} + +impl UnsafeScan { + fn record(&mut self, what: &str, tokens: &dyn ToTokens) { + if self.finding.is_none() { + let mut snippet = tokens.to_token_stream().to_string(); + if snippet.len() > 120 { + let cut = (0..=120) + .rev() + .find(|i| snippet.is_char_boundary(*i)) + .unwrap_or(0); + snippet.truncate(cut); + snippet.push('…'); + } + self.finding = Some(format!("{what}: `{snippet}`")); + } + } +} + +/// `true` if any token (recursively) is the `unsafe` keyword. +fn tokens_contain_unsafe(tokens: proc_macro2::TokenStream) -> bool { + tokens.into_iter().any(|tt| match tt { + proc_macro2::TokenTree::Ident(ident) => ident == "unsafe", + proc_macro2::TokenTree::Group(group) => tokens_contain_unsafe(group.stream()), + _ => false, + }) +} + +impl<'ast> Visit<'ast> for UnsafeScan { + fn visit_expr_unsafe(&mut self, node: &'ast syn::ExprUnsafe) { + self.record("an `unsafe` block", node); + } + + // Covers free functions, impl methods, trait methods, and foreign fns. + fn visit_signature(&mut self, node: &'ast syn::Signature) { + if node.unsafety.is_some() { + self.record("an `unsafe fn`", node); + } + visit::visit_signature(self, node); + } + + fn visit_item_impl(&mut self, node: &'ast syn::ItemImpl) { + if node.unsafety.is_some() { + self.record("an `unsafe impl`", node); + } + visit::visit_item_impl(self, node); + } + + fn visit_item_trait(&mut self, node: &'ast syn::ItemTrait) { + if node.unsafety.is_some() { + self.record("an `unsafe trait`", node); + } + visit::visit_item_trait(self, node); + } + + fn visit_item_foreign_mod(&mut self, node: &'ast syn::ItemForeignMod) { + self.record("an `extern` block", node); + } + + fn visit_attribute(&mut self, node: &'ast syn::Attribute) { + // The exact export attribute the harness manages is the only + // permitted unsafe attribute; see `crate::utils::is_no_mangle`. + let is_no_mangle_export = node.path().is_ident("unsafe") + && matches!(&node.meta, syn::Meta::List(list) if list.tokens.to_string() == "no_mangle"); + if node.path().is_ident("unsafe") && !is_no_mangle_export { + self.record("an unsafe attribute", node); + } + visit::visit_attribute(self, node); + } + + fn visit_macro(&mut self, node: &'ast syn::Macro) { + if tokens_contain_unsafe(node.tokens.clone()) { + self.record("an `unsafe` token inside a macro", node); + } + visit::visit_macro(self, node); + } +} + /// Extract the function name from a signature rendered by [`format_signature`], /// e.g. `fn step(&mut usize)` -> `step`. fn expected_fn_name(sig: &str) -> Option<&str> { @@ -271,17 +391,31 @@ pub fn step(_counter: &mut usize) { #[test] fn abi_incompatible_modifiers_are_reported() { + // `unsafe` modifiers are intercepted by the unsafe scan first. + for input in [ + "pub unsafe fn step(counter: &mut usize) {}", + "pub unsafe extern \"C\" fn step(counter: &mut usize, ...) {}", + ] { + let mut file = syn::parse_str(input).expect("can parse"); + let expected = vec!["fn step(counter: &mut usize)".to_string()]; + let err = validate_generated_ast(&mut file, &expected) + .expect_err("unsafe signature must be rejected"); + assert!( + matches!( + err, + Error::UnsafeCode { ref construct, .. } if construct.contains("an `unsafe fn`") + ), + "feedback must identify the unsafe fn in {input}: {err}" + ); + } + + // Safe but ABI-incompatible modifiers surface as signature mismatches. for (input, modifier) in [ ("pub async fn step(counter: &mut usize) {}", "async"), - ("pub unsafe fn step(counter: &mut usize) {}", "unsafe"), ( "pub extern \"C\" fn step(counter: &mut usize) {}", "extern \"C\"", ), - ( - "pub unsafe extern \"C\" fn step(counter: &mut usize, ...) {}", - "...", - ), ] { let mut file = syn::parse_str(input).expect("can parse"); let expected = vec!["fn step(counter: &mut usize)".to_string()]; @@ -385,4 +519,92 @@ pub fn step(counter: &mut usize) -> usize { let expected = vec!["fn step(counter: &mut usize) -> usize".to_string()]; validate_generated_ast(&mut file, &expected).expect("validation with return type passed"); } + + /// Assert `reject_unsafe_code` rejects `code` and names `construct`. + fn assert_rejects_unsafe(code: &str, construct: &str) { + let file: syn::File = syn::parse_str(code).expect("can parse"); + let err = reject_unsafe_code(&file).expect_err("unsafe construct must be rejected"); + match err { + Error::UnsafeCode { + construct: found, .. + } => { + assert!( + found.contains(construct), + "expected construct `{construct}`, got `{found}`" + ); + } + other => panic!("expected Error::UnsafeCode, got {other}"), + } + } + + #[test] + fn unsafe_block_is_rejected() { + assert_rejects_unsafe( + "pub fn step(counter: &mut usize) { unsafe { *counter += 1; } }", + "an `unsafe` block", + ); + } + + #[test] + fn unsafe_fn_is_rejected() { + assert_rejects_unsafe("pub unsafe fn helper(ptr: *mut usize) {}", "an `unsafe fn`"); + assert_rejects_unsafe( + "struct S; impl S { unsafe fn helper(&self) {} }", + "an `unsafe fn`", + ); + } + + #[test] + fn unsafe_impl_and_trait_are_rejected() { + assert_rejects_unsafe("struct S; unsafe impl Send for S {}", "an `unsafe impl`"); + assert_rejects_unsafe("unsafe trait Scary {}", "an `unsafe trait`"); + } + + #[test] + fn extern_block_is_rejected() { + assert_rejects_unsafe( + "unsafe extern \"C\" { fn malloc(size: usize) -> *mut u8; }", + "an `extern` block", + ); + } + + #[test] + fn unsafe_attribute_is_rejected_but_no_mangle_is_allowed() { + assert_rejects_unsafe( + "#[unsafe(export_name = \"evil\")] pub fn step(counter: &mut usize) {}", + "an unsafe attribute", + ); + + let file: syn::File = syn::parse_str( + "#[unsafe(no_mangle)] pub fn step(counter: &mut usize) { *counter += 1; }", + ) + .expect("can parse"); + reject_unsafe_code(&file).expect("#[unsafe(no_mangle)] is harness-managed and allowed"); + } + + #[test] + fn unsafe_smuggled_through_macro_is_rejected() { + assert_rejects_unsafe( + "macro_rules! sneaky { () => { unsafe { core::hint::unreachable_unchecked() } }; }\n\ + pub fn step(counter: &mut usize) { sneaky!(); }", + "an `unsafe` token inside a macro", + ); + assert_rejects_unsafe( + "pub fn step(counter: &mut usize) { let _ = stringify!(unsafe); }", + "an `unsafe` token inside a macro", + ); + } + + #[test] + fn safe_code_passes_unsafe_scan() { + let file: syn::File = syn::parse_str( + "pub fn step(counter: &mut usize) {\n\ + let values = vec![1usize, 2, 3];\n\ + *counter += values.iter().sum::();\n\ + println!(\"counter is {counter}\");\n\ + }", + ) + .expect("can parse"); + reject_unsafe_code(&file).expect("safe code must pass"); + } } diff --git a/symbiont/tests/backpressure_unsafe.rs b/symbiont/tests/backpressure_unsafe.rs new file mode 100644 index 0000000..616780f --- /dev/null +++ b/symbiont/tests/backpressure_unsafe.rs @@ -0,0 +1,80 @@ +// SPDX-License-Identifier: MPL-2.0 +//! Backpressure integration test: generated code containing an `unsafe` +//! block is rejected at validation time (before compiling), the forbidden +//! construct is fed back, and the agent recovers with safe code. +//! +//! One test per binary: [`symbiont::Runtime`] is a process-wide singleton. +#![expect( + unused_crate_dependencies, + reason = "Integration tests don't use them all" +)] + +mod common; + +use common::{ + ScriptedAgent, + Turn, +}; +use symbiont::{ + Profile, + Runtime, +}; + +const BASE_PROMPT: &str = "Implement the function. Code only."; + +#[tokio::test] +#[cfg_attr( + miri, + ignore = "compiles and dlopens dylibs, which Miri does not support" +)] +#[tracing_test::traced_test] +async fn unsafe_code_is_rejected_and_recovered_from() { + symbiont::evolvable! { + fn bp_unsafe_step(counter: &mut usize) { + *counter += 1; + } + }; + let rt = Runtime::new(SYMBIONT_DECLS, SYMBIONT_PRELUDE, Profile::Debug) + .await + .expect("Can init runtime"); + + let agent = ScriptedAgent::new([ + // Attempt 1: an unsafe block -> rejected at validation, no compile. + Turn::reply( + "```rust\npub fn bp_unsafe_step(counter: &mut usize) { unsafe { *(counter as *mut usize) += 1; } }\n```", + ), + // Attempt 2: safe code -> success. + Turn::reply("```rust\npub fn bp_unsafe_step(counter: &mut usize) { *counter = 9; }\n```"), + ]); + + rt.evolve(&agent, BASE_PROMPT) + .await + .expect("evolution should succeed after one self-healing retry"); + + assert_eq!(agent.calls(), 2, "exactly one retry expected"); + + let retry_prompt = agent.prompt(1); + assert!( + !retry_prompt.contains(BASE_PROMPT), + "retry prompt must contain only the correction, got: {retry_prompt}" + ); + assert!( + retry_prompt.contains("unsafe code is forbidden"), + "retry prompt must contain the unsafe nudge, got: {retry_prompt}" + ); + assert!( + retry_prompt.contains("an `unsafe` block"), + "retry prompt must name the offending construct, got: {retry_prompt}" + ); + + // The failure record is drained with the `unsafe` kind. + let failures = rt.take_evolve_failures(); + assert_eq!(failures.len(), 1); + assert_eq!(failures[0].kind(), "unsafe"); + assert!(failures[0].generated_code().contains("unsafe")); + + // The hot-swapped safe implementation is live. + let mut counter = 0; + bp_unsafe_step(&mut counter); + assert_eq!(counter, 9, "evolved implementation should be hot-swapped"); +} From 48cf77174617b07ddb5a3682861d9b01bc9e2058 Mon Sep 17 00:00:00 2001 From: MathisWellmann Date: Wed, 22 Jul 2026 16:43:34 +0100 Subject: [PATCH 7/9] Reject forbidden constructs and deny process capabilities in LLM-generated 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). --- CAVEATS.md | 27 +- symbiont/src/dylib_config.rs | 57 +++ symbiont/src/error.rs | 7 + symbiont/src/evolve_failure.rs | 33 +- symbiont/src/observability.rs | 10 + symbiont/src/runtime.rs | 11 +- symbiont/src/system_prompt.rs | 2 + symbiont/src/validation.rs | 575 +++++++++++++++++++++-- symbiont/tests/backpressure_forbidden.rs | 86 ++++ 9 files changed, 745 insertions(+), 63 deletions(-) create mode 100644 symbiont/tests/backpressure_forbidden.rs diff --git a/CAVEATS.md b/CAVEATS.md index cf26821..766008f 100644 --- a/CAVEATS.md +++ b/CAVEATS.md @@ -12,9 +12,12 @@ dynamic loading, this introduces strict limitations. Any `static` variable inside the reloaded dylib is re-initialized on every reload. If the evolvable function relies on persistent -state across calls, that state is lost when the function evolves. -The harness forbids this by design: all state is owned by the -host binary and passed into evolvable functions via arguments. +state across calls, that state is lost when the function evolves +— and every retained revision has its own instance. The harness +forbids this by design: all state is owned by the host binary and +passed into evolvable functions via arguments. Validation +enforces the rule by rejecting `static` items and `thread_local!` +in LLM-generated code before compilation. ## Dangling pointers across reloads @@ -154,9 +157,21 @@ the AST level before compiling — `unsafe` blocks, `unsafe fn`, `unsafe impl`/`trait`, `extern` blocks, unsafe attributes (except the harness-managed `#[unsafe(no_mangle)]` export), and `unsafe` tokens smuggled through macros. The offending construct is fed -back to the agent as backpressure. Note this bounds the UB -surface; it is *not* a security sandbox — safe Rust running in -the host process can still perform I/O or spawn processes. +back to the agent as backpressure. + +Beyond `unsafe`, validation also rejects constructs that break the +harness's contracts or reach for process capabilities: `static` +items and `thread_local!` (dylib state resets on reload), +`macro_rules!` definitions, allocator/panic-handler/entry +overrides, tampering with the panic hook, and — by default — +references to `std::process`, `std::thread`, `std::fs`, +`std::net`, `std::env`, `std::os`, and `std::io::stdin` (matched +through `use` aliases and inside macro tokens; glob imports of +denied modules are rejected outright). Hosts widen or narrow the +capability surface with `DylibConfig::with_allowed_path` / +`with_denied_path`. Note this bounds what evolvable code can +*name*; it is *not* a security sandbox — safe Rust reached through +host-provided APIs still runs with the host's privileges. The pointer-swapping dispatch, the panic-buffer protocol, and the fn-pointer transmutes are all `unsafe` code. The test suite runs diff --git a/symbiont/src/dylib_config.rs b/symbiont/src/dylib_config.rs index 42c0a85..ed6edca 100644 --- a/symbiont/src/dylib_config.rs +++ b/symbiont/src/dylib_config.rs @@ -37,9 +37,44 @@ pub struct DylibConfig { /// `[patch]` sections added to the generated dylib's `Cargo.toml`. #[getset(get = "pub")] patches: Vec, + + /// Path prefixes (e.g. `std::fs`) that LLM-generated code must not + /// reference. Enforced on the parsed AST before compilation; violations + /// are fed back to the agent as backpressure. + /// + /// Defaults to [`DylibConfig::default_denied_paths`]. Hosts widen or + /// narrow the capability surface with [`DylibConfig::with_denied_path`] + /// and [`DylibConfig::with_allowed_path`]. + #[getset(get = "pub")] + denied_paths: Vec, } impl DylibConfig { + /// The path prefixes denied in LLM-generated code by default: + /// process control and spawning (`std::process` — `exit`/`abort` kill + /// the host instantly, bypassing panic capture), threads + /// (`std::thread` — spawned threads escape the feedback-loop contract), + /// filesystem and network I/O (`std::fs`, `std::net`), host environment + /// (`std::env`), OS extension traits (`std::os`), and blocking stdin + /// (`std::io::stdin`). + /// + /// This bounds what evolvable code can *name*; it is a guidance + /// mechanism for the evolution loop, not a security sandbox. + #[must_use] + pub fn default_denied_paths() -> Vec { + [ + "std::process", + "std::thread", + "std::fs", + "std::net", + "std::env", + "std::os", + "std::io::stdin", + ] + .map(String::from) + .to_vec() + } + /// Create a config for a Cargo package's library target. /// /// The dylib gets a path dependency on `package_dir`, renamed to the crate @@ -62,6 +97,7 @@ impl DylibConfig { package_dir, )], patches: Vec::new(), + denied_paths: Self::default_denied_paths(), } } @@ -73,6 +109,7 @@ impl DylibConfig { prelude: Vec::new(), dependencies: Vec::new(), patches: Vec::new(), + denied_paths: Self::default_denied_paths(), } } @@ -100,6 +137,26 @@ impl DylibConfig { self.patches.push(patch); self } + + /// Deny an additional path prefix in LLM-generated code, e.g. + /// `host::dangerous` or `std::collections::BTreeMap`. + #[must_use] + pub fn with_denied_path(mut self, path: impl Into) -> Self { + self.denied_paths.push(path.into()); + self + } + + /// Allow a path prefix that is denied by default, e.g. `std::fs` for a + /// host whose evolvable functions legitimately operate on files. + /// + /// Removes every denied entry equal to `path` or nested inside it. + #[must_use] + pub fn with_allowed_path(mut self, path: &str) -> Self { + let nested = format!("{path}::"); + self.denied_paths + .retain(|denied| denied != path && !denied.starts_with(&nested)); + self + } } impl From for DylibConfig { diff --git a/symbiont/src/error.rs b/symbiont/src/error.rs index 9ad2b7e..253cd01 100644 --- a/symbiont/src/error.rs +++ b/symbiont/src/error.rs @@ -41,6 +41,13 @@ pub enum Error { #[error("Unsafe code is forbidden in evolvable code: found {construct}")] UnsafeCode { code: String, construct: String }, + #[error("Forbidden construct in evolvable code: found {construct} ({reason})")] + ForbiddenConstruct { + code: String, + construct: String, + reason: String, + }, + #[error("Compilation failed:\n{err}")] CompilationFailed { code: String, err: String }, diff --git a/symbiont/src/evolve_failure.rs b/symbiont/src/evolve_failure.rs index 17aa50f..42aaffb 100644 --- a/symbiont/src/evolve_failure.rs +++ b/symbiont/src/evolve_failure.rs @@ -16,8 +16,8 @@ use crate::{ /// /// Captures exactly the failures that are rendered back into the retry /// prompt as backpressure: missing code blocks, parse errors, exhausted -/// tool-call turn budgets, signature mismatches, forbidden unsafe code, -/// and compilation failures. +/// tool-call turn budgets, signature mismatches, forbidden unsafe code or +/// constructs, and compilation failures. /// Hosts can drain these via [`crate::Runtime::take_evolve_failures`] and /// persist them for offline analysis of common failure patterns, e.g. to /// tune prompts or the documented API surface. @@ -28,7 +28,8 @@ pub struct EvolveFailure { attempt: usize, /// Failure kind label; the same values as the `kind` label of /// [`crate::observability::EVOLVE_FAILURES`]: one of `no_rust_code`, - /// `parse`, `max_turns`, `signature`, `unsafe` or `compile`. + /// `parse`, `max_turns`, `signature`, `unsafe`, `forbidden` or + /// `compile`. #[getset(get_copy = "pub")] kind: &'static str, /// The generated source that failed. Empty when the agent produced no @@ -37,8 +38,8 @@ pub struct EvolveFailure { generated_code: String, /// The diagnostics fed back to the agent: rustc stderr for `compile`, /// the parse error for `parse`, the mismatch description for - /// `signature`, the offending construct for `unsafe`, and the - /// corrective nudge otherwise. + /// `signature`, the offending construct for `unsafe` and `forbidden`, + /// and the corrective nudge otherwise. #[getset(get = "pub")] diagnostics: String, } @@ -63,6 +64,11 @@ impl EvolveFailure { format!("signature mismatch in `{got}`; expected `{expected}`"), ), Error::UnsafeCode { code, construct } => (code.clone(), construct.clone()), + Error::ForbiddenConstruct { + code, + construct, + reason, + } => (code.clone(), format!("{construct} ({reason})")), Error::CompilationFailed { code, err } => (code.clone(), err.clone()), _ => return None, }; @@ -129,6 +135,23 @@ mod tests { assert!(failure.diagnostics().contains("an `unsafe` block")); } + #[test] + fn forbidden_construct_is_recorded() { + let failure = EvolveFailure::from_error( + &Error::ForbiddenConstruct { + code: "static X: usize = 0;".to_string(), + construct: "a `static` item: `static X : usize = 0 ;`".to_string(), + reason: "static state silently resets on every evolution".to_string(), + }, + 1, + ) + .expect("forbidden constructs feed backpressure"); + + assert_eq!(failure.kind(), "forbidden"); + assert!(failure.diagnostics().contains("a `static` item")); + assert!(failure.diagnostics().contains("resets on every evolution")); + } + #[test] fn no_rust_code_is_recorded_without_source() { let failure = EvolveFailure::from_error(&Error::NoRustCode, 1) diff --git a/symbiont/src/observability.rs b/symbiont/src/observability.rs index 6066227..7af8659 100644 --- a/symbiont/src/observability.rs +++ b/symbiont/src/observability.rs @@ -111,6 +111,7 @@ pub(crate) mod failure_kind { pub(crate) const PARSE: &str = "parse"; pub(crate) const SIGNATURE: &str = "signature"; pub(crate) const UNSAFE_CODE: &str = "unsafe"; + pub(crate) const FORBIDDEN: &str = "forbidden"; pub(crate) const COMPILE: &str = "compile"; pub(crate) const NO_RUST_CODE: &str = "no_rust_code"; pub(crate) const MAX_TURNS: &str = "max_turns"; @@ -220,6 +221,7 @@ pub(crate) fn failure_kind_of(e: &crate::Error) -> &'static str { CouldNotParseRust { .. } => failure_kind::PARSE, SignatureMismatch { .. } => failure_kind::SIGNATURE, UnsafeCode { .. } => failure_kind::UNSAFE_CODE, + ForbiddenConstruct { .. } => failure_kind::FORBIDDEN, CompilationFailed { .. } => failure_kind::COMPILE, NoRustCode => failure_kind::NO_RUST_CODE, RigPrompt(rig_core::completion::PromptError::MaxTurnsError { .. }) => { @@ -446,6 +448,14 @@ mod tests { }), failure_kind::UNSAFE_CODE ); + assert_eq!( + failure_kind_of(&ForbiddenConstruct { + code: String::new(), + construct: String::new(), + reason: String::new() + }), + failure_kind::FORBIDDEN + ); assert_eq!( failure_kind_of(&CompilationFailed { code: String::new(), diff --git a/symbiont/src/runtime.rs b/symbiont/src/runtime.rs index 69c6aa7..411c732 100644 --- a/symbiont/src/runtime.rs +++ b/symbiont/src/runtime.rs @@ -130,6 +130,9 @@ pub struct Runtime { so_path: PathBuf, /// Function signatures for validation of LLM-generated code. fn_sigs: Vec, + /// Path prefixes denied in LLM-generated code, from + /// [`DylibConfig::denied_paths`]. + denied_paths: Vec, /// Every successfully loaded dylib revision, retained for the lifetime of /// the process (keep-all). The index into this vec is the revision id. /// Entries are reference-counted so [`crate::RevisionFn`] handles can pin @@ -244,6 +247,7 @@ impl Runtime { crate_dir, so_path, fn_sigs, + denied_paths: config.denied_paths().clone(), revisions: RwLock::new(vec![Arc::new(initial)]), active: AtomicU64::new(Revision::INITIAL.as_u64()), decls, @@ -333,7 +337,7 @@ impl Runtime { let mut ast = parse_rust_code(&llm_response)?; // Validate signatures match declarations - validate_generated_ast(&mut ast, &self.fn_sigs)?; + validate_generated_ast(&mut ast, &self.fn_sigs, &self.denied_paths)?; histogram!( PIPELINE_STAGE_DURATION, "stage" => stage::PARSE_VALIDATE @@ -651,6 +655,11 @@ impl Runtime { Keep the logic and the function signatures unchanged. Full code: ```{}```", code.blue() ).expect("Can write to prompt"), + ForbiddenConstruct { code, construct, reason } => write!(prompt, + "Your generated code contains {construct}, which is forbidden in evolvable code: {reason}. \ + Rewrite the code without it, keeping the logic and the function signatures unchanged. Full code: ```{}```", + code.blue() + ).expect("Can write to prompt"), CompilationFailed{code, err} => write!(prompt, "Your generated code ```{}``` failed to compile. Compiler output:\n```\n{}\n```\n\ Fix the compilation errors while preserving the existing logic and behaviour. \ diff --git a/symbiont/src/system_prompt.rs b/symbiont/src/system_prompt.rs index 8ff1515..e6f7373 100644 --- a/symbiont/src/system_prompt.rs +++ b/symbiont/src/system_prompt.rs @@ -47,6 +47,8 @@ Do not add `#[no_mangle]`, `#[unsafe(no_mangle)]`, or `extern` attributes. The h Unsafe code is forbidden and rejected before compilation: never use `unsafe` blocks, `unsafe fn`, `unsafe impl`, `unsafe trait`, `extern` blocks, or unsafe attributes. +Also rejected before compilation: `static` items and `thread_local!` (dylib state resets on every reload — keep state host-owned and passed via arguments; use `const` for constants), `macro_rules!` definitions, allocator or panic-handler overrides, tampering with the panic hook, and (by default) access to `std::process`, `std::thread`, `std::fs`, `std::net`, `std::env`, `std::os`, and `std::io::stdin`. + # Compilation environment The generated crate uses Rust edition 2024. diff --git a/symbiont/src/validation.rs b/symbiont/src/validation.rs index d852a58..b4b75da 100644 --- a/symbiont/src/validation.rs +++ b/symbiont/src/validation.rs @@ -23,7 +23,8 @@ use crate::{ }; /// Validate that a parsed AST enforces typed generation: -/// - No `unsafe` code anywhere (see [`reject_unsafe_code`]) +/// - No `unsafe` code and no forbidden constructs (see +/// [`enforce_code_policy`]) /// - All functions are `pub` /// - All functions have `#[unsafe(no_mangle)]`) /// - All function signatures match the expected signatures from lib.rs. @@ -34,8 +35,12 @@ use crate::{ /// parameter (e.g. to `_market_state`) is not a mismatch. /// /// Returns `Err` with a descriptive message if any check fails. -pub(crate) fn validate_generated_ast(file: &mut syn::File, expected_sigs: &[String]) -> Result<()> { - reject_unsafe_code(file)?; +pub(crate) fn validate_generated_ast( + file: &mut syn::File, + expected_sigs: &[String], + denied_paths: &[String], +) -> Result<()> { + enforce_code_policy(file, denied_paths)?; if expected_sigs.is_empty() { return Ok(()); @@ -101,7 +106,26 @@ pub(crate) fn validate_generated_ast(file: &mut syn::File, expected_sigs: &[Stri Ok(()) } -/// Reject any `unsafe` construct in LLM-generated code. +/// Path prefixes that are always denied, independent of +/// [`crate::DylibConfig`]: replacing the panic hook would break the +/// harness's panic-reporting protocol — the injected preamble owns the +/// hook inside the dylib. +const ALWAYS_DENIED_PATHS: &[&str] = &[ + "std::panic::set_hook", + "std::panic::take_hook", + "std::panic::update_hook", +]; + +/// Attributes that hijack the dylib's runtime and break its contract with +/// the host (shared System allocator, unwinding panics, program entry). +const DENIED_ATTRIBUTES: &[&str] = &[ + "global_allocator", + "panic_handler", + "alloc_error_handler", + "no_main", +]; + +/// Reject `unsafe` code and forbidden constructs in LLM-generated code. /// /// Enforced on the parsed AST *before* compilation: the rejection is /// cheap (no cargo round-trip), pinpoints the offending construct for the @@ -111,7 +135,7 @@ pub(crate) fn validate_generated_ast(file: &mut syn::File, expected_sigs: &[Stri /// unsafe, and the `#[unsafe(no_mangle)]` export attribute on every /// evolvable function trips the `unsafe_code` lint in edition 2024. /// -/// Rejected constructs: +/// Rejected as **unsafe** ([`Error::UnsafeCode`]): /// - `unsafe { .. }` blocks /// - `unsafe fn` (free, impl, trait, and foreign) /// - `unsafe impl` and `unsafe trait` @@ -121,36 +145,184 @@ pub(crate) fn validate_generated_ast(file: &mut syn::File, expected_sigs: &[Stri /// manages /// - an `unsafe` token anywhere inside a macro definition or invocation, /// which would otherwise smuggle unsafe code past the AST scan -pub(crate) fn reject_unsafe_code(file: &syn::File) -> Result<()> { - let mut scan = UnsafeScan { finding: None }; +/// +/// Rejected as **forbidden** ([`Error::ForbiddenConstruct`]): +/// - `static` items and `thread_local!` — dylib-local state silently +/// resets on every evolution and every retained revision has its own +/// copy; state must be host-owned (see CAVEATS.md) +/// - `macro_rules!` definitions — macro bodies would otherwise be a +/// blind spot for every other rule +/// - [`DENIED_ATTRIBUTES`] — allocator/panic/entry overrides +/// - references to [`ALWAYS_DENIED_PATHS`] and the host-configurable +/// `denied_paths` prefixes ([`crate::DylibConfig::default_denied_paths`]), +/// matched after resolving the file's `use` aliases; glob imports of a +/// denied module are rejected outright +/// +/// This bounds what evolvable code can *name*. It is guidance for the +/// evolution loop, not a security sandbox: safe Rust reached through a +/// host-provided API can still do I/O on the host's behalf. +pub(crate) fn enforce_code_policy(file: &syn::File, denied_paths: &[String]) -> Result<()> { + let denied: Vec> = denied_paths + .iter() + .map(String::as_str) + .chain(ALWAYS_DENIED_PATHS.iter().copied()) + .map(|path| path.split("::").collect()) + .collect(); + + let mut collector = AliasCollector::default(); + collector.visit_file(file); + + let mut scan = PolicyScan { + denied: &denied, + aliases: collector.aliases, + finding: None, + }; scan.visit_file(file); match scan.finding { - Some(construct) => Err(Error::UnsafeCode { + Some(Finding::Unsafe(construct)) => Err(Error::UnsafeCode { + code: unparse(file), + construct, + }), + Some(Finding::Forbidden { construct, reason }) => Err(Error::ForbiddenConstruct { code: unparse(file), construct, + reason, }), None => Ok(()), } } -/// AST visitor recording the first forbidden `unsafe` construct. -struct UnsafeScan { - finding: Option, +/// The first policy violation found in the AST. +enum Finding { + /// An `unsafe` construct; reported as [`Error::UnsafeCode`]. + Unsafe(String), + /// A forbidden (but safe) construct; reported as + /// [`Error::ForbiddenConstruct`]. + Forbidden { construct: String, reason: String }, } -impl UnsafeScan { - fn record(&mut self, what: &str, tokens: &dyn ToTokens) { - if self.finding.is_none() { - let mut snippet = tokens.to_token_stream().to_string(); - if snippet.len() > 120 { - let cut = (0..=120) - .rev() - .find(|i| snippet.is_char_boundary(*i)) - .unwrap_or(0); - snippet.truncate(cut); - snippet.push('…'); +/// Render `tokens` as a short snippet for feedback messages. +fn snippet(tokens: &dyn ToTokens) -> String { + let mut snippet = tokens.to_token_stream().to_string(); + if snippet.len() > 120 { + let cut = (0..=120) + .rev() + .find(|i| snippet.is_char_boundary(*i)) + .unwrap_or(0); + snippet.truncate(cut); + snippet.push('…'); + } + snippet +} + +/// Resolves `use` imports to absolute paths so denied-path matching sees +/// through aliases (`use std::process::exit as quit;`, `use std as s;`, +/// `use std::fs::File;` + `File::open(..)`). The generated code is a +/// single file without external macro expansion, so this resolution is +/// complete for non-glob imports. +#[derive(Default)] +struct AliasCollector { + /// Local name -> absolute path segments. + aliases: std::collections::HashMap>, +} + +impl<'ast> Visit<'ast> for AliasCollector { + fn visit_item_use(&mut self, node: &'ast syn::ItemUse) { + collect_use_tree(&node.tree, &mut Vec::new(), &mut self.aliases); + } +} + +/// Walk a use tree, recording every imported leaf under its local name. +fn collect_use_tree( + tree: &syn::UseTree, + prefix: &mut Vec, + aliases: &mut std::collections::HashMap>, +) { + match tree { + syn::UseTree::Path(path) => { + prefix.push(path.ident.to_string()); + collect_use_tree(&path.tree, prefix, aliases); + prefix.pop(); + } + syn::UseTree::Name(name) => { + let mut full = prefix.clone(); + // `use std::process::{self};` imports the module itself. + if name.ident != "self" { + full.push(name.ident.to_string()); } - self.finding = Some(format!("{what}: `{snippet}`")); + let local = full.last().cloned().unwrap_or_default(); + aliases.insert(local, full); + } + syn::UseTree::Rename(rename) => { + let mut full = prefix.clone(); + if rename.ident != "self" { + full.push(rename.ident.to_string()); + } + aliases.insert(rename.rename.to_string(), full); + } + syn::UseTree::Glob(_) => {} + syn::UseTree::Group(group) => { + for item in &group.items { + collect_use_tree(item, prefix, aliases); + } + } + } +} + +/// AST visitor recording the first policy violation. +struct PolicyScan<'a> { + /// Denied path prefixes, pre-split into segments. + denied: &'a [Vec<&'a str>], + /// `use` aliases of this file, local name -> absolute segments. + aliases: std::collections::HashMap>, + finding: Option, +} + +impl PolicyScan<'_> { + fn record_unsafe(&mut self, what: &str, tokens: &dyn ToTokens) { + if self.finding.is_none() { + self.finding = Some(Finding::Unsafe(format!("{what}: `{}`", snippet(tokens)))); + } + } + + fn record_forbidden(&mut self, what: &str, tokens: &dyn ToTokens, reason: String) { + if self.finding.is_none() { + self.finding = Some(Finding::Forbidden { + construct: format!("{what}: `{}`", snippet(tokens)), + reason, + }); + } + } + + /// Match `segments` (after alias expansion) against the denied + /// prefixes; returns the matched denied prefix. + fn denied_prefix_of(&self, segments: &[String]) -> Option { + let expanded: Vec<&str> = match segments.split_first() { + Some((first, rest)) => match self.aliases.get(first.as_str()) { + Some(full) => full + .iter() + .map(String::as_str) + .chain(rest.iter().map(String::as_str)) + .collect(), + None => segments.iter().map(String::as_str).collect(), + }, + None => return None, + }; + self.denied + .iter() + .find(|denied| expanded.len() >= denied.len() && expanded[..denied.len()] == denied[..]) + .map(|denied| denied.join("::")) + } + + fn check_path(&mut self, segments: Vec, tokens: &dyn ToTokens) { + if let Some(denied) = self.denied_prefix_of(&segments) { + self.record_forbidden( + &format!("a use of `{denied}`"), + tokens, + format!( + "access to `{denied}` is denied for evolvable code (hosts control this via `DylibConfig`)" + ), + ); } } } @@ -164,35 +336,59 @@ fn tokens_contain_unsafe(tokens: proc_macro2::TokenStream) -> bool { }) } -impl<'ast> Visit<'ast> for UnsafeScan { +impl<'ast> Visit<'ast> for PolicyScan<'_> { fn visit_expr_unsafe(&mut self, node: &'ast syn::ExprUnsafe) { - self.record("an `unsafe` block", node); + self.record_unsafe("an `unsafe` block", node); } // Covers free functions, impl methods, trait methods, and foreign fns. fn visit_signature(&mut self, node: &'ast syn::Signature) { if node.unsafety.is_some() { - self.record("an `unsafe fn`", node); + self.record_unsafe("an `unsafe fn`", node); } visit::visit_signature(self, node); } fn visit_item_impl(&mut self, node: &'ast syn::ItemImpl) { if node.unsafety.is_some() { - self.record("an `unsafe impl`", node); + self.record_unsafe("an `unsafe impl`", node); } visit::visit_item_impl(self, node); } fn visit_item_trait(&mut self, node: &'ast syn::ItemTrait) { if node.unsafety.is_some() { - self.record("an `unsafe trait`", node); + self.record_unsafe("an `unsafe trait`", node); } visit::visit_item_trait(self, node); } fn visit_item_foreign_mod(&mut self, node: &'ast syn::ItemForeignMod) { - self.record("an `extern` block", node); + self.record_unsafe("an `extern` block", node); + } + + fn visit_item_static(&mut self, node: &'ast syn::ItemStatic) { + self.record_forbidden( + "a `static` item", + node, + "static state silently resets on every evolution and every retained revision has \ + its own copy; keep state host-owned and pass it in via arguments, or use `const`" + .to_string(), + ); + visit::visit_item_static(self, node); + } + + fn visit_item_macro(&mut self, node: &'ast syn::ItemMacro) { + // `macro_rules! name { .. }` carries the definition's name. + if node.ident.is_some() { + self.record_forbidden( + "a `macro_rules!` definition", + node, + "defining macros is forbidden in evolvable code; write the logic directly" + .to_string(), + ); + } + visit::visit_item_macro(self, node); } fn visit_attribute(&mut self, node: &'ast syn::Attribute) { @@ -201,19 +397,130 @@ impl<'ast> Visit<'ast> for UnsafeScan { let is_no_mangle_export = node.path().is_ident("unsafe") && matches!(&node.meta, syn::Meta::List(list) if list.tokens.to_string() == "no_mangle"); if node.path().is_ident("unsafe") && !is_no_mangle_export { - self.record("an unsafe attribute", node); + self.record_unsafe("an unsafe attribute", node); + } + if let Some(denied) = DENIED_ATTRIBUTES + .iter() + .find(|attr| node.path().is_ident(attr)) + { + self.record_forbidden( + &format!("a `#[{denied}]` attribute"), + node, + "overriding the allocator, panic handling, or program entry breaks the \ + contract between host and dylib" + .to_string(), + ); } visit::visit_attribute(self, node); } + fn visit_item_use(&mut self, node: &'ast syn::ItemUse) { + self.check_use_tree(&node.tree, &mut Vec::new()); + visit::visit_item_use(self, node); + } + + fn visit_path(&mut self, node: &'ast syn::Path) { + let segments: Vec = node + .segments + .iter() + .map(|segment| segment.ident.to_string()) + .collect(); + self.check_path(segments, node); + visit::visit_path(self, node); + } + fn visit_macro(&mut self, node: &'ast syn::Macro) { + if node + .path + .segments + .last() + .is_some_and(|segment| segment.ident == "thread_local") + { + self.record_forbidden( + "a `thread_local!` declaration", + node, + "thread-local state silently resets on every evolution; keep state host-owned \ + and pass it in via arguments" + .to_string(), + ); + } if tokens_contain_unsafe(node.tokens.clone()) { - self.record("an `unsafe` token inside a macro", node); + self.record_unsafe("an `unsafe` token inside a macro", node); + } + // Denied paths inside macro tokens: `to_string` renders paths with + // canonical `a :: b` spacing, so a plain substring match suffices. + let text = node.tokens.to_string(); + if let Some(denied) = self + .denied + .iter() + .find(|denied| text.contains(&denied.join(" :: "))) + { + let denied = denied.join("::"); + self.record_forbidden( + &format!("a use of `{denied}` inside a macro"), + node, + format!( + "access to `{denied}` is denied for evolvable code (hosts control this via `DylibConfig`)" + ), + ); } visit::visit_macro(self, node); } } +impl PolicyScan<'_> { + /// Check a `use` tree: leaves are matched against the denied prefixes, + /// and glob imports overlapping a denied module are rejected outright + /// (they would make denied items nameable without their path). + fn check_use_tree(&mut self, tree: &syn::UseTree, prefix: &mut Vec) { + match tree { + syn::UseTree::Path(path) => { + prefix.push(path.ident.to_string()); + self.check_use_tree(&path.tree, prefix); + prefix.pop(); + } + syn::UseTree::Name(name) => { + let mut full = prefix.clone(); + if name.ident != "self" { + full.push(name.ident.to_string()); + } + self.check_path(full, name); + } + syn::UseTree::Rename(rename) => { + let mut full = prefix.clone(); + if rename.ident != "self" { + full.push(rename.ident.to_string()); + } + self.check_path(full, rename); + } + syn::UseTree::Glob(glob) => { + let overlaps = self.denied.iter().find(|denied| { + let shorter = prefix.len().min(denied.len()); + prefix[..shorter] + .iter() + .map(String::as_str) + .eq(denied[..shorter].iter().copied()) + }); + if let Some(denied) = overlaps { + let denied = denied.join("::"); + self.record_forbidden( + &format!("a glob import of `{}`", prefix.join("::")), + glob, + format!( + "glob imports overlapping the denied module `{denied}` are rejected; import items explicitly" + ), + ); + } + } + syn::UseTree::Group(group) => { + for item in &group.items { + self.check_use_tree(item, prefix); + } + } + } + } +} + /// Extract the function name from a signature rendered by [`format_signature`], /// e.g. `fn step(&mut usize)` -> `step`. fn expected_fn_name(sig: &str) -> Option<&str> { @@ -283,6 +590,11 @@ mod tests { use super::*; use crate::parser::parse_rust_code; + /// The default denied-path configuration used by the tests. + fn denied() -> Vec { + crate::DylibConfig::default_denied_paths() + } + #[test] fn test_validate_valid_code() { let input = "```rust @@ -293,7 +605,7 @@ pub fn step(counter: &mut usize) { ```"; let mut file = parse_rust_code(input).expect("can parse"); let expected = vec!["fn step(counter: &mut usize)".to_string()]; - validate_generated_ast(&mut file, &expected).expect("validation passed"); + validate_generated_ast(&mut file, &expected, &denied()).expect("validation passed"); } #[test] @@ -305,7 +617,7 @@ pub fn step(counter: &mut usize) { ```"; let mut file = parse_rust_code(input).expect("can parse"); let expected = vec!["fn step(counter: &mut usize)".to_string()]; - validate_generated_ast(&mut file, &expected) + validate_generated_ast(&mut file, &expected, &denied()) .expect("should succeed by adding #[unsafe(no_mangle)]"); // Verify the attribute was actually added @@ -328,7 +640,7 @@ fn step(counter: &mut usize) { ```"; let mut file = parse_rust_code(input).expect("can parse"); let expected = vec!["fn step(counter: &mut usize)".to_string()]; - validate_generated_ast(&mut file, &expected) + validate_generated_ast(&mut file, &expected, &denied()) .expect("should succeed by adding `pub` and #[unsafe(no_mangle)]"); let item_fn = match &file.items[0] { @@ -355,7 +667,8 @@ pub fn add(a: i32, b: i32) -> i32 { ```"; let mut file = parse_rust_code(input).expect("can parse"); let expected = vec!["fn step(counter: &mut usize)".to_string()]; - let err = validate_generated_ast(&mut file, &expected).expect_err("should error"); + let err = + validate_generated_ast(&mut file, &expected, &denied()).expect_err("should error"); dbg!(&err); match err { Error::SignatureMismatch { @@ -386,7 +699,8 @@ pub fn step(_counter: &mut usize) { ```"; let mut file = parse_rust_code(input).expect("can parse"); let expected = vec!["fn step(counter: &mut usize)".to_string()]; - validate_generated_ast(&mut file, &expected).expect("renamed argument must validate"); + validate_generated_ast(&mut file, &expected, &denied()) + .expect("renamed argument must validate"); } #[test] @@ -398,7 +712,7 @@ pub fn step(_counter: &mut usize) { ] { let mut file = syn::parse_str(input).expect("can parse"); let expected = vec!["fn step(counter: &mut usize)".to_string()]; - let err = validate_generated_ast(&mut file, &expected) + let err = validate_generated_ast(&mut file, &expected, &denied()) .expect_err("unsafe signature must be rejected"); assert!( matches!( @@ -419,7 +733,7 @@ pub fn step(_counter: &mut usize) { ] { let mut file = syn::parse_str(input).expect("can parse"); let expected = vec!["fn step(counter: &mut usize)".to_string()]; - let err = validate_generated_ast(&mut file, &expected) + let err = validate_generated_ast(&mut file, &expected, &denied()) .expect_err("incompatible signature must be rejected"); assert!( matches!( @@ -450,7 +764,8 @@ pub fn on_order_update(update: &Update, extra: bool) { "fn action(tick: & Tick)".to_string(), "fn on_order_update(update: & Update)".to_string(), ]; - let err = validate_generated_ast(&mut file, &expected).expect_err("should error"); + let err = + validate_generated_ast(&mut file, &expected, &denied()).expect_err("should error"); match err { Error::SignatureMismatch { expected, got, .. } => { assert_eq!(expected, "fn on_order_update(update: & Update)"); @@ -470,7 +785,8 @@ pub fn step(counter: &mut usize) { ```"; let mut file = parse_rust_code(input).expect("can parse"); let expected = vec!["fn step(counter: &mut usize)".to_string()]; - validate_generated_ast(&mut file, &expected).expect("#[unsafe(no_mangle)] should be valid"); + validate_generated_ast(&mut file, &expected, &denied()) + .expect("#[unsafe(no_mangle)] should be valid"); } #[test] @@ -502,7 +818,7 @@ pub fn on_order_update( "fn action(step_data: & TickData, account: & Account < i64, DECIMALS, Cur, UserOrderId >, market_state: & MarketState < i64, DECIMALS >, account_tracker: & FullAccountTracker < DECIMALS, Cur >, commands: &mut CommandBuffer < DECIMALS, Cur >)".to_string(), "fn on_order_update(order_update: OrderUpdate < DECIMALS, Cur >, account: & Account < i64, DECIMALS, Cur, UserOrderId >, market_state: & MarketState < i64, DECIMALS >, commands: &mut CommandBuffer < DECIMALS, Cur >)".to_string(), ]; - validate_generated_ast(&mut file, &expected) + validate_generated_ast(&mut file, &expected, &denied()) .expect("renamed `_market_state` argument must validate"); } @@ -517,13 +833,16 @@ pub fn step(counter: &mut usize) -> usize { ```"; let mut file = parse_rust_code(input).expect("can parse"); let expected = vec!["fn step(counter: &mut usize) -> usize".to_string()]; - validate_generated_ast(&mut file, &expected).expect("validation with return type passed"); + validate_generated_ast(&mut file, &expected, &denied()) + .expect("validation with return type passed"); } - /// Assert `reject_unsafe_code` rejects `code` and names `construct`. + /// Assert `enforce_code_policy` rejects `code` with an unsafe finding + /// naming `construct`. fn assert_rejects_unsafe(code: &str, construct: &str) { let file: syn::File = syn::parse_str(code).expect("can parse"); - let err = reject_unsafe_code(&file).expect_err("unsafe construct must be rejected"); + let err = + enforce_code_policy(&file, &denied()).expect_err("unsafe construct must be rejected"); match err { Error::UnsafeCode { construct: found, .. @@ -579,16 +898,14 @@ pub fn step(counter: &mut usize) -> usize { "#[unsafe(no_mangle)] pub fn step(counter: &mut usize) { *counter += 1; }", ) .expect("can parse"); - reject_unsafe_code(&file).expect("#[unsafe(no_mangle)] is harness-managed and allowed"); + enforce_code_policy(&file, &denied()) + .expect("#[unsafe(no_mangle)] is harness-managed and allowed"); } #[test] fn unsafe_smuggled_through_macro_is_rejected() { - assert_rejects_unsafe( - "macro_rules! sneaky { () => { unsafe { core::hint::unreachable_unchecked() } }; }\n\ - pub fn step(counter: &mut usize) { sneaky!(); }", - "an `unsafe` token inside a macro", - ); + // Defining a smuggling macro is already rejected as a macro + // definition; invocation-side smuggling is caught by the token scan. assert_rejects_unsafe( "pub fn step(counter: &mut usize) { let _ = stringify!(unsafe); }", "an `unsafe` token inside a macro", @@ -605,6 +922,162 @@ pub fn step(counter: &mut usize) -> usize { }", ) .expect("can parse"); - reject_unsafe_code(&file).expect("safe code must pass"); + enforce_code_policy(&file, &denied()).expect("safe code must pass"); + } + + /// Assert `enforce_code_policy` rejects `code` with a forbidden finding + /// naming `construct`. + fn assert_rejects_forbidden(code: &str, construct: &str) { + let file: syn::File = syn::parse_str(code).expect("can parse"); + let err = enforce_code_policy(&file, &denied()) + .expect_err("forbidden construct must be rejected"); + match err { + Error::ForbiddenConstruct { + construct: found, .. + } => { + assert!( + found.contains(construct), + "expected construct `{construct}`, got `{found}`" + ); + } + other => panic!("expected Error::ForbiddenConstruct, got {other}"), + } + } + + #[test] + fn static_items_are_rejected() { + assert_rejects_forbidden( + "static CALLS: std::sync::atomic::AtomicUsize = std::sync::atomic::AtomicUsize::new(0);\n\ + pub fn step(counter: &mut usize) { *counter += 1; }", + "a `static` item", + ); + } + + #[test] + fn thread_local_is_rejected() { + assert_rejects_forbidden( + "thread_local! { static CACHE: std::cell::Cell = std::cell::Cell::new(0); }\n\ + pub fn step(counter: &mut usize) {}", + "a `thread_local!` declaration", + ); + } + + #[test] + fn macro_definitions_are_rejected() { + assert_rejects_forbidden( + "macro_rules! helper { () => { 1 }; }\npub fn step(counter: &mut usize) { *counter += helper!(); }", + "a `macro_rules!` definition", + ); + } + + #[test] + fn runtime_hijacking_attributes_are_rejected() { + assert_rejects_forbidden( + "#[panic_handler] fn handle(info: &PanicInfo) -> ! { loop {} }", + "a `#[panic_handler]` attribute", + ); + // `#[global_allocator]` requires a static item, which the static + // rule already rejects first. + assert_rejects_forbidden( + "#[global_allocator] static A: std::alloc::System = std::alloc::System;", + "a `static` item", + ); + } + + #[test] + fn panic_hook_tampering_is_rejected() { + assert_rejects_forbidden( + "pub fn step(counter: &mut usize) { std::panic::set_hook(Box::new(|_| {})); }", + "a use of `std::panic::set_hook`", + ); + } + + #[test] + fn denied_std_paths_are_rejected() { + assert_rejects_forbidden( + "pub fn step(counter: &mut usize) { std::process::exit(0); }", + "a use of `std::process`", + ); + assert_rejects_forbidden( + "use std::fs::File;\npub fn step(counter: &mut usize) {}", + "a use of `std::fs`", + ); + assert_rejects_forbidden( + "pub fn step(counter: &mut usize) { std::thread::sleep(std::time::Duration::from_secs(1)); }", + "a use of `std::thread`", + ); + } + + #[test] + fn denied_paths_behind_aliases_are_rejected() { + // Renamed leaf import. + assert_rejects_forbidden( + "use std::process::exit as quit;\npub fn step(counter: &mut usize) { quit(0); }", + "a use of `std::process`", + ); + // Renamed crate root. + assert_rejects_forbidden( + "use std as s;\npub fn step(counter: &mut usize) { s::process::abort(); }", + "a use of `std::process`", + ); + // Usage through an imported parent module. + assert_rejects_forbidden( + "use std::io;\npub fn step(counter: &mut usize) { let mut s = String::new(); let _ = io::stdin().read_line(&mut s); }", + "a use of `std::io::stdin`", + ); + } + + #[test] + fn glob_import_of_denied_module_is_rejected() { + assert_rejects_forbidden( + "use std::process::*;\npub fn step(counter: &mut usize) {}", + "a glob import of `std::process`", + ); + } + + #[test] + fn denied_path_inside_macro_is_rejected() { + assert_rejects_forbidden( + "pub fn step(counter: &mut usize) { let _ = stringify!(std::process::exit(0)); }", + "a use of `std::process` inside a macro", + ); + } + + #[test] + fn allowed_path_configuration_is_respected() { + let code = "pub fn step(counter: &mut usize) { let _ = std::fs::read(\"data.bin\"); }"; + let file: syn::File = syn::parse_str(code).expect("can parse"); + + // Denied by default. + enforce_code_policy(&file, &denied()).expect_err("std::fs is denied by default"); + + // Allowed once the host opts out. + let relaxed = crate::DylibConfig::standalone(crate::Profile::Debug) + .with_allowed_path("std::fs") + .denied_paths() + .clone(); + enforce_code_policy(&file, &relaxed).expect("std::fs was explicitly allowed"); + } + + #[test] + fn custom_denied_path_is_enforced() { + let code = "pub fn step(counter: &mut usize) { host::dangerous::wipe(); }"; + let file: syn::File = syn::parse_str(code).expect("can parse"); + + enforce_code_policy(&file, &denied()).expect("host paths are allowed by default"); + + let strict = crate::DylibConfig::standalone(crate::Profile::Debug) + .with_denied_path("host::dangerous") + .denied_paths() + .clone(); + enforce_code_policy(&file, &strict).expect_err("custom denied path must be enforced"); + } + + #[test] + fn panic_hook_paths_stay_denied_with_empty_config() { + let file: syn::File = + syn::parse_str("pub fn step(counter: &mut usize) { let _ = std::panic::take_hook(); }") + .expect("can parse"); + enforce_code_policy(&file, &[]).expect_err("hook tampering is always denied"); } } diff --git a/symbiont/tests/backpressure_forbidden.rs b/symbiont/tests/backpressure_forbidden.rs new file mode 100644 index 0000000..5bebf15 --- /dev/null +++ b/symbiont/tests/backpressure_forbidden.rs @@ -0,0 +1,86 @@ +// SPDX-License-Identifier: MPL-2.0 +//! Backpressure integration test: generated code containing a forbidden +//! construct (a `static` item) is rejected at validation time (before +//! compiling), the reason is fed back, and the agent recovers. +//! +//! One test per binary: [`symbiont::Runtime`] is a process-wide singleton. +#![expect( + unused_crate_dependencies, + reason = "Integration tests don't use them all" +)] + +mod common; + +use common::{ + ScriptedAgent, + Turn, +}; +use symbiont::{ + Profile, + Runtime, +}; + +const BASE_PROMPT: &str = "Implement the function. Code only."; + +#[tokio::test] +#[cfg_attr( + miri, + ignore = "compiles and dlopens dylibs, which Miri does not support" +)] +#[tracing_test::traced_test] +async fn forbidden_construct_is_rejected_and_recovered_from() { + symbiont::evolvable! { + fn bp_forbidden_step(counter: &mut usize) { + *counter += 1; + } + }; + let rt = Runtime::new(SYMBIONT_DECLS, SYMBIONT_PRELUDE, Profile::Debug) + .await + .expect("Can init runtime"); + + let agent = ScriptedAgent::new([ + // Attempt 1: dylib-local static state -> rejected at validation. + Turn::reply( + "```rust\nstatic CALLS: std::sync::atomic::AtomicUsize = std::sync::atomic::AtomicUsize::new(0);\npub fn bp_forbidden_step(counter: &mut usize) { CALLS.fetch_add(1, std::sync::atomic::Ordering::Relaxed); *counter += 1; }\n```", + ), + // Attempt 2: host-owned state only -> success. + Turn::reply( + "```rust\npub fn bp_forbidden_step(counter: &mut usize) { *counter = 11; }\n```", + ), + ]); + + rt.evolve(&agent, BASE_PROMPT) + .await + .expect("evolution should succeed after one self-healing retry"); + + assert_eq!(agent.calls(), 2, "exactly one retry expected"); + + let retry_prompt = agent.prompt(1); + assert!( + !retry_prompt.contains(BASE_PROMPT), + "retry prompt must contain only the correction, got: {retry_prompt}" + ); + assert!( + retry_prompt.contains("which is forbidden in evolvable code"), + "retry prompt must contain the forbidden nudge, got: {retry_prompt}" + ); + assert!( + retry_prompt.contains("a `static` item"), + "retry prompt must name the offending construct, got: {retry_prompt}" + ); + assert!( + retry_prompt.contains("host-owned"), + "retry prompt must explain the reason, got: {retry_prompt}" + ); + + // The failure record is drained with the `forbidden` kind. + let failures = rt.take_evolve_failures(); + assert_eq!(failures.len(), 1); + assert_eq!(failures[0].kind(), "forbidden"); + assert!(failures[0].generated_code().contains("static CALLS")); + + // The hot-swapped implementation is live. + let mut counter = 0; + bp_forbidden_step(&mut counter); + assert_eq!(counter, 11, "evolved implementation should be hot-swapped"); +} From 151a26bfb3bd9e344bd79f00fc279910cb81ec44 Mon Sep 17 00:00:00 2001 From: MathisWellmann Date: Wed, 22 Jul 2026 16:59:07 +0100 Subject: [PATCH 8/9] reformat system prompt to respect max line width --- symbiont/src/system_prompt.rs | 86 ++++++++++++++++++++++++++--------- symbiont/src/validation.rs | 2 +- 2 files changed, 65 insertions(+), 23 deletions(-) diff --git a/symbiont/src/system_prompt.rs b/symbiont/src/system_prompt.rs index e6f7373..6396510 100644 --- a/symbiont/src/system_prompt.rs +++ b/symbiont/src/system_prompt.rs @@ -13,8 +13,9 @@ You are a Rust coding agent running inside the `symbiont` function-evolution har Your job is to generate Rust implementations for one or more evolvable functions. The harness parses your response, validates the required function signatures, -compiles the code as a temporary dynamic library, hot-swaps the compiled functions into the host process, -evaluates them, and feeds results/errors back to you on later iterations. +compiles the code as a temporary dynamic library, hot-swaps the compiled functions +into the host process, evaluates them, and feeds results/errors back to you on +later iterations. # Output contract @@ -24,7 +25,8 @@ Always respond with exactly one fenced Rust code block: // code here ``` -Do not write prose, explanations, markdown tables, or additional code blocks outside the Rust block. +Do not write prose, explanations, markdown tables, or additional code blocks +outside the Rust block. Emit complete Rust function item(s), not just function bodies. @@ -38,16 +40,26 @@ Preserve every ABI-relevant part of each required function signature: - no changed lifetimes or generics Prefer emitting only the required top-level evolvable function(s). -If helper logic is needed, prefer local helper functions, closures, constants, or inline code inside the required function. +If helper logic is needed, prefer local helper functions, closures, constants, +or inline code inside the required function. Avoid extra top-level generic helper functions. -Do not emit `main`, tests, Cargo metadata, modules, or unrelated items unless the user explicitly asks. +Do not emit `main`, tests, Cargo metadata, modules, or unrelated items unless +the user explicitly asks. -Do not add `#[no_mangle]`, `#[unsafe(no_mangle)]`, or `extern` attributes. The harness handles dynamic-library exports. +Do not add `#[no_mangle]`, `#[unsafe(no_mangle)]`, or `extern` attributes. +The harness handles dynamic-library exports. -Unsafe code is forbidden and rejected before compilation: never use `unsafe` blocks, `unsafe fn`, `unsafe impl`, `unsafe trait`, `extern` blocks, or unsafe attributes. +Unsafe code is forbidden and rejected before compilation: never use `unsafe` +blocks, `unsafe fn`, `unsafe impl`, `unsafe trait`, `extern` blocks, or unsafe +attributes. -Also rejected before compilation: `static` items and `thread_local!` (dylib state resets on every reload — keep state host-owned and passed via arguments; use `const` for constants), `macro_rules!` definitions, allocator or panic-handler overrides, tampering with the panic hook, and (by default) access to `std::process`, `std::thread`, `std::fs`, `std::net`, `std::env`, `std::os`, and `std::io::stdin`. +Also rejected before compilation: `static` items and `thread_local!` (dylib +state resets on every reload — keep state host-owned and passed via arguments; +use `const` for constants), `macro_rules!` definitions, allocator or +panic-handler overrides, tampering with the panic hook, and (by default) +access to `std::process`, `std::thread`, `std::fs`, `std::net`, `std::env`, +`std::os`, and `std::io::stdin`. # Compilation environment @@ -58,15 +70,38 @@ You may use: - items, types, and methods documented in the host API section below - items already imported by the harness prelude, if any -Do not invent imports or dependencies. Emit no `use` item for a prelude that the harness already injects. - -When host APIs are documented, the generated crate can depend on `host` without depending directly on crates named in the documentation. Dependency API sections describe the origin and API of host-re-exported items; they do not make `dependency_name::...` paths available. Unless the task explicitly says a crate is a direct dylib dependency, use only unqualified names imported by `host::prelude::*` (or an explicit `host::...` path). Never add a dependency import merely because that dependency has a documentation section. - -Treat the synopsis literally: call only documented public methods on the exact receiver type and use documented enum variants and constructors. Do not infer fields, methods, or variants from similarly named APIs. For arithmetic or conversions between documented types, use only the operators listed in the type's `// Operator and conversion impls:` section (`impl OP for Type`); if no impl is listed for an operand combination, that operation does not exist — convert operands through documented constructors first. When a documented type is generic (for example over an id, currency, or state parameter), unify its generic parameters with the concrete types required by the evolvable function signature instead of treating them as incompatible. If several documented constructors exist for the same type, pick the one whose generic parameters produce the required concrete type (e.g. a `new_with_...` constructor that accepts the required field directly) rather than concluding the goal is unachievable. Only if the documented inputs truly expose no API needed for an idea, choose a simpler implementation or do nothing instead of inventing one. +Do not invent imports or dependencies. Emit no `use` item for a prelude that +the harness already injects. + +When host APIs are documented, the generated crate can depend on `host` +without depending directly on crates named in the documentation. Dependency +API sections describe the origin and API of host-re-exported items; they do +not make `dependency_name::...` paths available. Unless the task explicitly +says a crate is a direct dylib dependency, use only unqualified names imported +by `host::prelude::*` (or an explicit `host::...` path). Never add a +dependency import merely because that dependency has a documentation section. + +Treat the synopsis literally: call only documented public methods on the exact +receiver type and use documented enum variants and constructors. Do not infer +fields, methods, or variants from similarly named APIs. For arithmetic or +conversions between documented types, use only the operators listed in the +type's `// Operator and conversion impls:` section (`impl OP for Type`); +if no impl is listed for an operand combination, that operation does not +exist — convert operands through documented constructors first. When a +documented type is generic (for example over an id, currency, or state +parameter), unify its generic parameters with the concrete types required by +the evolvable function signature instead of treating them as incompatible. +If several documented constructors exist for the same type, pick the one whose +generic parameters produce the required concrete type (e.g. a `new_with_...` +constructor that accepts the required field directly) rather than concluding +the goal is unachievable. Only if the documented inputs truly expose no API +needed for an idea, choose a simpler implementation or do nothing instead of +inventing one. # Runtime constraints -Generated code runs inside a hot-reloaded dynamic library. Keep functions self-contained. +Generated code runs inside a hot-reloaded dynamic library. Keep functions +self-contained. Avoid: - panics @@ -78,27 +113,34 @@ Avoid: - printing or logging in hot paths - global mutable state or persistent static state -Static state inside the dynamic library is reset on every reload and should not be relied on. +Static state inside the dynamic library is reset on every reload and should +not be relied on. -Respect explicit `len` arguments. Usually process only the first `len` elements and guard against `len > slice.len()` when appropriate. +Respect explicit `len` arguments. Usually process only the first `len` +elements and guard against `len > slice.len()` when appropriate. # Optimization policy First satisfy correctness and safety. -If feedback reports compiler errors, signature mismatches, panics, invalid outputs, failed tests, or invalid moves, fix those before optimizing. +If feedback reports compiler errors, signature mismatches, panics, invalid +outputs, failed tests, or invalid moves, fix those before optimizing. When correctness is satisfied and benchmark/evaluation data is provided, optimize for the concrete metric requested by the user. -Use the previous implementation and evaluation feedback to target the worst cases first. +Use the previous implementation and evaluation feedback to target the worst +cases first. Prefer deterministic, simple, robust code. -For performance-sensitive functions, avoid unnecessary heap allocation, formatting, dynamic dispatch, excessive bounds checks, and avoidable cloning. +For performance-sensitive functions, avoid unnecessary heap allocation, +formatting, dynamic dispatch, excessive bounds checks, and avoidable cloning. # Host API documentation -The following section contains generated documentation for host APIs available to the evolved code. If empty, only `std` is available. - -".to_string(); +The following section contains generated documentation for host APIs +available to the evolved code. If empty, only `std` is available. + +" + .to_string(); if let Some(crate_name) = opt_crate_name { write_prelude_doc_string(&mut prompt, crate_name).await?; } diff --git a/symbiont/src/validation.rs b/symbiont/src/validation.rs index b4b75da..a463a2e 100644 --- a/symbiont/src/validation.rs +++ b/symbiont/src/validation.rs @@ -342,7 +342,7 @@ impl<'ast> Visit<'ast> for PolicyScan<'_> { } // Covers free functions, impl methods, trait methods, and foreign fns. - fn visit_signature(&mut self, node: &'ast syn::Signature) { + fn visit_signature(&mut self, node: &'ast Signature) { if node.unsafety.is_some() { self.record_unsafe("an `unsafe fn`", node); } From f8118c62a8dced0aea6979737fc94493be9e8d12 Mon Sep 17 00:00:00 2001 From: MathisWellmann Date: Wed, 22 Jul 2026 21:38:42 +0200 Subject: [PATCH 9/9] Fix clippy by extracting `BASE_PROMPT` constant. --- symbiont/src/system_prompt.rs | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/symbiont/src/system_prompt.rs b/symbiont/src/system_prompt.rs index 6396510..dd4d5b3 100644 --- a/symbiont/src/system_prompt.rs +++ b/symbiont/src/system_prompt.rs @@ -5,9 +5,7 @@ use crate::{ Result, doc_string::write_prelude_doc_string, }; - -pub(crate) async fn system_prompt(opt_crate_name: Option<&str>) -> Result { - let mut prompt = "#Role +const BASE_PROMPT: &str = "#Role You are a Rust coding agent running inside the `symbiont` function-evolution harness. @@ -139,8 +137,10 @@ formatting, dynamic dispatch, excessive bounds checks, and avoidable cloning. The following section contains generated documentation for host APIs available to the evolved code. If empty, only `std` is available. -" - .to_string(); +"; + +pub(crate) async fn system_prompt(opt_crate_name: Option<&str>) -> Result { + let mut prompt = BASE_PROMPT.to_string(); if let Some(crate_name) = opt_crate_name { write_prelude_doc_string(&mut prompt, crate_name).await?; }