#!/usr/bin/env node /** * Sandboxed File System MCP Server * * Replaces Claude Code's built-in Write and Edit tools with sandboxed versions * that validate all target paths against QIMING_SANDBOX_WRITABLE_ROOTS before * performing file I/O. * * Environment variables: * QIMING_SANDBOX_WRITABLE_ROOTS — JSON array of writable paths * QIMING_SANDBOX_MODE — "strict" | "compat" (permissive skips this MCP entirely) * TEMP / TMP — System temp directories (always allowed) * APPDATA / LOCALAPPDATA — Application data (allowed in compat mode) * * The built-in Write/Edit/NotebookEdit are disabled via * _meta.claudeCode.options.disallowedTools, so this MCP provides the only * way to write files in sandbox mode. * * Tools: * - Write (mcp__sandboxed-fs__Write) * - Edit (mcp__sandboxed-fs__Edit) */ import { readFile, writeFile, mkdir } from "node:fs/promises"; import { existsSync, realpathSync } from "node:fs"; import path from "node:path"; import { Server } from "@modelcontextprotocol/sdk/server/index.js"; import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js"; import { ListToolsRequestSchema, CallToolRequestSchema, } from "@modelcontextprotocol/sdk/types.js"; import { isWithinRoot } from "./sandboxed-bash-security.mjs"; // ---- Configuration from environment ---- // Resolve a path, falling back gracefully if it does not exist yet. function resolveReal(p) { try { return realpathSync(p); } catch { return path.resolve(p); } } const SANDBOX_MODE = process.env.QIMING_SANDBOX_MODE || "strict"; const WRITABLE_ROOTS = JSON.parse( process.env.QIMING_SANDBOX_WRITABLE_ROOTS || "[]", ).map((p) => resolveReal(path.resolve(p))); // Always include TEMP/TMP as writable (matching Rust allow.rs behavior) const ADDITIONAL_ROOTS = [process.env.TEMP, process.env.TMP] .filter(Boolean) .map((p) => resolveReal(path.resolve(p))); // compat mode: also allow APPDATA / LOCALAPPDATA (application data dirs) const COMPAT_ROOTS = SANDBOX_MODE === "compat" ? [process.env.APPDATA, process.env.LOCALAPPDATA] .filter(Boolean) .map((p) => resolveReal(path.resolve(p))) : []; const ALL_WRITABLE_ROOTS = [...WRITABLE_ROOTS, ...ADDITIONAL_ROOTS, ...COMPAT_ROOTS]; // ---- Path validation ---- function validatePath(targetPath) { if (!targetPath || typeof targetPath !== "string") { return { allowed: false, error: "Sandbox: file_path is required" }; } let resolved = path.resolve(targetPath); // Resolve symlinks for defense-in-depth try { resolved = realpathSync(resolved); } catch { // File doesn't exist yet — resolve the parent directory try { const parentReal = realpathSync(path.dirname(resolved)); resolved = path.join(parentReal, path.basename(resolved)); } catch { // Parent also doesn't exist — best effort with path.resolve } } if (ALL_WRITABLE_ROOTS.length === 0) { return { allowed: false, error: `Sandbox: No writable roots configured. Path rejected: ${resolved}`, }; } for (const root of ALL_WRITABLE_ROOTS) { if (isWithinRoot(resolved, root)) { return { allowed: true, resolved }; } } return { allowed: false, error: `Sandbox: Path outside writable roots: ${resolved}. Allowed: ${ALL_WRITABLE_ROOTS.join(", ")}`, }; } // ---- Helpers ---- async function ensureParentDir(filePath) { await mkdir(path.dirname(filePath), { recursive: true }); } // ---- Tool definitions ---- const WRITE_TOOL = { name: "Write", description: `Writes content to a file, creating it if it does not exist. All file paths are validated against the sandbox writable roots. In sessions with mcp__sandboxed-fs__Write always use it instead of the built-in Write tool (which is disabled in sandbox mode).`, inputSchema: { type: "object", properties: { file_path: { type: "string", description: "Absolute path to the file to write (must be within writable roots)", }, content: { type: "string", description: "The content to write to the file", }, }, required: ["file_path", "content"], }, }; const EDIT_TOOL = { name: "Edit", description: `Performs exact string replacements in a file. All file paths are validated against the sandbox writable roots. In sessions with mcp__sandboxed-fs__Edit always use it instead of the built-in Edit tool (which is disabled in sandbox mode).`, inputSchema: { type: "object", properties: { file_path: { type: "string", description: "Absolute path to the file to edit (must be within writable roots)", }, old_string: { type: "string", description: "The text to replace", }, new_string: { type: "string", description: "The text to replace it with", }, replace_all: { type: "boolean", description: "Replace all occurrences of old_string (default false)", }, }, required: ["file_path", "old_string", "new_string"], }, }; // ---- Create MCP server ---- const server = new Server( { name: "sandboxed-fs", version: "1.0.0" }, { capabilities: { tools: {} } }, ); // ---- List tools handler ---- server.setRequestHandler(ListToolsRequestSchema, async () => ({ tools: [WRITE_TOOL, EDIT_TOOL], })); // ---- Call tool handler ---- server.setRequestHandler(CallToolRequestSchema, async (request) => { const { name } = request.params; const args = request.params.arguments || {}; if (name === "Write") { return await handleWrite(args); } else if (name === "Edit") { return await handleEdit(args); } return { content: [{ type: "text", text: `Unknown tool: ${name}` }], isError: true, }; }); // ---- Write handler ---- async function handleWrite(args) { const { file_path, content } = args; if (!file_path || typeof file_path !== "string") { return { content: [{ type: "text", text: "Error: file_path is required" }], isError: true, }; } if (typeof content !== "string") { return { content: [{ type: "text", text: "Error: content must be a string" }], isError: true, }; } const validation = validatePath(file_path); if (!validation.allowed) { return { content: [{ type: "text", text: validation.error }], isError: true }; } try { await ensureParentDir(validation.resolved); await writeFile(validation.resolved, content, "utf-8"); log("file written", { path: validation.resolved, size: content.length }); return { content: [ { type: "text", text: `Successfully wrote to ${validation.resolved}` }, ], isError: false, }; } catch (err) { const msg = err instanceof Error ? err.message : String(err); return { content: [{ type: "text", text: `Write error: ${msg}` }], isError: true, }; } } // ---- Edit handler ---- async function handleEdit(args) { const { file_path, old_string, new_string, replace_all } = args; if (!file_path || typeof file_path !== "string") { return { content: [{ type: "text", text: "Error: file_path is required" }], isError: true, }; } if (!old_string || typeof old_string !== "string") { return { content: [ { type: "text", text: "Error: old_string must be a non-empty string" }, ], isError: true, }; } if (typeof new_string !== "string") { return { content: [ { type: "text", text: "Error: new_string must be a string", }, ], isError: true, }; } const validation = validatePath(file_path); if (!validation.allowed) { return { content: [{ type: "text", text: validation.error }], isError: true }; } try { if (!existsSync(validation.resolved)) { return { content: [ { type: "text", text: `File not found: ${validation.resolved}` }, ], isError: true, }; } const currentContent = await readFile(validation.resolved, "utf-8"); if (!currentContent.includes(old_string)) { return { content: [ { type: "text", text: `old_string not found in file. The exact text was not found.`, }, ], isError: true, }; } let count = 0; let newContent; if (replace_all) { // Count occurrences let idx = 0; while ((idx = currentContent.indexOf(old_string, idx)) !== -1) { count++; idx += old_string.length; } newContent = currentContent.split(old_string).join(new_string); } else { // Check for multiple occurrences when replace_all is not set const firstIdx = currentContent.indexOf(old_string); const secondIdx = currentContent.indexOf(old_string, firstIdx + 1); if (secondIdx !== -1) { const total = currentContent.split(old_string).length - 1; return { content: [ { type: "text", text: `old_string matches ${total} occurrences. Use replace_all: true to replace all, or provide a larger old_string to uniquely identify the location.`, }, ], isError: true, }; } count = 1; newContent = currentContent.replace(old_string, new_string); } await writeFile(validation.resolved, newContent, "utf-8"); log("file edited", { path: validation.resolved, replacements: count, replaceAll: !!replace_all, }); return { content: [ { type: "text", text: `Successfully edited ${validation.resolved} (${count} replacement${count > 1 ? "s" : ""})`, }, ], isError: false, }; } catch (err) { const msg = err instanceof Error ? err.message : String(err); return { content: [{ type: "text", text: `Edit error: ${msg}` }], isError: true, }; } } // ---- Logging (to stderr — stdout is MCP JSON-RPC) ---- function log(message, data) { process.stderr.write( `[sandboxed-fs] ${message}${data ? " " + JSON.stringify(data) : ""}\n`, ); } // ---- Start ---- const transport = new StdioServerTransport(); await server.connect(transport); log("ready", { mode: SANDBOX_MODE, writableRoots: ALL_WRITABLE_ROOTS, });