I Wanted a Pause Built Into My Code, Not Just Permission
- Published
- 2 September 2026
- Length
- 21 min
- Reading
AI agents and scripts increasingly get to touch your filesystem, run commands, and open connections. Nitera 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.
Here's the part that's easy to miss, though. Almost none of this is a code review problem. You can read every line of a script and still not know what it will do, because most languages don't ask that question at the level where it actually matters. std::fs::write doesn't know or care what path you hand it. std::process::Command doesn't know or care what argument you pass. The operating system underneath is even less picky, it just does what it's told, because that's the whole deal it made with you when you wrote the syscall. Permission, in the Unix sense, was decided once, at the level of the user account, years before your script ever ran. Nothing in that chain is asking "should this specific thing happen right now."
That gap, between what a process is technically allowed to do and what it should actually be allowed to do in this moment, is the thing I spent 72 hours poking at. What came out the other end is a small Rust crate called Nitera, and it's basically a checkpoint you put in front of your own code's filesystem, process, and network calls.
Think about what a typical AI agent tool looks like today. Someone writes a function called something like run_shell_command or write_file, hands it to a 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 interesting failure mode here isn't the model being malicious. It's that the model is the only thing standing between "read this file" and "delete this directory," and it's exactly the part of the system you should trust least with that particular decision, because it can be talked into things by whatever text it happens to be processing at the time. A prompt injected into a webpage, a comment in a file it's reading, a tool result that contains instructions disguised as data. None of that requires the model to be broken. It just requires the model to be doing exactly what language models do, which is take text seriously.
What's missing isn't a smarter model. It's a boundary that doesn't care how persuasive the request sounds, because it isn't reading the request, it's checking it against a rule that was written down before any of this started.
Instead of calling std::fs::write directly, your code calls nitera.write(path, content). Nitera 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 decision made right now, at runtime, an "ask." The same pattern covers spawning processes and opening network connections.
use nitera::Nitera;
fn main() {
let nitera = Nitera::load(".nitera").expect("failed to load policy");
nitera.write("output/report.txt", b"hello").expect("write failed");
let content = nitera.read("output/report.txt").expect("read failed");
println!("{}", String::from_utf8_lossy(&content));
}
That's the entire surface from the caller's side. The interesting part is what's sitting on the other side of that call, and the design decisions that had to get made to make it actually mean something.
Nitera reads its rules from a .nitera file, a small custom format I ended up writing a parser for by hand:
[filesystem]
allow read ./projects/**
allow write ./playground/**
allow create ./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. Nothing is implicitly allowed just because you forgot to write a rule for it, and if two rules could both apply, deny wins, then ask, then allow.
This sounds like a small implementation detail, but it's actually the whole reason a policy engine is different from a blocklist. A blocklist starts from "everything is fine" and tries to enumerate the bad things. That list is never complete, because you're trying to predict every path someone could take before they've taken it. A policy engine that defaults to deny starts from "nothing is fine" and makes you say what's allowed. You can't forget to block the thing you didn't think of, because there was never a default that let it through in the first place.
It's the same shift firewalls went through decades ago, from "block these known bad ports" to "deny everything, open only what you need." IAM systems in cloud platforms work the same way for the same reason. It's not a clever idea, it's just the boring, correct one, and it's surprising how often software skips it in favor of something that feels more permissive by default and gets locked down later, usually after something's already gone wrong.
Take that policy above and walk a request through it. A write to ./playground/notes.txt matches the allow rule and goes straight through. A delete on that same path matches ask delete, so it stops and waits for an actual decision instead of assuming one. A read on ./secrets/keys.env doesn't match anything at all, and because nothing matched, it's denied. Not because someone remembered to write a rule against secrets specifically, but because the absence of a rule is itself the safe outcome.
The very first version of this idea, before it was even in Rust, was much smaller. I'd been thinking about wrapping Node's child_process with something that would pause before running a delete. The moment I actually sat with that, it fell apart, because rm as a string carries no information about what's actually about to happen. It might delete a stray temp file. It might delete your entire project. The command name is identical either way.
That's a more general point than it sounds. Security decisions based on surface-level signals, a filename extension, a command name, a MIME type someone else claimed, tend to be trivially wrong the moment reality doesn't match the label. The actual ground truth has to come from somewhere that can't be spoofed by whoever's making the request.
That's why Nitera never lets the caller declare what kind of operation something is. An agent can't say "trust me, this is a safe delete," because agents can be prompt injected into misclassifying their own request, and a developer wiring things up by hand can just as easily get the classification wrong without meaning to. The classification comes from the engine itself, based on which function you actually called. nitera.delete(path) is unambiguously a delete, not because anyone asserted it, but because there's no other function that does that operation. The ground truth lives in the code path, not in a label someone attached to it.
Every guarded call, read, write, delete, create, execute, connect, returns a Result with a NiteraOperationError on failure, and loading a policy file returns its own NiteraError if something's wrong with the file itself.
match nitera.write(path, content) {
Ok(()) => println!("write succeeded"),
Err(err) => println!("{err}"), // e.g. "policy marks `...` as ask, but no approval handler is configured..."
}
This mattered more than it might look like on the surface. A security boundary that fails silently is almost worse than no boundary at all, because it teaches you to trust an outcome that was actually just a quiet no-op. If a call gets refused, whoever's debugging it should know immediately whether it was denied outright, needed an approval handler that wasn't registered, or fell outside every scope in the policy, without going spelunking through a .nitera file to reverse engineer which rule fired. Observable failure is a design decision, not an afterthought, and it's one a lot of systems get wrong by treating error messages as the boring part instead of the part someone's actually going to be staring at during an incident.
Some operations shouldn't be a flat yes or no. They should be a question, asked to something that can actually reason about context a static file can't have. That's what ask rules are for.
use nitera::{ApprovalDecision, Nitera};
let nitera = Nitera::load(".nitera")
.expect("failed to load policy")
.with_approval_handler(|request| {
println!("Approve: {request}?");
ApprovalDecision::Approved // or ApprovalDecision::Denied
});
This is the same pattern as a browser asking before a site gets your camera, or sudo asking before a command runs as root. The interesting design constraint isn't the asking itself, it's what the handler is and isn't allowed to do once it's been asked. It only ever gets consulted for something explicitly marked ask, never for a deny, so no amount of runtime cleverness can override a decision that was already made ahead of time in the policy. And it receives the exact request that was evaluated, nothing about the path, command, or host can be swapped out on the way through. Approving "write to notes.txt" can't quietly become "write to notes.txt" today and something else tomorrow. The thing you approved is the thing that happens, which sounds obvious until you think about how many confirmation dialogs in real software don't actually guarantee that.
Nitera doesn't pull in a single external crate. The parser, the glob matching, the policy evaluation, all of it sits on the Rust standard library alone.
This wasn't a purity thing. It came out of thinking about what this crate actually sits in front of. Something meant to guard every filesystem call, process spawn, and network connection in someone else's application ends up deep in their dependency tree, whether they think about it that way or not. Every crate Nitera pulled in would be a crate every user of Nitera inherits, and a boundary that's only as trustworthy as its weakest transitive dependency isn't really a boundary, it's a hope. The software world has had a few loud reminders of this over the years, packages that quietly changed hands or got compromised upstream, and the damage traveled through exactly the kind of indirect dependency nobody was individually watching. Writing a parser by hand instead of reaching for an existing crate costs more time upfront. It also means there's nothing here to audit except code I actually wrote myself, tracked in the repo's STDLIB.md, an honest, growing list of exactly which parts of the standard library it leans on.
There's a detail in how Nitera resolves paths that's worth sitting with, because it's one of those places where security bugs actually tend to live.
Path resolution here is purely lexical. ../../../etc/passwd gets normalized without ever touching the actual filesystem, which means it works even for paths that don't exist yet. But that also means normalization on its own doesn't clamp anything back inside a safe boundary. A relative path with enough ../ segments really can resolve to somewhere outside wherever you meant it to stay.
What actually keeps that safe is that policy rules match against the final resolved path, not the string someone originally typed. If a traversal attempt resolves somewhere your allow patterns don't cover, it simply doesn't match, and default deny catches it the same way it catches anything else nobody thought to name. The security boundary isn't the path normalization step, it's how narrowly you scope your allow rules afterward. allow read /** gives a traversal attempt infinite places to land. allow read ./playground/** gives it nowhere to go. The lesson generalizes past this one crate, normalizing input is necessary but it's not the same thing as authorizing it, and it's easy to write code that does the first and quietly assumes it got the second for free.
The build itself was a run of fast pivots rather than one clean line from idea to finished thing. 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 the same way a native connection does, catching network activity that slips out through a subprocess instead of through Nitera's own API. That piece didn't make it into the hackathon build, partly because I didn't start actually writing code until close to eighteen hours into the window, and a plan that only exists in your head loses pieces once the clock is compressed and you're building instead of thinking.
What did ship cleanly was the native network module, nitera.connect() checked against the same host rules as everything else, and a synchronous approval handler instead of a more elaborate two-phase ticket system I'd sketched out first. The fancier version wasn't earning its complexity for what this actually needed to do, which is a lesson that shows up in a lot of systems design, the more elegant option on paper isn't automatically the right one for the problem in front of you.
For a while this project was called Fence, which is a fine word for what it does, right up until you try to publish it and discover the plain word is already taken on crates.io by someone else's crate.
I went through a longer list of alternatives than I expected to, blends of English words, fence-related terms borrowed from other languages, before landing on Nitera. It comes from niti, the Hindi and Sanskrit word for policy, which turned out to be a better fit for what this actually is than fence ever was. Fence describes a boundary. Niti describes the thing deciding where that boundary sits. That's closer to the truth of what the crate does, it isn't really a wall, it's a set of rules about which walls exist and where.
Nitera is at v0.1.0, published on crates.io. Filesystem read, write, delete, and create are implemented and tested, including creating both files and new directories through the same create rule set. Process execution with scope and command gating works end to end, network connections go through the same checked path via nitera.connect(), and the approval flow covers all of it consistently. There's a runnable example in the repo that exercises the whole surface against a real policy file, prompting in the terminal for anything marked ask.
I'm also being upfront about what it doesn't do yet. Nitera 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 is pattern matching on normalized paths, not OS level sandboxing, and symlink resolution isn't handled as a separate security boundary yet, a symlink pointing somewhere your patterns didn't anticipate is still an open question rather than a solved one. Those are real limits, not hidden ones, and they're the honest starting point for a project that's only a few days old.
The process-argument host detection that didn't make it into the hackathon build is still on the list, catching a curl call the same way a native connection gets caught. After that, I want to sit properly with the symlink question rather than leaving it as a line in a readme, because it's exactly the kind of gap that looks small right up until it isn't. None of this is about adding features for their own sake. It's about closing the distance between what got built under a compressed deadline and what a project like this is actually supposed to guarantee once the pressure to just ship something has lifted.
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, the code is on GitHub and the crate is on crates.io. Issues and pull requests are welcome, and I'd genuinely like to hear where this fits for you or where it doesn't.