diff --git a/src/agent_tools/subprocess_tools.py b/src/agent_tools/subprocess_tools.py index 27ae30886..c8ffe9ddb 100644 --- a/src/agent_tools/subprocess_tools.py +++ b/src/agent_tools/subprocess_tools.py @@ -262,10 +262,36 @@ def _wrap_workspace_namespace( # tools keep their installed packages without exposing the host /tmp. if interpreter_prefix: prefix = os.path.abspath(interpreter_prefix) + resolved_prefix = os.path.realpath(prefix) mounted_roots = ("/usr", "/home", "/mnt") - if os.path.isdir(prefix) and not any( + reserved_roots = { + "/", "/tmp", "/var", "/opt", "/etc", "/workspace", + "/root", "/run", "/proc", "/dev", "/sys", *mounted_roots, + } + already_visible = any( prefix == root or prefix.startswith(root + os.sep) for root in mounted_roots + ) + # A prefix is trusted only when it names a specific interpreter tree. + # In particular, never overlay the private root, tmpfs, or workspace + # with a broad host directory. Reject symlinked prefixes too: bwrap + # would otherwise bind the resolved source at a different destination. + has_environment_layout = ( + os.path.isfile(os.path.join(prefix, "pyvenv.cfg")) + or ( + os.path.isfile(os.path.join(prefix, "bin", "python")) + and os.path.isdir(os.path.join( + prefix, "lib", f"python{sys.version_info.major}.{sys.version_info.minor}", + )) + ) + ) + if ( + not already_visible + and prefix == resolved_prefix + and prefix not in reserved_roots + and len(prefix.split(os.sep)) >= 3 + and os.path.isdir(prefix) + and has_environment_layout ): parents = [] parent = os.path.dirname(prefix) diff --git a/tests/test_workspace_artifact_tool_floor.py b/tests/test_workspace_artifact_tool_floor.py index 1d65ea9e3..3de10155e 100644 --- a/tests/test_workspace_artifact_tool_floor.py +++ b/tests/test_workspace_artifact_tool_floor.py @@ -2194,6 +2194,9 @@ def test_python_loaded_code_sees_virtual_workspace_alias(monkeypatch, tmp_path): """Absolute /workspace paths must work inside generated Python scripts.""" import asyncio import shutil + import sys + import venv + from types import SimpleNamespace if not shutil.which("bwrap"): return @@ -2203,10 +2206,21 @@ def test_python_loaded_code_sees_virtual_workspace_alias(monkeypatch, tmp_path): from src.agent_tools import subprocess_tools from src import tool_execution workspace = tmp_path + environment = tmp_path / "confined-venv" + venv.EnvBuilder(with_pip=False).create(environment) + monkeypatch.setattr(subprocess_tools, "sys", SimpleNamespace( + prefix=str(environment), + executable=str(environment / "bin" / "python"), + version_info=sys.version_info, + )) script = workspace / ".python-workspace-alias-test.py" output = workspace / ".python-workspace-alias-test.txt" + outside = workspace / "host-sibling.txt" + outside.write_text("must stay hidden from private /tmp") script.write_text( - "from pathlib import Path; Path('/workspace/.python-workspace-alias-test.txt').write_text('ok')" + "from pathlib import Path; " + "assert not list(Path('/tmp').rglob('host-sibling.txt')); " + "Path('/workspace/.python-workspace-alias-test.txt').write_text('ok')" ) monkeypatch.setattr(tool_execution, "agent_cwd", lambda: str(workspace)) result = asyncio.run(subprocess_tools.PythonTool().execute( @@ -2217,6 +2231,51 @@ def test_python_loaded_code_sees_virtual_workspace_alias(monkeypatch, tmp_path): assert output.read_text() == "ok" +def test_workspace_namespace_mounts_only_a_nested_python_environment(monkeypatch, tmp_path): + import shlex + + from src.agent_tools import subprocess_tools + + monkeypatch.setattr(subprocess_tools.shutil, "which", lambda name: "/usr/bin/bwrap") + environment = tmp_path / "nested" / "venv" + environment.mkdir(parents=True) + (environment / "pyvenv.cfg").write_text("home = /usr/bin\n") + command = subprocess_tools._wrap_workspace_namespace( + "echo ok", str(tmp_path), interpreter_prefix=str(environment), + ) + args = shlex.split(command) + assert ["--ro-bind", str(environment), str(environment)] in [ + args[index:index + 3] for index in range(len(args) - 2) + ] + assert ["--tmpfs", "/tmp"] in [ + args[index:index + 2] for index in range(len(args) - 1) + ] + + +def test_workspace_namespace_rejects_broad_or_symlinked_python_prefixes(monkeypatch, tmp_path): + import shlex + + from src.agent_tools import subprocess_tools + + monkeypatch.setattr(subprocess_tools.shutil, "which", lambda name: "/usr/bin/bwrap") + linked_root = tmp_path / "linked-root" + linked_root.symlink_to("/", target_is_directory=True) + for unsafe_prefix in ("/", "/tmp", "/var", "/home", str(linked_root)): + command = subprocess_tools._wrap_workspace_namespace( + "echo ok", str(tmp_path), interpreter_prefix=unsafe_prefix, + ) + args = shlex.split(command) + assert ["--ro-bind", unsafe_prefix, unsafe_prefix] not in [ + args[index:index + 3] for index in range(len(args) - 2) + ] + assert ["--tmpfs", "/tmp"] in [ + args[index:index + 2] for index in range(len(args) - 1) + ] + assert ["--bind", str(tmp_path), "/workspace"] in [ + args[index:index + 3] for index in range(len(args) - 2) + ] + + def test_workspace_namespace_preserves_the_64_bit_dynamic_loader(monkeypatch): """The namespace's /lib64 must mirror usrmerge hosts, not /usr/lib.""" import shlex