feat(hooks): wave-1 guards -- approval ask, plan gate, test lock, post-edit check
Wave-1 order 4 (P7, P12, the P9 test lock, the P3 plan-approval guard). All command hooks; none runs a model. - P12: pre-bash-executor answers permissionDecision "ask" before merge into main, push, tag/publish and destructive git (holds in auto mode). post-approval-log writes exactly what the person was asked to the K2 approval log: merge/push to main as `merge`, tag/publish as `release`, with a one-hour expiry; nothing when no person was asked. - P3: pre-plan-gate runs the K3 plan gate on a Voyage plan leaving plan mode and sends a refused plan back; "not measured" asks, never passes. - P9: lib/util/test-lock.mjs (K8). pre-write-executor refuses edits to locked test files; stop-test-lock refuses "done" for the locking session (stop_hook_active guarded). - P7: post-edit-check runs the target repo's own formatter/linter per edit, only during a Voyage plan run or after opt-in (.claude/voyage-guards.json); lib/verification/step-check.mjs (K9) fails the step on findings; no tool reads "not measured", never green. - STORM cap matcher also names `Agent` (the tool's name since 2.1.63). - Mod precondition stated in README, SECURITY.md and every PreToolUse stop guard: Voyage's guards are deterministic only when no installed mod handles `tool.check`. K2, K3 and K5 are stubbed in tests until orders 2 and 3 are merged. Two independent review rounds; every finding has a regression test. Suite 1600 (1598 pass / 0 fail / 2 skipped). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
parent
2c89a6b580
commit
2ee8137e7c
31 changed files with 3278 additions and 107 deletions
|
|
@ -1,51 +1,51 @@
|
|||
#!/usr/bin/env node
|
||||
// Hook: pre-bash-executor.mjs
|
||||
// Event: PreToolUse (Bash)
|
||||
// Purpose: Block or warn about destructive shell commands. 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.
|
||||
// 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 }
|
||||
// - 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.
|
||||
// - 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.
|
||||
// Command reading lives in hooks/lib/bash-view.mjs (shared with the hook that
|
||||
// logs approvals).
|
||||
|
||||
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;
|
||||
// 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.
|
||||
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;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
|
|
@ -197,69 +197,6 @@ const WARN_RULES = [
|
|||
},
|
||||
];
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Normalize: strip ANSI, collapse whitespace
|
||||
// ---------------------------------------------------------------------------
|
||||
function normalizeCommand(cmd) {
|
||||
return cmd
|
||||
.replace(/\x1B\[[0-9;]*m/g, '')
|
||||
.replace(/\s+/g, ' ')
|
||||
.trim();
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Command-position view — for rules that must distinguish a command from data
|
||||
// that merely names one. Whitespace is NOT collapsed, so newline stays a
|
||||
// separator. Quoted spans and heredoc bodies become data; the argument of a
|
||||
// shell wrapper stays a command; backslash-escaped names are seen through.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
// `out` ends with the `-c` of a shell invocation — the next quoted span is a
|
||||
// command string, not data. Optional leading `sudo` and absolute path.
|
||||
const SHELL_C_TAIL =
|
||||
/(?:^|[\s;|&])(?:sudo\s+(?:-[a-zA-Z]+\s+)*)?(?:[\w./-]*\/)?(?:ba|z|k|da|a)?sh\s+(?:-[a-zA-Z]+\s+)*-c\s*$/;
|
||||
|
||||
// Drop heredoc bodies, keeping the operator line. Their newlines are not
|
||||
// command separators, and without this every heredoc line that happens to
|
||||
// start with a matched word reads as command position. Runs before the quote
|
||||
// scan, since a body may contain quotes that would desync it.
|
||||
function stripHeredocBodies(cmd) {
|
||||
return cmd.replace(
|
||||
/(<<-?\s*(['"]?)(\w+)\2[^\n]*\n)[\s\S]*?(?:\n[ \t]*\3[ \t]*(?=\n|$)|$)/g,
|
||||
(_match, head) => head,
|
||||
);
|
||||
}
|
||||
|
||||
function commandPositionView(cmd) {
|
||||
const src = stripHeredocBodies(cmd);
|
||||
let out = '';
|
||||
let i = 0;
|
||||
while (i < src.length) {
|
||||
const ch = src[i];
|
||||
if (ch === "'" || ch === '"') {
|
||||
const close = src.indexOf(ch, i + 1);
|
||||
const inner = close === -1 ? src.slice(i + 1) : src.slice(i + 1, close);
|
||||
// Unterminated quote — treat the remainder as one span and stop.
|
||||
out += SHELL_C_TAIL.test(out) ? `;${inner};` : ' ';
|
||||
i = close === -1 ? src.length : close + 1;
|
||||
} else {
|
||||
out += ch;
|
||||
i += 1;
|
||||
}
|
||||
}
|
||||
return (
|
||||
out
|
||||
// `xargs [flags] <cmd>` puts <cmd> at command position with no separator
|
||||
// in front of it. Flags taking a separate argument (`-I {}`) are not
|
||||
// parsed — the separator lands before the argument, not the command.
|
||||
.replace(/\bxargs((?:\s+-[a-zA-Z0-9-]+)*)/g, 'xargs$1 ;')
|
||||
// `\name` runs name — the backslash only suppresses alias expansion.
|
||||
// normalizeBashExpansion covers the between-word-chars case; this covers
|
||||
// a backslash at command position.
|
||||
.replace(/\\(\w)/g, '$1')
|
||||
);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Main
|
||||
// ---------------------------------------------------------------------------
|
||||
|
|
@ -278,6 +215,20 @@ if (!command || typeof command !== 'string') {
|
|||
process.exit(0);
|
||||
}
|
||||
|
||||
if (loadError) {
|
||||
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);
|
||||
|
|
@ -311,4 +262,35 @@ if (warnings.length > 0) {
|
|||
);
|
||||
}
|
||||
|
||||
// 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.' }];
|
||||
}
|
||||
|
||||
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);
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue