#!/usr/bin/env node // Hook: pre-bash-executor.mjs // Event: PreToolUse (Bash) // Purpose: Block or warn about destructive shell commands, and ask a person // before merge into main, push, tag/release and destructive git (P12). // Wired universally (every Voyage session, not just execution) by deliberate // decision -- these are session-agnostic safety rails (rm -rf /, fork bombs, // ...). The CC if: path-scoping mechanism now works, but narrowing to // execute-only would only weaken protection with no offsetting benefit. See // cc-upgrade matrix CC-15/F2. // // Precondition: Voyage's guards are deterministic only when no installed mod // handles `tool.check`. A mod that does answers after the PreToolUse hooks // and can replace their answer (code.claude.com/docs/en/permissions section Extend // permissions with hooks). A hook cannot see whether such a mod is installed. // // Protocol: // - Read JSON from stdin: { tool_name, tool_input, cwd } // - tool_input.command — the shell command string // - BLOCK (exit 2): catastrophic/irreversible operations // - ASK (exit 0, JSON permissionDecision "ask"): merge into main, push, // tag/release, destructive git (hooks/lib/git-approval.mjs). The prompt is // labelled [plugin:voyage] and also appears in auto mode (hooks reference // section PreToolUse decision control). Advisories are added to the prompt. // In the permission mode that shows no prompts, every call it would ask // about is BLOCKED instead: there is no one to ask (operator, 2026-10-09). // - WARN (exit 0): risky but recoverable operations — advisory to stderr // - Allow (exit 0): everything else // // Based on llm-security's pre-bash-destructive.mjs with executor-specific additions. // Command reading lives in hooks/lib/bash-view.mjs (shared with the hook that // logs approvals). import { readFileSync } from 'node:fs'; // The guard's own modules are loaded so that a broken install cannot crash // the hook: a crash (exit 1) is a non-blocking error to Claude Code, which // would let every command through. A module that will not load makes the // guard ask about every command instead, and refuse it where no one can be asked. let lib; let loadError = null; try { const [view, approval, record] = await Promise.all([ import('../lib/bash-view.mjs'), import('../lib/git-approval.mjs'), import('../lib/approval-record.mjs'), ]); lib = { ...view, ...approval, ...record }; } catch (e) { loadError = e; } // --------------------------------------------------------------------------- // BLOCK rules — exit 2, command is not executed. // --------------------------------------------------------------------------- const BLOCK_RULES = [ { name: 'Filesystem root/home destruction (rm -rf /)', // Matches rm with both -r and -f flags targeting /, ~, or $HOME. // Uses (?:\s|$) instead of \b because / and ~ are non-word chars. pattern: /\brm\s+(?:-[a-zA-Z]*f[a-zA-Z]*\s+|--force\s+)*-[a-zA-Z]*r[a-zA-Z]*\s+(?:\/|~|\$HOME)(?:\s|$)/, description: '`rm -rf /`, `rm -rf ~`, and `rm -rf $HOME` would destroy the filesystem ' + 'or home directory. Unconditionally blocked.', }, { name: 'World-writable chmod (chmod 777)', pattern: /\bchmod\s+(?:-[a-zA-Z]+\s+)*777\b/, description: '`chmod 777` grants full read/write/execute to all users. ' + 'Use minimal permissions (e.g. 644, 755).', }, { name: 'Pipe-to-shell (curl|bash, wget|sh)', pattern: /(?:curl|wget)\b[^|]*\|\s*(?:bash|sh|zsh|ksh|dash)\b/, description: 'Piping remote content into a shell allows arbitrary remote code execution. ' + 'Download first, review, then execute.', }, { name: 'Fork bomb', pattern: /:\(\)\s*\{\s*:\s*\|\s*:&\s*\}\s*;?\s*:/, description: 'Fork bomb — exhausts system process resources. Blocked.', }, { name: 'Filesystem format (mkfs)', pattern: /\bmkfs(?:\.[a-z0-9]+)?\s/, description: '`mkfs` formats a filesystem, destroying all data. Blocked.', }, { name: 'Raw disk overwrite via dd', pattern: /\bdd\b[^&|;]*\bof=\/dev\/(?:sd|nvme|hd|vd|xvd|mmcblk)[a-z0-9]*/, description: '`dd` writing to a raw block device destroys disk data. Blocked.', }, { name: 'Direct device write (> /dev/sd*)', pattern: />\s*\/dev\/(?:sd|nvme|hd|vd|xvd|mmcblk)[a-z0-9]*/, description: 'Shell redirection to a block device destroys disk data. Blocked.', }, { name: 'eval with variable/command expansion', pattern: /\beval\s+(?:`|\$[\({]|"[^"]*\$)/, description: '`eval` with variable or command substitution is a code injection vector. ' + 'Refactor to use explicit commands.', }, // --- Executor-specific additions --- { name: 'System shutdown/reboot', // Anchored to command position — start of string/line, or after a // separator (`;`, `|`, `&`, `&&`), with optional `sudo` and an optional // absolute path. An unanchored \b match blocked the bare word anywhere, // including quoted grep patterns, heredoc data, and commit messages. // // Runs against commandView, not the whitespace-collapsed string: collapsing // \s+ to ' ' would erase the newline separator before the pattern ever saw // it, and quoted spans must read as data, not as command position. commandView: true, pattern: /(?:^|[\n;|&])\s*(?:sudo\s+(?:-[a-zA-Z]+\s+)*)?(?:[\w./-]*\/)?(?:shutdown|reboot|halt|poweroff)\b/, description: 'System shutdown/reboot commands are blocked during execution.', }, { name: 'Cron persistence', pattern: /\bcrontab\b|>\s*\/etc\/cron/, description: 'Writing to crontab or /etc/cron* creates persistent scheduled tasks. ' + 'Blocked during execution.', }, { name: 'Base64-encoded execution', pattern: /\bbase64\b[^|]*\|\s*(?:bash|sh|zsh)\b/, description: 'Base64-decoded content piped to shell is obfuscated code execution. Blocked.', }, { name: 'Kill all processes (kill -9 -1)', pattern: /\b(?:kill|pkill)\s+-9\s+-1\b/, description: 'Killing all user processes with signal 9. Blocked.', }, { name: 'History destruction', pattern: /\bhistory\s+-c\b|>\s*~\/\.bash_history\b|>\s*~\/\.zsh_history\b/, description: 'Clearing shell history or truncating history files. Blocked.', }, ]; // --------------------------------------------------------------------------- // WARN rules — exit 0 with advisory message on stderr. // --------------------------------------------------------------------------- const WARN_RULES = [ { name: 'Force push (git push --force)', pattern: /\bgit\s+push\b[^|&;]*(?:--force|-f)\b/, description: 'WARNING: `git push --force` rewrites remote history. Prefer `--force-with-lease`.', }, { name: 'Hard reset (git reset --hard)', pattern: /\bgit\s+reset\s+--hard\b/, description: 'WARNING: `git reset --hard` permanently discards uncommitted changes.', }, { name: 'Recursive remove (rm -rf, non-root)', pattern: /\brm\s+(?:-[a-zA-Z]*f[a-zA-Z]*\s+|--force\s+)*-[a-zA-Z]*r[a-zA-Z]*\s+/, description: 'WARNING: `rm -rf` permanently deletes files. Verify the target path.', }, { name: 'Docker system prune', pattern: /\bdocker\s+system\s+prune\b/, description: 'WARNING: `docker system prune` removes all stopped containers and unused images.', }, { name: 'npm publish', pattern: /\bnpm\s+publish\b/, description: 'WARNING: `npm publish` releases a package to the public registry.', }, { name: 'DROP TABLE or DROP DATABASE (SQL)', pattern: /\bDROP\s+(?:TABLE|DATABASE|SCHEMA)\b/i, description: 'WARNING: SQL DROP permanently deletes database objects.', }, { name: 'DELETE without WHERE (SQL)', pattern: /\bDELETE\s+FROM\s+\w+(?:\s*;|\s*$)/i, description: 'WARNING: DELETE FROM without WHERE deletes all rows.', }, // --- Executor-specific additions --- { name: 'Dependency installation during execution', pattern: /\b(?:npm\s+install\s+--save|pip3?\s+install\s+(?!-e\s+\.)|cargo\s+add)\b/, description: 'WARNING: Installing dependencies during plan execution is unusual. ' + 'Verify this is intentional.', }, ]; // --------------------------------------------------------------------------- // Main // --------------------------------------------------------------------------- let input; try { const raw = readFileSync(0, 'utf-8'); input = JSON.parse(raw); } catch { // Cannot parse stdin — fail open. process.exit(0); } const command = input?.tool_input?.command; if (!command || typeof command !== 'string') { process.exit(0); } // In the permission mode where Claude Code shows no prompts, an "ask" reaches // no one and the call runs unattended (see the refusals below). const NO_PROMPT_MODE = 'bypassPermissions'; const noOneToAsk = input.permission_mode === NO_PROMPT_MODE; // The one refusal text for "no one to ask", with the way forward (release // review of 6.3.0, round 7: it was written out twice). function refuseUnattended(what, commandLine, way) { process.stderr.write( `[voyage] BLOCKED: ${what} with no one to ask\n` + commandLine + ' This session runs in the permission mode where Claude Code asks nothing, so the guard cannot ask a person. ' + `Way forward: ${way}\n` ); process.exit(2); } if (loadError) { // A guard that cannot read its rules asks about every command; where no one // can be asked it refuses instead (release review of 6.3.0, 2026-10-10). if (noOneToAsk) { refuseUnattended(`Voyage's Bash guard could not load its rules (${loadError.message})`, '', 'reinstall Voyage to restore the guard, or run the command yourself.'); } process.stdout.write(JSON.stringify({ hookSpecificOutput: { hookEventName: 'PreToolUse', permissionDecision: 'ask', permissionDecisionReason: `Voyage's Bash guard could not load its rules (${loadError.message}), so this command was not checked. ` + 'Way forward: approve only a command you have read yourself; reinstall Voyage to restore the guard.', }, })); process.exit(0); } const { normalizeBashExpansion, normalizeCommand, commandPositionView, classifyGitApproval, askReason, writeApprovalRecord } = lib; // Strip bash evasion, then normalize whitespace const deobfuscated = normalizeBashExpansion(command); const normalized = normalizeCommand(deobfuscated); const commandView = commandPositionView(deobfuscated.replace(/\x1B\[[0-9;]*m/g, '')); // BEGIN args-heredoc-closed-early // The commands hand $ARGUMENTS to lib/parsers/arg-parser.mjs (--stdin) through // a quoted heredoc closed by VOYAGE_ARGS_EOF_7F3C9E. A typed line equal to that // delimiter would close the heredoc there, and the shell would run every line // after it as a command (fix round 4.5b, R6). This check reads the raw // command: `normalized` and `commandView` collapse lines and drop heredoc // bodies (hooks/lib/bash-view.mjs). The check runs when the command names the // delimiter once every backslash-newline is removed: the shell removes a // continuation inside the opener's word, and an unquoted body with no closing // line is still expanded (fix round 4.5b, part 4). // Every opener in the command is read in two ways, and the command is blocked // when either reading blocks it (the union): // 1. A regex finds < or at the end of the text. Its body lines are // the raw lines after the line on which the word ends: continuations // change only how the word is read, never the line count. // bash reads a heredoc body only from the lines after the opener's line, so // an opener with no non-blank line after it (a one-line grep or commit // message that only mentions it) has no body: nothing can close early and an // unquoted opener has nothing to expand, so it is skipped. An opener with a // non-blank line after it is blocked unless it is quoted (its body is not // expanded) and exactly one line, trimmed, equals the delimiter with every // line after that one empty (the regex reading counts every line of the // command, the shell reading the body lines). // A word the guard cannot read is treated as unquoted, whatever its letters: // a quote with no partner anywhere in the rest of the text (the word is cut // there, so its line is the quote's line), any backslash inside "...", // $"..." or $'...' (no escape there is decoded), a backslash at the end of // the text, a - reached only after a continuation (bash reads <<-, zsh // reads the - as part of the word), and a continuation between a $ and the // quote it opens. The word is compared with the delimiter after trim(), the // same trim the body lines get, so a CR (a CRLF line), VT, FF or other // character trim() strips around the word does not hide it. // After a quoted opener that passes, a later opener is skipped only when its // word is cut by an unpaired quote and is readable otherwise and not split by // a backslash-newline (fix round 4.5b2, part A, decision 126): the shell meets // the same unpaired quote, stops with a parse error and runs nothing after it, // so a typed argument such as fix "<<" handling passes. Every other later // opener is checked, because the accepted opener may sit inside a string and // must not hide a real opener after it. // Two kinds of << are text to the shell and are skipped (fix round 4.5b, // part 4): // - A cut word whose quote closes a string opened earlier on its line (an // odd count of that quote from the line start to the opener's first <), // as in grep -n '<<' or a commit message holding "<<". A cut word with // no string open before it stays unreadable and is blocked as unquoted. // The quote has no partner, so the outer shell never reads the word's // body as a heredoc body; a second shell (bash -c, eval) may, and the // regex reading still reads such a << when the delimiter follows it. An // opener whose raw span holds a backslash-newline has no regex match, so // it is never skipped (fix round 4.5b2, part A, decision 123). Each // opener is read on its own, so skipping one hides no later opener. // - A < after an odd run of backslashes (counted on the raw text, a line // break ends the run) starts no opener, in the regex reading and in the // shell reading alike, when a quote scan of the text before the run ends // outside every quote and every heredoc body. The scan skips a closed // '...' or "..." string, a here-string and the body of a heredoc closed // before the run, so none of these turns the rule off. A quote still open // before the run, a heredoc not closed before it, and text the scan // cannot follow (for example $' or $", $( ${ $[, a backquote, a (, a #, a // continuation, a heredoc word it cannot read, a second < on a heredoc's // line, a body line of an unquoted heredoc that ends in a backslash) keep // the rule off: there the shell may read the backslashes once more // (inside a backquote, or in a heredoc body fed to a shell, measured), so // the < is read as unescaped. The regex reading takes the shell reading's // decision for the same <. The < after the escaped one is read as usual, // so a << right after it is still checked (fix round 4.5b2, part A). // A < followed (continuations skipped) by an odd run of backslashes and a // second < is a heredoc operator a second shell may read: the outer shell // removes the backslashes, so eval, bash -c or sh -c reads << there. It is // blocked as an unquoted opener the guard could not read when the word after // the second < names the delimiter as a second shell reads it (every // backslash-newline removed, then a leading -, blanks, quotes and // backslashes) and a non-blank line follows anywhere after the opener's line // (the body may sit inside the quotes that carry the word). It is judged // before every skip. A one-line form has no such line and passes. The rule // does not look for the second shell, so the same text with none is blocked // as well (fix round 4.5b2, part A, decision 107). // The shell reading fails closed: an error in it blocks (exit 2), because an // exit 1 is a non-blocking error and the command would run. const ARGS_DELIM = 'VOYAGE_ARGS_EOF_7F3C9E'; function blockArgs(unquoted, unreadable) { const why = unquoted ? `The heredoc that hands the arguments to the arg parser opens ${ARGS_DELIM} unquoted, so the shell would expand its body. ` + `Way forward: open it as <<'${ARGS_DELIM}'.` : `The arguments hold a line equal to the heredoc's closing line ${ARGS_DELIM}, or text follows that closing line, ` + 'so the shell would run what follows as commands. Way forward: take that line out of the arguments and run the command again.'; const unread = unreadable ? " The guard could not read the opener's word, so it treats it as unquoted." : ''; process.stderr.write( '[voyage] BLOCKED: args-heredoc-closed-early\n' + ` Command: ${normalized.slice(0, 200)}${normalized.length > 200 ? '...' : ''}\n` + ` ${why}${unread}\n` ); process.exit(2); } // The quote state at `to` of `text` read from its start for quotes only: 'none' // (outside every quote), 'open' (inside '...' or "...", or inside an earlier // heredoc's body) or 'unknown' (the scan cannot follow the shell here). // `state` lets the calls on one text resume where the last stopped; a call // before that point starts over from the start of the text. function argsQuoteScan(text, to, openers, state) { const st = state.pos <= to ? state : { pos: 0, mode: 'none', pending: null, bodyEnd: -1 }; let p = st.pos; let mode = st.mode; let pending = st.pending; let bodyEnd = st.bodyEnd; const stop = (result) => { st.pos = p; st.mode = mode; st.pending = pending; st.bodyEnd = bodyEnd; return result; }; const unknown = () => { mode = 'unknown'; return stop('unknown'); }; for (;;) { if (mode === 'unknown') return stop('unknown'); if (mode === 'body') { if (to < bodyEnd) { p = to; return stop('open'); } p = bodyEnd; mode = 'none'; } // A token that runs past `to` (a heredoc word, a here-string) is not followed. if (p > to) return unknown(); if (p === to) return stop(mode === 'none' ? 'none' : 'open'); // The line break that ends a heredoc's line: its body starts after it. if (pending && p === pending.nl) { if (mode !== 'none' || pending.bodyEnd === null) return unknown(); mode = 'body'; bodyEnd = pending.bodyEnd; pending = null; continue; } const c = text[p]; const d = text[p + 1]; if (mode === 'single') { if (c === "'") mode = 'none'; p += 1; continue; } if (c === '\\') { // The scan never decides a backslash without the character it escapes. if (p + 1 >= to) return unknown(); if (d === '\n') return unknown(); // scan: a continuation cannot be followed p += 2; continue; } if (mode === 'double') { if (c === '"') mode = 'none'; else if (c === '`' || (c === '$' && (d === '(' || d === '{' || d === '['))) return unknown(); p += 1; continue; } if (c === "'" || c === '"') { mode = c === "'" ? 'single' : 'double'; p += 1; continue; } if (c === '`' || c === '(' || c === '#') return unknown(); if (c === '$' && (d === "'" || d === '"' || d === '(' || d === '{' || d === '[')) return unknown(); if (c === '<' && d === '<') { if (text[p + 2] === '<') { p += 3; // a here-string is text continue; } // The opener record at p (openers are in text order). let o = null; for (let lo = 0, hi = openers.length - 1; lo <= hi && !o;) { const m = (lo + hi) >> 1; if (openers[m].index < p) lo = m + 1; else if (openers[m].index > p) hi = m - 1; else o = openers[m]; } if (!o || o.unreadable || o.cutOnly || !/^[A-Za-z0-9_]+$/.test(o.word)) return unknown(); let nl = text.indexOf('\n', o.end); if (nl === -1) nl = text.length; if (text.slice(o.end, nl).includes('<')) return unknown(); // The closing line: the first line after nl equal to the word (after // <<- with its leading tabs stripped, nothing else). null: the body // cannot be followed (an unquoted body line joined to the next). let close = Infinity; for (let ls = nl + 1; nl < text.length;) { const le = text.indexOf('\n', ls); const line = text.slice(ls, le === -1 ? text.length : le); if ((o.dash ? line.replace(/^\t+/, '') : line) === o.word) { close = le === -1 ? text.length : le + 1; break; } if (!o.quoted && line.endsWith('\\')) { close = null; break; } if (le === -1) break; ls = le + 1; } pending = { nl, bodyEnd: close }; p = o.end; continue; } p += 1; } } // The escape rule cannot apply when no backslash stands directly before a < // (firstRun is -1: the fast path). The scan state is shared by the escape // checks of one command, so the text is read once (fix round 4.5b2, part A). function argsEscapeLimit(text) { return { firstRun: text.search(/\\= 0 && text[i - 1 - b] === '\\') b += 1; const s = i - b; return b % 2 === 1 && argsQuoteScan(text, s, openers, lim.state) === 'none'; } // An opener whose raw span holds a backslash-newline. The regex reading cannot // see such an opener, so it is never skipped, as string text or as body text // (fix round 4.5b2, part A, decisions 123 and 126). function argsSplit(text, o) { return text.slice(o.index, o.end).includes('\\\n'); } // A cut opener (its quote has no partner after it) whose quote closes a // string opened earlier on the raw line holding the opener's first <: an // odd count of that quote character from the line start to the <. function argsCutInString(text, o) { if (argsSplit(text, o)) return false; const q = text[o.end]; const lineStart = text.lastIndexOf('\n', o.index) + 1; return text.slice(lineStart, o.index).split(q).length % 2 === 0; } // After the second < of a twice-escaped operator (p), does the word a second // shell reads name the delimiter? Every backslash-newline is removed, then a // leading -, blanks, quotes and backslashes (fix round 4.5b2, part A, // decision 107). function argsRereadNames(text, p) { for (;;) { if (text[p] === '\\' && text[p + 1] === '\n') { p += 2; continue; } if ('- \t\'"\\'.includes(text[p]) && p < text.length) { p += 1; continue; } break; } let j = 0; while (j < ARGS_DELIM.length && p < text.length) { if (text[p] === '\\' && text[p + 1] === '\n') { p += 2; continue; } if (text[p] !== ARGS_DELIM[j]) return false; p += 1; j += 1; } return j === ARGS_DELIM.length; } // Each heredoc operator in `text`, in text order, as the shell reads its word: // { index (the operator's first <), end, word, quoted, unreadable, cutOnly, // dash (the - of <<- was read), reread (a twice-escaped operator a second // shell reads, decision 107) }. Every < it judges escaped goes into // lim.escaped, which the regex reading reads. function argsShellOpeners(text, lim = argsEscapeLimit(text)) { const n = text.length; // The index after any run of continuations that starts at k. const skip = (k) => { while (text[k] === '\\' && text[k + 1] === '\n') k += 2; return k; }; const openers = []; let i = text.indexOf('<'); while (i !== -1) { { const r = skip(i + 1); let b = 0; while (text[r + b] === '\\') b += 1; if (b % 2 === 1 && text[r + b] === '<' && argsRereadNames(text, r + b + 1)) { openers.push({ index: i, end: r + b + 1, word: '', quoted: false, unreadable: true, cutOnly: false, dash: false, reread: true }); } } if (argsEscapedLt(text, i, lim, openers)) { lim.escaped.add(i); i = text.indexOf('<', i + 1); continue; } let next = i + 1; let k = skip(i + 1); if (text[k] === '<') { const second = k; // The scan resumes right after the second <, never at the word's end, // so a quoted word that spans lines cannot hide a < inside it. next = second + 1; k = skip(second + 1); if (text[k] === '<') { next = k + 1; // a here-string: move past it } else { let unreadable = false; let dash = false; if (text[k] === '-') { dash = true; // After a continuation, bash reads <<- and zsh reads - as the word. if (k !== second + 1) unreadable = true; k = skip(k + 1); } while (text[k] === ' ' || text[k] === '\t') k = skip(k + 1); const start = k; let word = ''; let quoted = false; let cut = -1; while (k < n) { const c = text[k]; if (c === ' ' || c === '\t' || c === '\n' || ';&|()<>'.includes(c)) break; if (c === '\\') { if (text[k + 1] === '\n') { k += 2; continue; } if (k + 1 >= n) { unreadable = true; k += 1; break; } word += text[k + 1]; quoted = true; k += 2; continue; } // A $ opens $'...' or $"..." also across continuations; a // continuation between the $ and the quote makes the word unreadable. const q1 = c === '$' ? skip(k + 1) : k + 1; const dollar = c === '$' && (text[q1] === "'" || text[q1] === '"'); if (dollar && q1 !== k + 1) unreadable = true; if (c === "'" || c === '"' || dollar) { const open = dollar ? q1 : k; const q = text[open]; const close = text.indexOf(q, open + 1); if (close === -1) { cut = open; break; } const inner = text.slice(open + 1, close); if ((q === '"' || dollar) && inner.includes('\\')) unreadable = true; word += inner; quoted = true; k = close + 1; continue; } word += c; k += 1; } const isCut = cut !== -1; if (k > start || unreadable || isCut) { openers.push({ index: i, end: isCut ? cut : k, word, quoted, unreadable: unreadable || isCut, cutOnly: isCut && !unreadable, dash, }); } } } i = text.indexOf('<', next); } return openers; } if (command.replace(/\\\n/g, '').includes(ARGS_DELIM)) { // The regex reading and the shell reading. The shell reading's openers are // read first, so the regex reading takes its escape decisions; the shell // reading's loop records the first failing verdict; an error anywhere in // either reading blocks. let argsVerdict = null; try { const argsLim = argsEscapeLimit(command); const argsShell = argsShellOpeners(command, argsLim); const argsOpeners = command.matchAll(new RegExp(`<<-?\\s*(['"\\\\]?)${ARGS_DELIM}(['"]?)`, 'g')); const argsLines = command.split('\n'); const argsClosing = argsLines.flatMap((l, i) => (l.trim() === ARGS_DELIM ? [i] : [])); const closesOnceAtEnd = argsClosing.length === 1 && argsLines.slice(argsClosing[0] + 1).every((line) => line.trim() === ''); for (const argsOpener of argsOpeners) { // The shell reading's decision for the same <; a < it never reached as // a start counts as not escaped. if (argsLim.escaped.has(argsOpener.index)) continue; // Some line after the opener's line is non-blank (\s is the set trim() strips). const hasBody = /\n\s*\S/.test(command.slice(argsOpener.index)); if (!hasBody) continue; const [, open, close] = argsOpener; const quoted = open === '\\' ? close === '' : open !== '' && close === open; if (!quoted || !closesOnceAtEnd) blockArgs(!quoted, false); } let bodyFrom = Infinity; for (const o of argsShell) { if (o.reread) { if (/\n\s*\S/.test(command.slice(o.index))) { argsVerdict = [true, true]; break; } continue; } if (o.index >= bodyFrom && o.cutOnly && !argsSplit(command, o)) continue; if (o.cutOnly && argsCutInString(command, o)) continue; // A delimiter split by a backslash-newline whose word has a suffix is // judged as the regex reading judges the same opener written out (fix // round 4.5b2, part A). if (!o.unreadable && argsSplit(command, o)) { const w = o.word.trim(); if (w.startsWith(ARGS_DELIM) && w.length > ARGS_DELIM.length) { const m = new RegExp(`^<<-?\\s*(['"\\\\]?)${ARGS_DELIM}(['"]?)`).exec(command.slice(o.index, o.end).replace(/\\\n/g, '')); if (!m) continue; const snl = command.indexOf('\n', o.end); if (snl === -1) continue; const sbody = argsLines.slice(command.slice(0, snl).split('\n').length); if (!sbody.some((line) => line.trim() !== '')) continue; const [, sopen, sclose] = m; const squoted = sopen === '\\' ? sclose === '' : sopen !== '' && sclose === sopen; if (squoted && closesOnceAtEnd) continue; argsVerdict = [!squoted, false]; break; } } // The word is compared trimmed, as the body lines are: a word of D plus // characters trim() strips (a CR from a CRLF line, a VT, an FF, a // no-break space) is closed by a line equal to it, which trims to D. if (o.word.trim() !== ARGS_DELIM && !o.unreadable) continue; const nl = command.indexOf('\n', o.end); if (nl === -1) continue; // The raw lines after the line on which the word ends. const body = argsLines.slice(command.slice(0, nl).split('\n').length); if (!body.some((line) => line.trim() !== '')) continue; const isQuoted = o.quoted && !o.unreadable; const closing = body.flatMap((l, i) => (l.trim() === ARGS_DELIM ? [i] : [])); if (isQuoted && closing.length === 1 && body.slice(closing[0] + 1).every((line) => line.trim() === '')) { if (bodyFrom === Infinity) bodyFrom = nl + 1; continue; } argsVerdict = [!isQuoted, o.unreadable]; break; } } catch { blockArgs(true, true); } if (argsVerdict) blockArgs(argsVerdict[0], argsVerdict[1]); } // END args-heredoc-closed-early // Check BLOCK rules first for (const rule of BLOCK_RULES) { if (rule.pattern.test(rule.commandView ? commandView : normalized)) { process.stderr.write( `[voyage] BLOCKED: ${rule.name}\n` + ` Command: ${normalized.slice(0, 200)}${normalized.length > 200 ? '...' : ''}\n` + ` ${rule.description}\n` ); process.exit(2); } } // Check WARN rules (advisory — still exit 0) const warnings = []; for (const rule of WARN_RULES) { if (rule.pattern.test(normalized)) { warnings.push(` [WARN] ${rule.name}: ${rule.description}`); } } if (warnings.length > 0) { process.stderr.write( `[voyage] SECURITY ADVISORY: Potentially risky command.\n` + ` Command: ${normalized.slice(0, 200)}${normalized.length > 200 ? '...' : ''}\n` + warnings.join('\n') + '\n' ); } // P12: ask a person before merge into main, push, tag/release, destructive git. // A classifier error must not turn into a silent allow of a push: ask instead. let approvals; try { approvals = classifyGitApproval(command, { cwd: input.cwd }); } catch (e) { approvals = [{ name: `a command the approval guard could not read (${e.message})`, kind: null, way: 'approve only if you have read the command yourself.' }]; } // In the permission mode where Claude Code shows no prompts, an "ask" reaches // no one and the call runs unattended. Everything this guard would ask about - // merge into main, push, tag/release, destructive git, and a command it could // not read - is refused there instead, with the way forward (operator // decisions 2026-10-09: first merge and push, then the other two classes). // /trekexecute relies on this: its main-merge gate is a lock, and this guard's // question is the one question. if (noOneToAsk && approvals.length > 0) { refuseUnattended(approvals.map((m) => m.name).join(', '), ` Command: ${normalized.slice(0, 200)}${normalized.length > 200 ? '...' : ''}\n`, 'run the command yourself, or switch to a mode that asks, then retry.'); } if (approvals.length > 0) { // What the person is asked is what post-approval-log.mjs logs once the call ran. const loggable = approvals.filter((m) => m.kind).map(({ kind, name, subject }) => ({ kind, name, subject })); const recorded = writeApprovalRecord(input.session_id, input.tool_use_id, { command, permission_mode: input.permission_mode ?? null, matches: loggable, asked_at: new Date().toISOString(), }); // Say it now if this approval cannot reach the log afterwards. const unlogged = loggable.length > 0 && !recorded ? ' Note: this approval will not be logged (Voyage could not record the question).' : ''; const advisory = (warnings.length > 0 ? ` Advisory: ${warnings.map((w) => w.trim()).join(' ')}` : '') + unlogged; process.stdout.write(JSON.stringify({ hookSpecificOutput: { hookEventName: 'PreToolUse', permissionDecision: 'ask', permissionDecisionReason: askReason(approvals) + advisory, }, })); } process.exit(0);