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:
Kjell Tore Guttormsen 2026-08-12 22:03:22 +02:00
commit fa2404b63c
2 changed files with 122 additions and 3 deletions

View file

@ -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` +

View file

@ -139,6 +139,42 @@ test('pre-bash-executor BLOCKS a destructive keyword after a separator', async (
assert.strictEqual(code, 2);
});
// Bypasses opened when the rule was anchored to command position: whitespace
// was collapsed BEFORE the pattern ran (killing the newline branch), `&` was
// missing from the separator class, and a keyword handed to a shell wrapper
// sits at command position without any separator in front of it.
test('pre-bash-executor BLOCKS a destructive keyword after a newline separator', async () => {
const { code } = await runHook(PRE_BASH, bashInput('echo done\npoweroff'));
assert.strictEqual(code, 2);
});
test('pre-bash-executor BLOCKS a destructive keyword after a background separator', async () => {
const { code } = await runHook(PRE_BASH, bashInput('echo done & poweroff'));
assert.strictEqual(code, 2);
});
test('pre-bash-executor BLOCKS a destructive command wrapped in bash -c', async () => {
const { code } = await runHook(PRE_BASH, bashInput('bash -c "poweroff"'));
assert.strictEqual(code, 2);
});
test('pre-bash-executor BLOCKS a destructive command wrapped in sh -c', async () => {
const { code } = await runHook(PRE_BASH, bashInput("sh -c 'reboot'"));
assert.strictEqual(code, 2);
});
test('pre-bash-executor BLOCKS a destructive command handed to xargs', async () => {
const { code } = await runHook(PRE_BASH, bashInput('echo x | xargs reboot'));
assert.strictEqual(code, 2);
});
test('pre-bash-executor BLOCKS a backslash-escaped destructive command', async () => {
// `\reboot` runs reboot — the backslash suppresses alias expansion, nothing
// else. The command-position anchor must see through it.
const { code } = await runHook(PRE_BASH, bashInput('\\reboot'));
assert.strictEqual(code, 2);
});
// -----------------------------------------------------------------------
// ALLOW — the same keywords as DATA, not at command position.
// The rule matched the bare word anywhere in the string, so a quoted grep
@ -150,6 +186,30 @@ test('pre-bash-executor ALLOWS the keyword inside a quoted grep pattern', async
assert.strictEqual(code, 0);
});
// The change's own motivating case: a quoted grep alternation. Anchoring alone
// did not reach it — the `|` inside the quotes reads as a separator unless
// quoted spans are treated as data.
test('pre-bash-executor ALLOWS a quoted grep alternation over the keywords', async () => {
const { code } = await runHook(PRE_BASH, bashInput('grep "halt|poweroff" f.mjs'));
assert.strictEqual(code, 0);
});
// Heredoc bodies are data too, and a newline separator is what makes them look
// like command position. The rule's own comment names heredoc data as the
// friction anchoring was meant to remove.
test('pre-bash-executor ALLOWS the keyword at the start of a heredoc body line', async () => {
const { code } = await runHook(PRE_BASH, bashInput('cat <<EOF\nreboot is a word here\nEOF'));
assert.strictEqual(code, 0);
});
test('pre-bash-executor ALLOWS a commit message piped through a heredoc', async () => {
const { code } = await runHook(
PRE_BASH,
bashInput("git commit -F - <<'MSG'\nhalt the loop on empty turns\nMSG"),
);
assert.strictEqual(code, 0);
});
test('pre-bash-executor ALLOWS the keyword inside echoed prose', async () => {
const { code } = await runHook(PRE_BASH, bashInput('echo "we should halt here"'));
assert.strictEqual(code, 0);