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
2 changes: 2 additions & 0 deletions src/xgen_agent_runtime/tools/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,7 @@
SandboxPathError,
XgenySandbox,
sandbox_path,
sandbox_readonly_roots,
sandbox_root,
sb_read_bytes,
sb_run,
Expand Down Expand Up @@ -85,6 +86,7 @@
"SandboxPathError",
"XgenySandbox",
"sandbox_path",
"sandbox_readonly_roots",
"sandbox_root",
"sb_run",
"sb_read_bytes",
Expand Down
45 changes: 43 additions & 2 deletions src/xgen_agent_runtime/tools/_xgeny_sandbox.py
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@
"XgenySandbox",
"sandbox_extra_roots",
"sandbox_path",
"sandbox_readonly_roots",
"sandbox_root",
"sb_read_bytes",
"sb_run",
Expand Down Expand Up @@ -82,6 +83,15 @@ class XgenySandbox(Protocol):
#: 하던 일을, 러너 실행에서는 이 목록이 한다.
extra_roots: Sequence[str]

#: 그중 **읽기 전용**인 것들 (선택). ``extra_roots`` 의 부분집합이다.
#:
#: 공유받은 폴더가 여기 들어온다 — 읽기로 공유받았으면 읽을 수는 있지만
#: 쓸 수 없다. 목록이 비어 있으면 전부 읽고 쓸 수 있다 (예전 동작).
#:
#: ⚠ 보안 경계가 아니다 (:func:`sandbox_readonly_roots` 참고). 셸은 이
#: 검사를 지나가지 않는다 — 진짜 관문은 인덱스 커밋이다.
readonly_roots: Sequence[str]

async def ensure(self) -> None:
"""세션을 살아 있게 만든다. 멱등 — 몇 번 불러도 같다."""
...
Expand Down Expand Up @@ -125,11 +135,34 @@ def sandbox_extra_roots(sandbox: Any) -> Tuple[str, ...]:
return tuple(out)


def sandbox_readonly_roots(sandbox: Any) -> Tuple[str, ...]:
"""읽기 전용으로 열린 형제 트리들.

프로토콜의 **선택적 확장**이다 — 없으면 빈 튜플이고 모든 형제 트리는
읽고 쓸 수 있다 (예전 동작 그대로).

⚠ **이건 보안 경계가 아니라 빠른 피드백이다.** 셸은 파일시스템에 직접
쓰므로 이 검사를 지나가지 않는다. 진짜 관문은 인덱스 커밋이고, 거기서
거부되면 그 변경은 원본에 반영되지 않는다.

그래도 여기서 막는 이유: 커밋은 턴이 끝날 때 일어난다. 그때 처음 알면
에이전트는 이미 그 파일을 고쳤다고 믿고 30분을 더 일한 뒤다. 쓰기 도구가
그 자리에서 "읽기 전용입니다"를 말해 주면 에이전트가 방향을 바꾼다.
"""
raw = getattr(sandbox, "readonly_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:
def sandbox_path(sandbox: Any, path: str, workdir: str = "", *, write: bool = False) -> str:
"""도구가 준 경로 → 세션 안의 절대 경로.

상대 경로는 ``workdir``(없으면 세션 루트) 기준으로 푼다. 결과가 허용된
Expand Down Expand Up @@ -158,6 +191,14 @@ def sandbox_path(sandbox: Any, path: str, workdir: str = "") -> str:
f"경로가 샌드박스 세션 밖을 가리킵니다: {path!r} → {resolved!r} "
f"(허용: {', '.join(allowed)})"
)
if write:
for ro in sandbox_readonly_roots(sandbox):
if _within(resolved, ro):
raise SandboxPathError(
f"읽기 전용으로 열린 경로입니다: {resolved!r} — 읽을 수는 "
f"있지만 쓸 수 없습니다 (공유받은 폴더는 공유한 사람이 "
f"권한을 정합니다)"
)
return resolved


Expand Down Expand Up @@ -205,4 +246,4 @@ async def sb_read_bytes(sandbox: Any, path: str, *, workdir: str = "") -> bytes:

async def sb_write_bytes(sandbox: Any, path: str, data: bytes, *, workdir: str = "") -> int:
await sandbox.ensure()
return await sandbox.write_bytes(sandbox_path(sandbox, path, workdir), data)
return await sandbox.write_bytes(sandbox_path(sandbox, path, workdir, write=True), data)
70 changes: 69 additions & 1 deletion tests/unit/test_xgeny_sandbox_tools.py
Original file line number Diff line number Diff line change
Expand Up @@ -35,10 +35,12 @@
class LocalSandbox:
"""디렉터리 하나를 세션으로 삼는 :class:`XgenySandbox` 구현."""

def __init__(self, root: Path, extra_roots=()) -> None:
def __init__(self, root: Path, extra_roots=(), readonly_roots=()) -> None:
self.workdir = str(root)
# 호스트가 명시적으로 연 형제 트리 (사용자 클라우드 등).
self.extra_roots = [str(r) for r in extra_roots]
# 그중 읽기 전용인 것 (읽기로 공유받은 폴더).
self.readonly_roots = [str(r) for r in readonly_roots]
self.ensured = 0

async def ensure(self) -> None:
Expand Down Expand Up @@ -201,3 +203,69 @@ async def test_tools_can_write_into_an_opened_tree(self, tmp_path):
{"file_path": str(cloud / "note.txt"), "content": "클라우드"}, ctx
)
assert (cloud / "note.txt").read_text(encoding="utf-8") == "클라우드"


class TestReadOnlyTrees:
"""읽기로 공유받은 폴더 — 읽을 수는 있지만 쓸 수 없다.

⚠ 이건 **보안 경계가 아니라 빠른 피드백**이다. 셸은 파일시스템에 직접
쓰므로 이 검사를 지나가지 않는다. 진짜 관문은 인덱스 커밋이고, 거기서
거부되면 원본에 반영되지 않는다.

그래도 여기서 막는 이유: 커밋은 턴이 끝날 때다. 그때 처음 알면 에이전트는
이미 고쳤다고 믿고 한참 더 일한 뒤다.
"""

def _shared(self, tmp_path):
shared = tmp_path / "user" / "7" / "workspace" / "공유폴더"
shared.mkdir(parents=True)
root = tmp_path / "session"
root.mkdir(exist_ok=True)
return shared, root

def test_reading_is_allowed(self, tmp_path):
shared, root = self._shared(tmp_path)
sb = LocalSandbox(root, extra_roots=[str(shared)], readonly_roots=[str(shared)])
assert sandbox_path(sb, str(shared / "a.txt")) == str(shared / "a.txt")

def test_writing_is_refused(self, tmp_path):
shared, root = self._shared(tmp_path)
sb = LocalSandbox(root, extra_roots=[str(shared)], readonly_roots=[str(shared)])
with pytest.raises(SandboxPathError, match="읽기 전용"):
sandbox_path(sb, str(shared / "a.txt"), write=True)

async def test_the_write_tool_refuses(self, tmp_path):
shared, root = self._shared(tmp_path)
sb = LocalSandbox(root, extra_roots=[str(shared)], readonly_roots=[str(shared)])
ctx = ToolContext(
session_id="t", working_dir=str(root),
allowed_paths=[str(root), str(shared)], sandbox=sb,
)
result = await WriteTool().execute(
{"file_path": str(shared / "x.txt"), "content": "몰래"}, ctx
)
assert result.is_error, "읽기 전용 트리에 썼다"
assert not (shared / "x.txt").exists()

def test_my_own_workdir_is_never_readonly(self, tmp_path):
"""자기 작업 폴더까지 잠기면 에이전트가 아무것도 못 한다."""
shared, root = self._shared(tmp_path)
sb = LocalSandbox(root, extra_roots=[str(shared)], readonly_roots=[str(shared)])
assert sandbox_path(sb, "out.txt", write=True) == str(root / "out.txt")

def test_a_writable_sibling_stays_writable(self, tmp_path):
"""읽기 전용 목록에 없는 형제 트리는 그대로 쓸 수 있어야 한다."""
shared, root = self._shared(tmp_path)
cloud = tmp_path / "user" / "51" / "workspace"
cloud.mkdir(parents=True)
sb = LocalSandbox(
root, extra_roots=[str(shared), str(cloud)], readonly_roots=[str(shared)],
)
assert sandbox_path(sb, str(cloud / "a.txt"), write=True) == str(cloud / "a.txt")

def test_no_readonly_list_means_everything_is_writable(self, tmp_path):
"""이 확장을 모르는 구현(속성 없음)에서 예전 동작 그대로."""
shared, root = self._shared(tmp_path)
sb = LocalSandbox(root, extra_roots=[str(shared)])
del sb.readonly_roots
assert sandbox_path(sb, str(shared / "a.txt"), write=True) == str(shared / "a.txt")
2 changes: 1 addition & 1 deletion uv.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

Loading