{"slug": "giving-your-local-llm-safe-filesystem-access-with-ollama-tool-use", "title": "Giving Your Local LLM Safe Filesystem Access With Ollama Tool Use", "summary": "A developer has created a secure filesystem access system for local LLMs using Ollama tool use, implementing path traversal protections that prevent models from reading files outside a designated sandbox directory. The system uses three tools (`list_dir`, `read_file`, `grep`) with a `resolveInSandbox` function that checks resolved absolute paths against an allowed root using `path.relative` rather than string matching, and also handles symlink traversal by resolving real paths and re-checking against the sandbox boundary.", "body_md": "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.\n\nI 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`\n\n, `read_file`\n\n, `grep`\n\n), wire up the dispatch loop with Ollama, and then harden every single one so a confused (or adversarial) model can't read your `.env`\n\n, climb out of the project, or hand you back a 2GB file.\n\nThe 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.\n\nBefore 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:\n\n`/etc/passwd`\n\nor `~/.ssh/id_rsa`\n\n.`../../../../etc/shadow`\n\nas a \"relative\" path.`.env`\n\nand helpfully prints your API keys into the chat transcript.Every defense below maps to one of these. None of them trust the model.\n\nThe 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.\"\n\n``` python\nimport path from \"node:path\";\nimport fs from \"node:fs/promises\";\n\n// The ONLY directory the model is allowed to touch.\nconst SANDBOX_ROOT = path.resolve(process.env.SANDBOX_ROOT ?? \"./workspace\");\n\nclass PathError extends Error {}\n\n// Resolve a model-supplied path and prove it stays inside the sandbox.\nfunction resolveInSandbox(userPath: string): string {\n  // Resolve against the root, collapsing any `..` segments.\n  const resolved = path.resolve(SANDBOX_ROOT, userPath);\n\n  // The check that matters: is `resolved` actually under the root?\n  const rel = path.relative(SANDBOX_ROOT, resolved);\n  if (rel.startsWith(\"..\") || path.isAbsolute(rel)) {\n    throw new PathError(`Path escapes sandbox: ${userPath}`);\n  }\n  return resolved;\n}\n```\n\nWhy `path.relative`\n\ninstead of a `startsWith(SANDBOX_ROOT)`\n\nstring check? Because `startsWith`\n\nis a trap. `/home/pavel/workspace-secrets`\n\nstarts with `/home/pavel/workspace`\n\n, but it's a different directory. `path.relative`\n\ndoes it structurally: if the relative path begins with `..`\n\n, the target is above the root. Done.\n\nTest it before you trust it:\n\n``` php\nresolveInSandbox(\"notes.txt\");        // OK -> <root>/notes.txt\nresolveInSandbox(\"sub/dir/a.md\");     // OK\nresolveInSandbox(\"../secrets.env\");   // throws PathError\nresolveInSandbox(\"/etc/passwd\");      // throws PathError\nresolveInSandbox(\"a/../../etc/hosts\");// throws PathError\n```\n\nOne more thing `path.resolve`\n\ndoes 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:\n\n```\nasync function resolveRealInSandbox(userPath: string): Promise<string> {\n  const resolved = resolveInSandbox(userPath);\n  try {\n    const real = await fs.realpath(resolved);\n    const rel = path.relative(SANDBOX_ROOT, real);\n    if (rel.startsWith(\"..\") || path.isAbsolute(rel)) {\n      throw new PathError(`Symlink escapes sandbox: ${userPath}`);\n    }\n    return real;\n  } catch (err) {\n    if ((err as NodeJS.ErrnoException).code === \"ENOENT\") return resolved;\n    throw err;\n  }\n}\n```\n\nAllow-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`\n\ndoesn't get caught by an `.env`\n\nrule.\n\n``` js\nconst DENIED_NAMES = new Set([\".env\", \".git\", \"id_rsa\", \"id_ed25519\"]);\nconst DENIED_SUFFIXES = [\".env\", \".pem\", \".key\"];\n\nfunction assertReadable(absPath: string): void {\n  const base = path.basename(absPath);\n  if (DENIED_NAMES.has(base) || base.startsWith(\".env\")) {\n    throw new PathError(`Refusing to read protected file: ${base}`);\n  }\n  if (DENIED_SUFFIXES.some((s) => base.endsWith(s))) {\n    throw new PathError(`Refusing to read protected file type: ${base}`);\n  }\n}\n```\n\nKeep 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.\n\nThree read-only tools. Notice `read_file`\n\nhas a hard byte budget, and none of them write anything.\n\n``` js\nconst MAX_READ_BYTES = 256 * 1024; // 256 KB. Models do not need a 2GB file.\n\nasync function listDir(dirPath: string): Promise<string[]> {\n  const abs = await resolveRealInSandbox(dirPath);\n  const entries = await fs.readdir(abs, { withFileTypes: true });\n  return entries.map((e) => (e.isDirectory() ? `${e.name}/` : e.name));\n}\n\nasync function readFile(filePath: string): Promise<string> {\n  const abs = await resolveRealInSandbox(filePath);\n  assertReadable(abs);\n\n  const stat = await fs.stat(abs);\n  if (!stat.isFile()) throw new PathError(`Not a file: ${filePath}`);\n  if (stat.size > MAX_READ_BYTES) {\n    throw new PathError(\n      `File too large: ${stat.size} bytes (limit ${MAX_READ_BYTES}).`,\n    );\n  }\n  return fs.readFile(abs, \"utf8\");\n}\n\nasync function grep(pattern: string, dirPath: string): Promise<string[]> {\n  // Compile the model's pattern; reject anything that won't compile.\n  let re: RegExp;\n  try {\n    re = new RegExp(pattern);\n  } catch {\n    throw new PathError(`Invalid regex: ${pattern}`);\n  }\n\n  const abs = await resolveRealInSandbox(dirPath);\n  const hits: string[] = [];\n  const entries = await fs.readdir(abs, { withFileTypes: true });\n\n  for (const entry of entries) {\n    if (!entry.isFile()) continue;\n    const child = path.join(abs, entry.name);\n    try {\n      assertReadable(child);\n    } catch {\n      continue; // skip protected files silently in search results\n    }\n    const stat = await fs.stat(child);\n    if (stat.size > MAX_READ_BYTES) continue;\n\n    const text = await fs.readFile(child, \"utf8\");\n    text.split(\"\\n\").forEach((line, i) => {\n      if (re.test(line)) hits.push(`${entry.name}:${i + 1}: ${line.trim()}`);\n    });\n  }\n  return hits.slice(0, 100); // cap output so a broad pattern can't flood context\n}\n```\n\nThe schemas, in the same JSON Schema format from the function-calling post:\n\n``` js\nconst tools = [\n  {\n    type: \"function\",\n    function: {\n      name: \"list_dir\",\n      description: \"List files and folders in a directory inside the workspace\",\n      parameters: {\n        type: \"object\",\n        properties: { path: { type: \"string\", description: \"Relative path\" } },\n        required: [\"path\"],\n      },\n    },\n  },\n  {\n    type: \"function\",\n    function: {\n      name: \"read_file\",\n      description: \"Read a UTF-8 text file inside the workspace\",\n      parameters: {\n        type: \"object\",\n        properties: { path: { type: \"string\", description: \"Relative path\" } },\n        required: [\"path\"],\n      },\n    },\n  },\n  {\n    type: \"function\",\n    function: {\n      name: \"grep\",\n      description: \"Search files in a directory for a regex pattern\",\n      parameters: {\n        type: \"object\",\n        properties: {\n          pattern: { type: \"string\" },\n          path: { type: \"string\", description: \"Relative directory path\" },\n        },\n        required: [\"pattern\", \"path\"],\n      },\n    },\n  },\n];\n```\n\nHere is where most tutorials get sloppy: they `eval`\n\n-style dispatch on the tool name and pass arguments straight through. Don't. Validate arguments with Zod, route through an explicit `switch`\n\n, and turn every thrown error into a tool result the model can read and recover from. An error is data, not a crash.\n\n``` js\nimport { z } from \"zod\";\n\nconst PathArgs = z.object({ path: z.string() });\nconst GrepArgs = z.object({ pattern: z.string(), path: z.string() });\n\nasync function dispatch(name: string, rawArgs: string): Promise<string> {\n  try {\n    switch (name) {\n      case \"list_dir\":\n        return JSON.stringify(await listDir(PathArgs.parse(JSON.parse(rawArgs)).path));\n      case \"read_file\":\n        return await readFile(PathArgs.parse(JSON.parse(rawArgs)).path);\n      case \"grep\": {\n        const a = GrepArgs.parse(JSON.parse(rawArgs));\n        return JSON.stringify(await grep(a.pattern, a.path));\n      }\n      default:\n        return `Error: unknown tool ${name}`;\n    }\n  } catch (err) {\n    // Hand the failure back to the model. It will usually correct itself.\n    return `Error: ${(err as Error).message}`;\n  }\n}\n```\n\nNow the agent loop against Ollama. Same two-round-trip shape as before, wrapped so the model can chain calls:\n\n``` js\nasync function run(userPrompt: string): Promise<string> {\n  const messages: any[] = [\n    {\n      role: \"system\",\n      content:\n        \"You can read files inside the workspace only. Never assume a path \" +\n        \"outside it exists. If a tool returns an error, adjust and retry.\",\n    },\n    { role: \"user\", content: userPrompt },\n  ];\n\n  for (let turn = 0; turn < 8; turn++) {\n    const res = await fetch(\"http://localhost:11434/v1/chat/completions\", {\n      method: \"POST\",\n      headers: { \"Content-Type\": \"application/json\" },\n      body: JSON.stringify({ model: \"qwen2.5:7b\", messages, tools, tool_choice: \"auto\" }),\n    });\n    const msg = (await res.json()).choices[0].message;\n    messages.push(msg);\n\n    if (!msg.tool_calls?.length) return msg.content;\n\n    for (const call of msg.tool_calls) {\n      const result = await dispatch(call.function.name, call.function.arguments);\n      messages.push({ role: \"tool\", tool_call_id: call.id, content: result });\n    }\n  }\n  return \"Stopped: too many tool-calling turns.\";\n}\n```\n\nThe `turn < 8`\n\ncap matters. Without it, a model that keeps requesting tools (or gets stuck in a retry loop on a prompt-injected file) will run forever.\n\nReading 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.\n\n``` python\nimport readline from \"node:readline/promises\";\n\nasync function proposeWrite(filePath: string, content: string): Promise<string> {\n  const abs = resolveInSandbox(filePath);   // same sandbox check\n  assertReadable(abs);                       // same secrets guard\n\n  console.log(`\\nProposed write to ${path.relative(SANDBOX_ROOT, abs)}:`);\n  console.log(\"-\".repeat(40));\n  console.log(content.slice(0, 2000));\n  console.log(\"-\".repeat(40));\n\n  const rl = readline.createInterface({ input: process.stdin, output: process.stdout });\n  const answer = (await rl.question(\"Apply this write? [y/N] \")).trim().toLowerCase();\n  rl.close();\n\n  if (answer !== \"y\") return \"Write rejected by user.\";\n  await fs.writeFile(abs, content, \"utf8\");\n  return `Wrote ${content.length} bytes to ${filePath}.`;\n}\n```\n\nThe model can *want* to write all day. Nothing hits disk until a human types `y`\n\n. This is the same principle as the read sandbox, applied to the higher-stakes operation: the LLM proposes, your code (and you) dispose.\n\nEvery filesystem tool you expose to an LLM should pass all of these:\n\n`path.resolve`\n\n, then `path.relative`\n\nagainst the root. Reject anything starting with `..`\n\n. Never `startsWith`\n\non the raw string.`fs.realpath`\n\nand re-check, or you've left a back door inside the sandbox.`.env`\n\n, keys, `.git`\n\n. Short list, matched on basename, not substring.`fs`\n\n. The model hallucinates fields.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.\n\nI run this exact pattern in [spectr-ai](https://github.com/pavelEspitia/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.", "url": "https://wpnews.pro/news/giving-your-local-llm-safe-filesystem-access-with-ollama-tool-use", "canonical_source": "https://dev.to/pavelespitia/giving-your-local-llm-safe-filesystem-access-with-ollama-tool-use-4o79", "published_at": "2026-06-12 15:14:08+00:00", "updated_at": "2026-06-12 15:41:05.074251+00:00", "lang": "en", "topics": ["large-language-models", "ai-safety", "ai-agents", "ai-tools", "ai-infrastructure"], "entities": ["Ollama"], "alternates": {"html": "https://wpnews.pro/news/giving-your-local-llm-safe-filesystem-access-with-ollama-tool-use", "markdown": "https://wpnews.pro/news/giving-your-local-llm-safe-filesystem-access-with-ollama-tool-use.md", "text": "https://wpnews.pro/news/giving-your-local-llm-safe-filesystem-access-with-ollama-tool-use.txt", "jsonld": "https://wpnews.pro/news/giving-your-local-llm-safe-filesystem-access-with-ollama-tool-use.jsonld"}}