@slathar-dev/mcp-sensitive-read
Version:
MCP server for secure file reading within project boundaries
243 lines • 11.9 kB
JavaScript
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