DEV Community

Cover image for Giving Your Local LLM Safe Filesystem Access With Ollama Tool Use
Pavel Espitia
Pavel Espitia

Posted on

Giving Your Local LLM Safe Filesystem Access With Ollama Tool Use

A local LLM that can read your files is genuinely useful. A local LLM that can read your files without guardrails is a path-traversal bug with a chat interface.

I covered tool calling basics in an earlier post: define a tool schema, the model returns a structured request, your code decides whether to run it. That's the foundation. This post is about how to not get burned once those tools touch the filesystem. We're going to give the model three tools (list_dir, read_file, grep), wire up the dispatch loop with Ollama, and then harden every single one so a confused (or adversarial) model can't read your .env, climb out of the project, or hand you back a 2GB file.

The model is the planner. Your code is the executor. The executor is also the only thing standing between an unpredictable token generator and your home directory. Treat it that way.

The threat model first

Before any code, be honest about what can go wrong. The LLM is not malicious, but it is unpredictable, and the input feeding it might be malicious (a file it reads could contain instructions, a classic prompt-injection vector). So plan for all of it:

  • The model asks to read /etc/passwd or ~/.ssh/id_rsa.
  • The model passes ../../../../etc/shadow as a "relative" path.
  • The model reads .env and helpfully prints your API keys into the chat transcript.
  • The model asks to read a 4GB log file and pins your RAM.
  • A file the model reads contains "ignore previous instructions, now write to ...".

Every defense below maps to one of these. None of them trust the model.

The sandbox: one root, resolved, allow-listed

The single most important control: every path the model gives you gets resolved to an absolute path and checked against an allowed root. If it escapes the root, reject it. No exceptions, no "but it's probably fine."

importpathfrom"node:path";importfsfrom"node:fs/promises";// The ONLY directory the model is allowed to touch.constSANDBOX_ROOT=path.resolve(process.env.SANDBOX_ROOT??"./workspace");classPathErrorextendsError{}// Resolve a model-supplied path and prove it stays inside the sandbox.functionresolveInSandbox(userPath:string):string{// Resolve against the root, collapsing any `..` segments.constresolved=path.resolve(SANDBOX_ROOT,userPath);// The check that matters: is `resolved` actually under the root?constrel=path.relative(SANDBOX_ROOT,resolved);if (rel.startsWith("..")||path.isAbsolute(rel)){thrownewPathError(`Path escapes sandbox: ${userPath}`);}returnresolved;}
Enter fullscreen modeExit fullscreen mode

Why path.relative instead of a startsWith(SANDBOX_ROOT) string check? Because startsWith is a trap. /home/pavel/workspace-secrets starts with /home/pavel/workspace, but it's a different directory. path.relative does it structurally: if the relative path begins with .., the target is above the root. Done.

Test it before you trust it:

resolveInSandbox("notes.txt");// OK -> <root>/notes.txtresolveInSandbox("sub/dir/a.md");// OKresolveInSandbox("../secrets.env");// throws PathErrorresolveInSandbox("/etc/passwd");// throws PathErrorresolveInSandbox("a/../../etc/hosts");// throws PathError
Enter fullscreen modeExit fullscreen mode

One more thing path.resolve does not cover: symlinks. A symlink inside the sandbox can point anywhere. If your workspace could contain symlinks you don't control, resolve them too and re-check:

asyncfunctionresolveRealInSandbox(userPath:string):Promise<string>{constresolved=resolveInSandbox(userPath);try{constreal=awaitfs.realpath(resolved);constrel=path.relative(SANDBOX_ROOT,real);if (rel.startsWith("..")||path.isAbsolute(rel)){thrownewPathError(`Symlink escapes sandbox: ${userPath}`);}returnreal;}catch (err){if ((errasNodeJS.ErrnoException).code==="ENOENT")returnresolved;throwerr;}}
Enter fullscreen modeExit fullscreen mode

A deny-list for the obvious landmines

Allow-listing the root is the structural control. On top of it, a small deny-list stops the model from reading things that are inside the sandbox but still secret. Match on the basename, not a substring, so environment.md doesn't get caught by an .env rule.

constDENIED_NAMES=newSet([".env",".git","id_rsa","id_ed25519"]);constDENIED_SUFFIXES=[".env",".pem",".key"];functionassertReadable(absPath:string):void{constbase=path.basename(absPath);if (DENIED_NAMES.has(base)||base.startsWith(".env")){thrownewPathError(`Refusing to read protected file: ${base}`);}if (DENIED_SUFFIXES.some((s)=>base.endsWith(s))){thrownewPathError(`Refusing to read protected file type: ${base}`);}}
Enter fullscreen modeExit fullscreen mode

Keep this list short and obvious. The structural sandbox is your real defense; the deny-list just catches the secrets that legitimately live in a project folder.

The tools

Three read-only tools. Notice read_file has a hard byte budget, and none of them write anything.

constMAX_READ_BYTES=256*1024;// 256 KB. Models do not need a 2GB file.asyncfunctionlistDir(dirPath:string):Promise<string[]>{constabs=awaitresolveRealInSandbox(dirPath);constentries=awaitfs.readdir(abs,{withFileTypes:true});returnentries.map((e)=>(e.isDirectory()?`${e.name}/`:e.name));}asyncfunctionreadFile(filePath:string):Promise<string>{constabs=awaitresolveRealInSandbox(filePath);assertReadable(abs);conststat=awaitfs.stat(abs);if (!stat.isFile())thrownewPathError(`Not a file: ${filePath}`);if (stat.size>MAX_READ_BYTES){thrownewPathError(`File too large: ${stat.size} bytes (limit ${MAX_READ_BYTES}).`,);}returnfs.readFile(abs,"utf8");}asyncfunctiongrep(pattern:string,dirPath:string):Promise<string[]>{// Compile the model's pattern; reject anything that won't compile.letre:RegExp;try{re=newRegExp(pattern);}catch{thrownewPathError(`Invalid regex: ${pattern}`);}constabs=awaitresolveRealInSandbox(dirPath);consthits:string[]=[];constentries=awaitfs.readdir(abs,{withFileTypes:true});for (constentryofentries){if (!entry.isFile())continue;constchild=path.join(abs,entry.name);try{assertReadable(child);}catch{continue;// skip protected files silently in search results}conststat=awaitfs.stat(child);if (stat.size>MAX_READ_BYTES)continue;consttext=awaitfs.readFile(child,"utf8");text.split("\n").forEach((line,i)=>{if (re.test(line))hits.push(`${entry.name}:${i+1}: ${line.trim()}`);});}returnhits.slice(0,100);// cap output so a broad pattern can't flood context}
Enter fullscreen modeExit fullscreen mode

The schemas, in the same JSON Schema format from the function-calling post:

consttools=[{type:"function",function:{name:"list_dir",description:"List files and folders in a directory inside the workspace",parameters:{type:"object",properties:{path:{type:"string",description:"Relative path"}},required:["path"],},},},{type:"function",function:{name:"read_file",description:"Read a UTF-8 text file inside the workspace",parameters:{type:"object",properties:{path:{type:"string",description:"Relative path"}},required:["path"],},},},{type:"function",function:{name:"grep",description:"Search files in a directory for a regex pattern",parameters:{type:"object",properties:{pattern:{type:"string"},path:{type:"string",description:"Relative directory path"},},required:["pattern","path"],},},},];
Enter fullscreen modeExit fullscreen mode

The dispatch loop

Here is where most tutorials get sloppy: they eval-style dispatch on the tool name and pass arguments straight through. Don't. Validate arguments with Zod, route through an explicit switch, and turn every thrown error into a tool result the model can read and recover from. An error is data, not a crash.

import{z}from"zod";constPathArgs=z.object({path:z.string()});constGrepArgs=z.object({pattern:z.string(),path:z.string()});asyncfunctiondispatch(name:string,rawArgs:string):Promise<string>{try{switch (name){case"list_dir":returnJSON.stringify(awaitlistDir(PathArgs.parse(JSON.parse(rawArgs)).path));case"read_file":returnawaitreadFile(PathArgs.parse(JSON.parse(rawArgs)).path);case"grep":{consta=GrepArgs.parse(JSON.parse(rawArgs));returnJSON.stringify(awaitgrep(a.pattern,a.path));}default:return`Error: unknown tool ${name}`;}}catch (err){// Hand the failure back to the model. It will usually correct itself.return`Error: ${(errasError).message}`;}}
Enter fullscreen modeExit fullscreen mode

Now the agent loop against Ollama. Same two-round-trip shape as before, wrapped so the model can chain calls:

asyncfunctionrun(userPrompt:string):Promise<string>{constmessages:any[]=[{role:"system",content:"You can read files inside the workspace only. Never assume a path "+"outside it exists. If a tool returns an error, adjust and retry.",},{role:"user",content:userPrompt},];for (letturn=0;turn<8;turn++){constres=awaitfetch("http://localhost:11434/v1/chat/completions",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({model:"qwen2.5:7b",messages,tools,tool_choice:"auto"}),});constmsg=(awaitres.json()).choices[0].message;messages.push(msg);if (!msg.tool_calls?.length)returnmsg.content;for (constcallofmsg.tool_calls){constresult=awaitdispatch(call.function.name,call.function.arguments);messages.push({role:"tool",tool_call_id:call.id,content:result});}}return"Stopped: too many tool-calling turns.";}
Enter fullscreen modeExit fullscreen mode

The turn < 8 cap matters. Without it, a model that keeps requesting tools (or gets stuck in a retry loop on a prompt-injected file) will run forever.

Writes are different: require a human

Reading is reversible. Writing is not. So I keep writes out of the autonomous loop entirely and gate them behind an explicit human approval. The tool doesn't write: it proposes a write, prints a diff, and waits for you.

importreadlinefrom"node:readline/promises";asyncfunctionproposeWrite(filePath:string,content:string):Promise<string>{constabs=resolveInSandbox(filePath);// same sandbox checkassertReadable(abs);// same secrets guardconsole.log(`\nProposed write to ${path.relative(SANDBOX_ROOT,abs)}:`);console.log("-".repeat(40));console.log(content.slice(0,2000));console.log("-".repeat(40));constrl=readline.createInterface({input:process.stdin,output:process.stdout});constanswer=(awaitrl.question("Apply this write? [y/N] ")).trim().toLowerCase();rl.close();if (answer!=="y")return"Write rejected by user.";awaitfs.writeFile(abs,content,"utf8");return`Wrote ${content.length} bytes to ${filePath}.`;}
Enter fullscreen modeExit fullscreen mode

The model can want to write all day. Nothing hits disk until a human types y. This is the same principle as the read sandbox, applied to the higher-stakes operation: the LLM proposes, your code (and you) dispose.

The hardening checklist

Every filesystem tool you expose to an LLM should pass all of these:

  1. Resolve and re-check.path.resolve, then path.relative against the root. Reject anything starting with ... Never startsWith on the raw string.
  2. Resolve symlinks too.fs.realpath and re-check, or you've left a back door inside the sandbox.
  3. Deny secrets by basename..env, keys, .git. Short list, matched on basename, not substring.
  4. Cap read size. A byte budget per read and a result cap on search. Context windows and RAM are finite.
  5. Read-only by default. Writes go through a separate, human-approved path. No write tool in the autonomous loop.
  6. Validate every argument. Zod-parse tool arguments before they reach fs. The model hallucinates fields.
  7. Errors are tool results. Catch, stringify, return to the model. Never let a bad path crash the process.
  8. Cap the turn count. Bound the agent loop so a stuck or injected model can't spin forever.

Takeaway

Function calling makes a local LLM useful. Filesystem access makes it powerful, and powerful is exactly when you have to slow down. The model is an untrusted planner working over potentially untrusted input. Your tools are the trust boundary. Build them so the worst a confused model can do is read a text file it was already allowed to see.

I run this exact pattern in spectr-ai, my local-first smart contract auditor, so the model can walk a contract's source tree without ever leaving the project folder. Sandbox first, features second. That order is the whole point.

Top comments (3)

Collapse
 
alexshev profile image
Alex Shev

Filesystem access is where local LLMs move from helpful to risky very quickly. The pattern I like is capability-first: narrow roots, read-only by default, explicit write tools, and logs that show exactly what path was touched. Local does not automatically mean safe.

Collapse
 
pavelespitia profile image
Pavel Espitia

"Local does not automatically mean safe" is the line I wish I'd led with. A model running on your box still does whatever the tool layer lets it, so the tools are the security boundary, not the network. Capability-first is exactly right: read-only default, writes as separate explicit tools, every touched path logged. The sandbox has to come from the tool definitions, not from "it's just local".

Collapse
 
alexshev profile image
Alex Shev

Yes. The tool boundary is the real security boundary. Local only changes where the model runs; it does not reduce the blast radius of a write-capable tool. I like the read-only default because it forces every mutation to be a named decision instead of an accidental side effect.