feat(voyage)!: marketplace handoff — rename plugins/ultraplan-local to plugins/voyage [skip-docs]
Session 5 of voyage-rebrand (V6). Operator-authorized cross-plugin scope. - git mv plugins/ultraplan-local plugins/voyage (rename detected, history preserved) - .claude-plugin/marketplace.json: voyage entry replaces ultraplan-local - CLAUDE.md: voyage row in plugin list, voyage in design-system consumer list - README.md: bulk rename ultra*-local commands -> trek* commands; ultraplan-local refs -> voyage; type discriminators (type: trekbrief/trekreview); session-title pattern (voyage:<command>:<slug>); v4.0.0 release-note paragraph - plugins/voyage/.claude-plugin/plugin.json: homepage/repository URLs point to monorepo voyage path - plugins/voyage/verify.sh: drop URL whitelist exception (no longer needed) Closes voyage-rebrand. bash plugins/voyage/verify.sh PASS 7/7. npm test 361/361.
This commit is contained in:
parent
8f1bf9b7b4
commit
7a90d348ad
149 changed files with 26 additions and 33 deletions
247
plugins/voyage/hooks/scripts/pre-bash-executor.mjs
Normal file
247
plugins/voyage/hooks/scripts/pre-bash-executor.mjs
Normal file
|
|
@ -0,0 +1,247 @@
|
|||
#!/usr/bin/env node
|
||||
// Hook: pre-bash-executor.mjs
|
||||
// Event: PreToolUse (Bash)
|
||||
// Purpose: Block or warn about destructive shell commands during plan execution.
|
||||
//
|
||||
// Protocol:
|
||||
// - Read JSON from stdin: { tool_name, tool_input }
|
||||
// - tool_input.command — the shell command string
|
||||
// - BLOCK (exit 2): catastrophic/irreversible operations
|
||||
// - 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.
|
||||
// bash-normalize logic copied inline (MIT) — cannot import from separate plugin.
|
||||
|
||||
import { readFileSync } from 'node:fs';
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Bash normalization (from llm-security/scanners/lib/bash-normalize.mjs)
|
||||
// Strips bash evasion techniques: empty quotes, ${} expansion, backslash splitting.
|
||||
// ---------------------------------------------------------------------------
|
||||
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;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 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',
|
||||
pattern: /\b(?: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.',
|
||||
},
|
||||
];
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Normalize: strip ANSI, collapse whitespace
|
||||
// ---------------------------------------------------------------------------
|
||||
function normalizeCommand(cmd) {
|
||||
return cmd
|
||||
.replace(/\x1B\[[0-9;]*m/g, '')
|
||||
.replace(/\s+/g, ' ')
|
||||
.trim();
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 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);
|
||||
}
|
||||
|
||||
// Strip bash evasion, then normalize whitespace
|
||||
const deobfuscated = normalizeBashExpansion(command);
|
||||
const normalized = normalizeCommand(deobfuscated);
|
||||
|
||||
// Check BLOCK rules first
|
||||
for (const rule of BLOCK_RULES) {
|
||||
if (rule.pattern.test(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'
|
||||
);
|
||||
}
|
||||
|
||||
process.exit(0);
|
||||
Loading…
Add table
Add a link
Reference in a new issue