Rust Unsafe Review skill for Codex
Prove every unsafe block or flag it. Codex reviews unsafe Rust the way a soundness auditor does: it lists every unsafe operation, writes down the preconditions from the standard library contract, and checks that the surrounding module actually enforces them, including through panics and public fields. FFI wrappers get ownership, string and callback checks, and findings are backed by Miri or AddressSanitizer runs with a concrete call sequence.
curl -fsSL codexskills.dev/install.sh \ | sh -s -- rust-unsafe-review Installs to ~/.agents/skills/rust-unsafe-review/ for every project on this machine.
curl -fsSL codexskills.dev/cli.mjs -o codex-skills.mjsnode codex-skills.mjs install rust-unsafe-review The CLI also searches, updates and removes skills. Run it with no arguments to pick from a list. Needs Node 20.12+.
curl -fsSL codexskills.dev/install.sh \ | sh -s -- --project rust-unsafe-review Installs to .agents/skills/rust-unsafe-review/ in the current repository. Commit it and everyone who runs Codex there gets it.
curl -fsSLO codexskills.dev/downloads/rust-unsafe-review.zipunzip -o rust-unsafe-review.zip -d ~/.agents/skills/ The archive holds the skill folder with its generated agents/openai.yaml. Unzip into .agents/skills/ to scope it to a repo.
& ([scriptblock]::Create((irm codexskills.dev/install.ps1))) rust-unsafe-review PowerShell 5.1 or 7. Installs to %USERPROFILE%\.agents\skills\. Add -Project for the current repo.
104-line POSIX script. No sudo, no telemetry. Read install.sh
Use it in Codex
Start a prompt with $rust-unsafe-review, or pick it from /skills.
You can also just describe the task: Codex reads the description below and loads the playbook when your request matches.
What Codex reads before loading it
Reviews unsafe Rust and FFI code for soundness - checks every unsafe block against the documented preconditions of the operation it performs (raw pointer reads, from_raw_parts, transmute, MaybeUninit, unsafe impl Send and Sync, extern C calls), requires SAFETY comments that prove them, and validates with Miri, sanitizers and clippy's unsafe lints, including Rust 2024 unsafe extern and unsafe attribute rules. Use when a PR adds or changes unsafe, when wrapping a C library, when Miri reports undefined behaviour, or when auditing a crate before depending on it.
Use it when
- A pull request adds or edits unsafe blocks, unsafe impls or extern declarations
- You are wrapping a C library in a safe Rust API
- Miri, ASan or a crash points at undefined behaviour
- You are vetting a dependency that uses unsafe heavily
Use something else when
- unsafe is being considered only to get past a borrow error: use rust-ownership
- The bug is memory corruption in C code rather than the Rust side: use c-memory-safety
How to check the result
Every unsafe block has a SAFETY comment tied to enforced code, clippy undocumented_unsafe_blocks is denied, and cargo miri test (plus ASan for FFI paths) passes including new edge-case tests.
The mistake it prevents
Judging an unsafe block in isolation; a safe function elsewhere in the same module that changes a length or pointer field can break the invariant the block depends on.
A safe API is sound when no sequence of safe calls, however strange, can cause undefined
behaviour. Done well means: every unsafe block has a // SAFETY: comment that proves
each precondition from facts visible in the code, every unsafe fn has a # Safety
section stating what callers must guarantee, Miri passes on the tests that reach the
unsafe code, and anything unproven is reported as a finding, not assumed.
Scope the review
rg -n 'unsafe' --type rust # blocks, fns, impls, traits, extern
rg -n 'unsafe impl (Send|Sync)' --type rust
rg -n 'transmute|from_raw_parts|set_len|assume_init|get_unchecked|from_utf8_unchecked' --type rust
rg -n 'extern "C"|#\[(unsafe\()?no_mangle' --type rust
cargo geiger 2>/dev/null # dependency-wide unsafe counts, if installed
Read AGENTS.md and any SAFETY.md or module docs describing invariants. Note the
edition: Rust 2024 requires unsafe extern "C" { ... }, #[unsafe(no_mangle)] and
#[unsafe(export_name = ..)], warns on unsafe operations inside unsafe fn without an
inner unsafe {} block (unsafe_op_in_unsafe_fn), and denies references to static mut.
Review procedure
- [ ] 1. List each unsafe block/fn/impl with file:line and the exact unsafe operation(s) inside
- [ ] 2. For each operation, write down its preconditions (std docs "Safety" section)
- [ ] 3. Find where the code establishes each precondition; check the SAFETY comment says so
- [ ] 4. Widen the lens: which safe functions can change the state those proofs rely on?
- [ ] 5. Check panics, early returns and drops between establishing and using an invariant
- [ ] 6. Check Send/Sync, lifetimes and variance of any wrapper type
- [ ] 7. Run Miri (and sanitizers for FFI); add tests that hit edge cases
- [ ] 8. Report findings with severity and a concrete UB scenario
The soundness boundary is the module, not the block. A safe pub fn set_len_unchecked
elsewhere in the module, or a public field, can break an invariant that an unsafe block
relies on. Fields that unsafe code depends on must be private.
Preconditions of the usual suspects
| Operation | Must hold |
|---|---|
*ptr read/write, ptr::read, ptr::write | Non-null, aligned, points to a live allocation of T, initialised (for reads), no conflicting &mut alias |
slice::from_raw_parts(p, len) | p non-null and aligned even when len == 0 (use NonNull::dangling()), len * size_of::<T>() <= isize::MAX, all elements initialised, memory not mutated for lifetime 'a |
Vec::set_len(n) | n <= capacity, elements old_len..n initialised |
Vec::from_raw_parts / Box::from_raw / String::from_raw_parts | Pointer came from the same allocator with the same layout and capacity; ownership is unique (no double free) |
MaybeUninit::assume_init | Every byte the type needs is initialised; padding is fine; bool, enums, references have validity rules |
mem::transmute::<A, B> | Same size (compile-checked), every bit pattern of A is a valid B, alignment and lifetimes correct; prefer from_ne_bytes, cast, bytemuck |
str::from_utf8_unchecked | Bytes are valid UTF-8 |
get_unchecked(i) | i < len proven at this point, not merely checked earlier on a different value |
unsafe impl Send | Moving the value to another thread cannot create unsynchronised access to non-thread-safe state (e.g. a raw pointer to an Rc) |
unsafe impl Sync | &T shared across threads is safe: all interior mutation is atomic or locked |
Pin::new_unchecked / get_unchecked_mut | The pointee is never moved again until dropped, including in Drop |
Calling an extern "C" fn | Signature matches the C header exactly, pointers valid for the duration C uses them, C does not retain them unless documented, thread-safety of the library respected |
Detailed patterns, wrong-versus-right examples and FFI ownership rules are in references/unsafe-patterns.md; read it when a block uses raw allocation, uninitialised memory, self-referential pinning or a C library.
What a good SAFETY comment looks like
/// Returns the element at `i` without bounds checking.
///
/// # Safety
/// `i` must be less than `self.len()`.
pub unsafe fn get_unchecked(&self, i: usize) -> &T {
// SAFETY: caller guarantees i < self.len; self.len <= self.buf.len() is upheld by
// every method that changes len (push, truncate), and buf is fully initialised up to len.
unsafe { self.buf.get_unchecked(i).assume_init_ref() }
}
Reject comments that restate the operation (“SAFETY: we read the pointer”) or appeal to hope (“should be fine”, “pointer is always valid”). Each claim must point to code that makes it true.
Enforce in the crate:
[lints.rust]
unsafe_op_in_unsafe_fn = "deny"
[lints.clippy]
undocumented_unsafe_blocks = "deny"
multiple_unsafe_ops_per_block = "warn"
missing_safety_doc = "deny"
Common unsound patterns to look for
- Creating
&mut Tfrom a raw pointer while another&Tor&mut Tto the same data is live (aliasing violation even if never “used”). Use raw pointers throughout, or&raw mut field(Rust 1.82+) instead of&mut field as *mut _. - Reading uninitialised memory through
Vec::with_capacity+set_lenbefore writing. transmutebetweenVec<A>andVec<B>or between&[u8]and&[u32](layout and alignment not guaranteed). Usebytemuck::cast_sliceoralign_toand handle the prefix.- Lifetime extension: returning
&'static Tor a reference tied to the wrong input from a raw pointer. - Panic safety: a closure or
T::clonepanics midway while a buffer haslenupdated but elements uninitialised, soDropfrees garbage. Updatelenafter writing, or use a guard. - Integer-to-pointer casts that lose provenance; prefer
ptr.with_addr(),map_addr()(strict provenance APIs, stable since 1.84). unsafe impl Send/Sync for Wrapper<T>without bounds onT(T: Send), making anyTshareable.- Exposing a raw pointer field
pubon a type whose methods dereference it.
FFI checklist
- Bindings generated by
bindgen(or checked against the header), not handwritten guesses. Types:c_int/c_charfromcore::ffi,#[repr(C)]on every struct crossing the boundary, no Rust enums received from C (use integer constants and validate). - Strings: Rust to C via
CString::new(s)?(fails on interior NUL) and keep the CString alive while C usesas_ptr(). C to Rust viaCStr::from_ptr(p)after a null check. C string literals:c"name"(Rust 1.77+). - Ownership: memory allocated by C is freed by the library’s free function; memory allocated
by Rust is freed by Rust (
Box::into_rawout,Box::from_rawback). Neverfreea Rust allocation in C. - Callbacks from C into Rust:
extern "C" fnpanicking aborts the process (Rust 1.81+); if unwinding across the boundary is intended, the ABI must be"C-unwind"on both sides. Usually catch withstd::panic::catch_unwindand return an error code. - Thread safety: does the C library allow calls from multiple threads? If not, the wrapper
must not be
Send/Sync(aPhantomData<*const ()>field removes both). - Build:
build.rslinks withcargo:rustc-link-lib=...; check linking on every target the crate claims to support.
Validation tools
rustup +nightly component add miri # needs network
cargo +nightly miri test # interprets tests, detects UB
MIRIFLAGS="-Zmiri-strict-provenance" cargo +nightly miri test
RUSTFLAGS="-Zsanitizer=address" cargo +nightly test --target aarch64-apple-darwin # FFI paths Miri cannot run
cargo clippy --all-targets -- -D clippy::undocumented_unsafe_blocks
Miri cannot execute foreign C functions; run the FFI tests under AddressSanitizer (use your host target triple) and add Rust-only tests of the wrapper logic for Miri. A Miri pass covers only the paths the tests execute: add tests for empty inputs, zero-sized types, maximum lengths, and panics in user callbacks.
Severity
| Severity | Meaning |
|---|---|
| Unsound (blocker) | A safe caller can trigger UB; give the call sequence |
| Unproven (major) | The proof is missing or relies on something not enforced locally |
| Hardening (minor) | Sound today, fragile: missing SAFETY comment, block too large, public field |
| Replaceable (suggestion) | A safe std API or bytemuck/zerocopy does the same thing |
Report template
## Unsafe review: crates/ringbuf (5 unsafe blocks, 1 unsafe impl)
| # | Location | Operation | Verdict |
|---|---|---|---|
| 1 | src/lib.rs:88 | slice::from_raw_parts | Unsound: len can exceed initialised region after `clear()` then `extend_from_slice` panics |
| 2 | src/lib.rs:140 | unsafe impl Sync for Ring<T> | Unsound: missing `T: Send` bound |
| 3 | src/lib.rs:201 | ptr::copy_nonoverlapping | OK: proof in SAFETY comment holds |
**Repro for #1:** test `panic_during_extend_then_read` fails under `cargo +nightly miri test`
**Fixes proposed:** set len after copy; add `T: Send` bound; private `len` field
**Tools run:** Miri (strict provenance) 41 tests; ASan for ffi/ tests; clippy unsafe lints What the zip contains
Codex reads bundled files only when a step points to them. Every one is shown here, so you can review it before installing.
agents/openai.yaml
Gives the Codex app a display name, a one-line description and a starter prompt for this skill.
interface:
display_name: "Rust Unsafe Review"
short_description: "Prove every unsafe block or flag it"
default_prompt: "$rust-unsafe-review Review the unsafe blocks in this ring buffer for soundness" references/unsafe-patterns.md
# Unsafe Rust patterns: wrong and right
Read the section that matches the code under review. Each pattern states the bug, a
correct version, and the safe alternative to suggest first.
## 1. Writing into spare capacity
Wrong: exposes uninitialised elements, and a panic in `produce` leaves `len` too large.
```rust
let mut v: Vec<T> = Vec::with_capacity(n);
unsafe { v.set_len(n); }
for i in 0..n { v[i] = produce(i); } // drops uninitialised "old" values on assignment
```
Right: write through raw pointers or `spare_capacity_mut`, then set the length once.
```rust
let mut v: Vec<T> = Vec::with_capacity(n);
for (i, slot) in v.spare_capacity_mut()[..n].iter_mut().enumerate() {
slot.write(produce(i)); // a panic here leaks written items but is sound
}
// SAFETY: the loop above initialised exactly n elements; n <= capacity.
unsafe { v.set_len(n); }
```
Safe alternative: `(0..n).map(produce).collect::<Vec<_>>()` is usually as fast.
## 2. Arrays of MaybeUninit
```rust
use std::mem::MaybeUninit;
let mut buf: [MaybeUninit<u32>; 16] = [const { MaybeUninit::uninit() }; 16];
for (i, slot) in buf.iter_mut().enumerate() { slot.write(i as u32 * 2); }
// SAFETY: all 16 slots were written above.
let buf: [u32; 16] = unsafe { std::mem::transmute(buf) };
```
Safe alternative: `std::array::from_fn(|i| i as u32 * 2)`.
Never call `MaybeUninit::<T>::uninit().assume_init()` for any `T` with validity
invariants (`bool`, `char`, enums, references, `NonZero*`, `Box`): instant UB even if the
value is never read.
## 3. Aliasing through raw pointers
Wrong: two `&mut` exist at once, even if used one after the other.
```rust
let a = unsafe { &mut *p };
let b = unsafe { &mut *p }; // invalidates `a` under Stacked/Tree Borrows
*a += 1;
```
Right: keep raw pointers until the last moment, create one reference at a time, and derive
field pointers without intermediate references.
```rust
// SAFETY: p is valid and unaliased for this scope (see constructor invariant).
unsafe {
let len_ptr = &raw mut (*p).len; // no &mut to the whole struct
*len_ptr += 1;
}
```
`addr_of!`/`addr_of_mut!` are the pre-1.82 equivalents of `&raw const`/`&raw mut`.
## 4. Reinterpreting bytes
Wrong:
```rust
let words: &[u32] = unsafe { std::slice::from_raw_parts(bytes.as_ptr() as *const u32, bytes.len() / 4) };
// misaligned when bytes.as_ptr() is not 4-aligned; endianness implicit
```
Right (no unsafe):
```rust
let words: Vec<u32> = bytes.chunks_exact(4).map(|c| u32::from_le_bytes(c.try_into().unwrap())).collect();
```
Zero-copy when the data is aligned: `bytemuck::try_cast_slice::<u8, u32>(bytes)` returns an
error instead of UB; types need `#[derive(Pod, Zeroable)]`. `zerocopy` offers the same with
derive-checked layouts.
## 5. Box and raw ownership round trips
```rust
let raw: *mut State = Box::into_raw(Box::new(State::default()));
// hand `raw` to C as `void *user_data`
// ... later, exactly once:
// SAFETY: raw came from Box::into_raw above and C returns it only in the destroy callback,
// which runs at most once.
let state: Box<State> = unsafe { Box::from_raw(raw) };
```
Check for: `from_raw` called twice (double free), called on a pointer C allocated, or never
called (leak). Document who owns the pointer at each stage.
## 6. Send and Sync for wrappers
```rust
pub struct Handle { ptr: NonNull<ffi::ctx> }
// Without these lines Handle is neither Send nor Sync (raw pointers are !Send + !Sync).
// SAFETY: libfoo documents that a ctx may be used from any thread, but not concurrently.
unsafe impl Send for Handle {}
// Do NOT add Sync: concurrent &Handle calls would race inside libfoo.
```
Generic wrappers must bound `T`: `unsafe impl<T: Send> Send for Channel<T> {}`, and for
Sync usually `T: Send` (values move between threads) and sometimes `T: Sync` (shared refs
handed out).
## 7. Pinning and self-references
`Pin<&mut Self>` methods that call `get_unchecked_mut` must never move fields that are
pointed to, including via `mem::swap`, `mem::replace` or `Option::take` of the pinned
field. `Drop` for a `!Unpin` type gets `&mut self`; treat it as pinned. Prefer
`pin-project-lite` for projections instead of hand-written unsafe.
## 8. FFI signatures and strings
```rust
use core::ffi::{c_char, c_int};
use std::ffi::{CStr, CString};
unsafe extern "C" { // Rust 2024 syntax; plain `extern "C"` before
fn foo_open(path: *const c_char, out: *mut *mut FooCtx) -> c_int;
fn foo_last_error(ctx: *const FooCtx) -> *const c_char;
fn foo_close(ctx: *mut FooCtx);
}
pub fn open(path: &str) -> Result<Handle, FooError> {
let c_path = CString::new(path).map_err(|_| FooError::InteriorNul)?;
let mut ctx = std::ptr::null_mut();
// SAFETY: c_path is a valid NUL-terminated string alive for the call; ctx is a valid
// out-pointer; foo_open does not retain path (documented in foo.h).
let rc = unsafe { foo_open(c_path.as_ptr(), &mut ctx) };
if rc != 0 || ctx.is_null() {
return Err(FooError::Open(rc));
}
Ok(Handle { ptr: NonNull::new(ctx).expect("checked non-null above") })
}
impl Drop for Handle {
fn drop(&mut self) {
// SAFETY: ptr came from foo_open and is closed exactly once here.
unsafe { foo_close(self.ptr.as_ptr()) }
}
}
fn last_error(h: &Handle) -> String {
// SAFETY: foo_last_error returns a NUL-terminated string owned by ctx, valid until the
// next call on ctx; we copy it immediately.
unsafe {
let p = foo_last_error(h.ptr.as_ptr());
if p.is_null() { return String::new(); }
CStr::from_ptr(p).to_string_lossy().into_owned()
}
}
```
Classic bug: `CString::new(path).unwrap().as_ptr()` in one expression. The CString is a
temporary dropped at the end of the statement, leaving a dangling pointer.
## 9. Callbacks from C
```rust
extern "C" fn on_event(user_data: *mut c_void, code: c_int) -> c_int {
let result = std::panic::catch_unwind(|| {
// SAFETY: user_data is the Box<State> pointer registered in subscribe(), alive
// until unsubscribe() which first deregisters this callback.
let state = unsafe { &*(user_data as *const State) };
state.handle(code)
});
match result { Ok(Ok(())) => 0, _ => -1 }
}
```
Check that the C side cannot call the callback after the Rust state is freed, and that it
is not called concurrently unless `State: Sync`.
## 10. Global mutable state
`static mut` plus references is denied in Rust 2024 and UB-prone in all editions. Replace
with `static X: AtomicU64`, `static CONFIG: OnceLock<Config>`, `LazyLock` (1.80+), or a
`Mutex` in a `static`. If `static mut` must remain (FFI symbol), access it only through
`&raw const X` / `&raw mut X` and document the synchronisation.
## Severity heuristics
- A safe public function that can produce UB with any input: unsound, block release.
- Unsafe code relying on a caller-maintained invariant that is not part of an `unsafe fn`
contract: unsound.
- Correct code with a missing or vague SAFETY comment: hardening, but require the comment
before merge, since the next editor cannot see the proof.
About this skill
Written and maintained by the Codex Skills editors to the same editorial standard as every skill here: a selective description, a real workflow, known pitfalls and a way to verify the work. First published , last updated . MIT licensed. Something wrong or out of date? Tell us.