#!/usr/bin/env node /** * PreToolUse Hook: MCP 高危工具安全门 (v6.1 P1) * Matcher: mcp__supabase__execute_sql | mcp__supabase__apply_migration | * mcp__github__create_or_update_file | mcp__github__push_files | mcp__github__delete_file | * mcp__playwright__browser_evaluate | mcp__playwright__browser_run_code | * mcp__chrome-devtools__evaluate_script | mcp__slack__slack_post_message * * 设计原则: Fail-close — 解析异常时默认 ask,不放行 * * 决策矩阵: * deny — DROP TABLE/DATABASE, TRUNCATE, 写入 .env/.pem 等凭证文件 * ask — DELETE FROM, ALTER, migration, slack 消息, 浏览器 JS 执行 * pass — SELECT, 只读操作 * * 退出码: 0=放行 | 2=阻断(stderr 输出 JSON) */ 'use strict'; const fs = require('fs'); const path = require('path'); const { logSecurityEvent } = require('./lib/security-log.js'); const readStdin = require('./lib/read-stdin.js'); const MCP_CLASSIFICATION = (() => { try { const filePath = path.join(__dirname, 'rules', 'mcp-tool-classification.json'); return JSON.parse(fs.readFileSync(filePath, 'utf8')); } catch { return { readonlyPatterns: [], writePatterns: [], alwaysPassPrefixes: [] }; } })(); // ─── Supabase SQL 分析 ─────────────────────────────────────── /** * SQL 危险度判定 * 返回 { level: 'deny'|'ask'|'pass', reason: string } */ function analyzeSql(sql) { if (!sql || typeof sql !== 'string') return { level: 'pass', reason: '' }; // P1-2: SQL 注释剥离 — 跳过字符串字面量内的注释标记 // H5: 使用 PostgreSQL 标准 '' 双引号转义,而非反斜杠 \' let stripped = ''; for (let i = 0; i < sql.length; i++) { if (sql[i] === "'") { let j = i + 1; while (j < sql.length) { if (sql[j] === "'" && sql[j + 1] === "'") { j += 2; continue; } if (sql[j] === "'") break; j++; } stripped += sql.substring(i, j + 1); i = j; } else if (sql[i] === '-' && sql[i+1] === '-') { while (i < sql.length && sql[i] !== '\n') i++; } else if (sql[i] === '/' && sql[i+1] === '*') { // FIX: 支持嵌套注释深度追踪,防止 /* inner /* */ --line */ DROP TABLE 绕过 // 攻击向量: 第一个 */ 关闭注释后,-- 行注释吞掉剩余内容包括真正的 DROP i += 2; let depth = 1; while (i < sql.length - 1) { if (sql[i] === '/' && sql[i + 1] === '*') { depth++; i += 2; continue; } if (sql[i] === '*' && sql[i + 1] === '/') { depth--; i += 2; if (depth === 0) break; continue; } i++; } i--; // 补偿外层 for 循环的 i++ } else { stripped += sql[i]; } } const upper = stripped.toUpperCase().trim(); // deny: 结构性破坏操作 if (/\bDROP\s+(TABLE|DATABASE|SCHEMA|INDEX|VIEW|SEQUENCE|FUNCTION|TRIGGER|TYPE)\b/.test(upper)) { return { level: 'deny', reason: 'SQL 包含 DROP 语句,拒绝执行以防止数据丢失' }; } if (/\bTRUNCATE\b/.test(upper)) { return { level: 'deny', reason: 'SQL 包含 TRUNCATE 语句,拒绝执行以防止全表清空' }; } // H6: DELETE FROM 无 WHERE 子句 — 全表删除 if (/\bDELETE\s+FROM\s+\w+\s*(?:;|$)/.test(upper)) { return { level: 'deny', reason: 'SQL DELETE 无 WHERE 子句,拒绝执行以防止全表删除' }; } // DELETE FROM ... WHERE 1=1 / WHERE TRUE — 无条件全表删除 if (/\bDELETE\s+FROM\b.*\bWHERE\s+(?:1\s*=\s*1|TRUE|1\s*)\s*(?:;|$)/.test(upper)) { return { level: 'deny', reason: 'SQL 包含无条件 DELETE (WHERE 1=1/TRUE),拒绝执行' }; } // deny: 权限提升 if (/\bGRANT\s+.+\bTO\b/.test(upper)) { return { level: 'deny', reason: 'SQL 包含 GRANT 权限授予语句,拒绝执行' }; } // deny: 禁用 RLS 等安全配置 if (/ALTER\s+TABLE.*DISABLE\s+ROW\s+LEVEL\s+SECURITY/.test(upper)) { return { level: 'deny', reason: 'SQL 禁用 Row Level Security,拒绝执行' }; } // ask: 有条件 DELETE if (/\bDELETE\s+FROM\b/.test(upper)) { return { level: 'ask', reason: 'SQL 包含 DELETE FROM,需要用户确认' }; } // ask: DDL 变更 if (/\bALTER\s+(TABLE|INDEX|SEQUENCE|VIEW|FUNCTION)\b/.test(upper)) { return { level: 'ask', reason: 'SQL 包含 ALTER 语句,需要用户确认结构变更' }; } // ask: 创建/修改函数 if (/\bCREATE\s+(OR\s+REPLACE\s+)?FUNCTION\b/.test(upper)) { return { level: 'ask', reason: 'SQL 包含 CREATE FUNCTION,需要用户确认' }; } // ask: 无 WHERE 的 UPDATE if (/\bUPDATE\s+\w+\s+SET\b(?!.*\bWHERE\b)/.test(upper)) { return { level: 'ask', reason: 'SQL 包含无 WHERE 子句的 UPDATE,需要用户确认' }; } return { level: 'pass', reason: '' }; } // ─── GitHub 文件路径分析 ───────────────────────────────────── const SENSITIVE_FILE_DENY = [ { pattern: /(?:^|[\/\\])\.env(?:\.[^/\\]+)?$/i, reason: '目标文件为 .env 环境变量文件,拒绝写入防止凭证泄露' }, { pattern: /(?:^|[\/\\])\.env\.(?:local|production|staging|test|prod)$/i, reason: '目标文件为生产/测试环境变量文件,拒绝写入' }, { pattern: /\.(pem|key|p12|pfx|crt|cer|der|pkcs12)$/i, reason: '目标文件为私钥/证书文件,拒绝写入' }, { pattern: /credentials(?:\.json)?$/i, reason: '目标文件为凭证文件,拒绝写入' }, { pattern: /(?:^|[\/\\])\.ssh[\/\\]/i, reason: '目标路径位于 .ssh 目录,拒绝写入' }, { pattern: /(?:aws|gcp|azure)[_-]?(?:credentials|config|secret).*\.(?:json|yaml|yml|ini|toml)$/i, reason: '目标文件为云服务凭证,拒绝写入' }, ]; const SENSITIVE_FILE_ASK = [ { pattern: /(?:^|[\/\\])settings\.json$/i, reason: '目标文件为 settings.json 配置文件,需用户确认' }, { pattern: /(?:^|[\/\\])\.claude[\/\\]/i, reason: '目标路径位于 .claude 配置目录,需用户确认' }, { pattern: /(?:^|[\/\\])\.github[\/\\]workflows[\/\\]/i, reason: '目标路径为 GitHub Actions 工作流文件,需用户确认' }, { pattern: /(?:package\.json|Gemfile|requirements\.txt|go\.mod)$/i, reason: '目标文件为依赖清单,修改前需用户确认' }, { pattern: /(?:dockerfile|docker-compose)[^/\\]*(?:\.ya?ml|\.json)?$/i, reason: '目标文件为 Docker 配置,需用户确认' }, ]; function analyzeGithubPath(filePath) { if (!filePath || typeof filePath !== 'string') return { level: 'pass', reason: '' }; const normalized = filePath.replace(/\\/g, '/').toLowerCase(); for (const { pattern, reason } of SENSITIVE_FILE_DENY) { if (pattern.test(normalized)) return { level: 'deny', reason }; } for (const { pattern, reason } of SENSITIVE_FILE_ASK) { if (pattern.test(normalized)) return { level: 'ask', reason }; } return { level: 'pass', reason: '' }; } // ─── 浏览器 JS 执行分析 ───────────────────────────────────── const DANGEROUS_JS_PATTERNS = [ { pattern: /document\.cookie/i, reason: '脚本读取 document.cookie,可能泄露会话凭证' }, { pattern: /(?:localStorage|sessionStorage)\.(?:getItem|key|length)/i, reason: '脚本读取浏览器本地存储,可能提取敏感数据' }, { pattern: /\beval\s*\(/, reason: '脚本使用 eval() 动态执行代码' }, { pattern: /new\s+Function\s*\(/, reason: '脚本使用 new Function() 动态创建函数' }, { pattern: /fetch\s*\(\s*['"`]https?:\/\/(?!localhost|127\.0\.0\.1)/i, reason: '脚本包含向外部域名发送 fetch 请求' }, { pattern: /XMLHttpRequest/i, reason: '脚本使用 XMLHttpRequest' }, { pattern: /window\.location(?:\.href\s*=|\.replace\s*\()/i, reason: '脚本修改 window.location,可能导致页面跳转' }, { pattern: /navigator\.clipboard\.read/i, reason: '脚本读取剪贴板内容' }, { pattern: /__proto__|constructor\.prototype/, reason: '脚本包含原型污染模式' }, ]; function analyzeBrowserScript(script) { if (!script || typeof script !== 'string') return { level: 'pass', reason: '' }; for (const { pattern, reason } of DANGEROUS_JS_PATTERNS) { if (pattern.test(script)) return { level: 'ask', reason }; } // 所有浏览器 JS 执行默认 ask return { level: 'ask', reason: '浏览器脚本执行需用户确认(通用安全策略)' }; } // ─── 决策输出 ──────────────────────────────────────────────── function outputDeny(toolName, reason, detail) { logSecurityEvent('deny', 'mcp-safety-gate', reason, 'tool=' + toolName + ' | ' + (detail || '')); process.stderr.write(JSON.stringify({ hookSpecificOutput: { permissionDecision: 'deny' }, systemMessage : '[mcp-safety-gate] 已拦截高危 MCP 操作\n工具: ' + toolName + '\n原因: ' + reason + '\n此操作已被安全策略强制禁止,请改用更安全的方式。', })); process.exit(2); } function outputAsk(toolName, reason, detail) { // B5: CLAUDE_SAFETY_MODE=strict 时 ask 升级为 deny (无人值守自动循环模式) var isStrict = process.env.CLAUDE_SAFETY_MODE === 'strict'; var decision = isStrict ? 'deny' : 'ask'; var prefix = isStrict ? '[mcp-safety-gate][STRICT] 自动循环模式下已阻断' : '[mcp-safety-gate] 高风险 MCP 操作需用户确认'; logSecurityEvent(decision, 'mcp-safety-gate', reason, 'tool=' + toolName + ' | ' + (detail || '') + (isStrict ? ' | strict-escalated' : '')); process.stderr.write(JSON.stringify({ hookSpecificOutput: { permissionDecision: decision }, systemMessage : prefix + '\n工具: ' + toolName + '\n原因: ' + reason + (isStrict ? '\n(CLAUDE_SAFETY_MODE=strict: ask 已升级为 deny)' : '\n请用户确认后继续。'), })); process.exit(2); } // ─── 自动化工具白名单(无需确认的本地自动化 MCP)──────────── const AUTO_ALLOW_PREFIXES = [ 'mcp__orbination__', // 桌面 UI 控制(读屏/点击/键盘) 'mcp__askui-vision__', // 视觉自动化 'mcp__playwright__', // 本地浏览器自动化 'mcp__chrome-devtools__', // Chrome DevTools(调试/截图) 'mcp__context7__', // 文档查询(只读) 'mcp__sequential-thinking__', // 推理链(无副作用) 'mcp__deep-research__', // 深度研究(只读) // [REMOVED P0-3] 高危 MCP 已从前缀白名单移除 'mcp__browser-mcp__', // 真实浏览器会话控制(含登录态) // [REMOVED P0-3] 高危 MCP 已从前缀白名单移除 // [REMOVED P0-3] 高危 MCP 已从前缀白名单移除 ]; // ─── 路由分发 ──────────────────────────────────────────────── function handleTool(toolName, toolInput) { // [P0-3 2026-04-12] 高危子工具硬 deny 黑名单 (优先于白名单) const HARD_DENY_PATTERNS = [ /^mcp__windows-mcp__PowerShell$/, /^mcp__windows-mcp__Registry$/, /^mcp__desktop-commander__start_process$/, /^mcp__desktop-commander__write_file$/, /^mcp__desktop-commander__edit_block$/, /^mcp__desktop-commander__set_config_value$/, /^mcp__desktop-commander__force_terminate$/, /^mcp__desktop-commander__kill_process$/, ]; if (HARD_DENY_PATTERNS.some(function(p) { return p.test(toolName); })) { return outputDeny(toolName, '高危子工具被硬黑名单禁止 (P0-3 2026-04-12)', toolName); } // 自动化工具白名单 — 直接放行,仅记录审计日志 if (AUTO_ALLOW_PREFIXES.some(function(p) { return toolName.startsWith(p); })) { logSecurityEvent('pass', 'mcp-safety-gate', 'auto-allow prefix match', 'tool=' + toolName); process.stdout.write(JSON.stringify({ continue: true, suppressOutput: true })); process.exit(0); } switch (toolName) { case 'mcp__supabase__execute_sql': { const sql = toolInput.query || toolInput.sql || ''; const result = analyzeSql(sql); if (result.level === 'deny') outputDeny(toolName, result.reason, sql); if (result.level === 'ask') outputAsk(toolName, result.reason, sql); break; } case 'mcp__supabase__apply_migration': { const migrationName = toolInput.name || toolInput.migration_name || '(未知迁移)'; const sql = toolInput.query || toolInput.sql || ''; if (sql) { const result = analyzeSql(sql); if (result.level === 'deny') outputDeny(toolName, result.reason, sql); } outputAsk(toolName, '将应用数据库迁移 "' + migrationName + '",此操作不可逆,需用户确认', migrationName); break; } case 'mcp__github__create_or_update_file': { const filePath = toolInput.path || toolInput.file_path || ''; const result = analyzeGithubPath(filePath); if (result.level === 'deny') outputDeny(toolName, result.reason, filePath); if (result.level === 'ask') outputAsk(toolName, result.reason, filePath); break; } case 'mcp__github__push_files': { const files = toolInput.files || []; for (const f of files) { const fp = (typeof f === 'string' ? f : f.path || f.file_path || ''); const result = analyzeGithubPath(fp); if (result.level === 'deny') outputDeny(toolName, result.reason, fp); if (result.level === 'ask') outputAsk(toolName, result.reason + ' (文件: ' + fp + ')', fp); } if (files.length === 0) { outputAsk(toolName, '批量推送文件操作,无法确认目标路径,需用户确认', JSON.stringify(toolInput).slice(0, 100)); } break; } case 'mcp__github__delete_file': { const filePath = toolInput.path || toolInput.file_path || ''; const result = analyzeGithubPath(filePath); if (result.level === 'deny') outputDeny(toolName, result.reason, filePath); outputAsk(toolName, '将删除 GitHub 文件 "' + filePath + '",此操作不可逆', filePath); break; } case 'mcp__playwright__browser_evaluate': case 'mcp__playwright__browser_run_code': { const script = toolInput.script || toolInput.code || toolInput.expression || ''; const result = analyzeBrowserScript(script); if (result.level === 'deny') outputDeny(toolName, result.reason, script); if (result.level === 'ask') outputAsk(toolName, result.reason, script); break; } case 'mcp__chrome-devtools__evaluate_script': { const script = toolInput.script || toolInput.expression || ''; const result = analyzeBrowserScript(script); if (result.level === 'deny') outputDeny(toolName, result.reason, script); if (result.level === 'ask') outputAsk(toolName, result.reason, script); break; } case 'mcp__slack__slack_post_message': { const channel = toolInput.channel || toolInput.channel_id || '(未知频道)'; const text = toolInput.text || ''; outputAsk( toolName, '将向 Slack 频道 "' + channel + '" 发送消息,请确认内容和目标频道', 'channel=' + channel + ' | text_len=' + text.length, ); break; } default: // === v6.4: 新增 MCP 安全分类 === // windows-mcp: PowerShell/Registry/Process 需确认,其余放行 if (/^mcp__windows-mcp__(?:PowerShell|Registry|Process|FileSystem)$/.test(toolName)) { return outputAsk(toolName, 'Windows 系统操作需用户确认 (' + toolName.split('__').pop() + ')'); } if (/^mcp__windows-mcp__/.test(toolName)) break; // 其余 (Screenshot/Click/Type 等) 放行 // mcp-com-server: 创建/调用/设置需确认,查询放行 if (/^mcp__mcp-com-server__(?:CreateObject|InvokeMethod|SetProperty|DisposeObject)$/.test(toolName)) { return outputAsk(toolName, 'COM 对象写操作需用户确认'); } if (/^mcp__mcp-com-server__/.test(toolName)) break; // GetProperty/GetTypeInfo/ListActive/Query 放行 // orbination: run_sequence/open_app 需确认,OCR/窗口读取/点击放行 if (/^mcp__orbination__(?:run_sequence|open_app|paste_text|set_clipboard)$/.test(toolName)) { return outputAsk(toolName, '桌面批量操作/应用启动需用户确认'); } if (/^mcp__orbination__/.test(toolName)) break; // OCR/click/keyboard/mouse/screenshot/window 放行 // askui-vision: vision_act 需确认 (自主执行),其余视觉操作放行 if (/^mcp__askui-vision__vision_act$/.test(toolName)) { return outputAsk(toolName, '视觉自主操作需用户确认'); } if (/^mcp__askui-vision__/.test(toolName)) break; // click/type/scroll/screenshot 放行 // M7: pywinauto/firecrawl/browserbase agent ask (GH-3: moved from dead code at module level) if (/^mcp__pywinauto__/.test(toolName)) { return outputAsk(toolName, 'Windows 桌面自动化操作需用户确认'); } if (/^mcp__(firecrawl__firecrawl_agent|browserbase__browserbase_stagehand_agent)$/.test(toolName)) { return outputAsk(toolName, '自主浏览器代理操作需用户确认'); } // R3: 精确匹配工具名最后一段 action (防止恶意工具名子串匹配绕过) const actionPart = toolName.split('__').pop() || ''; const SAFE_ACTIONS = /^(?:navigate|screenshot|snapshot|click|hover|drag|select|press|wait|resize|close|tabs|back|observe|get_url|list|locate|scroll|key|get|status)(?:_[a-z]+)*$/; const INTERACTIVE_ACTIONS = /^(?:fill|type|extract)(?:_[a-z]+)*$/; if (SAFE_ACTIONS.test(actionPart)) { break; // 只读/导航操作: 安全放行 } if (INTERACTIVE_ACTIONS.test(actionPart)) { return outputAsk(toolName, '交互操作(填写/输入/提取)需用户确认'); } outputAsk(toolName, '未识别的 MCP 写入请求(安全策略: 未知工具默认询问)', toolName); } } // ─── 主流程 ────────────────────────────────────────────────── function main() { readStdin({ maxSize: 512 * 1024 }).then(input => { const toolName = input.tool_name || ''; const ti = input.tool_input || {}; if (!toolName) { process.exit(0); return; } handleTool(toolName, ti); process.exit(0); }).catch((e) => { // fail-close → ask (strict 模式下升级为 deny) const isStrict = process.env.CLAUDE_SAFETY_MODE === 'strict'; const decision = isStrict ? 'deny' : 'ask'; process.stderr.write(JSON.stringify({ hookSpecificOutput: { permissionDecision: decision }, systemMessage : '[mcp-safety-gate] 安全检查异常 (' + (e.message || '') + '),' + (isStrict ? '已拒绝执行' : '请用户确认是否继续执行 MCP 操作') + '。', })); process.exit(2); }); } if (typeof module !== 'undefined') { module.exports = { analyzeSql, analyzeGithubPath, analyzeBrowserScript }; } if (require.main === module) { main(); }