312 lines
13 KiB
JavaScript
312 lines
13 KiB
JavaScript
#!/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
|
|
};
|