Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
18 changes: 18 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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]
Expand Down
62 changes: 59 additions & 3 deletions CAVEATS.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -132,9 +135,62 @@ 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
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 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.

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
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 and cross-dylib calls through swapped pointers remain
outside its reach.
24 changes: 12 additions & 12 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
7 changes: 6 additions & 1 deletion examples/evolving-trader/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
1 change: 1 addition & 0 deletions flake.nix
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,7 @@
extensions = [
"rust-src"
"rust-analyzer"
"miri"
];
targets = ["x86_64-unknown-linux-gnu"];
}
Expand Down
38 changes: 37 additions & 1 deletion symbiont-macros/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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::{
Expand All @@ -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 `<name>_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);

Expand Down Expand Up @@ -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<T: ::core::default::Default>() {}
#assert_ident::<#ret_ty>();
};
});

// Build the EvolvableDecl entry (with reference to the AtomicPtr static)
decl_entries.push(quote! {
::symbiont::EvolvableDecl {
Expand Down
4 changes: 2 additions & 2 deletions symbiont/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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"] }

Expand Down
3 changes: 3 additions & 0 deletions symbiont/src/compiler.rs
Original file line number Diff line number Diff line change
@@ -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;
Expand Down
Loading
Loading