// hooks/lib/bash-view.mjs // How the Bash guards read a command. Moved out of pre-bash-executor.mjs so // the guard that asks before a call (pre-bash-executor.mjs) and the hook that // logs the approval after it (post-approval-log.mjs) read a command the same // way. Behavior unchanged from the inline copy. // // Bash normalization from llm-security/scanners/lib/bash-normalize.mjs (MIT, // same author); lib/parsers/bash-normalize.mjs keeps the test-suite copy. // Strips bash evasion techniques: empty quotes, ${} expansion, backslash splitting. export function normalizeBashExpansion(cmd) { if (!cmd || typeof cmd !== 'string') return cmd || ''; let result = cmd // Strip empty single quotes: w''get -> wget .replace(/''/g, '') // Strip empty double quotes: r""m -> rm .replace(/""/g, '') // Single-char ${x} -> x (evasion: c${u}rl -> curl, assumes x=x) .replace(/\$\{(\w)\}/g, '$1') // Multi-char ${ANYTHING} -> '' (unknown value, strip entirely) .replace(/\$\{[^}]*\}/g, '') // Strip backtick subshell with empty/whitespace content .replace(/`\s*`/g, ''); // Iteratively strip backslash between word chars (c\u\r\l needs 2 passes) let prev; do { prev = result; result = result.replace(/(\w)\\(\w)/g, '$1$2'); } while (result !== prev); return result; } // Strip ANSI, collapse whitespace. export function normalizeCommand(cmd) { return cmd .replace(/\x1B\[[0-9;]*m/g, '') .replace(/\s+/g, ' ') .trim(); } // --------------------------------------------------------------------------- // Command-position view -- for rules that must distinguish a command from data // that merely names one. Whitespace is NOT collapsed, so newline stays a // separator. Quoted spans and heredoc bodies become data; the argument of a // shell wrapper stays a command; backslash-escaped names are seen through. // --------------------------------------------------------------------------- // `out` ends with the `-c` of a shell invocation -- the next quoted span is a // command string, not data. Optional leading `sudo` and absolute path. const SHELL_C_TAIL = /(?:^|[\s;|&])(?:sudo\s+(?:-[a-zA-Z]+\s+)*)?(?:[\w./-]*\/)?(?:ba|z|k|da|a)?sh\s+(?:-[a-zA-Z]+\s+)*-c\s*$/; // Drop heredoc bodies, keeping the operator line. Their newlines are not // command separators, and without this every heredoc line that happens to // start with a matched word reads as command position. Runs before the quote // scan, since a body may contain quotes that would desync it. export function stripHeredocBodies(cmd) { return cmd.replace( /(<<-?\s*(['"]?)(\w+)\2[^\n]*\n)[\s\S]*?(?:\n[ \t]*\3[ \t]*(?=\n|$)|$)/g, (_match, head) => head, ); } // `out` ends with `eval` -- like `sh -c`, its quoted argument is run as a command. const EVAL_TAIL = /(?:^|[\s;|&])eval\s*$/; // A quoted span that is one plain word ("push", 'git', "main"). const PLAIN_WORD = /^[^\s'"`$;|&()<>{}\\]+$/; // opts.keepWords: a quoted plain word stays that word (`git "push"` reads as // `git push`); without it every quoted span is data, as the BLOCK rules have // always read it. Quoted text with spaces is data either way. export function commandPositionView(cmd, opts = {}) { const src = stripHeredocBodies(cmd); let out = ''; let i = 0; while (i < src.length) { const ch = src[i]; if (ch === '\\' && i + 1 < src.length) { // An escaped character is literal: `don\'t` opens no quote, and an // escaped space stays inside its word (so `a\ #b` starts no comment). // A backslash-newline continues the line. const next = src[i + 1]; out += next === '\n' ? ' ' : /\s/.test(next) ? '_' : next; i += 2; } else if (ch === '#' && (out === '' || /[\s;|&(]$/.test(out))) { // A comment runs to the end of the line; its apostrophes are not quotes. const nl = src.indexOf('\n', i); i = nl === -1 ? src.length : nl; } else if (ch === "'" || ch === '"') { // Inside double quotes a backslash escapes the next character (`"a \" b"` // is one span); inside single quotes nothing is escaped. let close = i + 1; while (close < src.length && src[close] !== ch) close += ch === '"' && src[close] === '\\' ? 2 : 1; if (close >= src.length) close = -1; if (close === -1) { // Unterminated quote: the shell would not run this as written, so // read the rest as code rather than hide it as data. i += 1; continue; } const inner = src.slice(i + 1, close); if (SHELL_C_TAIL.test(out) || EVAL_TAIL.test(out)) out += `;${inner};`; else out += opts.keepWords && PLAIN_WORD.test(inner) ? inner : ' '; i = close + 1; } else { out += ch; i += 1; } } return ( out // `xargs [flags] ` puts at command position with no separator // in front of it. Flags taking a separate argument (`-I {}`) are not // parsed -- the separator lands before the argument, not the command. .replace(/\bxargs((?:\s+-[a-zA-Z0-9-]+)*)/g, 'xargs$1 ;') // `\name` runs name -- the backslash only suppresses alias expansion. // normalizeBashExpansion covers the between-word-chars case; this covers // a backslash at command position. .replace(/\\(\w)/g, '$1') ); } // The command-position view of a raw command string, as the guards build it. export function viewOf(command, opts = {}) { return commandPositionView(normalizeBashExpansion(command).replace(/\x1B\[[0-9;]*m/g, ''), opts); }