I Wanted a Pause Built Into My Code, Not Just Permission
AI agents and scripts increasingly get to touch your filesystem, run commands, and open connections. Fence is the zero-dependency Rust policy engine I built in a 72-hour hackathon to decide, at the boundary, what any of that code is actually allowed to do.
Give a piece of code access to your filesystem and eventually it's going to touch something it shouldn't. Not out of malice, just because it followed a chain of logic that made sense to it and ended somewhere you didn't want it to go. rm -rf on the wrong path is the dramatic version. The far more common one is quieter: a script writes to a config it had no business touching, an agent runs a command it technically had permission to run but in a directory it never should have been in.
Most code, human written or agent written, just trusts itself. It calls std::fs::write, std::process::Command, whatever the language's equivalent is, and the operating system does it. There's no layer in between asking "should this actually happen?"
That question is what I spent 72 hours trying to answer. The project that came out of it is called Fence, at least for now, and it's a zero-dependency Rust policy engine that sits between your code and the filesystem, process, and network calls it makes.
Think about what a typical AI agent tool actually looks like today. Someone writes a function called something like run_shell_command or write_file, hands it to the model as a tool, and the model decides when to call it. The function itself usually just does what it's told. Maybe there's a hardcoded blocklist of a few dangerous strings, maybe there isn't even that. The model is the only thing standing between "read this file" and "delete this directory," and the model is exactly the part of the system you should trust the least with that decision, because it can be talked into things by whatever text it's currently processing. What's missing isn't a smarter model. It's a boundary that doesn't care how persuasive the request sounds.
What Fence actually does
The idea is simple to state and a little more involved to get right. Instead of calling std::fs::write directly, your code calls fence.write(path, content). Fence checks that call against a policy file before it touches disk. The policy can allow it outright, deny it outright, or mark it as something that needs a runtime decision, an "ask." The same pattern applies to spawning processes and opening network connections.
use fence::Fence;
fn main() {
let fence = Fence::load(".fence").expect("failed to load policy");
fence.write("output/report.txt", b"hello").expect("write failed");
let content = fence.read("output/report.txt").expect("read failed");
println!("{}", String::from_utf8_lossy(&content));
}
That's the whole surface from the caller's side. The interesting part is what happens on the other side of that call, and how the policy gets defined.
The policy file
Fence reads its rules from a .fence file. I went back and forth on using TOML for this (more on that in a bit) and landed on a small custom format instead, parsed with nothing but the Rust standard library:
[filesystem]
allow read ./projects/**
allow write ./playground/**
ask delete ./playground/**
[process]
allow command cargo, rustc
ask command rm
allow scope ./playground/**
[network]
allow host api.github.com
ask host *.internal.example.com
deny host *
Every rule resolves to allow, ask, or deny. If a request doesn't match anything in the file, it's denied by default. Nothing is implicitly allowed just because you forgot to write a rule for it. When more than one rule could apply, Fence checks deny first, then ask, then allow, so the most restrictive match always wins.
Filesystem rules split cleanly into read, write, and delete, each with its own list, because those three operations carry very different amounts of risk and deserve to be reasoned about separately. Process rules gate on the command name but also require a scope, a path glob the working directory has to fall inside. Scope is checked first: a command run outside every listed scope gets denied even if that exact binary is sitting on the allow list. Network rules match on host, and they're enforced through a real connection call, fence.connect(host, port) checks the host against the [network] list and only opens a std::net::TcpStream if it clears, same allow/ask/deny precedence as everything else. The matching has a bit of nuance to it too: a pattern like *.internal.example.com matches subdomains of that host but not the bare domain itself, so it's not just a blunt string comparison underneath.
Take the policy above and walk a couple of requests through it. A write to ./playground/notes.txt matches allow write ./playground/** and nothing else, so it goes straight through. A delete on that same path matches ask delete ./playground/**, so it stops and waits for a decision instead of assuming one. A read on ./secrets/keys.env doesn't match any filesystem rule at all, and because Fence denies by default, that's a denial, not an oversight. That default is doing a lot of quiet work. A policy engine that assumes "allow" for anything unspecified is really just a blocklist wearing a policy engine's clothes, and blocklists lose the moment someone finds the one thing you forgot to list.
Errors that tell you something
Every guarded call, read, write, delete, execute, connect, returns a Result with a FenceOperationError on failure, and loading a policy file returns its own FenceError if something's wrong with the file itself. Both implement the standard Display and Error traits, so they compose with ? the way any other error type in a Rust codebase would.
match fence.write(path, content) {
Ok(()) => println!("write succeeded"),
Err(err) => println!("{err}"), // e.g. "policy marks `...` as ask, but no approval handler is configured..."
}
That message in the error, not just a bare "denied," was a deliberate choice. If a call gets refused, the person debugging it should be able to tell immediately whether it was denied outright, needs an approval handler that isn't registered, or fell outside every scope in the policy, without having to go spelunking through the .fence file to figure out which rule fired.
When a rule can't decide on its own
Some operations shouldn't be a flat yes or no. They should be a question. That's what ask rules are for, and they resolve through an approval handler you register yourself:
use fence::{ApprovalDecision, Fence};
let fence = Fence::load(".fence")
.expect("failed to load policy")
.with_approval_handler(|request| {
println!("Approve: {request}?");
ApprovalDecision::Approved // or ApprovalDecision::Denied
});
The handler only ever gets called for something explicitly marked ask. It's never given the chance to override a deny, and whatever it approves is exactly the operation that was evaluated, nothing about the request gets substituted on the way through. If you don't register a handler at all and an operation hits an ask rule, Fence returns a specific error carrying the request that needed a decision, rather than silently doing nothing. I wanted a failure there to be loud, not a shrug.
Why zero dependencies
Fence doesn't pull in a single external crate. The .fence parser, the glob matching for paths and hosts, the policy evaluation, all of it sits on top of the Rust standard library alone. That wasn't a purity exercise, it came out of what this crate is for. Something meant to sit at the boundary of every filesystem call, process spawn, and network connection in someone else's application is going to end up deep in their dependency tree. Every crate Fence pulled in would be a crate every user of Fence inherits, and a security boundary that's only as trustworthy as its weakest transitive dependency isn't much of a boundary. Writing the parser by hand instead of reaching for a TOML crate cost more time upfront. It also means there's nothing to audit here except the code I actually wrote, which is tracked plainly in the repo's STDLIB.md, a running list of exactly which parts of the standard library the crate leans on, kept honest as it grows instead of asserted once at the start and left to go stale.
The 72 hours
The version of this idea I started with was much smaller. I'd been thinking about wrapping Node's child_process with something that would pause and confirm before running a delete, scoped globally or to specific directories. Once I actually sat with the problem, two things became clear. First, a command name alone can't tell you whether something is safe. rm might be deleting a temp file or your entire project, the string itself carries no information about intent or blast radius. Second, if AI agents were going to be the thing calling into this, the classification of what a command does couldn't be something the caller, developer or LLM, gets to declare. An agent can be prompt injected into misclassifying its own request. The ground truth has to live in the engine, not in whoever's asking.
That reframing is also what pushed the whole thing into Rust. Lower level control, and agent tooling tends to live there anyway.
The 72 hours themselves were a run of fast pivots rather than one clean build, and not all of them landed the way I'd planned them in my head. I'd sketched out having the process side watch for hosts and URLs appearing inside a spawned command's own arguments, so a curl call would get checked against the same [network] rules as a native connection, catching network activity that happens through a subprocess rather than through Fence's own API. That piece didn't make it into the actual build. Part of the reason is simple: I didn't sit down to start writing code until close to eighteen hours into the window, and a plan that only exists in your head has a way of losing pieces once the clock is compressed and you're building instead of thinking. What did ship cleanly is the native network module itself, fence.connect() checked against [network] host rules the same way filesystem and process calls are checked against theirs. I also moved off TOML and wrote the small custom .fence parser instead, mostly to keep the crate at zero dependencies, and I chose a synchronous approval handler over a more elaborate two-phase ticket system I'd sketched out, because it matched the actual use case and the fancier version wasn't earning its complexity.
Why a library, and not something more opinionated
Some of you are probably wondering why this isn't built directly as access control for agents themselves, some framework you'd bolt onto an existing agent runtime. I built it as a library on purpose, so it can be wrapped for different use cases instead of just one.
If you're building something for remote controlling a computer but want a layer of security in front of it, you can use Fence for that. If you're building an AI agent and want to give its tools scoped filesystem access instead of blanket access, you can implement Fence inside those tools. If you're a user of an existing AI agent product, this probably isn't useful to you directly, that's a fair thing to say plainly. But from a builder's perspective this is meant to be genuinely useful, and the best part is it can become a lot of different things depending on what you wrap it in.
Where it stands right now
Fence is at v0.1.0. Filesystem read, write, and delete are implemented and tested, process execution with scope and command gating is implemented and tested, network connections go through the same checked path via fence.connect(), and the approval flow works end to end across all three. There's a runnable example in the repo (examples/playground.rs) that reads, writes, and deletes a file against a real policy file, prompting in the terminal for anything marked ask. If you want the deeper reference, the full public API surface, the exact .fence grammar the parser accepts, and how path resolution actually works under the hood, is written up in DOCUMENTATION.md in the repo.
I'm also being upfront about what it doesn't do yet. Fence enforces operations that go through its own API, it doesn't stop application code from reaching around it and calling std::fs or std::process directly. Path authorization right now is based on normalized paths and glob patterns, not OS level sandboxing, and symlink resolution isn't handled as a separate security boundary yet. Those are real limits, not hidden ones, and they're the honest starting point for a project that's a few days old.
The other thing missing right now is a create operation. Filesystem support currently covers read, write, and delete, and adding create as its own guarded operation is the first thing on the list once this settles.
One thing that's still in flux
You'll notice I've been calling this project Fence the entire way through, and that's exactly what it's called right now, in the GitHub repository, in the source, in every code block above. There's a snag sitting underneath that, though. Fence, the plain word, is already taken on crates.io by someone else's crate, which means the day I go to actually publish this one, cargo publish says no. What survives that rename is the code, the API, the policy semantics, the approval flow, exactly as described above. What doesn't survive is anything spelled directly out of the word Fence, which includes the name itself and, most likely, the .fence file extension along with it, since a policy format named after a project probably shouldn't carry the old name once the project doesn't.
I've had a shortlist of replacement names sitting around for a while now, and I keep turning them over rather than just grabbing the first one that wasn't taken. Partly that's practical, a crate name is close to permanent once people start depending on it, so it's worth getting right rather than fast. Partly it's just that I've picked names too quickly before and regretted them a week later, and I'd rather sit with a decision for a bit before it's public and irreversible. So I'm deliberately not naming it here yet, not as a teaser, just because it isn't locked in the way I want it to be before I put it in writing. Once it is, the repository gets renamed, the crate goes up on crates.io under that name, and this post gets edited in place, every "Fence" above swapped for the real thing, so nothing here goes stale.
What's next
Short term, that rename and a proper 0.1.0 crates.io release. After that, the create operation, rounding out the filesystem side to match read, write, and delete, and picking back up the process-argument host detection that didn't make it into the hackathon build, so a spawned command reaching out over the network gets caught the same way a native connect call already does. Beyond that, I'd like to look at whether symlink handling needs to become a real part of the security model rather than an open question in the readme. None of this is about adding features for their own sake, it's about closing the gap between what got built under a compressed deadline and what the project is actually supposed to guarantee once results are out and the pressure to just ship something lifts.
If you want to look at the code as it stands, it's on GitHub under Fence for now. Issues and pull requests are welcome, and if you're building something where you're handing filesystem, process, or network access to code you don't fully trust, whether that's an AI agent's tools or just a script you don't want to babysit, I'd genuinely like to hear how it fits or where it doesn't.