The review of v6.2.0..f669b87 (BLOCK: 1 BLOCKER, 12 MAJOR, 1 MINOR), each fixed behind a test that failed first, or, for the two missing tests, a test shown to fail on a stub of the branch it covers. - Review steps appended by /trekexecute Phase 7.2 are held to the step rules on any plan version: plan-validator --steps-from <N> (on a plan below 1.8 a template review step validated and was executed). - The guard refuses where no one can be asked also when its rules will not load; the wave merge path names that refusal instead of MERGE_REFUSED. Tests for the classifier-error refusal and post-approval-log's own check. - release-check: the version files may change only in their version after the review (JSON compared without it), README diff lines are read inside hunks only, an unreadable diff is exit 2, and npm test runs without NODE_TEST_CONTEXT. - What counts as a step of only tests: a note in parentheses is not a path, and a data file under specs/ is not a test. End-to-end: rides on the last step that builds behaviour (validator hint, trekplan, template). - A removed line is cited as <rev>:<path>:<line> (findings-schema hint, trekreview re-ask). - skill-lint counts a trailing newline as no line; regression-check takes commandEnv from lib/util/command-env.mjs; CLAUDE.md names four mode-read exemptions. - /security-review, older than v6.2.0: the criteria runner refuses git --output, which writes a file. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
331 lines
13 KiB
JavaScript
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);
|