diff --git a/CHANGELOG.md b/CHANGELOG.md index 5da3672..e87e87e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,21 @@ All notable changes to `xgen-agent-runtime` are recorded here. The format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/) and this project adheres to [Semantic Versioning](https://semver.org/). +## [3.1.0] — 2026-08-12 + +### Added — 명시적으로 열어 주는 형제 트리 + +``XgenySandbox.extra_roots`` (선택). 에이전트는 자기 workspace 말고도 다룰 것이 +있다 — 사용자 계정의 클라우드 스토리지가 그렇다. 그걸 ``workdir`` 안으로 밀어 +넣으면 에이전트의 산출물과 사용자 파일이 한 트리에 섞이고, 한쪽의 삭제 전파가 +다른 쪽 파일을 지운다. 그래서 형제 트리로 두고 여기서 연다. + +``ToolContext.allowed_paths`` 와 같은 역할이다 — 로컬 실행에서 그것이 하던 일을 +러너 실행에서는 이 목록이 한다. 열어 준 것만 열린다: 상위 디렉터리가 통째로 +열리지 않는다. + +기본값은 없음이라 기존 호스트의 동작은 그대로다. + ## [3.0.0] — 2026-08-11 ### Removed — GAPT 컨테이너 샌드박스 (BREAKING) diff --git a/pyproject.toml b/pyproject.toml index 0c3d473..1d8073b 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "hatchling.build" [project] name = "xgen-agent-runtime" -version = "3.0.0" +version = "3.1.0" description = "Harness-engineered agent pipeline library with 21-stage dual-abstraction architecture, built on the Anthropic API" readme = "README.md" license = "Apache-2.0" diff --git a/src/xgen_agent_runtime/tools/_xgeny_sandbox.py b/src/xgen_agent_runtime/tools/_xgeny_sandbox.py index 04600a8..885ebe6 100644 --- a/src/xgen_agent_runtime/tools/_xgeny_sandbox.py +++ b/src/xgen_agent_runtime/tools/_xgeny_sandbox.py @@ -26,6 +26,7 @@ "SandboxError", "SandboxPathError", "XgenySandbox", + "sandbox_extra_roots", "sandbox_path", "sandbox_root", "sb_read_bytes", @@ -67,9 +68,20 @@ class XgenySandbox(Protocol): 클라이언트다. 테스트는 같은 모양의 로컬 구현을 쓴다. """ - #: 세션의 작업 루트 — **절대 경로**. 이 밖으로는 나갈 수 없다. + #: 세션의 작업 루트 — **절대 경로**. 기본적으로 이 밖으로는 나갈 수 없다. workdir: str + #: 그 밖에 **명시적으로** 열어 주는 트리들 (선택). 호스트가 붙여 준다. + #: + #: 에이전트는 자기 workspace 말고도 다룰 것이 있다 — 사용자 계정의 클라우드 + #: 스토리지가 그렇다. 그것까지 ``workdir`` 안으로 밀어 넣으면 에이전트의 + #: 산출물과 사용자 파일이 한 트리에 섞이고, 한쪽의 삭제 전파가 다른 쪽 + #: 파일을 지운다. 그래서 형제 트리로 두고 여기서 연다. + #: + #: ``ToolContext.allowed_paths`` 와 같은 역할이다 — 로컬 실행에서 그것이 + #: 하던 일을, 러너 실행에서는 이 목록이 한다. + extra_roots: Sequence[str] + async def ensure(self) -> None: """세션을 살아 있게 만든다. 멱등 — 몇 번 불러도 같다.""" ... @@ -102,12 +114,31 @@ def sandbox_root(sandbox: Any) -> str: return "/" + root.strip("/") if root != "/" else "/" +def sandbox_extra_roots(sandbox: Any) -> Tuple[str, ...]: + """호스트가 명시적으로 열어 준 형제 트리들 (없으면 빈 튜플).""" + raw = getattr(sandbox, "extra_roots", None) or () + out = [] + for r in raw: + r = str(r or "").strip() + if r: + out.append("/" + r.strip("/") if r != "/" else "/") + return tuple(out) + + +def _within(resolved: str, root: str) -> bool: + return resolved == root or resolved.startswith(root.rstrip("/") + "/") + + def sandbox_path(sandbox: Any, path: str, workdir: str = "") -> str: """도구가 준 경로 → 세션 안의 절대 경로. - 상대 경로는 ``workdir``(없으면 세션 루트) 기준으로 푼다. 결과가 루트 밖이면 - :class:`SandboxPathError` — ``..`` 나 절대경로로 세션을 빠져나가는 것을 - 여기서 한 번에 막는다. + 상대 경로는 ``workdir``(없으면 세션 루트) 기준으로 푼다. 결과가 허용된 + 트리 밖이면 :class:`SandboxPathError` — ``..`` 나 절대경로로 빠져나가는 + 것을 여기서 한 번에 막는다. + + 허용되는 곳은 세션 루트와 :func:`sandbox_extra_roots` 다. 후자는 호스트가 + **명시적으로** 연 것만 들어온다 (사용자 클라우드 등) — 목록이 한 곳에서만 + 늘어나야 "무엇이 열려 있는가" 를 답할 수 있다. ``workdir`` 은 보통 ``ToolContext.working_dir`` 이다. 호스트가 양쪽 루트를 같은 문자열로 맞추므로 그 값은 세션 안에서도 그대로 유효하다 — 이것이 @@ -121,9 +152,11 @@ def sandbox_path(sandbox: Any, path: str, workdir: str = "") -> str: if not posixpath.isabs(target): target = posixpath.join(base, target) resolved = posixpath.normpath(target) - if resolved != root and not resolved.startswith(root.rstrip("/") + "/"): + allowed = (root, *sandbox_extra_roots(sandbox)) + if not any(_within(resolved, r) for r in allowed): raise SandboxPathError( - f"경로가 샌드박스 세션 밖을 가리킵니다: {path!r} → {resolved!r} (루트 {root!r})" + f"경로가 샌드박스 세션 밖을 가리킵니다: {path!r} → {resolved!r} " + f"(허용: {', '.join(allowed)})" ) return resolved diff --git a/tests/unit/test_xgeny_sandbox_tools.py b/tests/unit/test_xgeny_sandbox_tools.py index fcb2e99..b141bd2 100644 --- a/tests/unit/test_xgeny_sandbox_tools.py +++ b/tests/unit/test_xgeny_sandbox_tools.py @@ -35,8 +35,10 @@ class LocalSandbox: """디렉터리 하나를 세션으로 삼는 :class:`XgenySandbox` 구현.""" - def __init__(self, root: Path) -> None: + def __init__(self, root: Path, extra_roots=()) -> None: self.workdir = str(root) + # 호스트가 명시적으로 연 형제 트리 (사용자 클라우드 등). + self.extra_roots = [str(r) for r in extra_roots] self.ensured = 0 async def ensure(self) -> None: @@ -153,3 +155,49 @@ async def test_nothing_lands_on_the_host_cwd(self, ctx, sandbox, tmp_path, monke await WriteTool().execute({"file_path": "leak.txt", "content": "x"}, ctx) assert list(host.iterdir()) == [] assert (Path(sandbox.workdir) / "leak.txt").exists() + + +class TestExplicitlyOpenedTrees: + """에이전트는 자기 workspace 말고도 다룰 것이 있다 — 사용자 계정의 클라우드. + + 그걸 workdir 안으로 밀어 넣으면 에이전트 산출물과 사용자 파일이 한 트리에 + 섞이고, 한쪽의 삭제 전파가 다른 쪽을 지운다. 형제 트리로 두고 명시적으로 연다. + """ + + def test_a_sibling_tree_is_reachable_when_opened(self, tmp_path): + cloud = tmp_path / "user" / "51" / "workspace" + cloud.mkdir(parents=True) + sb = LocalSandbox(tmp_path / "session", extra_roots=[str(cloud)]) + (tmp_path / "session").mkdir(exist_ok=True) + assert sandbox_path(sb, str(cloud / "a.txt")) == str(cloud / "a.txt") + + def test_it_is_refused_when_not_opened(self, tmp_path): + cloud = tmp_path / "user" / "51" / "workspace" + sb = LocalSandbox(tmp_path / "session") + (tmp_path / "session").mkdir(exist_ok=True) + with pytest.raises(SandboxPathError): + sandbox_path(sb, str(cloud / "a.txt")) + + def test_opening_one_tree_does_not_open_the_rest(self, tmp_path): + """열어 준 것만 열린다 — 상위 디렉터리가 통째로 열리면 안 된다.""" + cloud = tmp_path / "user" / "51" / "workspace" + other = tmp_path / "user" / "99" / "workspace" + sb = LocalSandbox(tmp_path / "session", extra_roots=[str(cloud)]) + (tmp_path / "session").mkdir(exist_ok=True) + with pytest.raises(SandboxPathError): + sandbox_path(sb, str(other / "secret.txt")) + + async def test_tools_can_write_into_an_opened_tree(self, tmp_path): + cloud = tmp_path / "user" / "51" / "workspace" + cloud.mkdir(parents=True) + root = tmp_path / "session" + root.mkdir() + sb = LocalSandbox(root, extra_roots=[str(cloud)]) + ctx = ToolContext( + session_id="t", working_dir=str(root), + allowed_paths=[str(root), str(cloud)], sandbox=sb, + ) + await WriteTool().execute( + {"file_path": str(cloud / "note.txt"), "content": "클라우드"}, ctx + ) + assert (cloud / "note.txt").read_text(encoding="utf-8") == "클라우드"