diff --git a/src/xgen_agent_runtime/tools/__init__.py b/src/xgen_agent_runtime/tools/__init__.py index c4bd86b..4f86475 100644 --- a/src/xgen_agent_runtime/tools/__init__.py +++ b/src/xgen_agent_runtime/tools/__init__.py @@ -35,6 +35,7 @@ SandboxPathError, XgenySandbox, sandbox_path, + sandbox_readonly_roots, sandbox_root, sb_read_bytes, sb_run, @@ -85,6 +86,7 @@ "SandboxPathError", "XgenySandbox", "sandbox_path", + "sandbox_readonly_roots", "sandbox_root", "sb_run", "sb_read_bytes", diff --git a/src/xgen_agent_runtime/tools/_xgeny_sandbox.py b/src/xgen_agent_runtime/tools/_xgeny_sandbox.py index 885ebe6..65f913b 100644 --- a/src/xgen_agent_runtime/tools/_xgeny_sandbox.py +++ b/src/xgen_agent_runtime/tools/_xgeny_sandbox.py @@ -28,6 +28,7 @@ "XgenySandbox", "sandbox_extra_roots", "sandbox_path", + "sandbox_readonly_roots", "sandbox_root", "sb_read_bytes", "sb_run", @@ -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: """세션을 살아 있게 만든다. 멱등 — 몇 번 불러도 같다.""" ... @@ -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``(없으면 세션 루트) 기준으로 푼다. 결과가 허용된 @@ -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 @@ -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) diff --git a/tests/unit/test_xgeny_sandbox_tools.py b/tests/unit/test_xgeny_sandbox_tools.py index b141bd2..fa55b2b 100644 --- a/tests/unit/test_xgeny_sandbox_tools.py +++ b/tests/unit/test_xgeny_sandbox_tools.py @@ -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: @@ -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") diff --git a/uv.lock b/uv.lock index 4f2ee69..7e2ecd9 100644 --- a/uv.lock +++ b/uv.lock @@ -4080,7 +4080,7 @@ wheels = [ [[package]] name = "xgen-agent-runtime" -version = "2.68.1" +version = "3.1.0" source = { editable = "." } dependencies = [ { name = "anthropic" },