#!/usr/bin/env node /** * scout-checker.cjs - Facade for scout-block modules * * Provides unified interface to scout-block/* modules for reuse in both * Claude hooks and OpenCode plugins. * * @module scout-checker */ const fs = require('fs'); const path = require('path'); // Import scout-block modules const { loadPatterns, createMatcher, matchPath } = require('../scout-block/pattern-matcher.cjs'); const { extractFromToolInput } = require('../scout-block/path-extractor.cjs'); const { detectBroadPatternIssue } = require('../scout-block/broad-pattern-detector.cjs'); // ═══════════════════════════════════════════════════════════════════════════ // COMMAND PATTERNS // ═══════════════════════════════════════════════════════════════════════════ // Build command allowlist - these are allowed even if they contain blocked paths // Handles flags and filters: npm build, pnpm --filter web run build, yarn workspace app build const BUILD_COMMAND_PATTERN = /^(npm|pnpm|yarn|bun)\s+([^\s]+\s+)*(run\s+)?(build|test|lint|dev|start|install|ci|add|remove|update|publish|pack|init|create|exec)/; // Tool commands - JS/TS, Go, Rust, Java, .NET, containers, IaC, Python, Ruby, PHP, Deno, Elixir const TOOL_COMMAND_PATTERN = /^(\.\/)?(npx|pnpx|bunx|tsc|esbuild|vite|webpack|rollup|turbo|nx|jest|vitest|mocha|eslint|prettier|go|cargo|make|mvn|mvnw|gradle|gradlew|dotnet|docker|podman|kubectl|helm|terraform|ansible|bazel|cmake|sbt|flutter|swift|ant|ninja|meson|python3?|pip|uv|deno|bundle|rake|gem|php|composer|ruby|mix|elixir)/; // Allow execution from .venv/bin/ or venv/bin/ (Unix) and .venv/Scripts/ or venv/Scripts/ (Windows) const VENV_EXECUTABLE_PATTERN = /(^|[\/\\])\.?venv[\/\\](bin|Scripts)[\/\\]/; // Allow Python venv creation commands (cross-platform): // - python/python3 -m venv (Unix/macOS/Windows) // - py -m venv (Windows py launcher, supports -3, -3.11, etc.) // - uv venv (fast Rust-based Python package manager) // - virtualenv (legacy but still widely used) const VENV_CREATION_PATTERN = /^(python3?|py)\s+(-[\w.]+\s+)*-m\s+venv\s+|^uv\s+venv(\s|$)|^virtualenv\s+/; // ═══════════════════════════════════════════════════════════════════════════ // HELPER FUNCTIONS // ═══════════════════════════════════════════════════════════════════════════ /** * Strip leading ENV variable assignments and command wrappers (sudo, env, etc.) * e.g., "NODE_ENV=production npm run build" → "npm run build" * @param {string} command - The command to strip * @returns {string} */ function stripCommandPrefix(command) { if (!command || typeof command !== 'string') return command; let stripped = command.trim(); // Strip env var assignments (KEY=VALUE KEY2=VALUE2 ...) stripped = stripped.replace(/^(\w+=\S+\s+)+/, ''); // Strip common command wrappers (one level) stripped = stripped.replace(/^(sudo|env|nice|nohup|time|timeout)\s+/, ''); // Strip env vars again (sudo env VAR=x cmd) stripped = stripped.replace(/^(\w+=\S+\s+)+/, ''); return stripped.trim(); } /** * Check if a command is a build/tooling command (should be allowed) * @param {string} command - The command to check * @returns {boolean} */ function isBuildCommand(command) { if (!command || typeof command !== 'string') return false; const trimmed = command.trim(); return BUILD_COMMAND_PATTERN.test(trimmed) || TOOL_COMMAND_PATTERN.test(trimmed); } /** * Split a compound command into sub-commands on &&, ||, and ;. * Does NOT split on newlines — newlines in command strings are typically * heredoc bodies or multiline strings, not compound operators. * Does not handle operators inside quoted strings (extremely rare in practice). * * @param {string} command - The compound command string * @returns {string[]} Array of sub-commands (trimmed, non-empty) */ function splitCompoundCommand(command) { if (!command || typeof command !== 'string') return []; return command.split(/\s*(?:&&|\|\||;)\s*/).filter(cmd => cmd && cmd.trim().length > 0); } /** * Unwrap shell executor wrappers (bash -c "...", sh -c '...', eval "..."). * Returns the inner command string for re-processing. * @param {string} command - The command to unwrap * @returns {string} Inner command, or original if not a shell executor */ function unwrapShellExecutor(command) { if (!command || typeof command !== 'string') return command; const match = command.trim().match( /^(?:(?:bash|sh|zsh)\s+-c|eval)\s+["'](.+)["']\s*$/ ); return match ? match[1] : command; } /** * Check if command executes from a .venv bin directory * @param {string} command - The command to check * @returns {boolean} */ function isVenvExecutable(command) { if (!command || typeof command !== 'string') return false; return VENV_EXECUTABLE_PATTERN.test(command); } /** * Check if command creates a Python virtual environment * @param {string} command - The command to check * @returns {boolean} */ function isVenvCreationCommand(command) { if (!command || typeof command !== 'string') return false; return VENV_CREATION_PATTERN.test(command.trim()); } /** * Check if command should be allowed (build, venv executable, or venv creation) * Strips ENV prefixes and command wrappers before checking. * @param {string} command - The command to check * @returns {boolean} */ function isAllowedCommand(command) { const stripped = stripCommandPrefix(command); return isBuildCommand(stripped) || isVenvExecutable(stripped) || isVenvCreationCommand(stripped); } function findGitRoot(startDir) { if (!startDir || typeof startDir !== 'string') return null; let dir = path.resolve(startDir); const root = path.parse(dir).root; while (true) { if (fs.existsSync(path.join(dir, '.git')) || dir === root) { return fs.existsSync(path.join(dir, '.git')) ? dir : null; } dir = path.dirname(dir); } } /** * Find an optional project-local .ckignore at the git root config directory. * This keeps overrides stable regardless of the caller cwd inside the repo. * * @param {string} startDir - Directory to start searching from * @param {string} [configDirName] - Config directory at git root (.claude, .opencode) * @returns {string|null} */ function findProjectCkignore(startDir, configDirName) { if (!configDirName || typeof configDirName !== 'string') return null; const gitRoot = findGitRoot(startDir); if (!gitRoot) return null; const candidate = path.join(gitRoot, configDirName, '.ckignore'); return fs.existsSync(candidate) ? candidate : null; } // ═══════════════════════════════════════════════════════════════════════════ // MAIN ENTRY POINT // ═══════════════════════════════════════════════════════════════════════════ /** * Check if a tool call accesses blocked directories or uses overly broad patterns * * @param {Object} params * @param {string} params.toolName - Name of tool (Bash, Glob, Read, etc.) * @param {Object} params.toolInput - Tool input with file_path, path, pattern, command * @param {Object} [params.options] * @param {string} [params.options.ckignorePath] - Path to .ckignore file * @param {string} [params.options.projectCkignorePath] - Explicit project-local .ckignore path * @param {string} [params.options.claudeDir] - Path to .claude or .opencode directory * @param {string} [params.options.cwd] - Working directory used to discover a project .ckignore * @param {string} [params.options.projectConfigDirName] - Git-root config dir for project-local overrides * @param {boolean} [params.options.checkBroadPatterns] - Check for overly broad glob patterns (default: true) * @returns {{ * blocked: boolean, * path?: string, * pattern?: string, * reason?: string, * configPath?: string, * isBroadPattern?: boolean, * suggestions?: string[], * isAllowedCommand?: boolean * }} */ function checkScoutBlock({ toolName, toolInput, options = {} }) { const { ckignorePath, projectCkignorePath, claudeDir = path.join(process.cwd(), '.claude'), cwd = process.cwd(), projectConfigDirName, checkBroadPatterns = true } = options; // Unwrap shell executor wrappers (bash -c "...", eval "...") // so the inner command gets properly analyzed if (toolInput.command) { const unwrapped = unwrapShellExecutor(toolInput.command); if (unwrapped !== toolInput.command) { toolInput = { ...toolInput, command: unwrapped }; } } // For Bash commands, split compound commands (&&, ||, ;) and check // each sub-command independently. This prevents "echo msg && npm run build" // from being blocked due to "build" token in the allowed build sub-command. // Must split BEFORE isAllowedCommand because BUILD_COMMAND_PATTERN has no end // anchor and would match the prefix of "npm run build && cat dist/file.js". if (toolInput.command) { const subCommands = splitCompoundCommand(toolInput.command); const nonAllowed = subCommands.filter(cmd => !isAllowedCommand(cmd.trim())); if (nonAllowed.length === 0) { return { blocked: false, isAllowedCommand: true }; } // Only extract paths from non-allowed sub-commands if (nonAllowed.length < subCommands.length) { toolInput = { ...toolInput, command: nonAllowed.join(' ; ') }; } } // Check for overly broad glob patterns (Glob tool) if (checkBroadPatterns && (toolName === 'Glob' || toolInput.pattern)) { const broadResult = detectBroadPatternIssue(toolInput); if (broadResult.blocked) { return { blocked: true, isBroadPattern: true, pattern: toolInput.pattern, reason: broadResult.reason || 'Pattern too broad - may fill context with too many files', suggestions: broadResult.suggestions || [] }; } } // Resolve .ckignore path const resolvedCkignorePath = ckignorePath || path.join(claudeDir, '.ckignore'); const discoveredProjectCkignorePath = projectCkignorePath || findProjectCkignore(cwd, projectConfigDirName); const resolvedProjectCkignorePath = discoveredProjectCkignorePath && path.resolve(discoveredProjectCkignorePath) !== path.resolve(resolvedCkignorePath) ? discoveredProjectCkignorePath : null; const configPath = resolvedProjectCkignorePath || resolvedCkignorePath; // Load patterns and create matcher const patterns = loadPatterns(resolvedCkignorePath, resolvedProjectCkignorePath); const matcher = createMatcher(patterns); // Extract paths from tool input const extractedPaths = extractFromToolInput(toolInput); // If no paths extracted, allow operation if (extractedPaths.length === 0) { return { blocked: false }; } // Check each path against patterns for (const extractedPath of extractedPaths) { const result = matchPath(matcher, extractedPath); if (result.blocked) { return { blocked: true, path: extractedPath, pattern: result.pattern, configPath, reason: `Path matches blocked pattern: ${result.pattern}` }; } } // All paths allowed return { blocked: false }; } // ═══════════════════════════════════════════════════════════════════════════ // EXPORTS // ═══════════════════════════════════════════════════════════════════════════ module.exports = { // Main entry point checkScoutBlock, // Command checkers isBuildCommand, isVenvExecutable, isVenvCreationCommand, isAllowedCommand, splitCompoundCommand, stripCommandPrefix, unwrapShellExecutor, findGitRoot, findProjectCkignore, // Re-export scout-block modules for direct access loadPatterns, createMatcher, matchPath, extractFromToolInput, detectBroadPatternIssue, // Patterns (for testing) BUILD_COMMAND_PATTERN, TOOL_COMMAND_PATTERN, VENV_EXECUTABLE_PATTERN, VENV_CREATION_PATTERN };