UNPKG

comfyui-mcp

Version:

Local-first, agent-native control plane for ComfyUI — MCP server + autonomous sidebar agent that drives your live graph in natural language on ANY LLM: Claude/ChatGPT/Gemini on your subscription (no API key), free local models via Ollama (fully offline),

138 lines • 6.46 kB
/** * Blind gate for the Claude backend's NATIVE tools (the issue #90 promise, * extended to the SDK subprocess). * * The MCP-side gate (src/tools/index.ts, COMFYUI_MCP_BLIND) scrubs image blocks * from every comfyui tool result — but the Claude Agent SDK runs the full * claude_code preset under bypassPermissions, so the model ALSO holds native * Read and WebFetch. Native Read on a PNG (or a PDF page, or a notebook's cell * outputs) delivers rendered pixels to the model without any MCP tool in the * path, which silently voids the "the agent NEVER receives image pixels" * promise. Reproduced live: Blind ON, the agent read a LoadImage input file * with native Read and described the photo in detail. * * Enforcement is a PreToolUse hook (mechanical — a deny the model cannot route * around), not a prompt. The hook reads the isBlind() predicate at CALL time, * so a mid-session Blind toggle binds the very next tool call — it does not * wait for the coalesced at-idle agent respawn the env gate needs. * * Coverage is the pixel-DELIVERY channels only: Read (renders raster images, * PDFs, .ipynb outputs) and WebFetch (can hand back image content — including * ComfyUI's own /view endpoint). Bash & friends return text; a model cannot * recover pixels from base64 text, so they stay open. Renaming a file does not * help: Read denial sniffs magic bytes as well as extensions. */ import { openSync, readSync, closeSync } from "node:fs"; /** Extensions native Read would render as pixels (raster formats), plus the * two container formats whose Read output embeds rendered images (PDF pages, * notebook cell outputs). SVG is excluded: Read returns its source text. */ const PIXEL_READ_EXTENSIONS = new Set([ "png", "jpg", "jpeg", "gif", "webp", "bmp", "tif", "tiff", "avif", "heic", "heif", "ico", "pdf", "ipynb", ]); /** Extensions that mark a URL as image content for WebFetch. */ const PIXEL_URL_EXTENSIONS = new Set([ "png", "jpg", "jpeg", "gif", "webp", "bmp", "tif", "tiff", "avif", "heic", "heif", "ico", ]); const extensionOf = (path) => { const base = path.slice(Math.max(path.lastIndexOf("/"), path.lastIndexOf("\\")) + 1); const dot = base.lastIndexOf("."); return dot > 0 ? base.slice(dot + 1).toLowerCase() : ""; }; /** True when the file's leading bytes are a known raster/PDF signature — the * defense against `cp photo.png notes.txt` extension games. Unreadable or * short files sniff false: if the harness can't read it, it can't render it. */ export function sniffsAsPixels(filePath) { let fd = null; try { fd = openSync(filePath, "r"); const head = Buffer.alloc(16); const n = readSync(fd, head, 0, 16, 0); if (n < 4) return false; if (head[0] === 0x89 && head[1] === 0x50 && head[2] === 0x4e && head[3] === 0x47) return true; // PNG if (head[0] === 0xff && head[1] === 0xd8 && head[2] === 0xff) return true; // JPEG if (head.toString("latin1", 0, 4) === "GIF8") return true; // GIF87a/89a if (head.toString("latin1", 0, 4) === "RIFF" && head.toString("latin1", 8, 12) === "WEBP") return true; if (head[0] === 0x42 && head[1] === 0x4d) return true; // BMP if (head.toString("latin1", 0, 4) === "II*\0" || head.toString("latin1", 0, 4) === "MM\0*") return true; // TIFF if (head.toString("latin1", 0, 4) === "%PDF") return true; if (n >= 12 && head.toString("latin1", 4, 8) === "ftyp") return true; // AVIF/HEIC/HEIF box return false; } catch { return false; } finally { if (fd !== null) closeSync(fd); } } /** Decide whether one native tool call would deliver pixels. Pure over its * inputs apart from the Read magic-byte sniff. Unknown tools and malformed * inputs allow: the MCP scrub and the message-path withholding still stand, * and a gate that blocks ordinary text work teaches users to turn Blind off. */ export function evaluateNativePixelTool(toolName, toolInput) { const input = (toolInput ?? {}); if (toolName === "Read") { const filePath = typeof input.file_path === "string" ? input.file_path : ""; if (!filePath) return { deny: false }; if (PIXEL_READ_EXTENSIONS.has(extensionOf(filePath)) || sniffsAsPixels(filePath)) { return { deny: true, what: `image/document file ${filePath}` }; } return { deny: false }; } if (toolName === "WebFetch") { const url = typeof input.url === "string" ? input.url : ""; if (!url) return { deny: false }; let pathname; try { pathname = new URL(url).pathname; } catch { return { deny: false }; } // ComfyUI serves every input/output/temp image through /view (and the // /api/view alias) — the exact endpoint the original #90 leak used. if (pathname === "/view" || pathname.endsWith("/api/view")) return { deny: true, what: `ComfyUI /view URL` }; if (PIXEL_URL_EXTENSIONS.has(extensionOf(pathname))) return { deny: true, what: `image URL ${url}` }; return { deny: false }; } return { deny: false }; } /** The SDK PreToolUse hook. Kept dependency-free of SDK types (structurally * compatible with HookCallback) so the SDK stays a lazy import. */ export function makeBlindNativeHook(isBlind) { return async (input) => { if (!isBlind?.()) return {}; const call = (input ?? {}); if (call.hook_event_name !== "PreToolUse" || !call.tool_name) return {}; const verdict = evaluateNativePixelTool(call.tool_name, call.tool_input); if (!verdict.deny) return {}; return { hookSpecificOutput: { hookEventName: "PreToolUse", permissionDecision: "deny", permissionDecisionReason: `Blind mode: ${verdict.what ?? "image content"} withheld. The user's Blind setting means ` + "you NEVER receive image pixels — through any tool, native ones included. Work from " + "filenames/metadata, and tell the user to inspect the image themselves if it matters.", }, }; }; } //# sourceMappingURL=blind-native-gate.js.map