fix(llm-security): HIGH — bare root/home targets bypassed the rm block

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) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TcQyMTQfyrsAapaCMPxTtQ
This commit is contained in:
Kjell Tore Guttormsen 2026-07-18 09:08:12 +02:00
commit a92b3c5962
2 changed files with 23 additions and 4 deletions

View file

@ -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.',

View file

@ -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'));