Provide Sandboxed Filesystem Access

Expose the fs module to agent code, but restrict it to a known directory with policy.

This example allows read, write, append, list, stat, create, remove, rename, copy, and exists checks only under /tmp/allowed/.

1. Write the filesystem policy

Create a local Rego policy:

cat > fs-policy.rego <<'EOF'
package mcp.filesystem

default allow = false

allow if {
  input.operation in [
    "readFile",
    "writeFile",
    "appendFile",
    "readdir",
    "stat",
    "exists",
    "mkdir",
    "rm"
  ]
  startswith(input.path, "/tmp/allowed/")
}

allow if {
  input.operation in ["rename", "copyFile"]
  startswith(input.path, "/tmp/allowed/")
  startswith(input.destination, "/tmp/allowed/")
}
EOF

This follows the runtime's filesystem policy path: data.mcp.filesystem.allow.

2. Point --policies-json at that policy

FS_POLICY_PATH="$(pwd)/fs-policy.rego"

cat > policies.json <<EOF
{
  "filesystem": {
    "policies": [
      {
        "url": "file://${FS_POLICY_PATH}",
        "rule": "data.mcp.filesystem.allow"
      }
    ]
  }
}
EOF

When filesystem is configured, mcp-v8 injects the fs module into the JavaScript runtime and checks each operation against the policy before execution.

3. Start the server

Use HTTP mode for a quick end-to-end test:

mcp-v8 \
  --stateless \
  --http-port 3000 \
  --policies-json ./policies.json

The same --policies-json file also works when mcp-v8 is used as an MCP server or driven through mcp-v8-cli.

4. Run a permitted filesystem session

Write a small script that stays inside the allowed directory:

cat > fs-demo.js <<'EOF'
await fs.mkdir("/tmp/allowed/demo", { recursive: true });
await fs.writeFile("/tmp/allowed/demo/note.txt", "hello from mcp-v8");
await fs.appendFile("/tmp/allowed/demo/note.txt", "\nsecond line");

const text = await fs.readFile("/tmp/allowed/demo/note.txt");
const entries = await fs.readdir("/tmp/allowed/demo");
const stats = await fs.stat("/tmp/allowed/demo/note.txt");
const exists = await fs.exists("/tmp/allowed/demo/note.txt");

let denied;
try {
  await fs.writeFile("/tmp/blocked.txt", "should fail");
} catch (e) {
  denied = e.message;
}

console.log(JSON.stringify({
  text,
  entries,
  size: stats.size,
  exists,
  denied
}));
EOF

Submit it:

EXECUTION_ID="$(
  jq -Rs '{code: .}' < fs-demo.js \
    | curl -s http://localhost:3000/api/exec \
        -H 'content-type: application/json' \
        -d @- \
    | jq -r '.execution_id'
)"

Wait for completion and print the output:

while true; do
  STATUS="$(curl -s "http://localhost:3000/api/executions/${EXECUTION_ID}" | jq -r '.status')"
  if [ "${STATUS}" = "completed" ] || [ "${STATUS}" = "Completed" ]; then
    break
  fi
  sleep 1
done

curl -s "http://localhost:3000/api/executions/${EXECUTION_ID}/output" | jq -r '.data'

Expected shape:

{"text":"hello from mcp-v8\nsecond line","entries":["note.txt"],"size":29,"exists":true,"denied":"fs.writeFile denied by policy"}

The exact size may differ if you change the file contents, but the denied case should still include denied by policy.

What this setup gives you

  • agent code can use familiar fs.* calls
  • policy decides which paths and operations are allowed
  • rename and copy can require both source and destination to stay in the sandbox
  • the same policy file works across HTTP, CLI, and MCP entry points

For the capability model behind this setup, see Filesystem Access. For the JSON config shape, see Policy Files.