From a92b3c59622d7b2542f429c05239147dfcee9451 Mon Sep 17 00:00:00 2001 From: Kjell Tore Guttormsen Date: Sat, 18 Jul 2026 09:08:12 +0200 Subject: [PATCH] =?UTF-8?q?fix(llm-security):=20HIGH=20=E2=80=94=20bare=20?= =?UTF-8?q?root/home=20targets=20bypassed=20the=20rm=20block?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The BLOCK rule named "Filesystem root destruction" did not block the bare root target it is named for. The target alternation ended in a shared `\b`, and a word boundary cannot hold after `/` or `~` at end-of-command. Measured against the shipped pattern: bare `/` target NOT blocked `$HOME` target blocked bare `~` target NOT blocked `/usr` target blocked `/*` glob target NOT blocked swapped flags (-fr) NOT blocked sudo-prefixed NOT blocked Only targets whose first character is a word character ever satisfied the assertion, so the rule caught `/etc` but not bare root. These fell through to WARN (exit 0) — advisory only, command executed. - Pattern: `\b` moved onto the `$HOME` alternative alone, where it is meaningful (it ends in a word char, so `$HOMEDIR` is still not swallowed). Dropped from `/` and `~`. Nothing else changes: `/etc`, `/home`, `./build` behave exactly as before. - The old test file encoded this defect as expected behaviour, with a NOTE claiming the pattern "requires separate flag groups (e.g. -f -r, not -rf combined)". That diagnosis was wrong — the `/etc` case blocks fine with merged flags. Comment replaced with the real root cause. npm test: 1872/1872 green (1865 + 7 new). Co-Authored-By: Claude Opus 4.8 (1M context) Claude-Session: https://claude.ai/code/session_01TcQyMTQfyrsAapaCMPxTtQ --- hooks/scripts/pre-bash-destructive.mjs | 6 +++++- tests/hooks/pre-bash-destructive.test.mjs | 21 ++++++++++++++++++--- 2 files changed, 23 insertions(+), 4 deletions(-) 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'));