Any crate in your dependency tree can open /etc/passwd, write to your ~/.ssh directory, or enumerate every file in your project. Rust’s standard library does not ask for permission. It assumes any code with the ability to call std::fs::File::open is authorized to touch any path the OS allows.
cap-std changes that assumption. It is a drop-in replacement for Rust’s std I/O modules that replaces ambient authority with explicit capabilities. If you want to open a file, you first need a capability that proves you have access to the directory containing it.
This matters more than you might think. Supply-chain attacks don’t always need sophisticated exploits. Sometimes they just need a build script or a transitive dependency that quietly exfiltrates files while your tests run. Sandboxing that risk at the library level, without containers or VMs, is what cap-std enables.
What capability-based filesystem access actually looks like
In standard Rust, opening a file is a one-liner with zero prerequisites:
use std::fs::File;
// Any code, anywhere, can do this
let file = File::open("/etc/passwd")?;
The path is absolute, and the authority is ambient. The operating system checks permissions, but the program itself never has to demonstrate that it should have been able to construct that path in the first place.
cap-std replaces this with a two-step model. You start with a capability, typically a directory handle, and you can only open paths relative to that directory:
use cap_std::fs::Dir;
use std::path::Path;
// Open the current working directory as a capability
let cwd = Dir::open_ambient_dir(".", cap_std::ambient_authority())?;
// Now we can only open files inside this directory tree
let file = cwd.open("config/app.toml")?;
Notice the open_ambient_dir call. That is the escape hatch. It converts an ambient path into a capability, and it is deliberately verbose and easy to audit. Once you have a Dir, there is no open method that accepts an absolute path. The API simply does not allow it.
How cap-std mirrors std without copying its security model
cap-std is structured as direct replacements for std::fs, std::net, and std::os::unix::net. The types are intentionally familiar. cap_std::fs::File wraps std::fs::File. cap_std::fs::Dir is the new entry point, roughly analogous to working within a chroot except enforced by the type system rather than a privileged OS call.
The crate achieves this through a lower-level abstraction called cap-primitives. Under the hood, cap-std uses openat-style system calls on Unix and similar restricted-relative operations on Windows. It never calls the standard library’s unrestricted File::open. On Linux, it uses openat2 with RESOLVE_BENEATH when available to prevent symlink escapes. On Windows, it uses NtCreateFile with restricted flags.
This is not a wrapper around containers or seccomp. It is a reimplementation of the standard filesystem APIs that simply omits the dangerous operations.
The Dir type supports most of what you would expect: open, create_dir, rename, remove_file, read_dir, and so on. The key difference is that every operation is relative to that directory handle. If you want to move a file outside the tree, you need two Dir capabilities, one for the source and one for the destination. The API forces you to carry the proof of access with you.
The portability tricks cap-std uses
Filesystem capability models vary across operating systems. Linux has openat2. FreeBSD has cap_rights_limit and O_RESOLVE_BENEATH. Windows has handle-based access control but no direct equivalent of RESOLVE_BENEATH. macOS has the most limited support of the major platforms.
cap-std handles this through a portability layer. On systems with strong kernel support, it uses the native restriction mechanisms. On systems without them, it falls back to a userspace implementation that resolves symlinks manually and validates that every component of a relative path stays within the capability boundary.
This fallback is more expensive, but it means your security model is portable. A WASI sandbox, a Linux server, and a developer’s MacBook can all enforce the same capability boundaries without platform-specific code in your application.
What using cap-std in a real project looks like
Converting an existing project is less painful than you might expect, because the APIs are deliberately close to std. The main change is that you stop passing &Path or &str around as your unit of filesystem address. You pass &Dir instead.
Here is a simplified example of reading configuration from a directory that the caller controls:
use cap_std::fs::Dir;
use std::io::{self, Read};
pub fn load_config(dir: &Dir, name: &str) -> io::Result<String> {
let mut file = dir.open(name)?;
let mut contents = String::new();
file.read_to_string(&mut contents)?;
Ok(contents)
}
The load_config function cannot access files outside the directory it is given. It does not need to know where that directory is on disk. This makes it trivial to test in isolation:
use cap_std::fs::Dir;
use cap_tempfile::TempDir;
#[test]
fn test_load_config() -> std::io::Result<()> {
let tmp = TempDir::new(Default::default())?;
let dir = tmp.dir();
{
let mut f = dir.create("app.toml")?;
std::io::Write::write_all(&mut f, b"key = \"value\"")?;
}
let config = load_config(dir, "app.toml")?;
assert!(config.contains("value"));
Ok(())
}
cap-tempfile provides a temporary directory as a capability, so even your tests never grant ambient authority. The load_config function would fail if it tried to open ../etc/passwd or any absolute path, regardless of what the test or caller provides.
What breaks when you switch
The biggest limitation is ecosystem compatibility. Most Rust crates that touch the filesystem accept a &Path or a PathBuf. They expect ambient authority. If you want to use cap-std with, say, a configuration parsing crate that reads include files from disk, that crate needs to be cap-std-aware or you need to read the files yourself and pass string contents to the parser.
This is the same migration story as async Rust. In the same way that a function taking std::fs::File cannot be called from async code expecting an async file handle, a function taking &Path cannot be called with a &Dir. The boundary is clean but real.
There are also operations that cap-std deliberately does not support. You cannot create hard links that escape a directory tree. You cannot follow arbitrary symlinks that point outside the capability boundary. You cannot call std::env::current_dir and assume it means anything in a capability-based program. These are not bugs. They are the security model.
Performance is usually a non-issue. On Linux with openat2, the overhead is a few extra flags to an existing syscall. On platforms using the userspace fallback, path resolution is slower, but typically still negligible compared to the actual I/O.
When cap-std is worth the friction
You probably don’t need cap-std for every CLI tool or web server. If you trust your dependencies and your threat model is external attackers rather than malicious crates, containers and normal OS permissions are fine.
cap-std shines in a few specific scenarios:
Plugin systems. If your application loads untrusted WASM modules or native plugins, cap-std lets you grant each plugin a Dir capability representing its sandbox. The plugin cannot break out without a kernel exploit, because the API simply does not express the concept of “open any file.”
Build tools and package managers. These run arbitrary code from the internet. A build.rs script that uses cap-std cannot read your SSH keys unless you explicitly hand it a capability to your home directory.
WASI targets. The WebAssembly System Interface is built on capabilities. cap-std’s API design directly influenced WASI, and using cap-std makes porting to WASI straightforward because the security model is already aligned.
Testing and reproducibility. Tests that take &Dir instead of relying on the current working directory are hermetic by default. You don’t need to chdir and restore state. You just hand the test a temporary directory capability.
Getting started
Add cap-std to your Cargo.toml:
[dependencies]
cap-std = "3.0"
cap-tempfile = "3.0"
Pick one module that reads files, change its public API to accept &Dir instead of &Path, and update the callers. You don’t have to migrate everything at once. cap-std and std::fs can coexist in the same binary. The migration is opt-in per module.
If you maintain a library that reads files, consider adding a cap_std::fs::Dir-based API alongside your existing path-based one. Your WASI users will thank you, and your security-conscious users will have an easier time sandboxing their applications.
The cap-std repository lives at github.com/bytecodealliance/cap-std. The crate documentation includes platform support notes and the full API surface. Start with Dir::open_ambient_dir for the entry point, then try to remove every other call to std::fs that accepts an absolute path. You’ll be surprised how much ambient authority your code was carrying around.