/** * SSH command policy: built-in deny patterns + per-connection custom regex. * * Layers (evaluated in order against the candidate command line): * 1. BUILT-IN deny — destructive operations we never want from automation * 2. CUSTOM deny — per-connection admin/operator additions * 3. CUSTOM allow — when non-empty, command must match at least one * (otherwise denied as `not_in_allowlist`) * * Custom regex sources are validated at save time with three caps: * - max 16 patterns per connection * - max 256 chars per pattern * - structural ReDoS check (nested quantifier rejected; no external dep) * * Invalid custom regex → save error (caller surfaces to UI); never silently * dropped, so the operator is never confused about what's enforced. * * Plan: docs/superpowers/plans/2026-05-12-ssh-tool-integration.md (Phase 2). */ export interface DenyPattern { /** Stable key for logs / audit / UI. */ name: string; regex: RegExp; description: string; } /** * Destructive shell patterns we never permit. These are intentionally narrow * — false-positives are worse than false-negatives at this layer (the LLM * will give up early and the user will have to debug). Operators are * expected to add their own deny patterns for site-specific concerns. * * All patterns are case-INSENSITIVE and tested against the raw command line. */ export const BUILTIN_DENY_PATTERNS: readonly DenyPattern[] = [ { name: 'rm_rf_system_dir', regex: /\brm\s+(?:-[A-Za-z]*[rf][A-Za-z]*\s+)+(?:--no-preserve-root\s+)?\/(?:etc|usr|var|boot|bin|sbin|lib|lib64|opt|root|sys|proc)(?:\s|\/|$|;|\||&)/i, description: 'rm -rf on a system directory', }, { name: 'rm_rf_root', regex: /\brm\s+(?:-[A-Za-z]*[rf][A-Za-z]*\s+)+(?:--no-preserve-root\s+)?\/(?![A-Za-z0-9_.-])/i, description: 'rm -rf / or rm -rf /*', }, { name: 'dd_to_block_device', regex: /\bdd\b[^\n]*\bof=\/dev\/(?:sd[a-z]|hd[a-z]|nvme\d|vd[a-z]|xvd[a-z]|mmcblk\d|loop\d)/i, description: 'dd writing to a block device', }, { name: 'mkfs', regex: /\bmkfs(?:\.[A-Za-z0-9]+)?\b/i, description: 'mkfs (format filesystem)', }, { name: 'fork_bomb', regex: /:\(\)\s*\{[^}]*:\s*\|[^}]*:[^}]*\}\s*;?\s*:/, description: 'classic :(){ :|:& };: fork bomb', }, { name: 'shutdown_or_reboot', regex: /\b(?:shutdown|reboot|poweroff|halt|init\s+0|init\s+6)\b/i, description: 'system power state change', }, { name: 'kill_init', regex: /\bkill\s+(?:-(?:9|KILL|SIGKILL|TERM|SIGTERM)\s+)?1\b/i, description: 'kill PID 1 (init)', }, { name: 'pipe_curl_to_shell', regex: /\b(?:curl|wget|fetch)\b[^|\n]*\|\s*(?:sudo\s+)?(?:bash|sh|zsh|ksh|dash|fish)\b/i, description: 'curl/wget piped to shell', }, { name: 'reverse_shell_tcp', regex: /(?:bash|sh|zsh|ksh)\s+-i\s+>&?\s*\/dev\/tcp\//i, description: 'bash -i >& /dev/tcp/ reverse shell', }, { name: 'nc_exec_shell', regex: /\bn(?:c|cat)\b[^\n]*-e\s+\/(?:bin\/)?(?:bash|sh|zsh)/i, description: 'netcat -e /bin/sh reverse shell', }, { name: 'overwrite_etc_passwd', regex: /(?:^|[\s;|&])>{1,2}\s*\/etc\/(?:passwd|shadow|sudoers|gshadow)\b/i, description: 'redirect over /etc/passwd-class files', }, { name: 'chmod_777_root', regex: /\bchmod\s+-R\s+0?777\s+\/(?:\s|$)/i, description: 'chmod -R 777 /', }, { name: 'history_clear', regex: /\bhistory\s+-c\b|(?:^|[\s;|&])>{1,2}\s*~?\/?\.?(?:bash_|zsh_)?history\b|\b(?:unset|export)\s+HISTFILE\b/i, description: 'clear or disable shell history', }, ]; export interface ValidateCustomResult { ok: boolean; /** Compiled regexes when ok=true (1:1 with input order). */ compiled?: RegExp[]; /** Per-index errors when ok=false. */ errors?: Array<{ index: number; reason: ValidateRejection }>; } export type ValidateRejection = | 'too_many' | 'too_long' | 'empty' | 'invalid_regex' | 'nested_quantifier' | 'forbidden_construct'; export const MAX_CUSTOM_PATTERNS = 16; export const MAX_PATTERN_LENGTH = 256; /** * Heuristic ReDoS check: rejects patterns with a quantifier applied to a * group whose own contents include a quantifier ("star height > 1"). This * catches the most common catastrophic-backtracking shape, like `(\w+)+` * or `(.*)*`. * * Imperfect — it does not detect overlapping alternation like `(a|aa)+`. * Operators with deep regex needs should run their own tests. */ function hasNestedQuantifier(source: string): boolean { // Match: '(' + optional non-capture marker + body + ')' + quantifier, // where body contains a quantifier (+ * ? or { ). // Limit to non-nested groups (no inner parens) so we don't blow up on // arbitrarily complex source. const re = /\((?:\?[:=!<])?[^()]*[+*?][^()]*\)[+*?{]/; if (re.test(source)) return true; // Also flag (?:.*)+ and (?:.+)+ where . isn't an additional quantifier // catch — already covered by the above. Good enough. return false; } function hasForbiddenConstruct(source: string): boolean { // Refuse Unicode-property escapes (\p{...}) and named groups — they're // legitimate, but rare enough that operators are unlikely to need them // here. Forbidding them keeps the surface area minimal. Comment them in // if a future use case appears. if (/\\p\{/.test(source)) return true; if (/\(\?P? MAX_CUSTOM_PATTERNS) { return { ok: false, errors: [{ index: MAX_CUSTOM_PATTERNS, reason: 'too_many' }] }; } const errors: Array<{ index: number; reason: ValidateRejection }> = []; const compiled: RegExp[] = []; for (let i = 0; i < sources.length; i++) { const src = sources[i]; if (typeof src !== 'string' || src.length === 0) { errors.push({ index: i, reason: 'empty' }); continue; } if (src.length > MAX_PATTERN_LENGTH) { errors.push({ index: i, reason: 'too_long' }); continue; } if (hasForbiddenConstruct(src)) { errors.push({ index: i, reason: 'forbidden_construct' }); continue; } if (hasNestedQuantifier(src)) { errors.push({ index: i, reason: 'nested_quantifier' }); continue; } try { compiled.push(new RegExp(src, 'i')); } catch { errors.push({ index: i, reason: 'invalid_regex' }); } } if (errors.length > 0) return { ok: false, errors }; return { ok: true, compiled }; } export interface CheckCommandArgs { command: string; customDenyPatterns?: RegExp[]; /** When non-empty, command must match at least one (allowlist mode). */ customAllowPatterns?: RegExp[]; } export type CheckCommandReason = 'builtin_deny' | 'custom_deny' | 'not_in_allowlist' | 'empty'; export interface CheckCommandResult { allowed: boolean; reason?: CheckCommandReason; /** Name of the built-in pattern (or 'custom') that matched. */ matched?: string; } export function checkCommand(args: CheckCommandArgs): CheckCommandResult { const cmd = args.command; if (typeof cmd !== 'string' || cmd.trim().length === 0) { return { allowed: false, reason: 'empty' }; } for (const p of BUILTIN_DENY_PATTERNS) { if (p.regex.test(cmd)) { return { allowed: false, reason: 'builtin_deny', matched: p.name }; } } if (args.customDenyPatterns) { for (let i = 0; i < args.customDenyPatterns.length; i++) { if (args.customDenyPatterns[i].test(cmd)) { return { allowed: false, reason: 'custom_deny', matched: `custom_deny[${i}]` }; } } } if (args.customAllowPatterns && args.customAllowPatterns.length > 0) { const hit = args.customAllowPatterns.some((r) => r.test(cmd)); if (!hit) return { allowed: false, reason: 'not_in_allowlist' }; } return { allowed: true }; }