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
130
hooks/lib/bash-view.mjs
Normal file
130
hooks/lib/bash-view.mjs
Normal file
|
|
@ -0,0 +1,130 @@
|
|||
// hooks/lib/bash-view.mjs
|
||||
// How the Bash guards read a command. Moved out of pre-bash-executor.mjs so
|
||||
// the guard that asks before a call (pre-bash-executor.mjs) and the hook that
|
||||
// logs the approval after it (post-approval-log.mjs) read a command the same
|
||||
// way. Behavior unchanged from the inline copy.
|
||||
//
|
||||
// Bash normalization from llm-security/scanners/lib/bash-normalize.mjs (MIT,
|
||||
// same author); lib/parsers/bash-normalize.mjs keeps the test-suite copy.
|
||||
|
||||
// Strips bash evasion techniques: empty quotes, ${} expansion, backslash splitting.
|
||||
export 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;
|
||||
}
|
||||
|
||||
// Strip ANSI, collapse whitespace.
|
||||
export 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.
|
||||
export function stripHeredocBodies(cmd) {
|
||||
return cmd.replace(
|
||||
/(<<-?\s*(['"]?)(\w+)\2[^\n]*\n)[\s\S]*?(?:\n[ \t]*\3[ \t]*(?=\n|$)|$)/g,
|
||||
(_match, head) => head,
|
||||
);
|
||||
}
|
||||
|
||||
// `out` ends with `eval` -- like `sh -c`, its quoted argument is run as a command.
|
||||
const EVAL_TAIL = /(?:^|[\s;|&])eval\s*$/;
|
||||
|
||||
// A quoted span that is one plain word ("push", 'git', "main").
|
||||
const PLAIN_WORD = /^[^\s'"`$;|&()<>{}\\]+$/;
|
||||
|
||||
// opts.keepWords: a quoted plain word stays that word (`git "push"` reads as
|
||||
// `git push`); without it every quoted span is data, as the BLOCK rules have
|
||||
// always read it. Quoted text with spaces is data either way.
|
||||
export function commandPositionView(cmd, opts = {}) {
|
||||
const src = stripHeredocBodies(cmd);
|
||||
let out = '';
|
||||
let i = 0;
|
||||
while (i < src.length) {
|
||||
const ch = src[i];
|
||||
if (ch === '\\' && i + 1 < src.length) {
|
||||
// An escaped character is literal: `don\'t` opens no quote, and an
|
||||
// escaped space stays inside its word (so `a\ #b` starts no comment).
|
||||
// A backslash-newline continues the line.
|
||||
const next = src[i + 1];
|
||||
out += next === '\n' ? ' ' : /\s/.test(next) ? '_' : next;
|
||||
i += 2;
|
||||
} else if (ch === '#' && (out === '' || /[\s;|&(]$/.test(out))) {
|
||||
// A comment runs to the end of the line; its apostrophes are not quotes.
|
||||
const nl = src.indexOf('\n', i);
|
||||
i = nl === -1 ? src.length : nl;
|
||||
} else if (ch === "'" || ch === '"') {
|
||||
// Inside double quotes a backslash escapes the next character (`"a \" b"`
|
||||
// is one span); inside single quotes nothing is escaped.
|
||||
let close = i + 1;
|
||||
while (close < src.length && src[close] !== ch) close += ch === '"' && src[close] === '\\' ? 2 : 1;
|
||||
if (close >= src.length) close = -1;
|
||||
if (close === -1) {
|
||||
// Unterminated quote: the shell would not run this as written, so
|
||||
// read the rest as code rather than hide it as data.
|
||||
i += 1;
|
||||
continue;
|
||||
}
|
||||
const inner = src.slice(i + 1, close);
|
||||
if (SHELL_C_TAIL.test(out) || EVAL_TAIL.test(out)) out += `;${inner};`;
|
||||
else out += opts.keepWords && PLAIN_WORD.test(inner) ? inner : ' ';
|
||||
i = 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')
|
||||
);
|
||||
}
|
||||
|
||||
// The command-position view of a raw command string, as the guards build it.
|
||||
export function viewOf(command, opts = {}) {
|
||||
return commandPositionView(normalizeBashExpansion(command).replace(/\x1B\[[0-9;]*m/g, ''), opts);
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue