The directory was gone before anyone read the log line that explained why.
An agent we were running on a scratch repo had been told to "clean up the build artifacts." It reasoned its way to rm -rf with a path it assembled from a variable that, two turns earlier, had quietly resolved to an empty string. rm -rf / is the textbook version of this joke. The real one is quieter: rm -rf "$BUILD_DIR/" where $BUILD_DIR is "", and now you're deleting from the working directory up. We got lucky — it was a throwaway clone, and the worst casualty was an afternoon. But I have watched enough agent transcripts to know the difference between "this didn't hurt us" and "this can't hurt us," and a model holding a raw shell is firmly in the first category.
That incident is the whole reason I care about how an agent touches a disk. The lesson is not "models are dangerous." Models are not malicious; they are confidently wrong, and confidently wrong with rm -rf in hand is a security problem whether or not anyone meant harm. The fix is the same one we have used for untrusted code for fifty years: don't hand it the whole machine. Give it a narrow, typed, observable interface, scope what that interface can reach, and make the dangerous operations either impossible or loud.
That interface, for files, is a filesystem MCP server. This post is about Octofs — the one we build and run — from a security angle: what it actually constrains, what it deliberately does not, and how to configure it so the next rm -rf simply has nothing to grab.
The threat model: what a raw shell actually grants
Start by being honest about what you are giving away when you give an agent bash.
A shell is not a "file tool." It is a launcher for arbitrary programs with the full ambient authority of the process that spawned it — your user account, your environment variables, your SSH agent, your cloud credentials sitting in ~/.aws, your kubeconfig, every mounted volume, the whole tree from / down. When a model emits curl ... | sh, the shell runs it. When it emits rm -rf against a path it hallucinated, the shell deletes it. When it runs git push against the wrong remote, the shell pushes. The shell's job is to do exactly what it is told, instantly, with no notion of "did you mean that?"
Layer the model's failure modes on top of that authority and you get a specific, recurring set of incidents:
- Path drift. The agent builds a path from a variable, a previous tool result, or its own memory of the repo layout. The variable is empty, the result was truncated, the memory is stale — and the path now points somewhere unintended. The shell does not care.
- Destructive verbs on bad paths.
rm -rf,mvover an existing file,> filetruncation,git checkout -- .wiping uncommitted work. Each is one token away from the benign version. - Reading what it shouldn't. A
cat ~/.ssh/id_ed25519orenvdump that pulls secrets into the context window, where they get logged, cached, and possibly shipped to a model API. Exfiltration doesn't need malice; it needs an agent that "helpfully" includes the wrong file. - Interactive hangs. A command that prompts for a password or opens a pager, with no TTY to answer it, and the session wedges.
- Orphaned processes. A background server the agent spawned and forgot, still holding a port after the session ends.
None of these require a jailbreak. They are the expected behavior of a probabilistic system wired directly to an imperative one. The mitigation is architectural: replace the open-ended launcher with a closed set of named operations, and scope the surface those operations can reach.
Why a scoped filesystem tool beats raw bash
The case for a dedicated filesystem MCP server over raw shell access is the same case we made for keeping custom tools in your repo: a narrow, named tool gives the model fewer ways to go wrong and gives you a place to enforce rules.
Concretely, swapping bash for a filesystem tool changes four things at once:
- The verbs are finite and named. Instead of "any program on the PATH," the model sees
view,text_editor,batch_edit,extract_lines,shell,workdir. Each has a schema. There is notext_editoroperation that means "recursively delete a tree," because that operation was never defined. You cannot foot-gun with a gun that isn't in the drawer. - The default surface is your project, not your home directory. Octofs takes a working root and resolves relative paths against it. The agent's natural mode of operation — "edit
src/main.rs" — never leaves that root. - Reads are filtered. Directory traversal is
.gitignore-aware and skips dotfiles by default, so.env,.ssh,node_modules, and anything you already told git to ignore don't show up in listings or content searches. The agent doesn't read what it can't see. - Every operation is observable and reversible. Edits go through atomic writes with up to ten levels of undo per file. There is no "the file is half-written and the process died" state, and there is an
undo_editfor "that edit was wrong."
The shell doesn't disappear — agents genuinely need to run builds and tests — but it stops being the primary file interface, and that is most of the battle. The fewer file operations route through a raw shell, the fewer chances the model has to drift a path into something destructive.
What Octofs actually exposes
Six MCP tools, and that's the entire surface. Knowing the exact list matters, because the security argument is "these and nothing else."
| Tool | What it does | Why it's safer than the shell equivalent |
|---|---|---|
view |
Read files (single, with line ranges), list directories with glob patterns, search file contents | Gitignore-aware and dotfile-filtered — replaces cat/grep/find/ls without exposing ignored paths |
text_editor |
create, str_replace, delete, undo_edit on a file |
Atomic writes, undo history, fuzzy match instead of silent sed corruption |
batch_edit |
Multiple insert/replace operations on one file, applied atomically | Conflict detection rejects overlapping or ambiguous edits before touching disk |
extract_lines |
Copy a line range from one file and append to another | A bounded, typed copy instead of a hand-built head/tail pipe |
shell |
Run a command, foreground or background | Non-interactive enforcement, process-group cleanup, misuse hints |
workdir |
Get, set, or reset the session working directory | Makes the scope explicit and inspectable rather than implicit in cd |
Two configuration flags shape the whole thing, set when you launch the server:
# scope the working root and use stable hash-based line IDs
octofs mcp --path /path/to/your/project --line-mode hash
--path sets the working root. --line-mode hash switches line identifiers from sequential numbers to content-derived 4-character hashes that stay stable across edits — a reliability feature, but a security-adjacent one too, because stable references mean fewer "edit the wrong line because the numbers shifted" mistakes. There is also --bind host:port to run over Streamable HTTP instead of stdio when you need a remote or multi-client setup.
In a Claude Desktop, Cursor, or Windsurf config it looks like this:
{
"mcpServers": {
"octofs": {
"command": "/path/to/octofs",
"args": ["mcp", "--path", "/path/to/your/project", "--line-mode", "hash"]
}
}
}
That --path is the single most important security decision you make with this tool. It is the difference between "the agent operates on this project" and "the agent operates on my laptop." Set it to the narrowest directory the task needs.
The protections, in order of how often they save you
Here is what Octofs actually enforces, grounded in the code rather than the brochure.
Scoped working root
Relative paths resolve against the working root you pass to --path (or the current directory if you don't). The agent saying view src/main.rs resolves to <root>/src/main.rs. The model's entire mental model of "the project" is anchored to that root, and as long as it works in relative paths — which is its default — it never reaches outside.
Gitignore-aware, dotfile-filtered traversal
Directory listing and content search use the ignore crate's walker with git_ignore(true) and hidden files excluded by default. The practical effect: when the agent lists a directory or searches for a string, it does not see .git/, .env, .ssh/, build output, node_modules, or anything in your .gitignore. Secrets you've already excluded from version control are also excluded from the agent's view. This is the cheapest and most underrated protection — the agent can't leak a file it never learned exists.
Atomic writes with undo history
Every edit writes to a temporary file in the same directory, then renames over the target — an atomic operation at the filesystem level. The file is never observed half-written; if the process dies mid-edit, the original is intact. Original file permissions are preserved across the write (a fix that landed in 0.4.2, specifically so a write doesn't silently widen a file's mode). Before each write, the previous content is snapshotted, up to ten levels deep per file, reachable through text_editor's undo_edit. "The agent broke the file" stops being a recovery operation and becomes one tool call.
Batch conflict and duplicate detection
batch_edit validates every operation against every other one before writing. Overlapping replace ranges are rejected. Two inserts at the same anchor are rejected as ambiguous. The whole batch is capped at 50 operations. And every replacement is checked for boundary duplication — the classic failure where the model includes one extra line of "context" and silently duplicates it into the file. None of this touches disk until the plan is internally consistent.
Non-interactive shell enforcement
When the agent does use shell, commands run with stdin set to null, in their own process group, with GIT_TERMINAL_PROMPT=0, DEBIAN_FRONTEND=noninteractive, PAGER=cat, and GIT_PAGER=cat. A command that would prompt for a credential fails fast instead of hanging the session. A command that would page output dumps it plainly instead of invoking less against a non-existent TTY. The session cannot wedge on an interactive prompt the model can't answer.
Process-group cleanup on shutdown
Foreground commands are spawned in their own process group and tracked by PID. On SIGTERM or EOF, Octofs sends SIGKILL to each tracked group — including grandchildren — so nothing a command left half-running outlives the session. Background commands (background: true) are the deliberate exception: they're detached precisely so they can outlive the call, Octofs hands the agent their PID, and killing them when the work is done is the agent's job — kill <pid>.
Shell-misuse hints
This one is a nudge, not a wall, but it shapes behavior over a session. When the agent runs cat, grep, find, ls, sed, or awk through shell, Octofs appends a hint to the response steering it toward the dedicated tool: use view to read or search, use text_editor to edit. Over a session the agent learns to route file operations through the tools that have the safety net, rather than the shell that doesn't. Fewer destructive verbs flowing through the open-ended interface is exactly the goal.
What Octofs does not do — and why I'm telling you
A security post that only lists protections is marketing. Here is the boundary, stated plainly, because configuring around it correctly depends on knowing where it is.
Octofs is not a chroot jail. The working root scopes relative paths. It does not confine absolute paths. resolve_path is two branches: a relative path is joined to the working root; an absolute path is used as-is. There is no rejection of .. traversal and no check that a resolved path stays under the root. If the model emits view /etc/passwd or text_editor against /Users/you/.ssh/config, Octofs will resolve that absolute path and operate on it. The scoping is a strong default and a strong convention — the agent working in relative paths never leaves the project — but it is not a hardened boundary against a path that deliberately or accidentally goes absolute.
This is a deliberate design point, not an oversight: Octofs is a productivity tool for a cooperative agent, not a containment layer for a hostile one. But it changes how you should deploy it when the blast radius matters:
- Run it against a dedicated directory, not your home. The narrowest
--pathyou can get away with. If the task only needs one repo, point it at that repo. - For hard isolation, use the OS. A filesystem tool cannot out-sandbox the kernel. If you genuinely don't trust what the agent might do — untrusted code, an autonomous loop, a CI runner — run Octofs and the agent inside a container, a VM, or a user account that physically cannot see your secrets, with only the project directory mounted. Then the absolute-path gap is irrelevant, because there's nothing outside the mount to reach. Defense in depth: the scoped tool narrows the common case; the OS sandbox catches the path that goes absolute.
- Keep secrets out of the working root, and out of git. Because traversal is gitignore-aware, secrets you've already gitignored stay invisible to listings and search. That's a real reduction in accidental exposure — lean on it. A credential the agent can't enumerate is a credential it can't paste into a context window.
The honest one-line summary: Octofs removes the accidental foot-guns — the empty-variable rm -rf, the silently corrupted sed, the half-written file, the hung pager — and narrows the default surface. It does not, on its own, contain a determined or thoroughly confused agent that constructs an absolute path. Pair it with an OS sandbox when you need that guarantee.
Configuring it tightly: a checklist
If you want the maximum safety this tool can give you, configure it like this:
- Set
--pathto the narrowest project directory the task needs. Not your home, not/, not a parent that contains five other repos. One project, one root. - Gitignore your secrets before you point an agent at the repo.
.env,*.pem, credential files, local config. They become invisible toviewlistings and search. This is free and it works. - Use
--line-mode hashfor any multi-step editing agent. Stable line references mean fewer edit-the-wrong-line mistakes, which is reliability and safety at once. - Prefer the typed tools over
shellin your agent's instructions. The misuse hints push this direction; reinforce it. Every file read throughviewinstead ofcatis one that respects the ignore rules. - Gate the destructive operations in your agent runtime's permission layer. Octofs exposes the tools; your runtime decides which need approval. Auto-approve
view. Always-askshellandtext_editordelete. This is where the human stays in the loop for the verbs that can't be undone withundo_edit. - When the blast radius is real, put the whole thing in a container. Mount only the project directory. Now the absolute-path gap is closed by the kernel, not by hope.
The mental shift is the point. You are not trusting the model to be careful. You are arranging the world so that carelessness has a small blast radius — a scoped root, filtered reads, reversible writes, a non-interactive shell, and an explicit working directory.
FAQ
Does Octofs prevent an agent from running rm -rf?
Not directly — shell still runs arbitrary commands, including rm. What Octofs changes is that file editing and reading no longer need the shell, so far fewer destructive shell commands get generated in the first place; and the working root plus gitignore-aware traversal mean the agent's natural operations stay inside the project. For a hard guarantee that rm -rf can't reach anything important, run Octofs inside a container with only the project mounted.
Is the working root a sandbox?
It's a scoped default, not a jail. Relative paths resolve against the root; absolute paths are used as-is, with no .. rejection. Treat --path as "where the agent works," not "what the agent is confined to." For confinement, use an OS-level sandbox.
How does it stop secrets from leaking into the context?
Directory listing and content search are .gitignore-aware and skip dotfiles by default. Files you've already excluded from git — .env, key files, anything in .gitignore — don't appear in listings or searches, so the agent can't read them into context. The defense is only as good as your .gitignore, so curate it before pointing an agent at the repo.
What about a half-written file if the process crashes mid-edit?
Can't happen. Writes go to a temp file in the same directory and are renamed over the target atomically. If the process dies, the original is intact. Permissions are preserved across the write.
Why six tools instead of just exposing the filesystem?
A finite, named set of operations is the security feature. The model can't reach for a delete-a-tree verb that was never defined, and you get a single place — the tool boundary — to enforce atomic writes, undo, conflict detection, and ignore rules. Fewer, narrower tools are also easier for the model to select correctly.
Does it work outside Octomind?
Yes. Octofs speaks MCP over stdio or Streamable HTTP and works with any MCP client — Claude Desktop, Cursor, Windsurf, Zed — plus the Octomind agent runtime.
The agent that nearly deleted that scratch directory still runs in our setup every day. The model is no more careful than it was. It just has a lot less to break.
What changed is the world around it: it reads through view, edits through text_editor, works inside a --path scoped to one repo, and when the stakes are real, all of it sits inside a container that can't see anything else.
Give an agent a filesystem. Just don't give it yours.
— Don
Octofs is open source under Apache-2.0 — a single Rust binary, current version 0.5.0. The scoping, gitignore-aware traversal, atomic writes, and non-interactive shell described here are all in the source; the absolute-path caveat is too. If a protection you need isn't there, open an issue.



