UNPKG

@felixgeelhaar/cclint

Version:

Catch CLAUDE.md drift before Claude misbehaves. Lints CLAUDE.md, skills, subagents, and hooks for Claude Code projects.

195 lines 8.57 kB
import { ContextFile } from '../domain/ContextFile.js'; import { Violation } from '../domain/Violation.js'; import { Location } from '../domain/Location.js'; import { Severity } from '../domain/Severity.js'; import { CodeBlockExtractor } from '../infrastructure/CodeBlockExtractor.js'; /** * Rule that validates bash command safety in CLAUDE.md * * @remarks * Validates that bash commands: * - Avoid dangerous operations (rm -rf, sudo without context) * - Include proper error handling * - Use safe patterns (quotes, error checks) * - Follow Anthropic's safety recommendations * * @see {@link https://www.anthropic.com/engineering/claude-code-best-practices | Claude Code Best Practices} * * @category Rules */ export class CommandSafetyRule { id = 'command-safety'; description = 'Validates safety of bash commands in CLAUDE.md files'; extractor; constructor() { this.extractor = new CodeBlockExtractor(); } lint(file) { const violations = []; const bashBlocks = this.extractor.extractCodeBlocksByLanguage(file, 'bash'); for (const block of bashBlocks) { violations.push(...this.checkDangerousCommands(block)); violations.push(...this.checkErrorHandling(block)); violations.push(...this.checkQuoting(block)); violations.push(...this.checkSudoUsage(block)); } return violations; } /** * Check for dangerous commands */ checkDangerousCommands(block) { const violations = []; const lines = block.getLines(); const dangerousPatterns = [ { pattern: /rm\s+-\S*r\S*\s+\*/, message: 'Dangerous: rm -rf with wildcard. Be explicit about files to delete', severity: Severity.ERROR, }, { pattern: /:\(\)\{\s*:\|:&\s*\};:/, message: 'Fork bomb detected. Remove this dangerous command', severity: Severity.ERROR, }, { pattern: /dd\s+if=\/dev\/(zero|random)\s+of=\/dev/, message: 'Dangerous: dd writing to device. This can destroy data. Add safety checks', severity: Severity.ERROR, }, { pattern: /mkfs\./, message: 'Dangerous: filesystem creation. Ensure this is intentional and add confirmation', severity: Severity.WARNING, }, { pattern: />\s*\/dev\/sd[a-z]/, message: 'Dangerous: writing to disk device. Add safety checks', severity: Severity.ERROR, }, { pattern: /chmod\s+-R\s+777/, message: 'Insecure: chmod 777 is overly permissive. Use specific permissions like 755 or 644', severity: Severity.WARNING, }, { pattern: /curl\s+.*\|\s*(?:bash|sh)/, message: 'Security risk: piping curl to bash. Download and inspect scripts first: curl -o script.sh URL && chmod +x script.sh && ./script.sh', severity: Severity.ERROR, }, { pattern: /wget\s+.*\|\s*(?:bash|sh)/, message: 'Security risk: piping wget to bash. Download and inspect scripts first', severity: Severity.ERROR, }, ]; for (let i = 0; i < lines.length; i++) { const line = lines[i] ?? ''; const lineNumber = block.location.line + i; for (const { pattern, message, severity } of dangerousPatterns) { if (pattern.test(line)) { violations.push(new Violation(this.id, message, severity, new Location(lineNumber, 1))); } } // Recursive+forced `rm` on a root path, catching flag reordering // (rm -fr, rm -r -f) and long-form flags (rm --recursive --force), // not just the literal `rm -rf /`. if (this.isRecursiveForcedRm(line) && this.targetsRootPath(line)) { violations.push(new Violation(this.id, 'Dangerous: rm -rf on root paths. Use specific paths and consider: mkdir -p /path/to/backup && cp -r target /path/to/backup', Severity.ERROR, new Location(lineNumber, 1))); } } return violations; } /** * Detect an `rm` invocation that is both recursive (-r/-R/--recursive) and * forced (-f/--force), regardless of flag order or short/long form. */ isRecursiveForcedRm(line) { if (!/(^|\s)rm\s/.test(line)) { return false; } const hasRecursive = /(^|\s)--recursive(\s|$)/.test(line) || /(^|\s)-[a-zA-Z]*[rR][a-zA-Z]*(\s|$)/.test(line); const hasForce = /(^|\s)--force(\s|$)/.test(line) || /(^|\s)-[a-zA-Z]*f[a-zA-Z]*(\s|$)/.test(line); return hasRecursive && hasForce; } /** * Detect a root-path argument (e.g. `/`, `/usr`, `/etc`) while allowing the * conventional temp directories `/tmp` and `/var/tmp`. */ targetsRootPath(line) { return /(^|\s)\/(?!tmp(?:[/\s]|$)|var\/tmp(?:[/\s]|$))/.test(line); } /** * Check error handling */ checkErrorHandling(block) { const violations = []; const lines = block.getLines(); const content = block.content; // Check for set -e or error handling const hasSetE = /set\s+-e/.test(content); const hasErrorHandling = /\|\|/.test(content) || /if\s+\[/.test(content); if (!hasSetE && !hasErrorHandling && lines.length > 3) { violations.push(new Violation(this.id, 'Add error handling to bash script: Use "set -e" at the start or add "|| exit 1" to critical commands', Severity.WARNING, block.location)); } // Check for cd without error handling for (let i = 0; i < lines.length; i++) { const line = lines[i] ?? ''; const lineNumber = block.location.line + i; if (/^\s*cd\s+/.test(line) && !/\|\|/.test(line)) { const nextLine = lines[i + 1] ?? ''; if (!nextLine.includes('||') && !hasSetE) { violations.push(new Violation(this.id, 'cd command without error handling. Use: cd /path || exit 1', Severity.WARNING, new Location(lineNumber, 1))); } } } return violations; } /** * Check variable quoting */ checkQuoting(block) { const violations = []; const lines = block.getLines(); for (let i = 0; i < lines.length; i++) { const line = lines[i] ?? ''; const lineNumber = block.location.line + i; // Check for unquoted variables in dangerous contexts const unquotedInRm = /rm\s+.*\$\w+(?!\s*")/.test(line); const unquotedInMv = /mv\s+.*\$\w+(?!\s*")/.test(line); if (unquotedInRm || unquotedInMv) { violations.push(new Violation(this.id, 'Unquoted variable in potentially destructive command. Use "$VAR" instead of $VAR', Severity.WARNING, new Location(lineNumber, 1))); } } return violations; } /** * Check sudo usage */ checkSudoUsage(block) { const violations = []; const lines = block.getLines(); for (let i = 0; i < lines.length; i++) { const line = lines[i] ?? ''; const lineNumber = block.location.line + i; if (/^\s*sudo/.test(line)) { // Check if it's in a documented/safe context const context = block.context.toLowerCase(); const isDocumented = context.includes('install') || context.includes('setup') || context.includes('admin'); if (!isDocumented) { violations.push(new Violation(this.id, 'sudo command without clear context. Document why sudo is needed: "# System setup requires sudo"', Severity.INFO, new Location(lineNumber, 1))); } // Check for sudo with dangerous commands if (/sudo\s+rm\s+-rf/.test(line)) { violations.push(new Violation(this.id, 'sudo rm -rf is extremely dangerous. Add explicit path validation and confirmation', Severity.ERROR, new Location(lineNumber, 1))); } } } return violations; } } //# sourceMappingURL=CommandSafetyRule.js.map