Rust guarantees memory safety — capsec guarantees behavioral safety.
# See what your code and dependencies actually do (zero config, zero code changes):
cargo install cargo-capsec
cargo capsec auditcargo audit checks CVEs. cargo vet checks trust. Neither tells you what the code actually does. Nothing stops your CSV parser from opening a TCP socket to phone home telemetry.
capsec fills that gap with three layers:
cargo capsec audit— a static audit tool that scans your code and reports every I/O call. Drop it into CI and know exactly what your dependencies do.- Compile-time type system — functions declare their I/O permissions via
Has<P>trait bounds, and the compiler rejects anything that exceeds them. Zero runtime cost. - Runtime capability control —
RuntimeCap(revocable) andTimedCap(expiring) wrap static capabilities with runtime validity checks for dynamic scenarios like server init or migration windows.
The audit tool finds the problems. The type system prevents them at compile time. Runtime caps handle the cases where permissions need to change dynamically.
Scans Rust source for ambient authority (filesystem, network, env, process) and reports what your code — and your dependencies — can do to the outside world. Zero config, zero code changes.
cargo install cargo-capsec
# Or from source:
cargo install --path crates/cargo-capseccargo capsec auditmy-app v0.1.0
─────────────
FS src/config.rs:8:5 fs::read_to_string load_config()
NET src/api.rs:15:9 TcpStream::connect fetch_data()
PROC src/deploy.rs:42:17 Command::new run_migration()
Summary
───────
Crates with findings: 1
Total findings: 3
Categories: FS: 1 NET: 1 ENV: 0 PROC: 1
1 critical-risk findings
# .github/workflows/capsec.yml
name: Capability Audit
on: [pull_request]
jobs:
audit:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: dtolnay/rust-toolchain@stable
- run: cargo install cargo-capsec
- run: cargo capsec audit --fail-on high --quietNew high-risk I/O in a PR? CI fails. No new I/O? CI passes. Teams can adopt incrementally with --baseline and --diff to only flag new findings.
To see it in action, you can reference these:
The audit tool tells you what your code does. The type system controls what it's allowed to do.
Any function can do anything. The signature lies — it looks pure, but it reads files and opens sockets:
// This function signature says nothing about I/O.
// But inside, it reads from disk and phones home over the network.
pub fn process_csv(input: &[u8]) -> Vec<Vec<String>> {
let config = std::fs::read_to_string("/etc/app/config.toml")
.unwrap_or_default();
if let Ok(mut stream) = std::net::TcpStream::connect("telemetry.example.com:8080") {
stream.write_all(input).ok();
}
parse(input, &config)
}The Rust compiler is perfectly happy with this. Clippy won't flag it. Nothing prevents it.
Functions declare their I/O requirements in the type signature. The compiler enforces them:
use capsec::prelude::*;
// Define a context with exactly the permissions your app needs.
// The macro generates Cap fields, constructor, and Has<P> impls.
#[capsec::context]
struct AppCtx {
fs: FsRead,
net: NetConnect,
}
// Leaf functions take &impl Has<P> — works with raw caps AND context structs.
pub fn load_config(path: &str, cap: &impl Has<FsRead>) -> Result<String, CapSecError> {
capsec::fs::read_to_string(path, cap)
}
// Intermediate functions take a single context reference — not N separate caps.
pub fn app_logic(ctx: &AppCtx) -> Result<String, CapSecError> {
load_config("/etc/app/config.toml", ctx) // ctx satisfies Has<FsRead>
}
// #[capsec::main] injects the capability root automatically.
#[capsec::main]
fn main(root: CapRoot) {
let ctx = AppCtx::new(&root);
let config = app_logic(&ctx).unwrap();
}Every capability traces back to CapRoot. If a function uses capsec wrappers (like capsec::fs::read_to_string) without being given a Cap<FsRead>, the code doesn't compile. The audit tool catches code that bypasses capsec wrappers entirely — calling std::fs directly, using FFI, or hiding I/O behind re-exports.
Wrong capability type — passing a Cap<NetConnect> where Cap<FsRead> is required:
let net_cap = root.grant::<NetConnect>();
let _ = capsec::fs::read_to_string("/etc/passwd", &net_cap);error[E0277]: the trait bound `Cap<NetConnect>: Has<FsRead>` is not satisfied
--> src/main.rs:4:55
|
4 | let _ = capsec::fs::read_to_string("/etc/passwd", &net_cap);
| -------------------------- ^^^^^^^^ the trait `Has<FsRead>` is not implemented for `Cap<NetConnect>`
| |
| required by a bound introduced by this call
Missing capability — calling a capsec function without providing a token at all:
let _ = capsec::fs::read_to_string("/etc/passwd");error[E0061]: this function takes 2 arguments but 1 argument was supplied
--> src/main.rs:2:13
|
2 | let _ = capsec::fs::read_to_string("/etc/passwd");
| ^^^^^^^^^^^^^^^^^^^^^^^^^^--------------- argument #2 of type `&_` is missing
|
help: provide the argument
|
2 | let _ = capsec::fs::read_to_string("/etc/passwd", /* cap */);
| +++++++++++
Cross-category violation — FsAll subsumes FsRead and FsWrite, but not NetConnect:
let fs_all = root.grant::<FsAll>();
needs_net(&fs_all); // fn needs_net(_: &impl Has<NetConnect>) {}error[E0277]: the trait bound `Cap<FsAll>: Has<NetConnect>` is not satisfied
--> src/main.rs:3:15
|
3 | needs_net(&fs_all);
| --------- ^^^^^^^ the trait `Has<NetConnect>` is not implemented for `Cap<FsAll>`
| |
| required by a bound introduced by this call
These are real rustc errors — no custom error framework, no runtime panics. The Rust compiler does the enforcement.
| Before | After | |
|---|---|---|
| Can any function read files? | Yes | Only if it has Cap<FsRead> |
| Can any function open sockets? | Yes | Only if it has Cap<NetConnect> |
| Can you audit who has what access? | Grep and pray | Grep for Has<FsRead> |
| Runtime cost? | N/A | Zero — all types are erased at compile time |
capsec protects against cooperative safe Rust — code that uses capsec wrappers cannot exceed its declared permissions, and the compiler enforces this at zero runtime cost.
The Has<P> trait is open for implementation — custom context structs can implement it to delegate capability access. Security is maintained because Cap::new() is pub(crate): no external code can forge a Cap<P> in safe Rust. The Permission trait remains sealed — external crates cannot invent new permission types.
What capsec does not protect against:
unsafecode that forges capability tokens viatransmute,MaybeUninit, or pointer tricks. The type system is sound only within safe Rust. (The adversarial test suite incapsec-tests/tests/type_system.rsdocuments these attacks and confirms they requireunsafe.)- Direct
stdcalls that bypass capsec wrappers. A function can always callstd::fs::read()without a capability token — the compiler won't stop it. This is wherecargo capsec auditcomes in: it detects these calls statically. - FFI and inline assembly that interact with the OS directly. The audit tool flags
externblocks but cannot reason about what foreign code does.
The three layers are complementary: the type system enforces boundaries within code that opts in, the audit tool surfaces code that hasn't opted in yet, and runtime caps handle dynamic permission lifecycles. No single layer is complete alone — together they provide defense in depth.
capsec ships with a 74-test adversarial security suite that documents every known evasion vector and attack surface — unsafe forgery, std bypass, FFI escape hatches, context delegation attacks, and more. Most security tools don't catalog their own weaknesses. capsec does, so you know exactly what's covered and what isn't. See capsec-tests/tests/type_system.rs and capsec-tests/tests/audit_evasion.rs.
Static Cap<P> tokens are permanent — once granted, they're valid forever. For scenarios where permissions should be temporary or revocable, capsec provides runtime capability wrappers.
Grant network access for server startup, then revoke it so no new connections can be made at runtime:
use capsec::prelude::*;
#[capsec::main]
fn main(root: CapRoot) -> Result<(), Box<dyn std::error::Error>> {
// Wrap a capability with a revocation handle
let (runtime_cap, revoker) = RuntimeCap::new(root.net_connect());
// During startup: try_cap() returns a real Cap<NetConnect>
let cap = runtime_cap.try_cap()?;
init_connection_pool(&cap);
// After init: revoke — no new connections possible
revoker.revoke();
// Now try_cap() returns Err(CapSecError::Revoked)
assert!(runtime_cap.try_cap().is_err());
Ok(())
}Runtime caps compose with the context pattern — wrap individual capabilities for revocation while keeping your context ergonomics:
#[capsec::context]
struct ServerCtx {
fs: FsRead,
net: NetConnect,
}
// Grant static fs access + revocable net access
let ctx = ServerCtx::new(&root); // static caps for the context
let (net_rt, revoker) = RuntimeCap::new(root.net_connect()); // revocable net cap
// Use ctx for Has<FsRead> bounds, net_rt.try_cap()? for revocable net accessGrant temporary write access for a migration window:
use capsec::prelude::*;
use std::time::Duration;
#[capsec::main]
fn main(root: CapRoot) -> Result<(), Box<dyn std::error::Error>> {
let timed_cap = TimedCap::new(root.fs_write(), Duration::from_secs(30));
// Within the window: try_cap() succeeds
let cap = timed_cap.try_cap()?;
capsec::fs::write("/tmp/migration.txt", "data", &cap)?;
// After TTL: try_cap() returns Err(CapSecError::Expired)
// timed_cap.remaining() returns Duration::ZERO
Ok(())
}LoggedCap<P> records every try_cap() invocation in an append-only audit log — implementing Saltzer & Schroeder's compromise recording principle (The Protection of Information in Computer Systems, 1975, Design Principle #8):
use capsec::prelude::*;
#[capsec::main]
fn main(root: CapRoot) -> Result<(), Box<dyn std::error::Error>> {
let logged_cap = LoggedCap::new(root.fs_read());
// Every exercise is recorded
let cap = logged_cap.try_cap()?;
capsec::fs::read("/dev/null", &cap)?;
// Inspect the audit trail
for entry in logged_cap.entries() {
println!("{}: {} (granted={})",
entry.permission, entry.timestamp.elapsed().as_micros(), entry.granted);
}
Ok(())
}DualKeyCap<P> requires two independent approvals before try_cap() succeeds — implementing Saltzer & Schroeder's separation of privilege principle (Design Principle #5: "a mechanism that requires two keys to unlock it is more robust than one that allows access to the presenter of only a single key"):
use capsec::prelude::*;
#[capsec::main]
fn main(root: CapRoot) -> Result<(), Box<dyn std::error::Error>> {
let (dual_cap, approver_a, approver_b) = DualKeyCap::new(root.fs_write());
// Distribute handles to separate subsystems
// Neither alone can exercise the capability
approver_a.approve(); // manager approves
approver_b.approve(); // security officer approves
// Only now does try_cap() succeed
let cap = dual_cap.try_cap()?;
capsec::fs::write("/tmp/authorized.txt", "data", &cap)?;
Ok(())
}RuntimeCap,TimedCap,LoggedCap, andDualKeyCapdo not implementHas<P>— fallibility is explicit viatry_cap()at every call site- All are
!Sendby default — usemake_send()to opt into cross-thread transfer - Cloning a
RuntimeCapshares the revocation flag — revoking one revokes all clones - Cloning a
LoggedCapshares the audit log — entries from any clone appear in the same log RevokerisSend + Sync + Clone— revoke from any threadApproverA/ApproverBareSend + Syncbut notClone— move-only to enforce separation of privilege
| Tool | Approach | Layer |
|---|---|---|
| capsec | Compile-time types + runtime caps (revocable, timed, audited, dual-key) + static audit | Source-level, cooperative |
| cap-std | Runtime capability handles (ambient authority removal) | OS-level, WASI-oriented |
| cargo-scan | Static analysis of dangerous API usage | Source-level, research prototype |
| cargo-cgsec | Call graph capability analysis (Capslock port) | Source-level, audit only |
cap-std operates at a different layer — it replaces OS-level file descriptors with capability handles at runtime, targeting WASI sandboxing. capsec works at the type level with zero runtime cost and no OS support required. The two are complementary: you could use cap-std handles inside capsec-gated functions.
cargo-scan (from UC San Diego) performs similar static analysis to cargo capsec audit. capsec adds the type-system enforcement layer and ships as a single workspace with both tools integrated.
cargo-cgsec is a Rust port of Google's Capslock, funded by the Rust Foundation. It performs call graph analysis to identify capability usage — audit only, no enforcement or runtime layer. capsec covers the same audit surface via cargo capsec audit and adds compile-time type enforcement and runtime capability control.
capsec's design draws from three foundational papers in capability-based security:
-
Dennis & Van Horn (1966) — Programming Semantics for Multiprogrammed Computations. Introduced capability lists (C-lists), unforgeable capability tokens, and spheres of protection. capsec's
Cap<P>is a direct descendant of their capability concept;Cap::new()beingpub(crate)enforces unforgeability in software the way their hardware enforced it in the supervisor. -
Saltzer & Schroeder (1975) — The Protection of Information in Computer Systems. Defined the eight design principles for protection mechanisms. capsec implements six: economy of mechanism (zero-sized types), fail-safe defaults (no cap = no access), least privilege (the core mission), open design (open source + adversarial test suite), separation of privilege (
DualKeyCap), and compromise recording (LoggedCap). The two partially met — complete mediation and least common mechanism — are inherent limitations of a library-level approach. -
Melicher et al. (2017) — A Capability-Based Module System for Authority Control (ECOOP 2017). Formalized non-transitive authority in the Wyvern language, proving that a module's authority can be determined by inspecting only its interface. capsec achieves the same property:
Has<P>bounds make a function's authority visible in its signature, andAttenuated<P, S>/ runtime cap types that don't implementHas<P>enforce non-transitivity.
Apache-2.0