diff --git a/tests/tools/test_hardline_blocklist.py b/tests/tools/test_hardline_blocklist.py index 669c867dc..7e6f918b8 100644 --- a/tests/tools/test_hardline_blocklist.py +++ b/tests/tools/test_hardline_blocklist.py @@ -31,6 +31,18 @@ _HARDLINE_BLOCK = [ # rm -rf targeting root / system dirs / home "rm -rf /", "rm -rf /*", + # Shell-equivalent spellings of "rm -rf /": repeated slashes and + # current/parent-dir segments all collapse back to root, so they must + # hit the hardline floor too (regression: these used to slip through the + # root pattern's target group and fall to the softer DANGEROUS_PATTERNS + # rule, which --yolo / approvals.mode=off / cron approve-mode bypass). + "rm -rf //", + "rm -rf /.", + "rm -rf /./", + "rm -rf /..", + "rm -rf //*", + "rm -fr /./", + "ls && rm -rf //", "rm -rf /home", "rm -rf /home/*", "rm -rf /etc", @@ -329,6 +341,37 @@ def test_yolo_env_var_cannot_bypass_hardline(clean_session, monkeypatch): assert r2.get("hardline") is True +def test_root_collapse_forms_cannot_bypass_hardline(clean_session, monkeypatch): + """Shell-equivalent spellings of "rm -rf /" stay blocked under yolo. + + "//", "/.", "/./", "/..", "//*" all collapse to the root filesystem in + the shell. They previously matched only the softer DANGEROUS_PATTERNS + rule, which yolo bypasses — leaving the hardline floor open to a full + root wipe under --yolo / approvals.mode=off / cron approve-mode. + """ + monkeypatch.setenv("HERMES_YOLO_MODE", "1") + + for cmd in ["rm -rf //", "rm -rf /.", "rm -rf /./", "rm -rf /..", "rm -rf //*"]: + is_hl, _ = detect_hardline_command(cmd) + assert is_hl, f"{cmd!r} should be hardline-blocked" + result = check_all_command_guards(cmd, "local") + assert result["approved"] is False, f"yolo leaked hardline on {cmd!r}" + assert result.get("hardline") is True + + +def test_root_collapse_pattern_leaves_real_paths_alone(clean_session): + """The broadened root token must not over-match real trailing segments. + + A path with a real component after the root-collapse prefix (/tmp, + /home/user/x, /.ssh, ./build) is recoverable-or-legitimate and must NOT + be pulled onto the hardline floor by the "collapse to /" broadening. + """ + for cmd in ["rm -rf /tmp", "rm -rf /home/user/x", "rm -rf /.ssh", + "rm -rf /.config", "rm -rf ./build", "rm -rf /opt/foo"]: + is_hl, _ = detect_hardline_command(cmd) + assert not is_hl, f"{cmd!r} must not be hardline-blocked (over-match)" + + def test_line_continuation_root_wipe_cannot_bypass_hardline(clean_session, monkeypatch): """A line-continuation root wipe must stay blocked even under yolo. diff --git a/tools/approval.py b/tools/approval.py index 92b6fb3ad..e7c1cecba 100644 --- a/tools/approval.py +++ b/tools/approval.py @@ -367,7 +367,18 @@ HARDLINE_PATTERNS = [ # `${HOME}` brace form and quoted paths (`rm -rf "/"`, `rm -rf "$HOME"`) # are handled via _hardline_rm_path so the floor cannot be bypassed with # the ordinary quoting/brace shell idioms. - (_RM_FLAG_PREFIX + _hardline_rm_path(r'/|/\*|/ \*'), "recursive delete of root filesystem"), + # + # The path token matches any root-anchored path whose components collapse + # back to "/" in the shell: a bare "/", repeated slashes ("//"), and + # "."/".." current/parent segments ("/.", "/./", "/..") all resolve to + # root, optionally followed by a trailing glob ("/*", "//*"). The earlier + # "/|/\*|/ \*" form only caught the literal "/" / "/*" spellings, so + # `rm -rf //`, `rm -rf /.`, `rm -rf /./`, `rm -rf /..` and `rm -rf //*` + # silently slipped the hardline floor and executed under --yolo / + # approvals.mode=off / cron approve-mode. A trailing real segment + # (e.g. "/tmp", "/home", "/.ssh") still fails to match here and stays + # with the softer DANGEROUS_PATTERNS / system-directory rules. + (_RM_FLAG_PREFIX + _hardline_rm_path(r'/[/.]*\**'), "recursive delete of root filesystem"), (_RM_FLAG_PREFIX + _hardline_rm_path(_HARDLINE_SYSTEM_DIRS), "recursive delete of system directory"), (_RM_FLAG_PREFIX + _hardline_rm_path(r'(?:~|\$\{?HOME\}?)(?:/?|/\*)?'), "recursive delete of home directory"), # Filesystem format