fix(hooks): close the bypasses anchoring opened in the destructive-command rule
Review finding 4638fea9 (MAJOR emitted, catalogue tier BLOCKER). Live in every
session regardless of the STORM flag, so it is not deferrable to the measurement
decision.
Anchoring the rule to command position (S75-S78) was right in intent - the
unanchored word match blocked quoted grep patterns, heredoc data and commit
messages - but it ran against the whitespace-collapsed string. Measured against
normalizeCommand() output, five forms that the previous rule blocked were
allowed:
- newline separator: \s+ -> ' ' collapsed the newline BEFORE the pattern ran,
making the \n branch of the separator class dead code
- `&` background separator: absent from the class entirely
- `bash -c` / `sh -c`: the wrapped command sits inside quotes, never at
command position
- `xargs <cmd>`: no separator in front of the command at all
And it missed its own motivating case: `grep "a|b" f` stayed blocked, because
the `|` inside the quotes still read as a separator.
Fix: the rule now runs against a command-position view (`commandView: true`,
per-rule input selection) instead of the collapsed string. The view keeps
newlines, adds `&` to the separator class, and classifies each span:
- quoted spans -> data (one space), so a grep alternation, echoed prose and a
commit message pass
- EXCEPT the argument of a shell wrapper (`sh -c`, `bash -c`, with optional
sudo and absolute path) -> spliced back in at command position
- heredoc bodies -> data, keeping the operator line. Restoring the newline
separator without this would newly block every heredoc line starting with a
matched word - the exact friction anchoring existed to remove
- `xargs [flags]` -> separator inserted after the flags
Two defects found while verifying, both the same regression class and both
fixed here rather than left:
- `\name` runs name (the backslash only suppresses alias expansion). The old
unanchored rule blocked it; the anchored one allowed it.
- heredoc bodies, as above - a false positive this change would otherwise have
introduced.
Known limit, stated rather than implied: `xargs -I {} <cmd>` is not parsed, so
the inserted separator lands before the argument, not the command.
Other BLOCK rules are untouched and still run against the collapsed string.
Verified by a 37-case adversarial probe through the real hook (all five bypass
forms, both wrapper forms, backslash, heredoc, quoted alternation, ordinary
commands, and the unrelated rules): 37/37 as expected. 9 new tests.
Suite 952 (950/0/2, baseline 937 + 15 across both Track A fixes).
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013zNqxP8qTWgJhn3wMYUFEh
This commit is contained in:
parent
cef3e7fa24
commit
fa2404b63c
2 changed files with 122 additions and 3 deletions
|
|
@ -106,10 +106,15 @@ const BLOCK_RULES = [
|
|||
{
|
||||
name: 'System shutdown/reboot',
|
||||
// Anchored to command position — start of string/line, or after a
|
||||
// separator (`;`, `|`, `&&`, `||`), with optional `sudo` and an optional
|
||||
// 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.
|
||||
pattern: /(?:^|[\n;|]|&&)\s*(?:sudo\s+(?:-[a-zA-Z]+\s+)*)?(?:[\w./-]*\/)?(?:shutdown|reboot|halt|poweroff)\b/,
|
||||
//
|
||||
// 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.',
|
||||
},
|
||||
{
|
||||
|
|
@ -202,6 +207,59 @@ function normalizeCommand(cmd) {
|
|||
.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
|
||||
// ---------------------------------------------------------------------------
|
||||
|
|
@ -223,10 +281,11 @@ if (!command || typeof command !== 'string') {
|
|||
// 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(normalized)) {
|
||||
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` +
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue