voyage/hooks/scripts/pre-bash-executor.mjs
Kjell Tore Guttormsen 45aa9e107a
fix: round 6 of the release review of 6.3.0
Round 6 (v6.2.0..c3641ff, WARN: 11 MAJOR, 3 MINOR; 0 BLOCKER). The round-5
fixes hold (code-correctness-reviewer); three of the new MAJOR were side
effects of running checks with no shell. Each fixed behind a test that
failed first on c3641ff.

- git checks stay in the repo too: no path that climbs out with .., starts
  at / or reads <rev>:../x, and no --no-index (both reviewers).
- A trailing `# comment` on a shell-fence line is dropped: with no shell,
  its words made an honest check NOT RUN, among them the first check of the
  shipped example plan.
- A glob reaches `node --test` only on Node 21 and later, which expands it
  itself; on an older Node it is refused with that reason (engines >=18).
- With no shell, exit 126/127 is the test program's own: red proof reads it
  as a red (the control run still tells an environment fault apart), the
  done line and the test lock as a failure.
- A runner that could not start (not installed) is NOT RUN in the criteria
  runner and not-run (exit 2) in regression-check, as red-proof and
  done-line already said; never a red suite.
- Tests-only: a backticked `Makefile` alone in its entry is a path, and red
  proof confirms the plan's tests-only reading against the step's own diff
  (the altitude finding: the diff is a fact, the Files: prose is not).
- release-check stops a suite that never ends (15 minutes) and reads it as
  not measured (exit 2).
- The two lines this release added to pre-bash-executor.mjs are ASCII.
- MINOR: git and the other runners share one plain-word pattern.

Not in this round: MINOR f00a2eb8 (shared norm/ticked-path and isMain
helpers) and MINOR on plan-review-dedup's repeated tokenizing.

Suite 2194 (2192/0/2).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-11 01:07:47 +02:00

331 lines
13 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 refusal below).
const NO_PROMPT_MODE = 'bypassPermissions';
const noOneToAsk = input.permission_mode === NO_PROMPT_MODE;
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) {
process.stderr.write(
`[voyage] BLOCKED: Voyage's Bash guard could not load its rules (${loadError.message}) and there is no one to ask\n` +
' This session runs in the permission mode where Claude Code asks nothing, so the guard cannot ask a person. ' +
'Way forward: reinstall Voyage to restore the guard, or run the command yourself.\n'
);
process.exit(2);
}
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, ''));
// 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.
const unattended = noOneToAsk ? approvals : [];
if (unattended.length > 0) {
process.stderr.write(
`[voyage] BLOCKED: ${unattended.map((m) => m.name).join(', ')} with no one to ask\n` +
` Command: ${normalized.slice(0, 200)}${normalized.length > 200 ? '...' : ''}\n` +
' This session runs in the permission mode where Claude Code asks nothing, so the approval guard ' +
'cannot ask a person. Way forward: run the command yourself, or switch to a mode that asks, then retry.\n'
);
process.exit(2);
}
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);