mirror of
https://github.com/agentscope-ai/ReMe.git
synced 2026-11-05 07:39:47 +00:00
683 lines
28 KiB
Python
683 lines
28 KiB
Python
"""Shell job and its optional read-only execution guard."""
|
|
|
|
import asyncio
|
|
import inspect
|
|
import os
|
|
import subprocess
|
|
import threading
|
|
from importlib.metadata import EntryPoint
|
|
from pathlib import Path
|
|
|
|
import pytest
|
|
import psutil
|
|
import yaml
|
|
|
|
from agentscope.message import ToolResultState
|
|
from agentscope.permission import PermissionBehavior
|
|
from agentscope.tool import ExecResult
|
|
from reme_home.auto_bash import AutoBash, AutoBashStep
|
|
from reme.utils.local_backend import LocalProcessBackend
|
|
|
|
from reme.utils.file_sandbox.path_bubble_backend import PathBubbleBackend
|
|
from reme.components.application_context import ApplicationContext
|
|
from reme.config.config_parser import _load_config
|
|
from reme.enumeration import ComponentEnum
|
|
from reme.plugin import resolve_plugin_runtime
|
|
|
|
|
|
@pytest.fixture(autouse=True)
|
|
def linux_backend_selection(monkeypatch):
|
|
"""Keep existing backend integration assertions on the Linux implementation."""
|
|
monkeypatch.setattr("reme.utils.file_sandbox.sys.platform", "linux")
|
|
|
|
|
|
@pytest.fixture(autouse=True)
|
|
def isolated_git_config(tmp_path, monkeypatch):
|
|
"""Keep Git tests independent of the developer's global and system config."""
|
|
monkeypatch.setenv("GIT_CONFIG_GLOBAL", str(tmp_path / "global.gitconfig"))
|
|
monkeypatch.setenv("GIT_CONFIG_NOSYSTEM", "1")
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_auto_bash_runs_in_workspace(tmp_path):
|
|
"""Approved commands run relative to the configured workspace."""
|
|
(tmp_path / "note.txt").write_text("hello", encoding="utf-8")
|
|
step = AutoBashStep(app_context=ApplicationContext(workspace_dir=str(tmp_path)))
|
|
|
|
response = await step(command="cat note.txt")
|
|
|
|
assert response.success is True
|
|
assert response.answer == "hello"
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
@pytest.mark.skipif(os.name != "posix", reason="POSIX shell pipelines")
|
|
@pytest.mark.parametrize("background", [False, True])
|
|
@pytest.mark.parametrize("cancel", [False, True])
|
|
async def test_local_pipeline_timeout_and_cancellation(tmp_path, monkeypatch, background, cancel):
|
|
"""Timeouts and cancellation clean up pipelines even after the shell exits."""
|
|
(tmp_path / "note.txt").write_text("hello\n", encoding="utf-8")
|
|
step = AutoBashStep(app_context=ApplicationContext(workspace_dir=str(tmp_path)))
|
|
processes = []
|
|
descendants = []
|
|
original_start = asyncio.create_subprocess_exec
|
|
original_stop = LocalProcessBackend._stop_process # pylint: disable=protected-access
|
|
started = asyncio.Event()
|
|
|
|
def group_children(pid):
|
|
children = []
|
|
for child in psutil.process_iter():
|
|
try:
|
|
if child.pid != pid and os.getpgid(child.pid) == pid:
|
|
children.append(child)
|
|
except (ProcessLookupError, PermissionError):
|
|
pass
|
|
return children
|
|
|
|
async def start(*args, **kwargs):
|
|
process = await original_start(*args, **kwargs)
|
|
processes.append(process)
|
|
started.set()
|
|
return process
|
|
|
|
async def stop(self, process, communication):
|
|
descendants.extend(group_children(process.pid))
|
|
await original_stop(self, process, communication)
|
|
|
|
monkeypatch.setattr(asyncio, "create_subprocess_exec", start)
|
|
monkeypatch.setattr(LocalProcessBackend, "_stop_process", stop)
|
|
command = "tail -f note.txt | cat" + (" &" if background else "")
|
|
assert AutoBash._is_read_only(command) # pylint: disable=protected-access
|
|
task = asyncio.create_task(step(command=command, timeout=60000 if cancel else 100))
|
|
try:
|
|
await asyncio.wait_for(started.wait(), timeout=2)
|
|
if cancel:
|
|
# Wait until pipeline children exist before cancelling the invocation.
|
|
async def pipeline_started():
|
|
while len(group_children(processes[0].pid)) < 2:
|
|
await asyncio.sleep(0.01)
|
|
|
|
await asyncio.wait_for(pipeline_started(), timeout=2)
|
|
task.cancel()
|
|
with pytest.raises(asyncio.CancelledError):
|
|
await asyncio.wait_for(task, timeout=0.8)
|
|
else:
|
|
response = await asyncio.wait_for(task, timeout=0.8)
|
|
assert response.success is False
|
|
assert "timed out after 100ms" in response.answer
|
|
assert descendants
|
|
assert processes[0].returncode is not None
|
|
|
|
async def descendants_stopped():
|
|
# Pipe EOF can precede exit after SIGKILL, especially for orphaned background children.
|
|
pending = descendants.copy()
|
|
while pending:
|
|
for child in pending.copy():
|
|
try:
|
|
if not child.is_running() or child.status() == psutil.STATUS_ZOMBIE:
|
|
pending.remove(child)
|
|
except psutil.NoSuchProcess:
|
|
pending.remove(child)
|
|
if pending:
|
|
await asyncio.sleep(0.01)
|
|
|
|
await asyncio.wait_for(descendants_stopped(), timeout=1)
|
|
finally:
|
|
if not task.done():
|
|
task.cancel()
|
|
for process in processes:
|
|
try:
|
|
os.killpg(process.pid, 9)
|
|
except ProcessLookupError:
|
|
pass
|
|
await asyncio.gather(task, return_exceptions=True)
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
@pytest.mark.parametrize(
|
|
"command",
|
|
[
|
|
"touch denied.txt",
|
|
"cat note.txt > denied.txt",
|
|
"echo hello > /dev/null-extra",
|
|
"cat $(touch denied.txt)",
|
|
"echo ${X:-$(touch denied.txt)}",
|
|
"cat note.txt; touch denied.txt",
|
|
"cat note.txt\ntouch denied.txt",
|
|
"tee denied.txt",
|
|
"find . -delete",
|
|
"find . -exec touch denied.txt {} \\;",
|
|
"find . -fprint denied.txt",
|
|
"sort -o denied.txt note.txt",
|
|
"sort -T denied.txt note.txt",
|
|
"sort -rTdenied.txt note.txt",
|
|
"sort --compress-program='touch denied.txt' note.txt",
|
|
"diff --output=denied.txt note.txt note.txt",
|
|
"git add note.txt",
|
|
"git branch new-branch",
|
|
"git diff --output=denied.txt",
|
|
"git log --ext-diff",
|
|
"git -c alias.status=add status",
|
|
"GIT_OPTIONAL_LOCKS=1 git status",
|
|
"GIT_PAGER=sh git log",
|
|
"nop",
|
|
"rg hello note.txt",
|
|
"file -C -m magic",
|
|
"file -bC -m magic",
|
|
"file --comp -m magic",
|
|
r"file -\C -m magic",
|
|
"file -z note.txt",
|
|
"file -p note.txt",
|
|
"echo hello >| denied.txt",
|
|
": <> denied.txt",
|
|
"sort --out=denied.txt note.txt",
|
|
"git diff --out=denied.txt",
|
|
"git diff --ext-di",
|
|
"git show --textcon HEAD",
|
|
"git log --show-signature",
|
|
'git log --format="%G?"',
|
|
"git log --pretty=verify",
|
|
"git log --pre=verify",
|
|
"git show --format=verify",
|
|
"git show --remerge-diff",
|
|
"for PATH in .; do git status; done",
|
|
"git() { echo overridden; }; git status",
|
|
"file -* magic",
|
|
],
|
|
)
|
|
async def test_auto_bash_denies_mutation_before_execution(tmp_path, command):
|
|
"""Writing and dynamic shell forms fail before a child process starts."""
|
|
(tmp_path / "note.txt").write_text("hello", encoding="utf-8")
|
|
tool = AutoBash(cwd=tmp_path)
|
|
|
|
chunks = [chunk async for chunk in tool.call(command)]
|
|
|
|
assert len(chunks) == 1
|
|
assert chunks[0].state == ToolResultState.ERROR
|
|
assert "Permission denied" in chunks[0].content[0].text
|
|
assert not (tmp_path / "denied.txt").exists()
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_step_returns_permission_error(tmp_path):
|
|
"""The job exposes a denied command as an unsuccessful response."""
|
|
step = AutoBashStep(app_context=ApplicationContext(workspace_dir=str(tmp_path)))
|
|
|
|
response = await step(command="touch denied.txt")
|
|
|
|
assert response.success is False
|
|
assert "Permission denied" in response.answer
|
|
assert not (tmp_path / "denied.txt").exists()
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_step_can_run_unrestricted_commands(tmp_path):
|
|
"""Job input can disable the guard for commands that write files."""
|
|
step = AutoBashStep(app_context=ApplicationContext(workspace_dir=str(tmp_path)))
|
|
|
|
response = await step(command="echo hello > written.txt", is_read_only=False)
|
|
|
|
assert response.success is True
|
|
assert (tmp_path / "written.txt").read_text(encoding="utf-8") == "hello\n"
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_unsupported_platform_runs_without_paths_but_rejects_path_policy(tmp_path, monkeypatch):
|
|
"""AutoBash never drops an explicitly requested sandbox on unsupported systems."""
|
|
monkeypatch.setattr("reme.utils.file_sandbox.sys.platform", "unsupported")
|
|
step = AutoBashStep(app_context=ApplicationContext(workspace_dir=str(tmp_path)))
|
|
|
|
plain = await step(command="echo host", is_read_only=False)
|
|
assert plain.success is True
|
|
assert plain.answer.strip() == "host"
|
|
|
|
restricted = await step(command="echo blocked", is_read_only=False, read_only_paths=[])
|
|
assert restricted.success is False
|
|
assert restricted.answer == "No file sandbox backend for unsupported"
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
@pytest.mark.parametrize("limit", [0, 1, -1])
|
|
async def test_step_rejects_memory_limit_without_path_policy(tmp_path, limit):
|
|
"""An explicitly supplied limit cannot be ignored by host execution."""
|
|
step = AutoBashStep(app_context=ApplicationContext(workspace_dir=str(tmp_path)))
|
|
response = await step(command="echo should-not-run", is_read_only=False, memory_limit_mib=limit)
|
|
assert response.success is False
|
|
assert response.answer == "memory_limit_mib requires read_only_paths or writable_paths to enable the sandbox"
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_step_configuration_can_disable_read_only(tmp_path):
|
|
"""Step configuration applies when the job does not supply the flag."""
|
|
step = AutoBashStep(app_context=ApplicationContext(workspace_dir=str(tmp_path)), is_read_only=False)
|
|
|
|
response = await step(command="touch written.txt")
|
|
|
|
assert response.success is True
|
|
assert (tmp_path / "written.txt").exists()
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
@pytest.mark.parametrize(
|
|
("inputs", "message"),
|
|
[
|
|
({"command": " "}, "command is required"),
|
|
({"command": 1}, "command is required"),
|
|
({"command": "echo ok", "timeout": 0}, "timeout must be a positive integer in milliseconds"),
|
|
({"command": "echo ok", "timeout": True}, "timeout must be a positive integer in milliseconds"),
|
|
({"command": "echo ok", "timeout": "1"}, "timeout must be a positive integer in milliseconds"),
|
|
({"command": "touch denied.txt", "is_read_only": "false"}, "is_read_only must be a boolean"),
|
|
({"command": "", "timeout": 0, "is_read_only": "false"}, "command is required"),
|
|
],
|
|
)
|
|
async def test_step_parameter_errors_do_not_execute(tmp_path, monkeypatch, inputs, message):
|
|
"""Parameter errors preserve their messages and precedence without executing."""
|
|
|
|
def unexpected_backend(_step):
|
|
pytest.fail("invalid parameters reached backend selection")
|
|
|
|
monkeypatch.setattr("reme_home.auto_bash.file_sandbox_for_step", unexpected_backend)
|
|
response = await AutoBashStep(app_context=ApplicationContext(workspace_dir=str(tmp_path)))(**inputs)
|
|
assert response.success is False
|
|
assert response.answer == message
|
|
assert not (tmp_path / "denied.txt").exists()
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_permission_decisions_never_ask(tmp_path):
|
|
"""Direct AgentScope use makes allow/deny decisions without ASK."""
|
|
tool = AutoBash(cwd=tmp_path)
|
|
|
|
allowed = await tool.check_permissions({"command": "ls"}, None)
|
|
denied = await tool.check_permissions({"command": "touch denied.txt"}, None)
|
|
|
|
assert allowed.behavior == PermissionBehavior.ALLOW
|
|
assert denied.behavior == PermissionBehavior.DENY
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_read_only_parsing_does_not_block_event_loop(tmp_path, monkeypatch):
|
|
"""A slow synchronous parser runs in a worker thread during permission checks."""
|
|
started = threading.Event()
|
|
release = threading.Event()
|
|
|
|
def slow_parse(_command):
|
|
started.set()
|
|
release.wait(timeout=2)
|
|
return True
|
|
|
|
tool = AutoBash(cwd=tmp_path)
|
|
monkeypatch.setattr(tool, "_is_read_only", slow_parse)
|
|
task = asyncio.create_task(tool.check_read_only({"command": "echo hello"}))
|
|
try:
|
|
assert await asyncio.wait_for(asyncio.to_thread(started.wait), timeout=1)
|
|
assert not task.done()
|
|
finally:
|
|
release.set()
|
|
assert await asyncio.wait_for(task, timeout=1)
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_unrestricted_tool_allows_commands_without_read_only_check(tmp_path, monkeypatch):
|
|
"""The unrestricted tool never invokes the read-only parser."""
|
|
tool = AutoBash(cwd=tmp_path, is_read_only=False)
|
|
monkeypatch.setattr(tool, "_is_read_only", lambda _command: pytest.fail("read-only parser was called"))
|
|
|
|
decision = await tool.check_permissions({"command": "touch written.txt"}, None)
|
|
chunks = [chunk async for chunk in tool.call("touch written.txt")]
|
|
|
|
assert decision.behavior == PermissionBehavior.ALLOW
|
|
assert all(chunk.state != ToolResultState.ERROR for chunk in chunks)
|
|
assert (tmp_path / "written.txt").exists()
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_auto_bash_uses_path_backend_when_paths_supplied(tmp_path, monkeypatch):
|
|
"""AutoBash routes an injected path policy through the backend."""
|
|
seen = {}
|
|
|
|
async def fake_exec(self, command, *, cwd=None, timeout=None):
|
|
seen.update(command=command, cwd=cwd, timeout=timeout, backend=self)
|
|
return ExecResult(exit_code=0, stdout=b"ok", stderr=b"")
|
|
|
|
monkeypatch.setattr(PathBubbleBackend, "exec_shell", fake_exec)
|
|
step = AutoBashStep(app_context=ApplicationContext(workspace_dir=str(tmp_path)))
|
|
response = await step(command="printf ok", is_read_only=False, read_only_paths=[], writable_paths=[str(tmp_path)])
|
|
|
|
assert response.success is True
|
|
assert response.answer == "ok"
|
|
assert seen["command"] == ["/bin/sh", "-c", "printf ok"]
|
|
assert seen["cwd"] == str(tmp_path)
|
|
assert seen["timeout"] == 60
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_sandbox_start_failure_does_not_run_on_host(tmp_path, monkeypatch):
|
|
"""The plugin reports a sandbox failure instead of executing on the host."""
|
|
|
|
async def unavailable(*_args, **_kwargs):
|
|
raise RuntimeError("bwrap unavailable")
|
|
|
|
monkeypatch.setattr(PathBubbleBackend, "_start_process", unavailable)
|
|
step = AutoBashStep(app_context=ApplicationContext(workspace_dir=str(tmp_path)))
|
|
result = await step(command="printf host", is_read_only=False, read_only_paths=[])
|
|
|
|
assert result.success is False
|
|
assert "bwrap unavailable" in result.answer
|
|
|
|
|
|
def test_home_manifest_declares_step_and_job():
|
|
"""The installed manifest declares the job, Step, and tool defaults."""
|
|
manifest = Path(__file__).parents[1] / "src" / "reme_home" / "plugin.yaml"
|
|
config = yaml.safe_load(manifest.read_text(encoding="utf-8"))
|
|
assert config["backends"]["auto_bash_step"] == "reme_home.auto_bash:AutoBashStep"
|
|
job = config["application_defaults"]["jobs"]["auto_bash"]
|
|
assert job["steps"] == [{"backend": "auto_bash_step"}]
|
|
assert inspect.signature(AutoBash.call).parameters["timeout"].default == 60000
|
|
assert AutoBash.input_schema["properties"]["timeout"]["default"] == 60000
|
|
assert job["parameters"]["properties"]["timeout"]["default"] == 60000
|
|
assert job["parameters"]["properties"]["is_read_only"]["default"] is True
|
|
assert job["parameters"]["properties"]["read_only_paths"]["type"] == "array"
|
|
assert job["parameters"]["properties"]["writable_paths"]["type"] == "array"
|
|
assert job["parameters"]["properties"]["memory_limit_mib"]["minimum"] == 0
|
|
|
|
|
|
def test_home_plugin_registers_job_and_step(monkeypatch):
|
|
"""Enabling home adds its job and Step to one application runtime."""
|
|
entry = EntryPoint(name="home", value="reme_home", group="reme.plugins")
|
|
monkeypatch.setattr("reme.plugin.find_entry_points", lambda group, name: [entry] if name == "home" else [])
|
|
config = _load_config("default")
|
|
assert "auto_bash" not in config["jobs"]
|
|
config["plugins"] = ["home"]
|
|
|
|
runtime = resolve_plugin_runtime(config)
|
|
|
|
assert runtime.registry.get(ComponentEnum.STEP, "auto_bash_step") is AutoBashStep
|
|
assert runtime.config["jobs"]["auto_bash"]["steps"] == [{"backend": "auto_bash_step"}]
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
@pytest.mark.parametrize(
|
|
"command",
|
|
[
|
|
":",
|
|
"cd . && pwd",
|
|
"echo hello",
|
|
"true",
|
|
"false",
|
|
"grep hello note.txt",
|
|
"find . -name note.txt",
|
|
"diff note.txt note.txt",
|
|
"cmp note.txt note.txt",
|
|
"git status --short",
|
|
"git diff --stat",
|
|
"git rev-parse HEAD",
|
|
"git ls-files",
|
|
"echo hello > /dev/null",
|
|
"cat < note.txt",
|
|
"echo hello >| /dev/null",
|
|
"echo hello 2>&1",
|
|
"find . -name '*.txt'",
|
|
"(cat note.txt) | head -n 1",
|
|
],
|
|
)
|
|
async def test_safe_command_forms_are_allowed(tmp_path, command):
|
|
"""Common non-writing commands pass the policy regardless of exit code."""
|
|
(tmp_path / "note.txt").write_text("hello\n", encoding="utf-8")
|
|
tool = AutoBash(cwd=tmp_path)
|
|
|
|
assert await tool.check_read_only({"command": command})
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_sort_does_not_create_temporary_files(tmp_path):
|
|
"""Read-only sorting works in memory and fails closed when a spill is needed."""
|
|
(tmp_path / "small.txt").write_text("b\na\n", encoding="utf-8")
|
|
tool = AutoBash(cwd=tmp_path)
|
|
|
|
small = [chunk async for chunk in tool.call("sort small.txt")]
|
|
assert "".join(block.text for chunk in small for block in chunk.content) == "a\nb\n"
|
|
|
|
with (tmp_path / "large.txt").open("w", encoding="utf-8") as output:
|
|
for number in range(100000, 0, -1):
|
|
output.write(f"{number:012d} abcdefghijklmnopqrstuvwxyz\n")
|
|
large = [chunk async for chunk in tool.call("sort -S 1K large.txt > /dev/null")]
|
|
assert "sort:" in "".join(block.text for chunk in large for block in chunk.content)
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
@pytest.mark.parametrize("trace_variable", ["GIT_TRACE_REFS", "GIT_TRACE", "GIT_TRACE2_EVENT"])
|
|
async def test_read_only_git_cannot_write_inherited_traces(tmp_path, monkeypatch, trace_variable):
|
|
"""Allowed metadata queries must not write Git traces from the parent environment."""
|
|
|
|
def git(*args):
|
|
return subprocess.run(["git", *args], cwd=tmp_path, check=True, capture_output=True)
|
|
|
|
git("init", "-q")
|
|
git(
|
|
"-c",
|
|
"user.name=Test",
|
|
"-c",
|
|
"user.email=test@example.org",
|
|
"-c",
|
|
"commit.gpgsign=false",
|
|
"commit",
|
|
"--allow-empty",
|
|
"-qm",
|
|
"test",
|
|
)
|
|
expected = git("rev-parse", "HEAD").stdout.decode().strip()
|
|
marker = tmp_path / "trace output.txt"
|
|
tool = AutoBash(cwd=tmp_path)
|
|
# Set after construction to check that each invocation uses the current environment.
|
|
monkeypatch.setenv(trace_variable, str(marker))
|
|
|
|
chunks = [chunk async for chunk in tool.call("git rev-parse HEAD")]
|
|
|
|
assert chunks
|
|
assert all(chunk.state != ToolResultState.ERROR for chunk in chunks)
|
|
assert "".join(block.text for chunk in chunks for block in chunk.content).strip() == expected
|
|
assert not marker.exists()
|
|
|
|
# Control: the same environment still enables tracing for unrestricted execution.
|
|
unrestricted = AutoBash(cwd=tmp_path, is_read_only=False)
|
|
chunks = [chunk async for chunk in unrestricted.call("git rev-parse HEAD")]
|
|
assert all(chunk.state != ToolResultState.ERROR for chunk in chunks)
|
|
assert marker.stat().st_size > 0
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_read_only_git_clears_future_trace_variables(tmp_path, monkeypatch):
|
|
"""Trace isolation uses a prefix rule instead of an enumeration of known names."""
|
|
helper = tmp_path / "git"
|
|
helper.write_text('#!/bin/sh\nprintf "%s" "${GIT_TRACE_FUTURE-unset}"\n', encoding="utf-8")
|
|
helper.chmod(0o700)
|
|
monkeypatch.setenv("PATH", str(tmp_path) + os.pathsep + os.environ["PATH"])
|
|
monkeypatch.setenv("GIT_TRACE_FUTURE", "inherited")
|
|
tool = AutoBash(cwd=tmp_path)
|
|
|
|
chunks = [chunk async for chunk in tool.call("git rev-parse HEAD")]
|
|
|
|
assert all(chunk.state != ToolResultState.ERROR for chunk in chunks)
|
|
assert "".join(block.text for chunk in chunks for block in chunk.content) == "unset"
|
|
assert os.environ["GIT_TRACE_FUTURE"] == "inherited"
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_git_pretty_config_cannot_launch_gpg(tmp_path):
|
|
"""Git format aliases and defaults cannot smuggle signature verification."""
|
|
repo = tmp_path / "repo"
|
|
repo.mkdir()
|
|
|
|
def git(*args, input_data=None):
|
|
return subprocess.run(["git", *args], cwd=repo, input=input_data, check=True, capture_output=True)
|
|
|
|
git("init", "-q")
|
|
git("config", "user.name", "Test")
|
|
git("config", "user.email", "test@example.org")
|
|
(repo / "note.txt").write_text("hello\n", encoding="utf-8")
|
|
git("add", "note.txt")
|
|
git("commit", "-qm", "test")
|
|
commit = git("cat-file", "commit", "HEAD").stdout
|
|
signed = commit.replace(b"\n\n", b"\ngpgsig -----BEGIN PGP SIGNATURE-----\n \n -----END PGP SIGNATURE-----\n\n", 1)
|
|
signed_hash = git("hash-object", "-t", "commit", "-w", "--stdin", input_data=signed).stdout.strip().decode()
|
|
git("update-ref", "HEAD", signed_hash)
|
|
|
|
marker = tmp_path / "gpg-ran"
|
|
helper = tmp_path / "fake-gpg"
|
|
helper.write_text(f'#!/bin/sh\nprintf called > "{marker}"\nexit 1\n', encoding="utf-8")
|
|
helper.chmod(0o700)
|
|
git("config", "pretty.verify", "format:%G?")
|
|
git("config", "pretty.medium", "format:%G?")
|
|
git("config", "pretty.oneline", "format:%G?")
|
|
git("config", "format.pretty", "format:%G?")
|
|
git("config", "gpg.program", str(helper))
|
|
tool = AutoBash(cwd=repo)
|
|
|
|
for command in ("git log", "git show HEAD", "git log --oneline"):
|
|
chunks = [chunk async for chunk in tool.call(command)]
|
|
assert chunks
|
|
assert all(chunk.state != ToolResultState.ERROR for chunk in chunks)
|
|
assert not marker.exists()
|
|
|
|
for command in ("git log --pretty=verify", "git show --format=verify"):
|
|
chunks = [chunk async for chunk in tool.call(command)]
|
|
assert chunks[-1].state == ToolResultState.ERROR
|
|
assert not marker.exists()
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
@pytest.mark.parametrize(
|
|
("subcommand", "flag"),
|
|
[(name, "--help") for name in ("status", "diff", "log", "show", "rev-parse", "ls-files")]
|
|
+ [("status", flag) for flag in ("--hel", "--he", "--h", "-h")],
|
|
)
|
|
async def test_git_help_flags_are_denied(tmp_path, monkeypatch, subcommand, flag):
|
|
"""All allowlisted Git commands reject help before starting a process."""
|
|
|
|
async def unexpected_process(*_args, **_kwargs):
|
|
pytest.fail("Git help must be denied before execution")
|
|
|
|
monkeypatch.setattr(asyncio, "create_subprocess_exec", unexpected_process)
|
|
tool = AutoBash(cwd=tmp_path)
|
|
command = f"git {subcommand} {flag}"
|
|
|
|
assert not await tool.check_read_only({"command": command})
|
|
chunks = [chunk async for chunk in tool.call(command)]
|
|
assert len(chunks) == 1
|
|
assert chunks[0].state == ToolResultState.ERROR
|
|
assert "Permission denied" in chunks[0].content[0].text
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
@pytest.mark.parametrize("subcommand", ["rev-parse", "ls-files"])
|
|
async def test_git_help_cannot_run_configured_man_viewer(tmp_path, monkeypatch, subcommand):
|
|
"""Help flags must not dispatch repository-configured commands on the host."""
|
|
monkeypatch.delenv("GIT_MAN_VIEWER", raising=False)
|
|
|
|
def git(*args):
|
|
return subprocess.run(["git", *args], cwd=tmp_path, check=True, capture_output=True)
|
|
|
|
git("init", "-q")
|
|
marker = tmp_path / "denied.txt"
|
|
git("config", "man.viewer", "reme-test")
|
|
git("config", "man.reme-test.cmd", f'echo ran > "{marker}"')
|
|
# Prove that this fixture would execute the helper without the guard.
|
|
git(subcommand, "--help")
|
|
assert marker.exists()
|
|
marker.unlink()
|
|
tool = AutoBash(cwd=tmp_path)
|
|
command = f"git {subcommand} --help"
|
|
|
|
chunks = [chunk async for chunk in tool.call(command)]
|
|
|
|
assert not marker.exists()
|
|
assert not await tool.check_read_only({"command": command})
|
|
assert len(chunks) == 1
|
|
assert chunks[0].state == ToolResultState.ERROR
|
|
assert "Permission denied" in chunks[0].content[0].text
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
@pytest.mark.parametrize("helper", ["fsmonitor", "diff", "textconv", "clean"])
|
|
async def test_git_configured_helpers_cannot_write(tmp_path, helper):
|
|
"""Repository configuration cannot turn an allowed read into helper execution."""
|
|
|
|
def git(*args):
|
|
return subprocess.run(["git", *args], cwd=tmp_path, check=True, capture_output=True)
|
|
|
|
git("init", "-q")
|
|
(tmp_path / "note.txt").write_text("before\n", encoding="utf-8")
|
|
(tmp_path / ".gitattributes").write_text("note.txt diff=test filter=test\n", encoding="utf-8")
|
|
git("add", "note.txt", ".gitattributes")
|
|
hook = tmp_path / "helper"
|
|
hook.write_text('#!/bin/sh\ntouch "' + str(tmp_path / "denied.txt") + '"\ncat\n', encoding="utf-8")
|
|
hook.chmod(0o700)
|
|
key = {
|
|
"fsmonitor": "core.fsmonitor",
|
|
"diff": "diff.external",
|
|
"textconv": "diff.test.textconv",
|
|
"clean": "filter.test.clean",
|
|
}[helper]
|
|
git("config", key, str(hook))
|
|
(tmp_path / "note.txt").write_text("after\n", encoding="utf-8")
|
|
command = "git status --short" if helper == "fsmonitor" else "git diff"
|
|
tool = AutoBash(cwd=tmp_path)
|
|
|
|
chunks = [chunk async for chunk in tool.call(command)]
|
|
|
|
assert not (tmp_path / "denied.txt").exists()
|
|
if helper == "clean":
|
|
assert chunks[-1].state == ToolResultState.ERROR
|
|
assert "external filters" in chunks[-1].content[0].text
|
|
else:
|
|
assert all(chunk.state != ToolResultState.ERROR for chunk in chunks)
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_global_git_lfs_config_allows_metadata_queries(tmp_path, monkeypatch):
|
|
"""A global filter alone must not block Git queries that never use it."""
|
|
repo = tmp_path / "repo"
|
|
repo.mkdir()
|
|
subprocess.run(["git", "init", "-q", str(repo)], check=True, capture_output=True)
|
|
(repo / "note.txt").write_text("hello\n", encoding="utf-8")
|
|
subprocess.run(["git", "add", "note.txt"], cwd=repo, check=True, capture_output=True)
|
|
|
|
global_config = tmp_path / "global.gitconfig"
|
|
subprocess.run(
|
|
["git", "config", "--file", str(global_config), "filter.lfs.process", "/bin/false"],
|
|
check=True,
|
|
capture_output=True,
|
|
)
|
|
monkeypatch.setenv("GIT_CONFIG_GLOBAL", str(global_config))
|
|
tool = AutoBash(cwd=repo)
|
|
|
|
for command, expected in (("git rev-parse --git-dir", ".git"), ("git ls-files", "note.txt")):
|
|
chunks = [chunk async for chunk in tool.call(command)]
|
|
assert chunks
|
|
assert all(chunk.state != ToolResultState.ERROR for chunk in chunks)
|
|
assert expected in "".join(block.text for chunk in chunks for block in chunk.content)
|
|
|
|
chunks = [chunk async for chunk in tool.call("git diff")]
|
|
assert chunks[-1].state == ToolResultState.ERROR
|
|
assert "external filters" in chunks[-1].content[0].text
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_allow_and_denied_command_options_are_combined(tmp_path, monkeypatch):
|
|
"""Allow constrains the first argument while denied still checks later flags."""
|
|
from reme_home import auto_bash
|
|
|
|
commands = auto_bash._READ_COMMANDS # pylint: disable=protected-access
|
|
# Rebuild specs from the temporary policy rather than the production cache.
|
|
monkeypatch.setattr(auto_bash, "_read_specs", auto_bash._read_specs.__wrapped__) # pylint: disable=protected-access
|
|
monkeypatch.setitem(commands, "echo", {"allow": ["hello"]})
|
|
tool = AutoBash(cwd=tmp_path)
|
|
|
|
assert await tool.check_read_only({"command": "echo hello"})
|
|
assert not await tool.check_read_only({"command": "echo goodbye"})
|
|
|
|
monkeypatch.setitem(commands, "echo", {"allow": ["hello"], "denied": ["-n"]})
|
|
assert not await tool.check_read_only({"command": "echo hello -n"})
|
|
assert await tool.check_read_only({"command": "find . -name note.txt"})
|
|
assert not await tool.check_read_only({"command": "find . -delete"})
|