This commit is contained in:
xyf2020 2026-10-11 18:33:41 +08:00 • committed by GitHub
commit b6a3e2e8e1
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
15 changed files with 1545 additions and 2 deletions

20
plugins/home/README.md Normal file
View file

@ -0,0 +1,20 @@
# Home plugin
Provides the `auto_bash` job through an independently enabled ReMe plugin.
Install it into the same Python environment as ReMe:
```bash
reme plugins install plugins/home
```
Enable it in an application config with `plugins: [home]`. The plugin then
contributes the `auto_bash` job, which can be run with
`reme start config=<your-config.yaml> job=auto_bash command=pwd`.
The job defaults to read-only command validation and a 60-second timeout. Set
`is_read_only=false` to disable command validation. Supplying `read_only_paths`
or `writable_paths` enables a file sandbox (Bubblewrap on Linux, Seatbelt on
macOS), with sandbox paths identical to their host paths. Linux defaults to a
4 GiB cgroup memory limit; macOS has no memory limit and warns when a nonzero
`memory_limit_bytes` value is supplied.

View file

@ -0,0 +1,29 @@
[project]
name = "reme-home"
version = "0.1.0"
description = "Local command execution jobs for ReMe."
readme = "README.md"
requires-python = ">=3.11"
dependencies = [
"reme-ai[as]>=0.4.1.12",
"safecmd>=0.1.16,<0.2",
]
[project.entry-points."reme.plugins"]
home = "reme_home"
[tool.setuptools.packages.find]
where = ["src"]
include = ["reme_home*"]
[tool.setuptools.package-data]
reme_home = ["plugin.yaml"]
[tool.pytest.ini_options]
asyncio_default_fixture_loop_scope = "function"
pythonpath = ["src", "../.."]
testpaths = ["tests"]
[build-system]
requires = ["setuptools>=77", "wheel"]
build-backend = "setuptools.build_meta"

View file

@ -0,0 +1,5 @@
"""Home plugin: local command execution jobs for ReMe."""
from .auto_bash import AutoBash, AutoBashStep
__all__ = ["AutoBash", "AutoBashStep"]

View file

@ -0,0 +1,256 @@
"""Run allowlisted shell commands without an approval prompt."""
from agentscope.message import TextBlock, ToolResultState
from agentscope.permission import PermissionBehavior, PermissionContext, PermissionDecision
from agentscope.tool import Bash, ToolChunk
from reme.utils.file_sandbox import create_file_sandbox_backend
from reme.steps.base_step import BaseStep
_READ_COMMANDS = {
":": {},
"cat": {},
"cd": {},
"cmp": {},
"diff": {"denied": ["--output"]},
"echo": {},
"false": {},
"file": {
"denied": ["-C", "--compile", "-p", "--preserve-date", "-z", "-Z", "--uncompress", "--uncompress-noreport"],
},
"find": {"denied": ["-delete", "-exec", "-execdir", "-fls", "-fprint", "-fprint0", "-fprintf", "-ok", "-okdir"]},
"git": {
"allow": ["status", "diff", "log", "show", "rev-parse", "ls-files"],
"denied": [
"--output",
"--ext-diff",
"--textconv",
"--paginate",
"--submodule",
"--ignore-submodules",
"--show-signature",
"--remerge-diff",
"--diff-merges",
],
},
"grep": {},
"head": {},
"ls": {},
"pwd": {},
"sort": {"denied": ["-o", "--output", "--compress-program", "--temporary-directory"]},
"stat": {},
"tail": {},
"true": {},
"wc": {},
"which": {},
}
_SAFE_DESTINATIONS = {"/dev/null"}
# Trusted shell setup, applied only after validating the user's command.
# Git's configuration can launch helpers even for otherwise read-only commands.
_READ_ONLY_SETUP = r"""
unset GIT_EXTERNAL_DIFF GIT_TRACE GIT_TRACE_SETUP GIT_TRACE_PERFORMANCE
unset GIT_TRACE_PACK_ACCESS GIT_TRACE_PACKET GIT_TRACE_CURL GIT_TRACE2 GIT_TRACE2_EVENT GIT_TRACE2_PERF
export GIT_OPTIONAL_LOCKS=0 GIT_PAGER=/bin/cat GIT_NO_LAZY_FETCH=1 GIT_TERMINAL_PROMPT=0
git() {
if command git -c trace2.normalTarget=0 -c trace2.eventTarget=0 -c trace2.perfTarget=0 \
config --get-regexp '^filter\..*\.(clean|smudge|process)$' > /dev/null; then
echo 'Permission denied: Git repositories with external filters require unrestricted mode.' >&2
return 1
fi
case "$1" in
diff|log|show)
reme_git_subcommand="$1"
shift
set -- "$reme_git_subcommand" --no-ext-diff --no-textconv --ignore-submodules=all "$@"
;;
status) shift; set -- status --ignore-submodules=all "$@" ;;
esac
command git --no-pager -c core.fsmonitor=false -c diff.submodule=short \
-c log.showSignature=false -c log.diffMerges=off \
-c trace2.normalTarget=0 -c trace2.eventTarget=0 -c trace2.perfTarget=0 "$@"
}
"""
def _literal_shell(node: dict, command: str, quoted: bool = False) -> bool:
"""Reject expansions and syntax whose effects safecmd does not fully check."""
from safecmd.bashxtract import word_text # pylint: disable=import-outside-toplevel
kind = node.get("Type")
if kind in {"FuncDecl", "ForClause", "DeclClause", "ArithmExp", "ArithmCmd", "ProcSubst", "CmdSubst"}:
return False
if kind == "SglQuoted":
return True
quoted = quoted or kind == "DblQuoted"
if kind == "Lit":
value = node.get("Value", "")
if "\\" in value or (not quoted and any(char in value for char in "*?[]{}~")):
return False
for redirect in node.get("Redirs", []):
op = redirect.get("Op")
destination = word_text(redirect.get("Word", {}), command)
if op in {"<", "<<", "<<-"}:
continue
if op in {">&", "<&"} and (destination == "-" or destination.rstrip("-").isdigit()):
continue
if op not in {">", ">>", ">|", "<>", "&>", "&>>", ">&"} or destination != "/dev/null":
return False
for value in node.values():
children = value if isinstance(value, list) else [value]
if any(isinstance(child, dict) and not _literal_shell(child, command, quoted) for child in children):
return False
return True
def _allowed_arguments(tokens: list[str]) -> bool:
"""Supplement CmdSpec with subcommands, abbreviated flags and Git formats."""
options = _READ_COMMANDS[tokens[0]]
if "allow" in options and (len(tokens) < 2 or tokens[1] not in options["allow"]):
return False
# Signature placeholders invoke Git's configured GPG program.
if tokens[0] == "git" and any("%G" in token for token in tokens[1:]):
return False
# getopt and Git also accept unambiguous long-option abbreviations.
for token in tokens[1:]:
flag = token.split("=", 1)[0]
if flag.startswith("--") and len(flag) > 2:
if any(denied.startswith(flag) for denied in options.get("denied", []) if denied.startswith("--")):
return False
return True
class AutoBash(Bash):
"""AgentScope Bash with an optional conservative read-only command guard.
Shell expansions, function definitions and for loops are rejected because
their runtime arguments or environment can differ from the parsed tokens.
Git external filters require unrestricted mode; other Git helpers are
disabled. Use path injection for OS file isolation.
"""
name = "AutoBash"
description = "Execute shell commands; read-only mode denies commands that may write or run other commands."
input_schema = {
**Bash.input_schema,
"properties": {
**Bash.input_schema["properties"],
"timeout": {
**Bash.input_schema["properties"]["timeout"],
"description": "Optional timeout in milliseconds (default: 60000, max: 600000)",
"default": 60000,
},
},
}
def __init__(self, *, is_read_only: bool = True, **kwargs):
super().__init__(**kwargs)
self.is_read_only = is_read_only
@staticmethod
def _is_read_only(command: str) -> bool:
"""Use safecmd's Bash parser and a strict command policy."""
if not isinstance(command, str) or not command.strip():
return False
# Variable and command expansion can hide a second operation in an
# argument. Keep this first version literal while safecmd checks the
# remaining shell syntax and nested commands.
if "$" in command or "`" in command:
return False
try:
from safecmd import CmdSpec, validate # pylint: disable=import-outside-toplevel
from safecmd.bashxtract import extract_commands, parse_bash # pylint: disable=import-outside-toplevel
from safecmd.core import normalize_dest # pylint: disable=import-outside-toplevel
if not _literal_shell(parse_bash(command), command):
return False
specs = {CmdSpec(name, denied=options.get("denied")) for name, options in _READ_COMMANDS.items()}
validate(command, cmds=specs, dests=_SAFE_DESTINATIONS)
commands, operators, redirects = extract_commands(command)
if any(not _allowed_arguments(tokens) for tokens in commands):
return False
return "=" not in operators and all(
normalize_dest(destination) == "/dev/null" for _, destination in redirects
)
except Exception: # pylint: disable=broad-exception-caught
# Parser failures and missing shfmt must never allow execution.
return False
async def check_read_only(self, tool_input: dict) -> bool:
"""Report the same policy used by the execution guard."""
return self.is_read_only and self._is_read_only(tool_input.get("command", ""))
async def check_permissions(self, tool_input: dict, _context: PermissionContext) -> PermissionDecision:
"""Resolve permission automatically, without an ASK decision."""
if not self.is_read_only:
return PermissionDecision(behavior=PermissionBehavior.ALLOW, message="Command allowed")
allowed = await self.check_read_only(tool_input)
return PermissionDecision(
behavior=PermissionBehavior.ALLOW if allowed else PermissionBehavior.DENY,
message=(
"Read-only command allowed" if allowed else "Permission denied: only read-only commands are allowed"
),
)
async def call(self, command: str, description: str = "", timeout: int = 60000):
"""Apply the read-only guard when configured, then run the command."""
if self.is_read_only and (not isinstance(command, str) or not self._is_read_only(command)):
yield ToolChunk(
content=[TextBlock(text="Permission denied: only read-only shell commands are allowed.")],
state=ToolResultState.ERROR,
is_last=True,
)
return
if self.is_read_only:
command = _READ_ONLY_SETUP + command
async for chunk in super().call(command=command, description=description, timeout=timeout):
yield chunk
class AutoBashStep(BaseStep):
"""Expose AgentScope Bash with configurable read-only mode as a ReMe job."""
async def execute(self):
assert self.context is not None
command = self.context.get("command", "")
timeout = self.context.get("timeout", self.kwargs.get("timeout", 60000))
is_read_only = self.context.get("is_read_only", self.kwargs.get("is_read_only", True))
read_only_paths = self.context.get("read_only_paths", self.kwargs.get("read_only_paths"))
writable_paths = self.context.get("writable_paths", self.kwargs.get("writable_paths"))
memory_limit_bytes = self.context.get("memory_limit_bytes", self.kwargs.get("memory_limit_bytes"))
if not isinstance(command, str) or not command.strip():
self.context.response.success = False
self.context.response.answer = "command is required"
return self.context.response
if not isinstance(timeout, int) or isinstance(timeout, bool) or timeout <= 0:
self.context.response.success = False
self.context.response.answer = "timeout must be a positive integer in milliseconds"
return self.context.response
if not isinstance(is_read_only, bool):
self.context.response.success = False
self.context.response.answer = "is_read_only must be a boolean"
return self.context.response
try:
backend = (
create_file_sandbox_backend(
cwd=self.workspace_path,
read_only_paths=[] if read_only_paths is None else read_only_paths,
writable_paths=[] if writable_paths is None else writable_paths,
memory_limit_bytes=memory_limit_bytes,
)
if read_only_paths is not None or writable_paths is not None
else None
)
except (TypeError, ValueError, RuntimeError) as exc:
self.context.response.success = False
self.context.response.answer = str(exc)
return self.context.response
tool = AutoBash(cwd=self.workspace_path, is_read_only=is_read_only, backend=backend)
chunks = [chunk async for chunk in tool.call(command=command, timeout=timeout)]
self.context.response.success = all(chunk.state != ToolResultState.ERROR for chunk in chunks)
self.context.response.answer = "".join(
block.text for chunk in chunks for block in chunk.content if isinstance(block, TextBlock)
)
return self.context.response

View file

@ -0,0 +1,40 @@
backends:
auto_bash_step: reme_home.auto_bash:AutoBashStep
application_defaults:
jobs:
auto_bash:
backend: base
description: "Run a shell command in the workspace; read-only mode is enabled by default"
parameters:
type: object
properties:
command:
type: string
description: "shell command to execute"
is_read_only:
type: boolean
description: "restrict execution to read-only commands"
default: true
timeout:
type: integer
description: "maximum execution time in milliseconds (default 60000, max 600000)"
default: 60000
minimum: 1
maximum: 600000
read_only_paths:
type: array
items: { type: string }
description: "Read-only host paths; providing either path list enables the platform file sandbox"
writable_paths:
type: array
items: { type: string }
description: "Writable host paths; sandbox paths match host paths"
memory_limit_bytes:
type: integer
minimum: 0
description: "Linux cgroup memory limit in bytes (default 4 GiB); macOS warns and ignores nonzero values"
required:
- command
steps:
- backend: auto_bash_step

View file

@ -0,0 +1,358 @@
"""Shell job and its optional read-only execution guard."""
import inspect
import subprocess
from importlib.metadata import EntryPoint
from pathlib import Path
import pytest
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.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.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.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 --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 --pre='touch denied.txt' pattern .",
"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 show --remerge-diff",
"rg --hostname-bin=./hook --hyperlink-format=default hello .",
"rg -z hello .",
"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
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
async def test_step_rejects_non_boolean_mode(tmp_path):
"""A string false value must not silently enable unrestricted execution."""
step = AutoBashStep(app_context=ApplicationContext(workspace_dir=str(tmp_path)))
response = await step(command="touch denied.txt", is_read_only="false")
assert response.success is False
assert response.answer == "is_read_only must be a boolean"
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_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()
def test_auto_bash_defaults_to_sixty_seconds():
"""Tool and default job expose the same 60-second timeout."""
manifest = Path(__file__).parents[1] / "src" / "reme_home" / "plugin.yaml"
with manifest.open(encoding="utf-8") as config_file:
job = yaml.safe_load(config_file)["application_defaults"]["jobs"]["auto_bash"]
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
@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 is the source for backend and job registration."""
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 job["parameters"]["properties"]["read_only_paths"]["type"] == "array"
assert job["parameters"]["properties"]["writable_paths"]["type"] == "array"
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",
"sort note.txt",
"diff note.txt note.txt",
"cmp note.txt note.txt",
"git status --short",
"git diff --stat",
"git log --oneline",
"git show HEAD",
"git rev-parse HEAD",
"git ls-files",
],
)
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_safe_redirection_is_allowed(tmp_path):
"""safecmd allows output to /dev/null and input from workspace files."""
(tmp_path / "note.txt").write_text("hello\n", encoding="utf-8")
tool = AutoBash(cwd=tmp_path)
assert await tool.check_read_only({"command": "echo hello > /dev/null"})
assert await tool.check_read_only({"command": "cat < note.txt"})
assert await tool.check_read_only({"command": "echo hello >| /dev/null"})
assert await tool.check_read_only({"command": "echo hello 2>&1"})
assert await tool.check_read_only({"command": "find . -name '*.txt'"})
assert await tool.check_read_only({"command": "(cat note.txt) | head -n 1"})
@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_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
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"})

View file

@ -142,6 +142,10 @@ jobs:
python_timeout:
type: number
description: "Execution timeout in seconds; defaults to 60."
memory_limit_bytes:
type: integer
minimum: 0
description: "Linux memory limit in bytes (default 4 GiB); macOS ignores nonzero values"
required:
- code
steps:

View file

@ -5,6 +5,8 @@ import sys
from dataclasses import dataclass
from typing import Any
from ...utils.file_sandbox import create_file_sandbox_backend
from ...utils.file_sandbox.file_sandbox_backend import FileSandboxBackend
from ..base_step import BaseStep
from ...components import R
@ -39,7 +41,31 @@ class PythonExecuteStep(BaseStep):
self.context.response.answer = timeout_error
return self.context.response
result = await self._run_python(code, timeout)
read_only_paths = self.context.get("read_only_paths", self.kwargs.get("read_only_paths"))
writable_paths = self.context.get("writable_paths", self.kwargs.get("writable_paths"))
memory_limit_bytes = self.context.get("memory_limit_bytes", self.kwargs.get("memory_limit_bytes"))
try:
backend = (
create_file_sandbox_backend(
cwd=self.workspace_path,
read_only_paths=[] if read_only_paths is None else read_only_paths,
writable_paths=[] if writable_paths is None else writable_paths,
memory_limit_bytes=memory_limit_bytes,
)
if read_only_paths is not None or writable_paths is not None
else None
)
except (TypeError, ValueError, RuntimeError) as exc:
self.context.response.success = False
self.context.response.answer = str(exc)
return self.context.response
try:
result = await self._run_python(code, timeout, backend)
except (OSError, RuntimeError) as exc:
self.context.response.success = False
self.context.response.answer = str(exc)
return self.context.response
if result.timed_out:
self.context.response.success = False
self.context.response.answer = f"Python execution timed out after {timeout:g}s"
@ -65,7 +91,19 @@ class PythonExecuteStep(BaseStep):
)
return self.context.response
async def _run_python(self, code: str, timeout: float) -> _PythonResult:
async def _run_python(self, code: str, timeout: float, backend: FileSandboxBackend | None = None) -> _PythonResult:
if backend is not None:
result = await backend.exec_shell(
[sys.executable, "-c", code],
cwd=str(self.workspace_path),
timeout=timeout,
)
return _PythonResult(
stdout=result.stdout.decode(errors="replace"),
stderr=result.stderr.decode(errors="replace"),
returncode=result.exit_code,
timed_out=result.exit_code == -1 and result.stderr == b"timed out",
)
process = await asyncio.create_subprocess_exec(
sys.executable,
"-c",

View file

@ -0,0 +1,18 @@
"""Select a local file sandbox for supported operating systems."""
import sys
from .file_sandbox_backend import FileSandboxBackend
from .path_bubble_backend import PathBubbleBackend
from .path_seatbelt_backend import PathSeatbeltBackend
def create_file_sandbox_backend(*, memory_limit_bytes: int | None = None, **kwargs) -> FileSandboxBackend:
"""Construct the platform backend; never silently execute without isolation."""
if sys.platform.startswith("linux"):
limit = 4 * 1024**3 if memory_limit_bytes is None else memory_limit_bytes
return PathBubbleBackend(memory_limit_bytes=limit, **kwargs)
if sys.platform == "darwin":
limit = 0 if memory_limit_bytes is None else memory_limit_bytes
return PathSeatbeltBackend(memory_limit_bytes=limit, **kwargs)
raise RuntimeError(f"No file sandbox backend for {sys.platform}")

View file

@ -0,0 +1,132 @@
"""Common contract and path validation for subprocess file sandboxes."""
import asyncio
import os
import signal
import sys
from abc import abstractmethod
from agentscope.tool import BackendBase, ExecResult
def contains(root: str, path: str) -> bool:
"""Whether a path is inside a configured root."""
return os.path.commonpath((root, path)) == root
class FileSandboxBackend(BackendBase):
"""Execute subprocesses with explicit host-path read and write access."""
def __init__(self, *, cwd: str | os.PathLike[str], read_only_paths: list[str], writable_paths: list[str]) -> None:
self._cwd = self._validate_path(os.fspath(cwd))
if not os.path.isdir(self._cwd):
raise ValueError("sandbox working directory must be a directory")
self._read_only_paths = self._validate_paths(read_only_paths, "read_only_paths")
self._writable_paths = self._validate_paths(writable_paths, "writable_paths")
if not any(contains(path, self._cwd) for path in self._read_only_paths + self._writable_paths):
self._read_only_paths.append(self._cwd)
runtime_prefix = os.path.realpath(sys.prefix)
if runtime_prefix not in ("/", "/usr", "/usr/local") and not runtime_prefix.startswith("/usr/"):
if not any(contains(path, runtime_prefix) for path in self._read_only_paths + self._writable_paths):
self._read_only_paths.append(self._validate_path(runtime_prefix))
paths = self._read_only_paths + self._writable_paths
if len(set(paths)) != len(paths):
raise ValueError("sandbox paths must not contain duplicates")
self._path_identities = {path: self._identity(path) for path in paths}
@staticmethod
def _identity(path: str) -> tuple[int, int]:
info = os.stat(path, follow_symlinks=False)
return info.st_dev, info.st_ino
@staticmethod
def _validate_path(raw: str) -> str:
if not isinstance(raw, str) or not raw or not os.path.isabs(raw):
raise ValueError("sandbox paths must be non-empty absolute paths")
path = os.path.normpath(raw)
if path == "/" or not os.path.exists(path) or os.path.realpath(path) != path:
raise ValueError(f"sandbox path must exist without symlinks and cannot be root: {raw}")
return path
@classmethod
def _validate_paths(cls, raw: list[str], name: str) -> list[str]:
if not isinstance(raw, list) or any(not isinstance(item, str) for item in raw):
raise ValueError(f"{name} must be a list of absolute paths")
return [cls._validate_path(path) for path in raw]
def _check_paths(self, cwd: str) -> None:
if not any(contains(path, cwd) for path in self._read_only_paths + self._writable_paths):
raise ValueError("command working directory must be inside a sandbox path")
for path, identity in self._path_identities.items():
if not os.path.exists(path) or os.path.realpath(path) != path or self._identity(path) != identity:
raise RuntimeError(f"sandbox path changed before execution: {path}")
async def getcwd(self) -> str:
"""Return the host-identical sandbox working directory."""
return self._cwd
@abstractmethod
async def _start_process(self, command: list[str], cwd: str, *, stdin=asyncio.subprocess.DEVNULL):
"""Start one sandboxed process, never falling back to host execution."""
def _release_process(self, process: asyncio.subprocess.Process) -> None:
"""Release platform resources after the child has exited."""
async def exec_shell(
self,
command: list[str],
*,
cwd: str | None = None,
timeout: float | None = None,
) -> ExecResult:
"""Execute an argv list with a timeout inside the platform sandbox."""
process = await self._start_process(command, cwd or self._cwd)
try:
stdout, stderr = await asyncio.wait_for(process.communicate(), timeout=timeout)
except TimeoutError:
self._kill_process(process)
await process.communicate()
return ExecResult(exit_code=-1, stdout=b"", stderr=b"timed out")
except BaseException:
self._kill_process(process)
await asyncio.shield(process.communicate())
raise
finally:
self._release_process(process)
return ExecResult(exit_code=process.returncode, stdout=stdout, stderr=stderr)
@staticmethod
def _kill_process(process: asyncio.subprocess.Process) -> None:
"""Terminate the sandbox launcher and its process group."""
try:
os.killpg(process.pid, signal.SIGKILL)
except ProcessLookupError:
pass
async def read_file(self, path: str) -> bytes:
"""Read through a sandboxed command so file rules still apply."""
result = await self.exec_shell(["cat", path])
if not result.ok():
raise OSError(result.stderr.decode(errors="replace"))
return result.stdout
async def write_file(self, path: str, data: bytes) -> None:
"""Write through a sandboxed command so file rules still apply."""
if not isinstance(data, bytes):
raise TypeError("data must be bytes")
script = 'mkdir -p -- "$(dirname -- "$1")" && cat > "$1"'
process = await self._start_process(
["/bin/sh", "-c", script, "sh", path],
self._cwd,
stdin=asyncio.subprocess.PIPE,
)
try:
_stdout, stderr = await process.communicate(data)
except BaseException:
self._kill_process(process)
await asyncio.shield(process.communicate())
raise
finally:
self._release_process(process)
if process.returncode != 0:
raise OSError(stderr.decode(errors="replace"))

View file

@ -0,0 +1,130 @@
"""Bubblewrap execution with host-identical, explicitly mounted paths."""
import asyncio
import os
import shutil
import uuid
from pathlib import Path
import sys
from .file_sandbox_backend import FileSandboxBackend
# Run after exec of a fresh interpreter: Python preexec_fn can deadlock when
# the parent has threads. The requested command starts only after enrollment.
_CGROUP_EXEC = """
import os
import sys
with open(sys.argv[1], 'w', encoding='ascii') as group:
group.write(str(os.getpid()))
os.execvp(sys.argv[2], sys.argv[2:])
"""
class PathBubbleBackend(FileSandboxBackend):
"""Run commands with selected host paths at the same sandbox paths."""
def __init__(
self,
*,
cwd: str | os.PathLike[str],
read_only_paths: list[str],
writable_paths: list[str],
memory_limit_bytes: int = 4 * 1024**3,
) -> None:
if isinstance(memory_limit_bytes, bool) or not isinstance(memory_limit_bytes, int) or memory_limit_bytes < 0:
raise ValueError("memory_limit_bytes must be a non-negative integer")
self._memory_limit_bytes = memory_limit_bytes
self._process_cgroups: dict[int, Path] = {}
super().__init__(cwd=cwd, read_only_paths=read_only_paths, writable_paths=writable_paths)
def _bwrap_argv(self, command: list[str], cwd: str) -> list[str]:
"""Build one isolated execution with host-identical bind targets."""
if not command:
raise ValueError("command must not be empty")
self._check_paths(cwd)
args = ["bwrap", "--die-with-parent", "--new-session", "--unshare-all", "--proc", "/proc", "--dev", "/dev"]
for path in ("/usr", "/usr/local"):
if os.path.isdir(path):
args.extend(("--ro-bind", path, path))
for path in ("/bin", "/sbin", "/lib", "/lib64"):
if os.path.islink(path):
args.extend(("--symlink", os.readlink(path), path))
elif os.path.exists(path):
args.extend(("--ro-bind", path, path))
args.extend(("--dir", "/etc"))
for path in ("/etc/passwd", "/etc/group", "/etc/nsswitch.conf", "/etc/hosts", "/etc/resolv.conf", "/etc/ssl"):
if os.path.exists(path):
args.extend(("--ro-bind", path, path))
args.extend(("--tmpfs", "/tmp", "--tmpfs", "/run"))
mounts = [(path, "--ro-bind") for path in self._read_only_paths]
mounts += [(path, "--bind") for path in self._writable_paths]
for path, mode in sorted(mounts, key=lambda item: (item[0].count("/"), item[0])):
args.extend((mode, path, path))
binary_paths = [str(Path(sys.prefix) / "bin"), "/usr/local/bin", "/usr/bin", "/bin"]
args.extend(("--clearenv", "--setenv", "HOME", self._cwd, "--setenv", "TMPDIR", "/tmp"))
args.extend(("--setenv", "PATH", ":".join(binary_paths), "--chdir", cwd, "--"))
return args + command
async def _start_process(self, command: list[str], cwd: str, *, stdin=asyncio.subprocess.DEVNULL):
"""Start bwrap without falling back to host execution."""
if not sys.platform.startswith("linux"):
raise RuntimeError("PathBubbleBackend requires Linux")
if shutil.which("bwrap") is None:
raise RuntimeError("PathBubbleBackend requires bwrap on PATH")
argv = self._bwrap_argv(command, cwd)
group = self._create_cgroup() if self._memory_limit_bytes else None
try:
if group is not None:
argv = [sys.executable, "-I", "-S", "-c", _CGROUP_EXEC, str(group / "cgroup.procs"), *argv]
process = await asyncio.create_subprocess_exec(
*argv,
stdin=stdin,
stdout=asyncio.subprocess.PIPE,
stderr=asyncio.subprocess.PIPE,
start_new_session=True,
)
except BaseException:
if group is not None:
group.rmdir()
raise
if group is not None:
self._process_cgroups[process.pid] = group
return process
def _create_cgroup(self) -> Path:
"""Create a separate cgroup v2 for one execution, failing closed if unavailable."""
try:
entries = Path("/proc/self/cgroup").read_text(encoding="ascii").splitlines()
relative = next(line.split("::", 1)[1] for line in entries if line.startswith("0::"))
root = Path("/sys/fs/cgroup")
parent = root / relative.lstrip("/")
if ".." in Path(relative).parts or not parent.resolve().is_relative_to(root):
raise RuntimeError("invalid cgroup v2 path")
group = parent / f"reme-{uuid.uuid4().hex}"
group.mkdir()
try:
(group / "memory.max").write_text(str(self._memory_limit_bytes), encoding="ascii")
except BaseException:
group.rmdir()
raise
return group
except (OSError, StopIteration) as exc:
raise RuntimeError("PathBubbleBackend requires a writable delegated cgroup v2 for memory limits") from exc
def _release_process(self, process: asyncio.subprocess.Process) -> None:
"""Release the per-execution group after its process has been reaped."""
group = self._process_cgroups.pop(process.pid, None)
if group is None:
return
try:
group.rmdir()
except OSError as exc:
try:
(group / "cgroup.kill").write_text("1", encoding="ascii")
group.rmdir()
except OSError as cleanup_error:
raise RuntimeError(f"could not remove sandbox cgroup {group}: {cleanup_error}") from exc
# Keep the existing helper name for callers that manage a process directly.
_release_cgroup = _release_process

View file

@ -0,0 +1,109 @@
"""macOS Seatbelt file sandbox with host-identical paths."""
import asyncio
import json
import os
import shutil
import sys
import tempfile
import warnings
from .file_sandbox_backend import FileSandboxBackend, contains
class PathSeatbeltBackend(FileSandboxBackend):
"""Run each command under a deny-by-default Seatbelt profile."""
def __init__(
self,
*,
cwd: str | os.PathLike[str],
read_only_paths: list[str],
writable_paths: list[str],
memory_limit_bytes: int = 0,
) -> None:
if isinstance(memory_limit_bytes, bool) or not isinstance(memory_limit_bytes, int) or memory_limit_bytes < 0:
raise ValueError("memory_limit_bytes must be a non-negative integer")
if memory_limit_bytes:
warnings.warn(
"PathSeatbeltBackend does not support memory_limit_bytes; the limit is ignored on macOS",
RuntimeWarning,
stacklevel=2,
)
super().__init__(cwd=cwd, read_only_paths=read_only_paths, writable_paths=writable_paths)
self._process_tempdirs: dict[int, str] = {}
@staticmethod
def _path_rule(path: str) -> str:
"""Quote a path as one Seatbelt profile string, never as profile syntax."""
return f"(subpath {json.dumps(path)})" if os.path.isdir(path) else f"(literal {json.dumps(path)})"
def _profile(self, tempdir: str) -> str:
"""Allow only configured files plus the system runtime needed to launch tools."""
system_roots = ("/System", "/usr", "/bin", "/sbin", "/private/etc", "/dev")
read_paths = [path for path in system_roots if os.path.exists(path)]
read_paths += self._read_only_paths + self._writable_paths + [tempdir]
lines = [
"(version 1)",
"(deny default)",
"(allow process*)",
"(allow sysctl-read)",
"(allow mach-lookup)",
# dyld opens the root directory; /bin/sh reads the system shell selector.
'(allow file-read* (literal "/") (literal "/private/var/select/sh"))',
]
ancestors = set()
for path in read_paths:
parent = os.path.dirname(path)
while parent != "/":
ancestors.add(parent)
parent = os.path.dirname(parent)
lines.extend(f"(allow file-read-metadata (literal {json.dumps(path)}))" for path in sorted(ancestors))
lines.extend(f"(allow file-read* {self._path_rule(path)})" for path in read_paths)
for path in self._writable_paths + [tempdir]:
# Seatbelt combines allow rules with OR, unlike ordered bwrap mounts.
# Exclude read-only descendants from each writable parent's grant.
exclusions = [
f"(require-not {self._path_rule(read_only)})"
for read_only in self._read_only_paths
if contains(path, read_only)
]
rule = self._path_rule(path)
if exclusions:
rule = f"(require-all {rule} {' '.join(exclusions)})"
lines.append(f"(allow file-write* {rule})")
return "\n".join(lines)
async def _start_process(self, command: list[str], cwd: str, *, stdin=asyncio.subprocess.DEVNULL):
if sys.platform != "darwin":
raise RuntimeError("PathSeatbeltBackend requires macOS")
if shutil.which("sandbox-exec") is None:
raise RuntimeError("PathSeatbeltBackend requires sandbox-exec on PATH")
if not command:
raise ValueError("command must not be empty")
self._check_paths(cwd)
tempdir = tempfile.mkdtemp(prefix="reme-seatbelt-", dir=os.path.realpath(tempfile.gettempdir()))
try:
env = {**os.environ, "HOME": self._cwd, "TMPDIR": tempdir}
process = await asyncio.create_subprocess_exec(
"/usr/bin/sandbox-exec",
"-p",
self._profile(tempdir),
*command,
cwd=cwd,
env=env,
stdin=stdin,
stdout=asyncio.subprocess.PIPE,
stderr=asyncio.subprocess.PIPE,
start_new_session=True,
)
except BaseException:
shutil.rmtree(tempdir)
raise
self._process_tempdirs[process.pid] = tempdir
return process
def _release_process(self, process: asyncio.subprocess.Process) -> None:
tempdir = self._process_tempdirs.pop(process.pid, None)
if tempdir is not None:
shutil.rmtree(tempdir)

View file

@ -0,0 +1,71 @@
"""Exercise macOS sandbox startup and file isolation using real subprocesses."""
import sys
import pytest
from reme.utils.file_sandbox.path_seatbelt_backend import PathSeatbeltBackend
pytestmark = pytest.mark.skipif(sys.platform != "darwin", reason="Seatbelt requires macOS")
@pytest.mark.asyncio
@pytest.mark.parametrize(
"command",
[
["/bin/sh", "-c", "pwd"],
[sys.executable, "-c", "import os; print(os.getcwd())"],
],
)
async def test_seatbelt_starts_shell_and_python(tmp_path, command):
"""System runtime access permits startup and resolution of the working directory."""
workspace = tmp_path.resolve()
backend = PathSeatbeltBackend(cwd=workspace, read_only_paths=[], writable_paths=[])
result = await backend.exec_shell(command, timeout=10)
assert result.exit_code == 0, result.stderr.decode(errors="replace")
assert result.stdout.decode().strip() == str(workspace)
assert result.stderr == b""
@pytest.mark.asyncio
async def test_seatbelt_runtime_permissions_preserve_file_isolation(tmp_path):
"""Startup grants do not expose sibling files or writable access to protected children."""
root = tmp_path.resolve()
workspace = root / "workspace"
protected = workspace / "protected"
output = protected / "output"
output.mkdir(parents=True)
(protected / "note.txt").write_text("readable", encoding="utf-8")
(root / "outside.txt").write_text("private", encoding="utf-8")
backend = PathSeatbeltBackend(
cwd=workspace,
read_only_paths=[str(protected)],
writable_paths=[str(workspace), str(output)],
)
code = """
from pathlib import Path
assert Path('protected/note.txt').read_text() == 'readable'
Path('allowed.txt').write_text('ok')
Path('protected/output/allowed.txt').write_text('ok')
for path, operation in [
(Path('protected/denied.txt'), 'write'),
(Path('../outside.txt'), 'read'),
(Path('../outside.txt'), 'write'),
]:
try:
path.read_text() if operation == 'read' else path.write_text('denied')
except PermissionError:
pass
else:
raise AssertionError(f'{operation} unexpectedly allowed: {path}')
"""
result = await backend.exec_shell([sys.executable, "-c", code], timeout=10)
assert result.exit_code == 0, result.stderr.decode(errors="replace")
assert (workspace / "allowed.txt").read_text() == "ok"
assert (output / "allowed.txt").read_text() == "ok"
assert not (protected / "denied.txt").exists()
assert (root / "outside.txt").read_text() == "private"

View file

@ -0,0 +1,116 @@
"""Shared file sandbox selection and macOS policy construction."""
import sys
import pytest
from agentscope.tool import ExecResult
from reme.components.application_context import ApplicationContext
from reme.steps.common.python_execute import PythonExecuteStep
from reme.utils.file_sandbox import create_file_sandbox_backend
from reme.utils.file_sandbox.path_bubble_backend import PathBubbleBackend
from reme.utils.file_sandbox.path_seatbelt_backend import PathSeatbeltBackend
def test_platform_selection_and_memory_defaults(tmp_path, monkeypatch):
"""Linux retains cgroups while macOS defaults to no unsupported memory cap."""
monkeypatch.setattr("reme.utils.file_sandbox.sys.platform", "linux")
linux = create_file_sandbox_backend(cwd=tmp_path, read_only_paths=[], writable_paths=[])
assert isinstance(linux, PathBubbleBackend)
assert linux._memory_limit_bytes == 4 * 1024**3 # pylint: disable=protected-access
monkeypatch.setattr("reme.utils.file_sandbox.sys.platform", "darwin")
mac = create_file_sandbox_backend(cwd=tmp_path, read_only_paths=[], writable_paths=[])
assert isinstance(mac, PathSeatbeltBackend)
with pytest.warns(RuntimeWarning, match="limit is ignored on macOS"):
limited = create_file_sandbox_backend(
cwd=tmp_path,
read_only_paths=[],
writable_paths=[],
memory_limit_bytes=1024,
)
assert isinstance(limited, PathSeatbeltBackend)
@pytest.mark.asyncio
async def test_unsupported_platform_requires_backend_only_for_path_policy(tmp_path, monkeypatch):
"""An unsupported platform runs ordinary Python but rejects a requested sandbox."""
monkeypatch.setattr("reme.utils.file_sandbox.sys.platform", "unsupported")
step = PythonExecuteStep(app_context=ApplicationContext(workspace_dir=str(tmp_path)))
plain = await step(code="print('host')")
assert plain.success is True
assert plain.answer == "host\n"
restricted = await step(code="print('blocked')", read_only_paths=[])
assert restricted.success is False
assert restricted.answer == "No file sandbox backend for unsupported"
def test_seatbelt_profile_limits_file_access(tmp_path):
"""The profile grants configured paths and a separate per-run temporary directory."""
output = tmp_path / "output"
output.mkdir()
backend = PathSeatbeltBackend(cwd=tmp_path, read_only_paths=[str(tmp_path)], writable_paths=[str(output)])
profile = backend._profile(str(tmp_path / "temporary")) # pylint: disable=protected-access
assert "(deny default)" in profile
assert f'(allow file-read* (subpath "{tmp_path}"))' in profile
assert f'(allow file-write* (subpath "{output}"))' in profile
assert f'(allow file-write* (subpath "{tmp_path}"))' not in profile
assert "network*" not in profile
def test_seatbelt_excludes_read_only_child_from_writable_parent(tmp_path):
"""A parent write grant must not override a nested read-only restriction."""
protected = tmp_path / "protected"
protected.mkdir()
output = protected / "output"
output.mkdir()
backend = PathSeatbeltBackend(
cwd=tmp_path,
read_only_paths=[str(protected)],
writable_paths=[str(tmp_path), str(output)],
)
profile = backend._profile(str(tmp_path / "temporary")) # pylint: disable=protected-access
assert (
f'(allow file-write* (require-all (subpath "{tmp_path}") ' f'(require-not (subpath "{protected}"))))'
) in profile
assert f'(allow file-write* (subpath "{output}"))' in profile
@pytest.mark.asyncio
async def test_seatbelt_launch_failure_cleans_up_without_host_fallback(tmp_path, monkeypatch):
"""A failed sandbox launcher never starts the requested command directly."""
monkeypatch.setattr(sys, "platform", "darwin")
monkeypatch.setattr("reme.utils.file_sandbox.path_seatbelt_backend.shutil.which", lambda _: "/usr/bin/sandbox-exec")
calls = []
async def fail(*argv, **_kwargs):
calls.append(argv)
raise OSError("sandbox unavailable")
monkeypatch.setattr("reme.utils.file_sandbox.path_seatbelt_backend.asyncio.create_subprocess_exec", fail)
backend = PathSeatbeltBackend(cwd=tmp_path, read_only_paths=[], writable_paths=[])
with pytest.raises(OSError, match="sandbox unavailable"):
await backend.exec_shell(["/bin/echo", "unsafe"])
assert calls[0][0] == "/usr/bin/sandbox-exec"
assert calls[0][-2:] == ("/bin/echo", "unsafe")
assert not backend._process_tempdirs # pylint: disable=protected-access
@pytest.mark.asyncio
async def test_python_job_selects_seatbelt_on_macos(tmp_path, monkeypatch):
"""Path injection reaches the macOS backend with no implicit memory cap."""
monkeypatch.setattr("reme.utils.file_sandbox.sys.platform", "darwin")
seen = {}
async def fake_exec(self, command, *, cwd=None, timeout=None):
seen.update(backend=self, command=command, cwd=cwd, timeout=timeout)
return ExecResult(exit_code=0, stdout=b"ok\n", stderr=b"")
monkeypatch.setattr(PathSeatbeltBackend, "exec_shell", fake_exec)
step = PythonExecuteStep(app_context=ApplicationContext(workspace_dir=str(tmp_path)))
response = await step(code="print('ok')", read_only_paths=[])
assert response.success is True
assert isinstance(seen["backend"], PathSeatbeltBackend)
assert seen["command"] == [sys.executable, "-c", "print('ok')"]

View file

@ -0,0 +1,217 @@
"""Path-preserving Bubblewrap mounts and job integration."""
import sys
from types import SimpleNamespace
from pathlib import Path
import pytest
import yaml
from agentscope.tool import ExecResult
from reme.utils.file_sandbox.path_bubble_backend import PathBubbleBackend
from reme.components.application_context import ApplicationContext
from reme.steps.common.python_execute import PythonExecuteStep
@pytest.fixture(autouse=True)
def linux_backend_selection(monkeypatch):
"""Keep existing integration assertions on the Linux backend."""
monkeypatch.setattr("reme.utils.file_sandbox.sys.platform", "linux")
def _mounts(argv):
return [
(argv[index], argv[index + 1], argv[index + 2])
for index in range(len(argv) - 2)
if argv[index] in ("--bind", "--ro-bind")
]
def test_path_bubble_backend_preserves_host_paths_and_mount_order(tmp_path):
"""Nested writable mounts override a read-only parent at the same path."""
output = tmp_path / "output"
output.mkdir()
backend = PathBubbleBackend(cwd=tmp_path, read_only_paths=[str(tmp_path)], writable_paths=[str(output)])
argv = backend._bwrap_argv(["/bin/sh", "-c", "pwd"], str(tmp_path)) # pylint: disable=protected-access
mounts = _mounts(argv)
assert ("--ro-bind", str(tmp_path), str(tmp_path)) in mounts
assert ("--bind", str(output), str(output)) in mounts
assert mounts.index(("--ro-bind", str(tmp_path), str(tmp_path))) < mounts.index(
("--bind", str(output), str(output)),
)
assert argv[-3:] == ["/bin/sh", "-c", "pwd"]
assert argv[argv.index("--chdir") + 1] == str(tmp_path)
assert "--share-net" not in argv
def test_path_bubble_backend_adds_workspace_read_only(tmp_path):
"""The working directory is always accessible inside the sandbox."""
backend = PathBubbleBackend(cwd=tmp_path, read_only_paths=[], writable_paths=[])
argv = backend._bwrap_argv(["true"], str(tmp_path)) # pylint: disable=protected-access
assert ("--ro-bind", str(tmp_path), str(tmp_path)) in _mounts(argv)
@pytest.mark.parametrize("limit", [-1, True, 1.5, "4096"])
def test_path_bubble_backend_rejects_invalid_memory_limit(tmp_path, limit):
"""Memory limits must be non-negative whole bytes."""
with pytest.raises(ValueError, match="memory_limit_bytes"):
PathBubbleBackend(cwd=tmp_path, read_only_paths=[], writable_paths=[], memory_limit_bytes=limit)
@pytest.mark.asyncio
async def test_each_execution_gets_separate_memory_cgroup(tmp_path, monkeypatch):
"""Each process receives its own cgroup before bubblewrap is executed."""
backend = PathBubbleBackend(cwd=tmp_path, read_only_paths=[], writable_paths=[], memory_limit_bytes=1024)
groups = []
subprocess_options = []
subprocess_commands = []
def create_group():
group = tmp_path / f"group-{len(groups)}"
group.mkdir()
groups.append(group)
return group
async def create_process(*argv, **kwargs):
subprocess_commands.append(argv)
subprocess_options.append(kwargs)
return SimpleNamespace(pid=100 + len(subprocess_options))
monkeypatch.setattr(sys, "platform", "linux")
monkeypatch.setattr("reme.utils.file_sandbox.path_bubble_backend.shutil.which", lambda _: "/usr/bin/bwrap")
monkeypatch.setattr(backend, "_create_cgroup", create_group)
monkeypatch.setattr("reme.utils.file_sandbox.path_bubble_backend.asyncio.create_subprocess_exec", create_process)
first = await backend._start_process(["true"], str(tmp_path)) # pylint: disable=protected-access
second = await backend._start_process(["true"], str(tmp_path)) # pylint: disable=protected-access
assert len(groups) == 2 and groups[0] != groups[1]
assert all("preexec_fn" not in options for options in subprocess_options)
for argv, group in zip(subprocess_commands, groups):
assert argv[:4] == (sys.executable, "-I", "-S", "-c")
assert argv[5:7] == (str(group / "cgroup.procs"), "bwrap")
backend._release_cgroup(first) # pylint: disable=protected-access
backend._release_cgroup(second) # pylint: disable=protected-access
assert not any(group.exists() for group in groups)
@pytest.mark.asyncio
async def test_zero_memory_limit_skips_cgroup(tmp_path, monkeypatch):
"""A zero limit bypasses cgroup creation and child enrollment."""
backend = PathBubbleBackend(cwd=tmp_path, read_only_paths=[], writable_paths=[], memory_limit_bytes=0)
options = {}
async def create_process(*_argv, **kwargs):
options.update(kwargs)
return SimpleNamespace(pid=123)
monkeypatch.setattr(sys, "platform", "linux")
monkeypatch.setattr("reme.utils.file_sandbox.path_bubble_backend.shutil.which", lambda _: "/usr/bin/bwrap")
monkeypatch.setattr("reme.utils.file_sandbox.path_bubble_backend.asyncio.create_subprocess_exec", create_process)
await backend._start_process(["true"], str(tmp_path)) # pylint: disable=protected-access
assert "preexec_fn" not in options
@pytest.mark.parametrize("path", ["relative", "/", "/does-not-exist"])
def test_path_bubble_backend_rejects_invalid_mounts(tmp_path, path):
"""Only existing absolute non-root paths can be mounted."""
with pytest.raises(ValueError):
PathBubbleBackend(cwd=tmp_path, read_only_paths=[path], writable_paths=[])
def test_path_bubble_backend_rejects_symlink_and_duplicates(tmp_path):
"""Ambiguous mount sources are rejected before a process starts."""
alias = tmp_path.parent / "alias"
alias.symlink_to(tmp_path)
try:
with pytest.raises(ValueError):
PathBubbleBackend(cwd=tmp_path, read_only_paths=[str(alias)], writable_paths=[])
with pytest.raises(ValueError):
PathBubbleBackend(cwd=tmp_path, read_only_paths=[str(tmp_path)], writable_paths=[str(tmp_path)])
finally:
alias.unlink()
def test_path_bubble_backend_rejects_replaced_mount_source(tmp_path):
"""A path swapped after validation cannot become a new mount source."""
source = tmp_path / "source"
source.mkdir()
backend = PathBubbleBackend(cwd=tmp_path, read_only_paths=[str(source)], writable_paths=[])
source.rename(tmp_path / "old-source")
source.mkdir()
with pytest.raises(RuntimeError, match="sandbox path changed"):
backend._bwrap_argv(["true"], str(tmp_path)) # pylint: disable=protected-access
@pytest.mark.asyncio
async def test_python_execute_uses_path_backend_when_paths_supplied(tmp_path, monkeypatch):
"""Python execution uses the same backend and timeout units."""
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\n", stderr=b"")
monkeypatch.setattr(PathBubbleBackend, "exec_shell", fake_exec)
step = PythonExecuteStep(app_context=ApplicationContext(workspace_dir=str(tmp_path)))
response = await step(code="print('ok')", read_only_paths=[], writable_paths=[str(tmp_path)])
assert response.success is True
assert response.answer == "ok\n"
assert seen["command"] == [sys.executable, "-c", "print('ok')"]
assert seen["cwd"] == str(tmp_path)
assert seen["timeout"] == 60
assert seen["backend"]._memory_limit_bytes == 4 * 1024**3 # pylint: disable=protected-access
@pytest.mark.asyncio
async def test_python_execute_passes_custom_memory_limit(tmp_path, monkeypatch):
"""Job parameters reach the backend without changing path selection."""
seen = {}
async def fake_exec(self, _command, *, cwd=None, timeout=None): # pylint: disable=unused-argument
seen["limit"] = self._memory_limit_bytes # pylint: disable=protected-access
return ExecResult(exit_code=0, stdout=b"", stderr=b"")
monkeypatch.setattr(PathBubbleBackend, "exec_shell", fake_exec)
step = PythonExecuteStep(app_context=ApplicationContext(workspace_dir=str(tmp_path)))
response = await step(code="pass", read_only_paths=[], memory_limit_bytes=0)
assert response.success is True
assert seen["limit"] == 0
@pytest.mark.asyncio
async def test_step_rejects_invalid_mount_parameters(tmp_path):
"""Malformed path injection cannot silently disable sandboxing."""
step = PythonExecuteStep(app_context=ApplicationContext(workspace_dir=str(tmp_path)))
response = await step(code="print('unsafe')", writable_paths="/tmp")
assert response.success is False
assert "writable_paths must be a list" in response.answer
@pytest.mark.asyncio
async def test_sandbox_start_failure_does_not_run_on_host(tmp_path, monkeypatch):
"""Python execution reports a sandbox failure instead of running on the host."""
async def unavailable(*_args, **_kwargs):
raise RuntimeError("bwrap unavailable")
monkeypatch.setattr(PathBubbleBackend, "_start_process", unavailable)
app_context = ApplicationContext(workspace_dir=str(tmp_path))
python_step = PythonExecuteStep(app_context=app_context)
python_result = await python_step(code="print('host')", read_only_paths=[])
assert python_result.success is False
assert "bwrap unavailable" in python_result.answer
def test_job_hides_injected_path_policy():
"""Path policy reaches the Step through context without becoming a tool argument."""
config_path = Path(__file__).parents[2] / "reme" / "config" / "benchmark.yaml"
parameters = yaml.safe_load(config_path.read_text(encoding="utf-8"))["jobs"]["python_execute"]["parameters"]
assert "read_only_paths" not in parameters["properties"]
assert "writable_paths" not in parameters["properties"]
assert "default" not in parameters["properties"]["memory_limit_bytes"]