UNPKG

@slathar-dev/mcp-sensitive-read

Version:

MCP server for secure file reading within project boundaries

243 lines 11.9 kB
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js"; import { z } from "zod"; import fs from "node:fs/promises"; import path from "node:path"; import os from "node:os"; import { GitLeaksManager } from "./gitleaks-manager.js"; // Create a file-based logger for debugging const logFilePath = path.join(os.tmpdir(), "mcp-sensitive-read-debug.log"); const debugLog = async (message) => { try { const timestamp = new Date().toISOString(); await fs.appendFile(logFilePath, `[${timestamp}] ${message}\n`, "utf8"); } catch (error) { // Ignore logging errors to not interfere with MCP protocol } }; // Write security violations to JSONL file for OTEL collector const writeViolationLog = async (eventType, actionTaken, reason, filePath, policyAction, secretsRedacted, patternMatched) => { try { const logEntry = { timestamp: new Date().toISOString(), event_type: eventType, event_source: "mcp-server", action_taken: actionTaken, reason: reason, file_path: filePath, file_name: filePath ? path.basename(filePath) : null, policy_action: policyAction, secrets_redacted: secretsRedacted, pattern_matched: patternMatched, tool_name: "mcp__sensitive-read__read_file", user_email: process.env.USER_EMAIL, user_id: process.env.USER_ID, machine_id: process.env.MACHINE_ID, session_id: process.env.SESSION_ID }; // Ensure violations directory exists const violationsDir = path.join(os.homedir(), ".claude", ".violations"); await fs.mkdir(violationsDir, { recursive: true }); // Write JSONL entry to file const violationsFile = path.join(violationsDir, ".violations.jsonl"); await fs.appendFile(violationsFile, JSON.stringify(logEntry) + "\n", "utf8"); await debugLog(`Violation logged to ${violationsFile}`); } catch (error) { await debugLog(`Failed to write violation log: ${error instanceof Error ? error.message : String(error)}`); // Don't fail the operation if logging fails - core functionality continues } }; const server = new McpServer({ name: "sensitive-read", version: "0.7.0" }); // Initialize GitLeaks manager const gitleaksManager = new GitLeaksManager({ maxFileSize: 50 * 1024 * 1024, // 50MB max file size enableCache: true, cacheTimeout: 10 * 60 * 1000, // 10 minutes cache timeout }); // Initialize Gitleaks on startup let gitleaksInitialized = false; let gitleaksInitPromise; const initializeGitleaks = async () => { await debugLog("Starting Gitleaks initialization..."); try { await gitleaksManager.initialize(); gitleaksInitialized = true; await debugLog("Gitleaks manager initialized successfully"); } catch (error) { await debugLog(`Failed to initialize Gitleaks manager: ${error instanceof Error ? error.message : String(error)}`); await debugLog("Files will be returned without secret scanning!"); } }; gitleaksInitPromise = initializeGitleaks(); // --- Schema: identical names/defaults to Serena's read_file tool ---------------- // Source: https://glama.ai/mcp/servers/@oraios/serena/tools/read_file // Helper: ensure target path stays within the project root function ensureWithin(baseDir, absolutePath) { const base = path.resolve(baseDir) + path.sep; const real = path.resolve(absolutePath); return real.startsWith(base); } // Base directory = project root (Serena reads project files) // You can override with SAFE_PROJECT_ROOT if you like, but cwd is typical. const getProjectRoot = () => process.env.SAFE_PROJECT_ROOT ?? process.cwd(); // Load app config to check for blockRead policies async function loadAppConfig() { try { const homeDir = os.homedir(); const configPath = path.join(homeDir, ".claude", ".app-config", "app-config.json"); await debugLog(`Attempting to load config from: ${configPath}`); const configContent = await fs.readFile(configPath, "utf8"); const config = JSON.parse(configContent); await debugLog(`Config loaded successfully with ${config.sensitiveFiles?.length || 0} sensitive file rules`); return config; } catch (error) { await debugLog(`Failed to load app config: ${error instanceof Error ? error.message : String(error)}`); return null; } } // Check if a file should be blocked from reading function shouldBlockRead(filePath, config) { if (!config?.sensitiveFiles) { return false; } const filename = path.basename(filePath); const normalizedPath = filePath.replace(/\\/g, '/'); // Normalize path separators for (const rule of config.sensitiveFiles) { if (rule.action === "blockRead") { // Check against filename for simple patterns (exact match or wildcards) if (rule.filename === filename) { return true; } // Handle directory patterns if (rule.filename.includes("/")) { const pattern = rule.filename; // Remove leading slash if present for relative path matching const cleanPattern = pattern.startsWith("/") ? pattern.substring(1) : pattern; // Check if the file path starts with the directory pattern if (normalizedPath.startsWith(cleanPattern)) { return true; } // Also check if the normalized path contains the full pattern if (normalizedPath.includes(pattern)) { return true; } } } } return false; } // --- Tool registration --------------------------------------------------------- server.registerTool("read_file", { title: "read_file", description: "Reads the given file or a chunk of it. Returns the full text of the file at the given relative path.", inputSchema: { relative_path: z.string().describe("Path relative to project root"), start_line: z.number().int().default(0).describe("0-based index of first line to return"), end_line: z.union([z.number().int(), z.null()]).default(null).describe("0-based index of last line (inclusive); if null, read to end of file"), max_answer_chars: z.number().int().default(200000).describe("If the resulting content exceeds this length, no content is returned") } }, async (args) => { const baseDir = getProjectRoot(); const abs = path.resolve(baseDir, args.relative_path); // Mirror Serena's project scoping: read files within the project only if (!ensureWithin(baseDir, abs)) { throw new Error("read_file: path escapes project root"); } // Load config and check for blockRead policy const config = await loadAppConfig(); if (shouldBlockRead(args.relative_path, config)) { await debugLog(`Blocked read access to file due to blockRead policy: ${args.relative_path}`); // Find which pattern matched let matchedPattern = undefined; if (config?.sensitiveFiles) { const filename = path.basename(args.relative_path); for (const rule of config.sensitiveFiles) { if (rule.action === "blockRead") { if (rule.filename === filename || (rule.filename.includes("/") && args.relative_path.includes(rule.filename))) { matchedPattern = rule.filename; break; } } } } // Log violation await writeViolationLog("file_read_denied", "denied", `Access to file blocked by blockRead security policy`, args.relative_path, "blockRead", undefined, matchedPattern); throw new Error(`Access to file '${path.basename(args.relative_path)}' is blocked by security policy`); } // Read UTF-8 text; if file doesn't exist or can't be read, throw (client shows error) const fullText = await fs.readFile(abs, "utf8"); // Scan and redact secrets from the full content await debugLog(`About to scan file ${args.relative_path}, gitleaksInitialized=${gitleaksInitialized}`); // Wait for Gitleaks initialization to complete if it's still in progress if (!gitleaksInitialized) { await debugLog(`Waiting for Gitleaks initialization to complete for ${args.relative_path}`); await gitleaksInitPromise; await debugLog(`Gitleaks initialization wait complete, gitleaksInitialized=${gitleaksInitialized}`); } let scannedText = fullText; if (gitleaksInitialized) { try { await debugLog(`Calling scanAndRedactContent for ${args.relative_path}`); scannedText = await gitleaksManager.scanAndRedactContent(abs, fullText); await debugLog(`Scanning complete for ${args.relative_path}, content changed: ${scannedText !== fullText}`); // If secrets were found and redacted, log this (but don't expose details) if (scannedText !== fullText) { await debugLog(`Secrets detected and redacted in file: ${args.relative_path}`); // Count the number of redactions const redactionCount = (scannedText.match(/REDACTED/g) || []).length; // Find which pattern matched in config let matchedPattern = undefined; if (config?.sensitiveFiles) { const filename = path.basename(args.relative_path); for (const rule of config.sensitiveFiles) { if (rule.action === "filter") { if (rule.filename === filename || (rule.filename.includes("/") && args.relative_path.includes(rule.filename))) { matchedPattern = rule.filename; break; } } } } // Log violation await writeViolationLog("file_filtered", "filtered", `Sensitive file read with ${redactionCount} secrets redacted`, args.relative_path, "filter", redactionCount, matchedPattern); } } catch (error) { await debugLog(`Gitleaks scanning failed for ${args.relative_path}: ${error}`); // If scanning fails, continue with original content but log the issue // In production, you might want to throw an error instead for security } } else { await debugLog(`Gitleaks failed to initialize - returning file without secret scanning: ${args.relative_path}`); } // Compute line slice using 0-based indices; end_line inclusive // Apply slicing AFTER redaction to preserve line number integrity const { start_line, end_line, max_answer_chars } = args; let text = scannedText; if (start_line !== 0 || end_line !== null) { const lines = scannedText.split(/\r?\n/); const start = Math.max(0, start_line); const end = end_line === null ? lines.length - 1 : Math.min(lines.length - 1, end_line); // If start > end, slice yields empty text = lines.slice(start, end + 1).join("\n"); } // Enforce Serena's max_answer_chars rule: if exceeded, return NO content if (text.length > max_answer_chars) { // "no content will be returned" => empty content list return { content: [] }; } // Otherwise return the text as a single text item return { content: [{ type: "text", text }] }; }); const transport = new StdioServerTransport(); await server.connect(transport); //# sourceMappingURL=server.js.map