Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
15 changes: 15 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
2 changes: 1 addition & 1 deletion pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
45 changes: 39 additions & 6 deletions src/xgen_agent_runtime/tools/_xgeny_sandbox.py
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@
"SandboxError",
"SandboxPathError",
"XgenySandbox",
"sandbox_extra_roots",
"sandbox_path",
"sandbox_root",
"sb_read_bytes",
Expand Down Expand Up @@ -67,9 +68,20 @@ class XgenySandbox(Protocol):
클라이언트다. 테스트는 같은 모양의 로컬 구현을 쓴다.
"""

#: 세션의 작업 루트 — **절대 경로**. 이 밖으로는 나갈 수 없다.
#: 세션의 작업 루트 — **절대 경로**. 기본적으로 이 밖으로는 나갈 수 없다.
workdir: str

#: 그 밖에 **명시적으로** 열어 주는 트리들 (선택). 호스트가 붙여 준다.
#:
#: 에이전트는 자기 workspace 말고도 다룰 것이 있다 — 사용자 계정의 클라우드
#: 스토리지가 그렇다. 그것까지 ``workdir`` 안으로 밀어 넣으면 에이전트의
#: 산출물과 사용자 파일이 한 트리에 섞이고, 한쪽의 삭제 전파가 다른 쪽
#: 파일을 지운다. 그래서 형제 트리로 두고 여기서 연다.
#:
#: ``ToolContext.allowed_paths`` 와 같은 역할이다 — 로컬 실행에서 그것이
#: 하던 일을, 러너 실행에서는 이 목록이 한다.
extra_roots: Sequence[str]

async def ensure(self) -> None:
"""세션을 살아 있게 만든다. 멱등 — 몇 번 불러도 같다."""
...
Expand Down Expand Up @@ -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`` 이다. 호스트가 양쪽 루트를
같은 문자열로 맞추므로 그 값은 세션 안에서도 그대로 유효하다 — 이것이
Expand All @@ -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

Expand Down
50 changes: 49 additions & 1 deletion tests/unit/test_xgeny_sandbox_tools.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down Expand Up @@ -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") == "클라우드"
Loading