Files
opensquilla--opensquilla/tests/test_tools/test_workspace_write_deny_levers.py
2026-07-13 13:12:33 +08:00

454 lines
17 KiB
Python

"""Opt-in workspace write deny levers: host shell, mutator targets, guidance.
Covers OPENSQUILLA_WORKSPACE_WRITE_DENY_HOST_SHELL,
OPENSQUILLA_WORKSPACE_WRITE_DENY_COMMAND_TARGETS and
OPENSQUILLA_WORKSPACE_WRITE_DENY_GUIDANCE (all off by default). Motivation:
deny globs are enforced for filesystem and patch tools, but shell-side
enforcement only recognizes redirection/tee write targets and is skipped
entirely under host execution, and the default block message suggests
recreating the file under the scratch directory — the wrong remediation when
deny globs protect files that must not be modified at all (e.g. test files).
"""
from __future__ import annotations
import json
from pathlib import Path
import pytest
from opensquilla.gateway.approval_queue import reset_approval_queue
from opensquilla.sandbox.integration import reset_runtime
from opensquilla.tools import write_policy
from opensquilla.tools.builtin import shell
from opensquilla.tools.types import (
CallerKind,
InteractionMode,
SafeToolError,
ToolContext,
current_tool_context,
)
_HOST_SHELL_ENV = "OPENSQUILLA_WORKSPACE_WRITE_DENY_HOST_SHELL"
_COMMAND_TARGETS_ENV = "OPENSQUILLA_WORKSPACE_WRITE_DENY_COMMAND_TARGETS"
_GUIDANCE_ENV = "OPENSQUILLA_WORKSPACE_WRITE_DENY_GUIDANCE"
@pytest.fixture(autouse=True)
def _tool_context(monkeypatch: pytest.MonkeyPatch):
monkeypatch.delenv(_HOST_SHELL_ENV, raising=False)
monkeypatch.delenv(_COMMAND_TARGETS_ENV, raising=False)
monkeypatch.delenv(_GUIDANCE_ENV, raising=False)
reset_approval_queue()
reset_runtime()
token = current_tool_context.set(
ToolContext(is_owner=True, caller_kind=CallerKind.CLI, session_key="agent:main:test")
)
yield
current_tool_context.reset(token)
reset_approval_queue()
reset_runtime()
def _configure_ctx(workspace: Path, globs: list[str]) -> ToolContext:
ctx = current_tool_context.get()
assert ctx is not None
ctx.interaction_mode = InteractionMode.UNATTENDED
ctx.elevated = "bypass"
ctx.workspace_dir = str(workspace)
ctx.workspace_write_deny_globs = globs # type: ignore[attr-defined]
return ctx
@pytest.mark.parametrize(
("command", "expected"),
[
("sed -i 's/a/b/' tests/test_a.py", ["tests/test_a.py"]),
("sed -i.bak -e 's/a/b/' tests/test_a.py", ["tests/test_a.py"]),
(
"sed -ri 's/a/b/' tests/test_a.py tests/test_b.py",
["tests/test_a.py", "tests/test_b.py"],
),
("sed --in-place=.bak 's/a/b/' tests/test_a.py", ["tests/test_a.py"]),
# BSD sed: the empty token is -i's backup suffix, not the script.
("sed -i '' 's/a/b/' tests/test_a.py", ["tests/test_a.py"]),
# Script delimiters and separators are not shell operators.
("sed -i 's|a|b|' tests/test_a.py", ["tests/test_a.py"]),
("sed -i 's/a/b/; s/c/d/' tests/test_a.py", ["tests/test_a.py"]),
# No in-place flag: read-only sed must not produce write targets.
("sed -n '1p' tests/test_a.py", []),
("perl -pi -e 's/a/b/' tests/test_a.py", ["tests/test_a.py"]),
("perl -i.bak -pe 's/a/b/' tests/test_a.py", ["tests/test_a.py"]),
("perl -e 'print 1' tests/test_a.py", []),
# -I consumes the rest of the token; its 'i' is not the in-place switch.
("perl -Ilib -e 'print 1' tests/test_a.py", []),
("rm -f tests/test_a.py", ["tests/test_a.py"]),
# Directory operands are extracted; the gate matches them as dir/**.
("rm -rf tests", ["tests"]),
("unlink tests/test_a.py", ["tests/test_a.py"]),
# mv mutates both operands: the source is removed, the dest written.
("mv tests/test_a.py /tmp/aside.py", ["tests/test_a.py", "/tmp/aside.py"]),
("cp /tmp/patched.py tests/test_a.py", ["tests/test_a.py"]),
("cp -t tests src_a.py", ["tests"]),
("dd if=/dev/zero of=tests/test_a.py bs=1", ["tests/test_a.py"]),
("truncate -s 0 tests/test_a.py", ["tests/test_a.py"]),
("git rm tests/test_a.py", ["tests/test_a.py"]),
# git rm --cached only unstages; the worktree file is untouched.
("git rm --cached tests/test_a.py", []),
("git -C sub mv tests/test_a.py aside.py", ["tests/test_a.py", "aside.py"]),
# git checkout/restore recover original content; not treated as writes.
("git checkout -- tests/test_a.py", []),
("LC_ALL=C sed -i 's/a/b/' tests/test_a.py", ["tests/test_a.py"]),
(
"cat tests/test_a.py | grep x && sed -i 's/a/b/' tests/test_b.py",
["tests/test_b.py"],
),
# Operators inside quotes are data, not command separators.
("git commit -m 'cleanup; rm tests/test_a.py'", []),
# Heredoc bodies are data, not commands.
("cat <<'EOF' > /tmp/notes.txt\nrm tests/test_a.py\nEOF", []),
("pytest tests/test_a.py", []),
("cat tests/test_a.py", []),
],
)
def test_mutating_command_write_target_extraction(command: str, expected: list[str]) -> None:
assert shell._mutating_command_write_targets(command) == expected
def test_command_targets_lever_default_off_skips_mutator_extraction(
tmp_path: Path,
) -> None:
workspace = tmp_path / "workspace"
(workspace / "tests").mkdir(parents=True)
_configure_ctx(workspace, ["tests/**"])
block = shell._workspace_write_deny_shell_block(
"exec_command", "sed -i 's/a/b/' tests/test_a.py", str(workspace)
)
assert block is None
def test_command_targets_lever_adds_mutator_targets(
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
) -> None:
monkeypatch.setenv(_COMMAND_TARGETS_ENV, "1")
workspace = tmp_path / "workspace"
(workspace / "tests").mkdir(parents=True)
_configure_ctx(workspace, ["tests/**"])
block = shell._workspace_write_deny_shell_block(
"exec_command", "sed -i 's/a/b/' tests/test_a.py", str(workspace)
)
assert block is not None
assert block["reason"] == "workspace_write_deny"
assert block["matched_pattern"] == "tests/**"
assert block["target"] == "tests/test_a.py"
def test_command_targets_lever_blocks_directory_operands(
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
) -> None:
monkeypatch.setenv(_COMMAND_TARGETS_ENV, "1")
workspace = tmp_path / "workspace"
tests_dir = workspace / "tests"
tests_dir.mkdir(parents=True)
(tests_dir / "test_a.py").write_text("assert a\n", encoding="utf-8")
_configure_ctx(workspace, ["tests/**"])
for command in ("rm -rf tests", "cp -t tests src_a.py"):
block = shell._workspace_write_deny_shell_block(
"exec_command", command, str(workspace)
)
assert block is not None, command
assert block["reason"] == "workspace_write_deny"
assert block["matched_pattern"] == "tests/**"
assert block["target"] == "tests"
def test_command_targets_quoted_operators_and_heredocs_stay_data(
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
) -> None:
monkeypatch.setenv(_COMMAND_TARGETS_ENV, "1")
workspace = tmp_path / "workspace"
tests_dir = workspace / "tests"
tests_dir.mkdir(parents=True)
(tests_dir / "test_a.py").write_text("assert a\n", encoding="utf-8")
_configure_ctx(workspace, ["tests/**"])
for command in (
"git commit -m 'cleanup; rm tests/test_a.py'",
"cat <<'EOF' > notes.txt\nrm tests/test_a.py\nEOF",
):
block = shell._workspace_write_deny_shell_block(
"exec_command", command, str(workspace)
)
assert block is None, command
def test_match_workspace_write_deny_directory_form_is_opt_in(
tmp_path: Path,
) -> None:
# The trailing-slash candidates only apply on request; existing callers
# (file tools, patch gate) keep byte-identical matching for bare dirs.
workspace = tmp_path / "workspace"
(workspace / "tests").mkdir(parents=True)
_configure_ctx(workspace, ["tests/**"])
target = workspace / "tests"
assert write_policy.match_workspace_write_deny(target, workspace=workspace) is None
match = write_policy.match_workspace_write_deny(
target, workspace=workspace, as_directory=True
)
assert match is not None
assert match.pattern == "tests/**"
@pytest.mark.asyncio
async def test_exec_command_blocks_mutator_on_denied_path_when_lever_on(
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
) -> None:
monkeypatch.setenv(_COMMAND_TARGETS_ENV, "1")
workspace = tmp_path / "workspace"
tests_dir = workspace / "tests"
tests_dir.mkdir(parents=True)
target = tests_dir / "test_a.py"
target.write_text("assert a\n", encoding="utf-8")
_configure_ctx(workspace, ["tests/**"])
result = await shell.exec_command(
"sed -i 's/a/b/' tests/test_a.py", workdir=str(workspace)
)
payload = json.loads(result)
assert payload["status"] == "blocked"
assert payload["reason"] == "workspace_write_deny"
assert payload["matched_pattern"] == "tests/**"
assert target.read_text(encoding="utf-8") == "assert a\n"
@pytest.mark.asyncio
async def test_background_process_blocks_mutator_on_denied_path_when_lever_on(
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
) -> None:
monkeypatch.setenv(_COMMAND_TARGETS_ENV, "1")
workspace = tmp_path / "workspace"
tests_dir = workspace / "tests"
tests_dir.mkdir(parents=True)
target = tests_dir / "test_a.py"
target.write_text("assert a\n", encoding="utf-8")
_configure_ctx(workspace, ["tests/**"])
result = await shell.background_process(
"sed -i 's/a/b/' tests/test_a.py", workdir=str(workspace)
)
payload = json.loads(result)
assert payload["status"] == "blocked"
assert payload["reason"] == "workspace_write_deny"
assert payload["tool"] == "background_process"
assert target.read_text(encoding="utf-8") == "assert a\n"
@pytest.mark.asyncio
async def test_exec_command_scans_stdin_for_mutators_when_lever_on(
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
) -> None:
monkeypatch.setenv(_COMMAND_TARGETS_ENV, "1")
workspace = tmp_path / "workspace"
tests_dir = workspace / "tests"
tests_dir.mkdir(parents=True)
(tests_dir / "test_a.py").write_text("assert a\n", encoding="utf-8")
_configure_ctx(workspace, ["tests/**"])
result = await shell.exec_command(
"sh",
workdir=str(workspace),
stdin="sed -i 's/a/b/' tests/test_a.py\n",
)
payload = json.loads(result)
assert payload["status"] == "blocked"
assert payload["reason"] == "workspace_write_deny"
@pytest.mark.asyncio
async def test_exec_command_mutator_passes_through_by_default(
tmp_path: Path,
) -> None:
# Documents the default gap the lever closes: without the lever, in-place
# mutators are not recognized as writes and execute against denied paths.
workspace = tmp_path / "workspace"
tests_dir = workspace / "tests"
tests_dir.mkdir(parents=True)
target = tests_dir / "test_a.py"
target.write_text("assert a\n", encoding="utf-8")
_configure_ctx(workspace, ["tests/**"])
result = await shell.exec_command(
"sed -i 's/assert a/assert b/' tests/test_a.py", workdir=str(workspace)
)
assert result.startswith("exit_code=0")
assert target.read_text(encoding="utf-8") == "assert b\n"
@pytest.mark.asyncio
async def test_exec_command_reads_stay_unblocked_with_lever_on(
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
) -> None:
monkeypatch.setenv(_COMMAND_TARGETS_ENV, "1")
workspace = tmp_path / "workspace"
tests_dir = workspace / "tests"
tests_dir.mkdir(parents=True)
(tests_dir / "test_a.py").write_bytes(b"assert a\n")
_configure_ctx(workspace, ["tests/**"])
result = await shell.exec_command("cat tests/test_a.py", workdir=str(workspace))
assert result == "exit_code=0\nassert a\n"
@pytest.mark.asyncio
async def test_host_shell_lever_enforces_deny_globs_under_host_execution(
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
) -> None:
monkeypatch.setenv(_HOST_SHELL_ENV, "1")
monkeypatch.setattr(shell, "_host_execution_allowed", lambda: True)
workspace = tmp_path / "workspace"
(workspace / "tests").mkdir(parents=True)
_configure_ctx(workspace, ["tests/**"])
result = await shell.exec_command(
"echo ok > tests/out.txt", workdir=str(workspace)
)
payload = json.loads(result)
assert payload["status"] == "blocked"
assert payload["reason"] == "workspace_write_deny"
assert not (workspace / "tests" / "out.txt").exists()
@pytest.mark.asyncio
async def test_host_shell_default_off_keeps_host_execution_unchecked(
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
) -> None:
# Documents the default gap the lever closes: under host execution the
# whole sandbox policy block — including deny globs — is skipped.
monkeypatch.setattr(shell, "_host_execution_allowed", lambda: True)
workspace = tmp_path / "workspace"
(workspace / "tests").mkdir(parents=True)
_configure_ctx(workspace, ["tests/**"])
result = await shell.exec_command(
"echo ok > tests/out.txt", workdir=str(workspace)
)
assert result.startswith("exit_code=0")
assert (workspace / "tests" / "out.txt").exists()
@pytest.mark.asyncio
async def test_host_shell_and_command_targets_levers_compose(
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
) -> None:
monkeypatch.setenv(_HOST_SHELL_ENV, "1")
monkeypatch.setenv(_COMMAND_TARGETS_ENV, "1")
monkeypatch.setattr(shell, "_host_execution_allowed", lambda: True)
workspace = tmp_path / "workspace"
tests_dir = workspace / "tests"
tests_dir.mkdir(parents=True)
target = tests_dir / "test_a.py"
target.write_text("assert a\n", encoding="utf-8")
_configure_ctx(workspace, ["tests/**"])
result = await shell.exec_command(
"sed -i 's/a/b/' tests/test_a.py", workdir=str(workspace)
)
payload = json.loads(result)
assert payload["status"] == "blocked"
assert payload["reason"] == "workspace_write_deny"
assert target.read_text(encoding="utf-8") == "assert a\n"
def test_deny_guidance_env_overrides_scratch_guidance(
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
) -> None:
guidance = "Protected files must not be modified; change the source instead."
monkeypatch.setenv(_GUIDANCE_ENV, guidance)
workspace = tmp_path / "workspace"
scratch = tmp_path / "scratch"
workspace.mkdir()
scratch.mkdir()
ctx = _configure_ctx(workspace, ["tests/**"])
ctx.scratch_dir = str(scratch) # type: ignore[attr-defined]
match = write_policy.match_workspace_write_deny(
workspace / "tests" / "test_a.py", workspace=workspace
)
assert match is not None
block = write_policy.workspace_write_deny_block("write_file", match)
message = str(block["message"])
assert message.endswith(guidance)
assert "scratch directory" not in message
def test_deny_guidance_default_keeps_scratch_guidance(
tmp_path: Path,
) -> None:
workspace = tmp_path / "workspace"
scratch = tmp_path / "scratch"
workspace.mkdir()
scratch.mkdir()
ctx = _configure_ctx(workspace, ["tests/**"])
ctx.scratch_dir = str(scratch) # type: ignore[attr-defined]
match = write_policy.match_workspace_write_deny(
workspace / "tests" / "test_a.py", workspace=workspace
)
assert match is not None
block = write_policy.workspace_write_deny_block("write_file", match)
message = str(block["message"])
assert "must be written under the configured scratch directory" in message
assert str(scratch) in message
def test_gate_raise_carries_same_message_as_block_envelope(
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
) -> None:
guidance = "Fix the source code so the existing checks pass."
monkeypatch.setenv(_GUIDANCE_ENV, guidance)
workspace = tmp_path / "workspace"
workspace.mkdir()
_configure_ctx(workspace, ["tests/**"])
target = workspace / "tests" / "test_a.py"
match = write_policy.match_workspace_write_deny(target, workspace=workspace)
assert match is not None
expected = str(write_policy.workspace_write_deny_block("write_file", match)["message"])
with pytest.raises(SafeToolError) as excinfo:
write_policy.gate_workspace_write_deny("write_file", target, workspace=workspace)
assert expected in str(excinfo.value)
assert guidance in str(excinfo.value)
def test_lever_env_parsing(monkeypatch: pytest.MonkeyPatch) -> None:
for name in (_HOST_SHELL_ENV, _COMMAND_TARGETS_ENV):
monkeypatch.delenv(name, raising=False)
assert shell._write_deny_lever_enabled(name) is False
monkeypatch.setenv(name, "1")
assert shell._write_deny_lever_enabled(name) is True
monkeypatch.setenv(name, "true")
assert shell._write_deny_lever_enabled(name) is True
monkeypatch.setenv(name, "0")
assert shell._write_deny_lever_enabled(name) is False
monkeypatch.setenv(name, "off")
assert shell._write_deny_lever_enabled(name) is False