Files
english/.opencode/plugin/lib/scout-checker.cjs
2026-04-12 01:06:31 +07:00

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
};