532 lines
23 KiB
JavaScript
532 lines
23 KiB
JavaScript
#!/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). A command that never names the delimiter
|
|
// skips the whole check.
|
|
// 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 <<D, <<'D', <<"D" and <<\D, with an optional - and
|
|
// spaces (fix round 4.5b and its part 2).
|
|
// 2. A shell reading (fix round 4.5b, part 3) starts at every < in the
|
|
// text, wherever it stands; like the regex, it does not lex the command
|
|
// around it. A backslash directly followed by a line break is a
|
|
// continuation: removed, not quoting. The word after << (and an optional
|
|
// -, spaces and tabs) is read as the shell reads it: a backslash makes
|
|
// the next character literal and quotes the word; '...', "...", $'...'
|
|
// and $"..." quote it, so a partly quoted word such as
|
|
// <<"VOYAGE"_ARGS_EOF_7F3C9E counts. The word ends at unquoted
|
|
// whitespace (a line break included),
|
|
// at one of ; & | ( ) < > 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: 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.
|
|
// 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);
|
|
}
|
|
|
|
// Each heredoc operator in `text`, in text order, as the shell reads its word:
|
|
// { index (the operator's first <), end, word, quoted, unreadable, cutOnly }.
|
|
function argsShellOpeners(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) {
|
|
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;
|
|
if (text[k] === '-') {
|
|
// After a continuation, bash reads <<- and zsh reads - as the word.
|
|
if (k !== second + 1) unreadable = true;
|
|
k = skip(k + 1);
|
|
}
|
|
while (k < n) {
|
|
if (text[k] === ' ' || text[k] === '\t') k += 1;
|
|
else if (text[k] === '\\' && text[k + 1] === '\n') k += 2;
|
|
else break;
|
|
}
|
|
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,
|
|
});
|
|
}
|
|
}
|
|
}
|
|
i = text.indexOf('<', next);
|
|
}
|
|
return openers;
|
|
}
|
|
|
|
if (command.includes(ARGS_DELIM)) {
|
|
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) {
|
|
// 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);
|
|
}
|
|
// The shell reading. The loop records the first failing verdict; an error
|
|
// anywhere in it blocks.
|
|
let argsVerdict = null;
|
|
try {
|
|
let bodyFrom = Infinity;
|
|
for (const o of argsShellOpeners(command)) {
|
|
if (o.index >= bodyFrom && o.cutOnly) continue;
|
|
// 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);
|