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/passwdThe 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)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"
ROOTos.path.join("/home/app/docs", "/etc/passwd")/etc/passwdos.path.joinAnd even relative paths escape:
os.path.join(ROOT, "../../../etc/passwd")⚠️ Common mistake: Assuming
os.path.join(root, user_path)rootuser_path..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
..realpath..⚡ Pro tip: The
startswith(ROOT + os.sep)/home/app/docs-secret/home/app/docsResults and What Changed
After the change, the escape simply stopped being possible. The team wrote a handful of adversarial tests — absolute paths,
..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⚡ Pro tip: Handle symlink races explicitly if your jail holds anything sensitive. Between the
realpathopenO_NOFOLLOWSeparating 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/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/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 tree, so a misfired path can't corrupt engine source files elsewhere in the repo.
assets/ - 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../../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
joinContinue 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.
