File System Access in an Agent Harness: A Case Study
Safe agent file system access means never trusting a path you didn't resolve. A docs team's case study on the os.path.join trap and the jail that fixed it.
import os
ROOT = "/home/app/docs"
def read_file(path):
full = os.path.join(ROOT, path)
return open(full).read()
def write_file(path, content):
full = os.path.join(ROOT, path)
with open(full, "w") as f:
f.write(content)
return "written"How do you let an agent read and write files without letting it wander into /etc/passwd or overwrite something three directories up? That's the question a documentation-tooling team asked me after their agent, told to "update the README," somehow modified a file outside the project entirely. Nobody wrote malicious code. The bug hid in one line of path handling that looked completely innocent. Agent file system access is where a lot of harnesses quietly leak, and this case study walks through exactly how — and how the team closed it.
The Problem a Docs Team Faced
A four-person developer-experience team built an agent to maintain their documentation. Give it a task like "add a section on rate limits to the API guide," and it would read the relevant file, edit it, and write it back. The tools were simple: read_file(path) and write_file(path, content).
It worked until a task phrased a path with a leading slash. The agent, reasoning about "the config at /config/limits.md," passed an absolute-looking path — and the write landed somewhere nobody intended. The file that got modified wasn't even in the repository. The team's first reaction was to blame the model for "using the wrong path." But the model's path wasn't the bug. The harness was, because it trusted the path without ever checking where it actually pointed.
The Wrong Approach
Here's the original file access code. Read it closely, because the flaw is famous and almost invisible:
[object Object], os
ROOT = ,[object Object],
,[object Object], ,[object Object],(,[object Object],):
full = os.path.join(ROOT, path)
,[object Object], ,[object Object],(full).read()
,[object Object], ,[object Object],(,[object Object],):
full = os.path.join(ROOT, path)
,[object Object], ,[object Object],(full, ,[object Object],) ,[object Object], f:
f.write(content)
,[object Object], ,[object Object],What this does: it joins the requested path onto a root directory and reads or writes there. It looks jailed — everything goes "under" ROOT, right? Not quite. os.path.join("/home/app/docs", "/etc/passwd") returns /etc/passwd. When the second argument is absolute, os.path.join throws the root away entirely. The jail has a hole you can drive an absolute path straight through.
And even relative paths escape: os.path.join(ROOT, "../../../etc/passwd") resolves happily upward, out of the docs directory and into the system. The naive join enforces nothing.
⚠️ Common mistake: Assuming os.path.join(root, user_path) confines the result to root. It does not. An absolute user_path silently discards the root, and .. segments climb out of it. This single misconception is behind a large share of agent file-access escapes, and it's invisible in every test that only ever passes clean relative paths.
The Correct Approach: Jailing Agent File System Access
The fix is to resolve the final path and verify it still lives under the root before touching disk:
[object Object], os
ROOT = os.path.realpath(,[object Object],)
,[object Object], ,[object Object],(,[object Object],):
,[object Object],
candidate = os.path.realpath(os.path.join(ROOT, path.lstrip(,[object Object],)))
,[object Object], ,[object Object], (candidate == ROOT ,[object Object], candidate.startswith(ROOT + os.sep)):
,[object Object], PermissionError(,[object Object],)
,[object Object], candidate
,[object Object], ,[object Object],(,[object Object],):
,[object Object], ,[object Object],(resolve_in_jail(path)).read()[:,[object Object],]
,[object Object], ,[object Object],(,[object Object],):
full = resolve_in_jail(path)
os.makedirs(os.path.dirname(full), exist_ok=,[object Object],)
,[object Object], ,[object Object],(full, ,[object Object],) ,[object Object], f:
f.write(content)
,[object Object], ,[object Object],What this does: it strips leading slashes so absolute paths can't blow away the root, resolves the real path (following any .. and symlinks), and refuses anything that doesn't land inside the jail. The realpath call is the important part — it collapses .. segments and resolves symlinks, so both escape routes close at once. A blocked path raises a clear error the model can read and correct.
⚡ Pro tip: The startswith(ROOT + os.sep) check needs that trailing separator. Without it, a sibling directory like /home/app/docs-secret would pass the check because its name starts with /home/app/docs. It's a one-character bug that reopens the jail — add the separator and confirm it with a test that a sibling path gets rejected.
Results and What Changed
After the change, the escape simply stopped being possible. The team wrote a handful of adversarial tests — absolute paths, .. chains, a symlink pointing outside the jail — and watched each one raise cleanly instead of touching a forbidden file. The model still occasionally produced an odd path, but now an odd path became a readable error and a retry, not a write to the wrong place.
The subtler win was confidence. Before, the team hesitated to give the agent write access at all, because they couldn't prove it was contained. After, they could point to the jail check and the adversarial tests and say "here's the boundary, here's the proof it holds." That proof is what let them actually turn the agent on for real repositories instead of keeping it in a sandbox nobody trusted.
The debugging story changed too. With the old code, an escaped write left no obvious trace — a file changed somewhere outside the project, discovered days later, with no clue which run did it. With the jail in place, every escape attempt logs a PermissionError naming the exact path the model tried to reach. Those logs turned into a useful signal: the team noticed the model kept trying to read a shared config one level above the jail, which told them the task framing was pushing it outside the intended scope. They fixed the prompt, not just the jail. A good boundary doesn't only stop escapes — it tells you when your instructions are quietly asking the agent to go somewhere it shouldn't.
⚡ Pro tip: Handle symlink races explicitly if your jail holds anything sensitive. Between the realpath check and the open, a symlink could in theory be swapped — a time-of-check-to-time-of-use gap. For most docs tooling it's a non-issue, but for anything security-critical, open with O_NOFOLLOW on the final component so a last-moment symlink swap fails instead of following.
Separating Read Access From Write Access
Jailing paths solves where the agent can go. The next question is what it can do once it's there, and the docs team learned to split those apart. Most agent tasks read widely and write narrowly — an agent updating one file might need to read ten for context. Granting write access to everything it can read is a needless expansion of blast radius.
READ_ROOT = os.path.realpath(,[object Object],)
WRITE_ROOT = os.path.realpath(,[object Object],) ,[object Object],
,[object Object], ,[object Object],(,[object Object],):
,[object Object], ,[object Object],(resolve_in(path, READ_ROOT)).read()[:,[object Object],]
,[object Object], ,[object Object],(,[object Object],):
full = resolve_in(path, WRITE_ROOT) ,[object Object],
,[object Object], ,[object Object],(full, ,[object Object],) ,[object Object], f:
f.write(content)
,[object Object], ,[object Object],What this does: it gives the agent read access across the whole docs tree but confines writes to a drafts/ subdirectory, using the same resolve-and-verify jail with two different roots. The agent can read anything for context and change only what's meant to be changed. A misfired write lands in drafts, where a human reviews it before it's promoted — turning "the agent edited the wrong file" into "the agent proposed an edit in the review folder."
This read-wide, write-narrow split maps onto how careful teams already work with junior contributors: full visibility, limited commit rights. The agent gets the context it needs without the authority to break things it shouldn't touch.
⚡ Pro tip: Return a manifest of what an agent wrote at the end of each run — the list of paths it created or modified. A human scanning five paths in drafts/ can approve a change in seconds, and the manifest doubles as a rollback list if something looks wrong. Cheap to produce, and it makes the whole write path reviewable.
A regulated-industry engineer uses exactly this split so an agent can read across a large knowledge base for grounding but can only ever write to an isolated output folder that's version-controlled and reviewed — read access for quality, write confinement for safety.
How to Apply This to Your Situation
The pattern generalizes anywhere an agent touches paths:
- A legal-tech engineer whose agent reads contract files jails access to a single matter's folder, so an agent working case A can never read case B — a confidentiality boundary enforced in code, not policy.
- A game developer letting an agent edit asset configs confines it to the
assets/tree, so a misfired path can't corrupt engine source files elsewhere in the repo. - A healthcare data engineer jails an agent to a de-identified working directory and adds an audit log to every resolved path, satisfying a requirement that access to patient-adjacent files be traceable.
In each case the move is identical: pick a root, resolve every requested path to its real location, and refuse anything that lands outside. The jail is a dozen lines and it converts "we hope the paths are fine" into "escapes are structurally impossible."
That shift — from hoping to proving — is the whole point, and it's what makes an agent you can actually put to work on files that matter.
Next Steps
Audit your own file tools for the os.path.join trap today — pass them an absolute path and a ../../ chain and see what happens. If either escapes, you have the same hole this team did. Add the resolve-and-verify jail, write the three adversarial tests, and you've closed it. It's an hour of work that removes an entire category of incident, and the tests keep it closed as the code changes around it.
Secure agent file system access comes down to never trusting a path you didn't resolve, and the jail function above is small enough to copy into any harness. Pair it with the read-wide, write-narrow split and a per-run manifest, and you have a file layer that's both useful and provably contained. The tool descriptions that go with it — telling the model it works within a fixed root and paths are relative to that root — are worth saving too, because they cut down how often the model produces an escaping path in the first place. Keeping those proven descriptions in a prompt library like PromptABCD, paired with the jail code they assume, means every file-touching agent you build starts contained by default instead of one innocent-looking join away from a leak.
Continue Reading
Save the prompts from this post
PromptABCD is a free prompt manager. Paste, organize, and reuse your best AI prompts — no more hunting through chat history.
