diff --git a/hooks/scripts/pre-bash-destructive.mjs b/hooks/scripts/pre-bash-destructive.mjs index 5abb619..0510a78 100644 --- a/hooks/scripts/pre-bash-destructive.mjs +++ b/hooks/scripts/pre-bash-destructive.mjs @@ -21,7 +21,11 @@ import { getPolicyValue } from '../../scanners/lib/policy-loader.mjs'; const BLOCK_RULES = [ { name: 'Filesystem root destruction (rm -rf /)', - pattern: /\brm\s+(?:-[a-zA-Z]*f[a-zA-Z]*\s+|--force\s+)*-[a-zA-Z]*r[a-zA-Z]*\s+(?:\/|~|\$HOME)\b/, + // The target alternation must NOT end in a shared `\b`: a trailing word-boundary + // cannot hold after `/` or `~` at end-of-command, so the bare `rm -rf /` and + // `rm -rf ~` forms this rule is named for would fall through. `\b` belongs only on + // `$HOME`, which ends in a word character (so `$HOMEDIR` is not swallowed). + pattern: /\brm\s+(?:-[a-zA-Z]*f[a-zA-Z]*\s+|--force\s+)*-[a-zA-Z]*r[a-zA-Z]*\s+(?:\/|~|\$HOME\b)/, description: '`rm -rf /`, `rm -rf ~`, and `rm -rf $HOME` would destroy the entire filesystem ' + 'or home directory. This command is unconditionally blocked.', diff --git a/tests/hooks/pre-bash-destructive.test.mjs b/tests/hooks/pre-bash-destructive.test.mjs index b0d26a3..37366d8 100644 --- a/tests/hooks/pre-bash-destructive.test.mjs +++ b/tests/hooks/pre-bash-destructive.test.mjs @@ -17,9 +17,24 @@ function bashPayload(command) { // --------------------------------------------------------------------------- describe('pre-bash-destructive — BLOCK cases', () => { - // NOTE: The block pattern requires separate flag groups (e.g. -f -r, not -rf combined). - // `rm -rf /` with merged flags is caught only by the WARN rule, not the BLOCK rule. - // Commands with split flags and a word-boundary target are reliably blocked. + // Regression: the root-destruction rule previously ended in a `\b` assertion, which + // cannot hold after a non-word target character. `rm -rf /` and `rm -rf ~` — the two + // most literal forms the rule is named for — therefore fell through to WARN only. + // The bare-target cases below are the regression guard; do not relax them. + + for (const cmd of ['rm -rf /', 'rm -rf ~', 'rm -rf /*', 'rm -fr /', 'sudo rm -rf /', 'rm -rf ~/']) { + it(`blocks ${cmd} (bare root/home target)`, async () => { + const result = await runHook(SCRIPT, bashPayload(cmd)); + assert.equal(result.code, 2, `expected BLOCK (exit 2) for: ${cmd}`); + assert.match(result.stderr, /BLOCKED/); + assert.match(result.stderr, /Filesystem root destruction/); + }); + } + + it('does not block rm -rf $HOMEDIR (different variable, not $HOME)', async () => { + const result = await runHook(SCRIPT, bashPayload('rm -rf $HOMEDIR/cache')); + assert.notEqual(result.code, 2); + }); it('blocks rm -f -r /home (split flags targeting root-level directory)', async () => { const result = await runHook(SCRIPT, bashPayload('rm -f -r /home'));