Files
agent-desktop/tests/unit/security/test_tool_guard_rm_bypass.py
T

237 lines
8.2 KiB
Python

# -*- coding: utf-8 -*-
"""rm protection bypass regression tests.
Regression for #5090: rm protection must block root/home/absolute targets.
The guard in ``pineagents.security.tool_guard.guardians.rule_guardian`` extracts
rm targets, expands ``~``/``$HOME``/``${VAR}``, resolves them to absolute
paths, and flags any target that falls outside the workspace. Issue #5090
reported bypasses via ``~``, ``$HOME``, absolute paths and globs that expand
toward root.
These tests pin the contract that those targets are all flagged as outside
the workspace, while legitimate in-workspace rm commands remain allowed.
"""
# pylint: disable=protected-access,redefined-outer-name,unused-argument,use-implicit-booleaness-not-comparison # noqa: E501
from __future__ import annotations
import os
from collections.abc import Iterator
from pathlib import Path
from unittest.mock import patch
import pytest
from pineagents.security.tool_guard.guardians.rule_guardian import (
_check_rm_targets_outside_workspace,
)
# These cases assert Unix ``rm`` semantics - absolute ``/``-rooted targets
# and the ``\\rm`` / ``$(which rm)`` / ``command rm`` escape family. On
# Windows the guard's ``/foo`` token is ambiguous with ``del /F`` flags, so
# ``_extract_rm_targets`` skips it (see rule_guardian._extract_rm_targets).
# That quirk is unrelated to the #5090 bypass; the Unix rm family only ever
# runs on POSIX, so skip on win32.
_unix_rm_only = pytest.mark.skipif(
"sys.platform == 'win32'",
reason="Unix rm semantics (/abs targets, \\rm, $(which rm)) not "
"exercised on Windows; guard treats /foo as a del flag there "
"(rule_guardian._extract_rm_targets). See #5090.",
)
@pytest.fixture
def workspace(tmp_path: Path) -> Iterator[Path]:
"""A tmp workspace; rm below this root is legitimate."""
ws = tmp_path / "ws"
ws.mkdir()
home = tmp_path / "fake-home"
home.mkdir()
with (
patch(
"pineagents.config.context.get_current_workspace_dir",
return_value=ws,
),
patch(
"pineagents.security.tool_guard.guardians."
"rule_guardian._get_workspace_root",
return_value=ws,
),
patch.dict(os.environ, {"HOME": str(home), "USERPROFILE": str(home)}),
):
yield ws
# ---------------------------------------------------------------------------
# Blocked: root, home, absolute, glob-to-root
# ---------------------------------------------------------------------------
@pytest.mark.parametrize(
"command",
[
"rm -rf /",
"rm -rf /*",
"rm -rf /etc",
"rm -rf /etc/passwd",
"rm -rf /tmp/foo",
"rm -rf /usr",
"rm -rf /var/log",
],
)
@_unix_rm_only
def test_absolute_and_root_targets_blocked(
workspace: Path,
command: str,
) -> None:
"""Regression for #5090: absolute-path rm targets outside the workspace
(including root, /* glob, /etc, /tmp) must be flagged."""
has_outside, paths = _check_rm_targets_outside_workspace(command)
assert has_outside is True, f"expected outside for: {command}"
assert paths, f"expected non-empty outside list for: {command}"
def test_tilde_expansion_blocked(workspace: Path) -> None:
"""Regression for #5090: ``rm -rf ~`` must expand to $HOME and be flagged
as outside the workspace."""
has_outside, paths = _check_rm_targets_outside_workspace("rm -rf ~")
assert has_outside is True
assert any("~" in p for p in paths)
def test_home_env_var_blocked(workspace: Path) -> None:
"""Regression for #5090: ``rm -rf $HOME`` must expand via ``expandvars``
and be flagged."""
has_outside, _ = _check_rm_targets_outside_workspace("rm -rf $HOME")
assert has_outside is True
def test_home_brace_env_var_blocked(workspace: Path) -> None:
"""Regression for #5090: ``rm -rf ${HOME}`` must be flagged.
The detection/extraction split fix lands in the paired fix(security) PR
(#5866). Once that PR merges, this test passes for real; the former
xfail(strict=True) is removed here. CI will be red on this branch alone
until #5866 also lands — that pairing is intentional."""
has_outside, _ = _check_rm_targets_outside_workspace("rm -rf ${HOME}")
assert has_outside is True
def test_relative_path_escaping_workspace_blocked(workspace: Path) -> None:
"""Regression for #5090: ``rm -rf ../outside`` resolves outside the
workspace and must be flagged."""
has_outside, _ = _check_rm_targets_outside_workspace("rm -rf ../outside")
assert has_outside is True
@_unix_rm_only
def test_compound_command_with_outside_rm_blocked(workspace: Path) -> None:
"""Regression for #5090: a rm after a ``;`` / ``&&`` / ``|`` separator
must still be analysed."""
has_outside, _ = _check_rm_targets_outside_workspace(
"echo hi; rm -rf /etc/passwd",
)
assert has_outside is True
@_unix_rm_only
def test_escaped_rm_bin_path_blocked(workspace: Path) -> None:
"""Regression for #5090: ``/bin/rm -rf /`` must be normalised so the rm
token is recognised and the target flagged."""
has_outside, _ = _check_rm_targets_outside_workspace("/bin/rm -rf /")
assert has_outside is True
@_unix_rm_only
def test_backslash_rm_blocked(workspace: Path) -> None:
"""Regression for #5090: ``\\rm -rf /`` must be normalised and flagged."""
has_outside, _ = _check_rm_targets_outside_workspace("\\rm -rf /")
assert has_outside is True
@_unix_rm_only
def test_command_substitution_rm_blocked(workspace: Path) -> None:
"""Regression for #5090: ``$(which rm) -rf /`` must be normalised and
flagged."""
has_outside, _ = _check_rm_targets_outside_workspace("$(which rm) -rf /")
assert has_outside is True
@_unix_rm_only
def test_backtick_rm_blocked(workspace: Path) -> None:
"""Regression for #5090: `` `which rm` -rf / `` must be normalised and
flagged."""
has_outside, _ = _check_rm_targets_outside_workspace("`which rm` -rf /")
assert has_outside is True
@_unix_rm_only
def test_command_prefix_rm_blocked(workspace: Path) -> None:
"""Regression for #5090: ``command rm -rf /`` must be normalised and
flagged."""
has_outside, _ = _check_rm_targets_outside_workspace("command rm -rf /")
assert has_outside is True
@_unix_rm_only
def test_env_prefix_rm_blocked(workspace: Path) -> None:
"""Regression for #5090: ``env rm -rf /`` must be normalised."""
has_outside, _ = _check_rm_targets_outside_workspace("env rm -rf /")
assert has_outside is True
# ---------------------------------------------------------------------------
# Allowed: legitimate in-workspace rm
# ---------------------------------------------------------------------------
@pytest.mark.parametrize(
"command",
[
"rm somefile.txt",
"rm -rf ./build/",
"rm -rf sub/dir",
"rm -rf ./node_modules",
"rm -r ./dist",
"rm -f scratch.txt",
],
)
def test_in_workspace_rm_allowed(
workspace: Path,
command: str,
) -> None:
"""Regression for #5090: legitimate rm of relative paths inside the
workspace must NOT be flagged as outside."""
has_outside, paths = _check_rm_targets_outside_workspace(command)
assert has_outside is False, f"unexpected outside flag for: {command}"
assert paths == []
def test_workspace_absolute_path_allowed(workspace: Path) -> None:
"""Regression for #5090: an absolute path that resolves inside the
workspace (e.g. ``rm <workspace>/scratch``) must be allowed."""
target = workspace / "scratch"
has_outside, _ = _check_rm_targets_outside_workspace(f"rm -rf {target}")
assert has_outside is False
# ---------------------------------------------------------------------------
# Comments / no-target rm
# ---------------------------------------------------------------------------
def test_comment_is_ignored(workspace: Path) -> None:
"""Regression for #5090: a commented-out rm must not be flagged."""
has_outside, _ = _check_rm_targets_outside_workspace("# rm -rf /")
assert has_outside is False
def test_rm_with_no_targets(workspace: Path) -> None:
"""Regression for #5090: ``rm -rf`` with no path argument returns no
targets — nothing to flag."""
has_outside, paths = _check_rm_targets_outside_workspace("rm -rf")
assert has_outside is False
assert paths == []