595 lines
20 KiB
Python
595 lines
20 KiB
Python
from __future__ import annotations
|
|
|
|
import os
|
|
from collections.abc import Callable
|
|
from dataclasses import dataclass
|
|
from pathlib import Path, PurePath, PurePosixPath, PureWindowsPath
|
|
from typing import Any, cast
|
|
|
|
import pytest
|
|
from pydantic import ValidationError
|
|
|
|
from agents.sandbox import Manifest, SandboxPathGrant
|
|
from agents.sandbox.errors import InvalidManifestPathError, WorkspaceArchiveWriteError
|
|
from agents.sandbox.workspace_paths import (
|
|
WorkspacePathPolicy,
|
|
coerce_posix_path,
|
|
posix_path_as_path,
|
|
)
|
|
|
|
PathInput = str | PurePath
|
|
PathPolicyMethod = Callable[[WorkspacePathPolicy, PathInput], Path]
|
|
|
|
|
|
@dataclass(frozen=True)
|
|
class WorkspacePathCase:
|
|
name: str
|
|
path: PathInput
|
|
expected: Path | None = None
|
|
error_message: str | None = None
|
|
error_context: dict[str, str] | None = None
|
|
|
|
|
|
def _policy(root: Path | str = "/workspace") -> WorkspacePathPolicy:
|
|
return WorkspacePathPolicy(root=root)
|
|
|
|
|
|
def test_workspace_path_policy_rejects_relative_root() -> None:
|
|
with pytest.raises(ValueError, match="sandbox workspace root must be absolute"):
|
|
WorkspacePathPolicy(root="workspace")
|
|
|
|
|
|
def _assert_workspace_path_case(
|
|
*,
|
|
method: PathPolicyMethod,
|
|
test_case: WorkspacePathCase,
|
|
root: Path | str = "/workspace",
|
|
) -> None:
|
|
if test_case.error_message is None:
|
|
assert method(_policy(root), test_case.path) == test_case.expected
|
|
return
|
|
|
|
with pytest.raises(InvalidManifestPathError) as exc_info:
|
|
method(_policy(root), test_case.path)
|
|
|
|
assert str(exc_info.value) == test_case.error_message
|
|
assert exc_info.value.context == test_case.error_context
|
|
|
|
|
|
ABSOLUTE_WORKSPACE_PATH_CASES = [
|
|
WorkspacePathCase(
|
|
name="relative path anchors under root",
|
|
path="pkg/file.py",
|
|
expected=Path("/workspace/pkg/file.py"),
|
|
),
|
|
WorkspacePathCase(
|
|
name="Path input anchors under root",
|
|
path=Path("pkg/file.py"),
|
|
expected=Path("/workspace/pkg/file.py"),
|
|
),
|
|
WorkspacePathCase(
|
|
name="absolute path inside root is accepted",
|
|
path="/workspace/pkg/file.py",
|
|
expected=Path("/workspace/pkg/file.py"),
|
|
),
|
|
WorkspacePathCase(
|
|
name="absolute path inside root is normalized",
|
|
path="/workspace/pkg/../file.py",
|
|
expected=Path("/workspace/file.py"),
|
|
),
|
|
WorkspacePathCase(
|
|
name="relative parent segment inside root is normalized",
|
|
path="pkg/../secret.txt",
|
|
expected=Path("/workspace/secret.txt"),
|
|
),
|
|
WorkspacePathCase(
|
|
name="absolute path outside root is rejected",
|
|
path="/tmp/secret.txt",
|
|
error_message="manifest path must be relative: /tmp/secret.txt",
|
|
error_context={"rel": "/tmp/secret.txt", "reason": "absolute"},
|
|
),
|
|
WorkspacePathCase(
|
|
name="relative parent traversal is rejected",
|
|
path="../secret.txt",
|
|
error_message="manifest path must not escape root: ../secret.txt",
|
|
error_context={"rel": "../secret.txt", "reason": "escape_root"},
|
|
),
|
|
WorkspacePathCase(
|
|
name="nested relative parent traversal outside root is rejected",
|
|
path="pkg/../../secret.txt",
|
|
error_message="manifest path must not escape root: pkg/../../secret.txt",
|
|
error_context={"rel": "pkg/../../secret.txt", "reason": "escape_root"},
|
|
),
|
|
]
|
|
|
|
|
|
@pytest.mark.parametrize(
|
|
"test_case",
|
|
ABSOLUTE_WORKSPACE_PATH_CASES,
|
|
ids=lambda test_case: test_case.name,
|
|
)
|
|
def test_absolute_workspace_path(test_case: WorkspacePathCase) -> None:
|
|
_assert_workspace_path_case(
|
|
method=lambda policy, path: policy.absolute_workspace_path(path),
|
|
test_case=test_case,
|
|
)
|
|
|
|
|
|
RELATIVE_PATH_CASES = [
|
|
WorkspacePathCase(
|
|
name="relative path stays relative",
|
|
path="pkg/file.py",
|
|
expected=Path("pkg/file.py"),
|
|
),
|
|
WorkspacePathCase(
|
|
name="absolute path inside root becomes relative",
|
|
path="/workspace/pkg/file.py",
|
|
expected=Path("pkg/file.py"),
|
|
),
|
|
WorkspacePathCase(
|
|
name="relative parent segment inside root is normalized",
|
|
path="pkg/../secret.txt",
|
|
expected=Path("secret.txt"),
|
|
),
|
|
WorkspacePathCase(
|
|
name="workspace root becomes dot",
|
|
path="/workspace",
|
|
expected=Path("."),
|
|
),
|
|
WorkspacePathCase(
|
|
name="provider root is not exposed",
|
|
path="/provider/private/root/images/dot.png",
|
|
expected=Path("images/dot.png"),
|
|
),
|
|
WorkspacePathCase(
|
|
name="relative provider path stays relative",
|
|
path="images/dot.png",
|
|
expected=Path("images/dot.png"),
|
|
),
|
|
WorkspacePathCase(
|
|
name="absolute path outside root is rejected",
|
|
path="/tmp/secret.txt",
|
|
error_message="manifest path must be relative: /tmp/secret.txt",
|
|
error_context={"rel": "/tmp/secret.txt", "reason": "absolute"},
|
|
),
|
|
WorkspacePathCase(
|
|
name="relative parent traversal is rejected",
|
|
path="../secret.txt",
|
|
error_message="manifest path must not escape root: ../secret.txt",
|
|
error_context={"rel": "../secret.txt", "reason": "escape_root"},
|
|
),
|
|
]
|
|
|
|
|
|
@pytest.mark.parametrize(
|
|
"test_case",
|
|
RELATIVE_PATH_CASES,
|
|
ids=lambda test_case: test_case.name,
|
|
)
|
|
def test_relative_path(test_case: WorkspacePathCase) -> None:
|
|
root = "/provider/private/root" if "provider" in test_case.name else "/workspace"
|
|
_assert_workspace_path_case(
|
|
method=lambda policy, path: policy.relative_path(path),
|
|
test_case=test_case,
|
|
root=root,
|
|
)
|
|
|
|
|
|
def test_normalize_path_with_symlink_resolution(tmp_path: Path) -> None:
|
|
workspace = tmp_path / "workspace"
|
|
outside = tmp_path / "outside"
|
|
workspace.mkdir()
|
|
outside.mkdir()
|
|
|
|
target = workspace / "target.txt"
|
|
target.write_text("hello", encoding="utf-8")
|
|
os.symlink(target, workspace / "link.txt")
|
|
os.symlink(outside, workspace / "outside-link", target_is_directory=True)
|
|
|
|
alias = tmp_path / "workspace-alias"
|
|
os.symlink(workspace, alias, target_is_directory=True)
|
|
|
|
test_cases = [
|
|
WorkspacePathCase(
|
|
name="relative path resolves under host root",
|
|
path="target.txt",
|
|
expected=target.resolve(),
|
|
),
|
|
WorkspacePathCase(
|
|
name="relative parent segment inside root resolves under host root",
|
|
path="nested/../target.txt",
|
|
expected=target.resolve(),
|
|
),
|
|
WorkspacePathCase(
|
|
name="safe internal leaf symlink resolves to target",
|
|
path="link.txt",
|
|
expected=target.resolve(),
|
|
),
|
|
WorkspacePathCase(
|
|
name="absolute path through root alias is accepted",
|
|
path=alias / "target.txt",
|
|
expected=target.resolve(),
|
|
),
|
|
WorkspacePathCase(
|
|
name="absolute resolved root path is accepted",
|
|
path=target,
|
|
expected=target.resolve(),
|
|
),
|
|
WorkspacePathCase(
|
|
name="symlink parent escape is rejected",
|
|
path="outside-link/secret.txt",
|
|
error_message="manifest path must not escape root: outside-link/secret.txt",
|
|
error_context={"rel": "outside-link/secret.txt", "reason": "escape_root"},
|
|
),
|
|
WorkspacePathCase(
|
|
name="absolute path outside root is rejected",
|
|
path=outside / "secret.txt",
|
|
error_message=f"manifest path must be relative: {(outside / 'secret.txt').as_posix()}",
|
|
error_context={"rel": (outside / "secret.txt").as_posix(), "reason": "absolute"},
|
|
),
|
|
]
|
|
|
|
for test_case in test_cases:
|
|
_assert_workspace_path_case(
|
|
method=lambda policy, path: policy.normalize_path(path, resolve_symlinks=True),
|
|
test_case=test_case,
|
|
root=alias,
|
|
)
|
|
|
|
|
|
def test_normalize_sandbox_path_uses_posix_paths_for_windows_inputs() -> None:
|
|
policy = WorkspacePathPolicy(root="/workspace")
|
|
|
|
assert policy.sandbox_root() == PurePosixPath("/workspace")
|
|
assert policy.normalize_sandbox_path(PureWindowsPath("/workspace/pkg/file.py")) == (
|
|
PurePosixPath("/workspace/pkg/file.py")
|
|
)
|
|
assert policy.normalize_sandbox_path(PureWindowsPath("pkg/file.py")) == (
|
|
PurePosixPath("/workspace/pkg/file.py")
|
|
)
|
|
|
|
|
|
def test_normalize_path_uses_posix_paths_for_windows_inputs() -> None:
|
|
policy = WorkspacePathPolicy(root="/workspace")
|
|
|
|
assert policy.normalize_path(PureWindowsPath("/workspace/pkg/file.py")).as_posix() == (
|
|
"/workspace/pkg/file.py"
|
|
)
|
|
assert policy.absolute_workspace_path(PureWindowsPath("pkg/file.py")).as_posix() == (
|
|
"/workspace/pkg/file.py"
|
|
)
|
|
|
|
|
|
def test_inaccessible_root_is_treated_as_remote_path(monkeypatch: pytest.MonkeyPatch) -> None:
|
|
root = PurePosixPath("/root/project")
|
|
|
|
def raise_for_root(path: Path) -> bool:
|
|
if path.as_posix() == root.as_posix():
|
|
raise PermissionError("permission denied")
|
|
return False
|
|
|
|
monkeypatch.setattr(Path, "exists", raise_for_root)
|
|
|
|
policy = WorkspacePathPolicy(root=root)
|
|
|
|
assert policy.root_is_existing_host_path() is False
|
|
assert policy.normalize_path("pkg/file.py").as_posix() == "/root/project/pkg/file.py"
|
|
|
|
|
|
def test_absolute_workspace_path_rejects_windows_rooted_escape_as_absolute() -> None:
|
|
policy = WorkspacePathPolicy(root="/workspace")
|
|
|
|
with pytest.raises(InvalidManifestPathError) as exc_info:
|
|
policy.absolute_workspace_path(PureWindowsPath("/tmp/secret.txt"))
|
|
|
|
assert str(exc_info.value) == "manifest path must be relative: /tmp/secret.txt"
|
|
assert exc_info.value.context == {"rel": "/tmp/secret.txt", "reason": "absolute"}
|
|
|
|
|
|
def test_windows_drive_absolute_path_is_rejected_before_posix_coercion() -> None:
|
|
policy = WorkspacePathPolicy(root="/workspace")
|
|
|
|
with pytest.raises(InvalidManifestPathError) as exc_info:
|
|
policy.normalize_path(PureWindowsPath("C:/tmp/secret.txt"))
|
|
|
|
assert str(exc_info.value) == "manifest path must be relative: C:/tmp/secret.txt"
|
|
assert exc_info.value.context == {"rel": "C:/tmp/secret.txt", "reason": "absolute"}
|
|
|
|
with pytest.raises(InvalidManifestPathError) as exc_info:
|
|
policy.absolute_workspace_path("C:\\tmp\\secret.txt")
|
|
|
|
assert str(exc_info.value) == "manifest path must be relative: C:/tmp/secret.txt"
|
|
assert exc_info.value.context == {"rel": "C:/tmp/secret.txt", "reason": "absolute"}
|
|
|
|
with pytest.raises(InvalidManifestPathError) as exc_info:
|
|
policy.normalize_path(coerce_posix_path(PureWindowsPath("C:/tmp/secret.txt")))
|
|
|
|
assert str(exc_info.value) == "manifest path must be relative: C:/tmp/secret.txt"
|
|
assert exc_info.value.context == {"rel": "C:/tmp/secret.txt", "reason": "absolute"}
|
|
|
|
|
|
def test_existing_host_root_rejects_windows_drive_absolute_paths(tmp_path: Path) -> None:
|
|
workspace = tmp_path / "workspace"
|
|
workspace.mkdir()
|
|
policy = WorkspacePathPolicy(root=workspace)
|
|
methods: tuple[PathPolicyMethod, ...] = (
|
|
lambda policy, path: policy.absolute_workspace_path(path),
|
|
lambda policy, path: policy.normalize_path(path),
|
|
lambda policy, path: policy.normalize_path(path, resolve_symlinks=True),
|
|
)
|
|
|
|
for method in methods:
|
|
for path in (
|
|
PureWindowsPath("C:/tmp/secret.txt"),
|
|
"C:\\tmp\\secret.txt",
|
|
coerce_posix_path(PureWindowsPath("C:/tmp/secret.txt")),
|
|
):
|
|
with pytest.raises(InvalidManifestPathError) as exc_info:
|
|
method(policy, path)
|
|
|
|
assert str(exc_info.value) == "manifest path must be relative: C:/tmp/secret.txt"
|
|
assert exc_info.value.context == {"rel": "C:/tmp/secret.txt", "reason": "absolute"}
|
|
|
|
|
|
def test_relative_path_rejects_windows_drive_absolute_path_for_host_root(
|
|
tmp_path: Path,
|
|
) -> None:
|
|
workspace = tmp_path / "workspace"
|
|
workspace.mkdir()
|
|
policy = WorkspacePathPolicy(root=workspace)
|
|
|
|
for path in (
|
|
PureWindowsPath("C:/tmp/secret.txt"),
|
|
"C:\\tmp\\secret.txt",
|
|
coerce_posix_path(PureWindowsPath("C:/tmp/secret.txt")),
|
|
):
|
|
with pytest.raises(InvalidManifestPathError) as exc_info:
|
|
policy.relative_path(path)
|
|
|
|
assert str(exc_info.value) == "manifest path must be relative: C:/tmp/secret.txt"
|
|
assert exc_info.value.context == {"rel": "C:/tmp/secret.txt", "reason": "absolute"}
|
|
|
|
|
|
def test_posix_path_as_path_returns_native_path() -> None:
|
|
path = posix_path_as_path(PurePosixPath("/workspace/file.txt"))
|
|
|
|
assert isinstance(path, Path)
|
|
assert path.as_posix() == "/workspace/file.txt"
|
|
|
|
|
|
def test_sandbox_extra_path_grant_rules_use_posix_paths() -> None:
|
|
policy = WorkspacePathPolicy(
|
|
root="/workspace",
|
|
extra_path_grants=(SandboxPathGrant(path="/tmp"),),
|
|
)
|
|
|
|
assert policy.extra_path_grant_rules() == ((PurePosixPath("/tmp"), False),)
|
|
assert policy.normalize_sandbox_path(PureWindowsPath("/tmp/result.txt")) == (
|
|
PurePosixPath("/tmp/result.txt")
|
|
)
|
|
|
|
|
|
def test_extra_path_grant_rejects_non_native_windows_drive_absolute_path() -> None:
|
|
if Path(PureWindowsPath("C:/tmp")).is_absolute():
|
|
pytest.skip("Windows drive paths are native absolute paths on this host")
|
|
|
|
for path in (
|
|
PureWindowsPath("C:/tmp"),
|
|
"C:\\tmp",
|
|
coerce_posix_path(PureWindowsPath("C:/tmp")),
|
|
):
|
|
with pytest.raises(ValidationError) as exc_info:
|
|
SandboxPathGrant(path=cast(Any, path))
|
|
|
|
errors = exc_info.value.errors(include_url=False)
|
|
assert len(errors) == 1
|
|
error = dict(errors[0])
|
|
ctx = cast(dict[str, Any], error["ctx"])
|
|
error["ctx"] = {"error": str(ctx["error"])}
|
|
assert error == {
|
|
"type": "value_error",
|
|
"loc": ("path",),
|
|
"msg": "Value error, sandbox path grant path must be POSIX absolute",
|
|
"input": path,
|
|
"ctx": {"error": "sandbox path grant path must be POSIX absolute"},
|
|
}
|
|
|
|
|
|
def test_extra_path_grant_accepts_native_windows_drive_absolute_path(
|
|
tmp_path: Path,
|
|
) -> None:
|
|
if not Path(PureWindowsPath("C:/tmp")).is_absolute():
|
|
pytest.skip("Windows drive paths are not native absolute paths on this host")
|
|
|
|
grant = SandboxPathGrant(path=str(tmp_path))
|
|
|
|
assert Path(grant.path).is_absolute()
|
|
|
|
|
|
def test_extra_path_grant_rules_reject_windows_drive_absolute_path() -> None:
|
|
grant = SandboxPathGrant.model_construct(
|
|
path="C:/tmp",
|
|
read_only=False,
|
|
description=None,
|
|
)
|
|
policy = WorkspacePathPolicy(root="/workspace", extra_path_grants=(grant,))
|
|
|
|
with pytest.raises(ValueError) as exc_info:
|
|
policy.extra_path_grant_rules()
|
|
|
|
assert str(exc_info.value) == "sandbox path grant path must be POSIX absolute"
|
|
|
|
|
|
def test_manifest_serializes_extra_path_grants() -> None:
|
|
manifest = Manifest(
|
|
extra_path_grants=(
|
|
SandboxPathGrant(
|
|
path="/tmp",
|
|
description="temporary files",
|
|
),
|
|
SandboxPathGrant(
|
|
path="/opt/toolchain",
|
|
read_only=True,
|
|
description="compiler runtime",
|
|
),
|
|
),
|
|
)
|
|
|
|
assert manifest.model_dump(mode="json")["extra_path_grants"] == [
|
|
{
|
|
"path": "/tmp",
|
|
"read_only": False,
|
|
"description": "temporary files",
|
|
},
|
|
{
|
|
"path": "/opt/toolchain",
|
|
"read_only": True,
|
|
"description": "compiler runtime",
|
|
},
|
|
]
|
|
|
|
|
|
def test_extra_path_grant_accepts_absolute_path() -> None:
|
|
policy = WorkspacePathPolicy(
|
|
root="/workspace",
|
|
extra_path_grants=(SandboxPathGrant(path="/tmp"),),
|
|
)
|
|
|
|
assert policy.normalize_path("/tmp/result.txt") == Path("/tmp/result.txt")
|
|
|
|
|
|
def test_extra_path_grant_rejects_ungranted_absolute_path() -> None:
|
|
policy = WorkspacePathPolicy(
|
|
root="/workspace",
|
|
extra_path_grants=(SandboxPathGrant(path="/tmp"),),
|
|
)
|
|
|
|
with pytest.raises(InvalidManifestPathError) as exc_info:
|
|
policy.normalize_path("/var/result.txt")
|
|
|
|
assert str(exc_info.value) == "manifest path must be relative: /var/result.txt"
|
|
assert exc_info.value.context == {"rel": "/var/result.txt", "reason": "absolute"}
|
|
|
|
|
|
def test_extra_path_grant_rejects_write_under_read_only_grant() -> None:
|
|
policy = WorkspacePathPolicy(
|
|
root="/workspace",
|
|
extra_path_grants=(SandboxPathGrant(path="/opt/toolchain", read_only=True),),
|
|
)
|
|
|
|
with pytest.raises(WorkspaceArchiveWriteError) as exc_info:
|
|
policy.normalize_path("/opt/toolchain/cache.db", for_write=True)
|
|
|
|
assert str(exc_info.value) == "failed to write archive for path: /opt/toolchain/cache.db"
|
|
assert exc_info.value.context == {
|
|
"path": "/opt/toolchain/cache.db",
|
|
"reason": "read_only_extra_path_grant",
|
|
"grant_path": "/opt/toolchain",
|
|
}
|
|
|
|
|
|
def test_extra_path_grant_allows_read_under_read_only_grant() -> None:
|
|
policy = WorkspacePathPolicy(
|
|
root="/workspace",
|
|
extra_path_grants=(SandboxPathGrant(path="/opt/toolchain", read_only=True),),
|
|
)
|
|
|
|
assert policy.normalize_path("/opt/toolchain/cache.db") == Path("/opt/toolchain/cache.db")
|
|
|
|
|
|
def test_host_io_rejects_write_under_resolved_read_only_extra_path_grant(
|
|
tmp_path: Path,
|
|
) -> None:
|
|
workspace = tmp_path / "workspace"
|
|
allowed = tmp_path / "allowed"
|
|
grant_alias = tmp_path / "allowed-alias"
|
|
workspace.mkdir()
|
|
allowed.mkdir()
|
|
os.symlink(allowed, grant_alias, target_is_directory=True)
|
|
target = allowed / "cache.db"
|
|
grant = SandboxPathGrant(path=str(grant_alias), read_only=True)
|
|
policy = WorkspacePathPolicy(
|
|
root=workspace,
|
|
extra_path_grants=(grant,),
|
|
)
|
|
|
|
with pytest.raises(WorkspaceArchiveWriteError) as exc_info:
|
|
policy.normalize_path(target, for_write=True, resolve_symlinks=True)
|
|
|
|
assert str(exc_info.value) == f"failed to write archive for path: {target}"
|
|
assert exc_info.value.context == {
|
|
"path": str(target),
|
|
"reason": "read_only_extra_path_grant",
|
|
"grant_path": grant.path,
|
|
}
|
|
|
|
|
|
def test_extra_path_grant_rejects_relative_path() -> None:
|
|
with pytest.raises(ValidationError) as exc_info:
|
|
SandboxPathGrant(path="tmp")
|
|
|
|
errors = exc_info.value.errors(include_url=False)
|
|
assert len(errors) == 1
|
|
error = dict(errors[0])
|
|
ctx = cast(dict[str, Any], error["ctx"])
|
|
error["ctx"] = {"error": str(ctx["error"])}
|
|
assert error == {
|
|
"type": "value_error",
|
|
"loc": ("path",),
|
|
"msg": "Value error, sandbox path grant path must be absolute",
|
|
"input": "tmp",
|
|
"ctx": {"error": "sandbox path grant path must be absolute"},
|
|
}
|
|
|
|
|
|
def test_extra_path_grant_rejects_root_path() -> None:
|
|
with pytest.raises(ValidationError) as exc_info:
|
|
SandboxPathGrant(path="/")
|
|
|
|
errors = exc_info.value.errors(include_url=False)
|
|
assert len(errors) == 1
|
|
error = dict(errors[0])
|
|
ctx = cast(dict[str, Any], error["ctx"])
|
|
error["ctx"] = {"error": str(ctx["error"])}
|
|
assert error == {
|
|
"type": "value_error",
|
|
"loc": ("path",),
|
|
"msg": "Value error, sandbox path grant path must not be filesystem root",
|
|
"input": "/",
|
|
"ctx": {"error": "sandbox path grant path must not be filesystem root"},
|
|
}
|
|
|
|
|
|
def test_extra_path_grant_rejects_root_alias_path() -> None:
|
|
with pytest.raises(ValidationError) as exc_info:
|
|
SandboxPathGrant(path="//")
|
|
|
|
errors = exc_info.value.errors(include_url=False)
|
|
assert len(errors) == 1
|
|
error = dict(errors[0])
|
|
ctx = cast(dict[str, Any], error["ctx"])
|
|
error["ctx"] = {"error": str(ctx["error"])}
|
|
assert error == {
|
|
"type": "value_error",
|
|
"loc": ("path",),
|
|
"msg": "Value error, sandbox path grant path must not be filesystem root",
|
|
"input": "//",
|
|
"ctx": {"error": "sandbox path grant path must not be filesystem root"},
|
|
}
|
|
|
|
|
|
def test_host_io_rejects_extra_path_grant_symlink_to_root(tmp_path: Path) -> None:
|
|
workspace = tmp_path / "workspace"
|
|
root_alias = tmp_path / "root-alias"
|
|
workspace.mkdir()
|
|
os.symlink(Path("/"), root_alias, target_is_directory=True)
|
|
policy = WorkspacePathPolicy(
|
|
root=workspace,
|
|
extra_path_grants=(SandboxPathGrant(path=str(root_alias)),),
|
|
)
|
|
|
|
with pytest.raises(ValueError) as exc_info:
|
|
policy.normalize_path(root_alias / "etc" / "passwd", resolve_symlinks=True)
|
|
|
|
assert str(exc_info.value) == "sandbox path grant path must not resolve to filesystem root"
|