Skip to content

Commit 814ad2e

Browse files
committed
fix(environments): contain model-supplied paths to the sandbox workspace
The four remote sandbox environments resolved relative paths with no normalization or containment check, so a model steered by untrusted input could reach files outside the workspace with '..' segments, unlike LocalEnvironment which raises. Resolve relative paths through a shared helper that normalizes lexically and raises ValueError on escape; absolute paths keep their documented pass-through contract. Fixes #7699
1 parent 48d18a6 commit 814ad2e

10 files changed

Lines changed: 164 additions & 17 deletions

File tree

‎src/google/adk/environment/_base_environment.py‎

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,7 +19,10 @@
1919
from abc import ABC
2020
from abc import abstractmethod
2121
import dataclasses
22+
import os
2223
from pathlib import Path
24+
from pathlib import PurePosixPath
25+
import posixpath
2326

2427
from ..utils.feature_decorator import experimental
2528

@@ -132,3 +135,36 @@ async def write_file(self, path: Path, content: str | bytes) -> None:
132135
path: Absolute or working-dir-relative path to the file.
133136
content: The string or raw bytes to write.
134137
"""
138+
139+
140+
def resolve_sandbox_path(
141+
path: str | os.PathLike[str], workspace_root: str
142+
) -> str:
143+
"""Resolves a file path against a remote sandbox workspace root.
144+
145+
Absolute paths pass through unchanged, matching the ``BaseEnvironment``
146+
path contract. Relative paths are anchored at ``workspace_root`` and
147+
normalized lexically; a relative path whose normalization escapes the
148+
root raises ``ValueError``, so model-supplied ``..`` segments cannot
149+
reach files outside the workspace. Normalization is lexical only: the
150+
filesystem that would resolve symlinks lives inside the sandbox, not in
151+
this process.
152+
153+
Args:
154+
path: The model-supplied path to resolve.
155+
workspace_root: The absolute workspace root relative paths anchor at.
156+
157+
Returns:
158+
The resolved POSIX path.
159+
160+
Raises:
161+
ValueError: If a relative path escapes ``workspace_root``.
162+
"""
163+
candidate = PurePosixPath(os.fspath(path))
164+
if candidate.is_absolute():
165+
return str(candidate)
166+
root = PurePosixPath(workspace_root)
167+
anchored = PurePosixPath(posixpath.normpath(str(root / candidate)))
168+
if not anchored.is_relative_to(root):
169+
raise ValueError(f"Path escapes working directory: {path}")
170+
return str(anchored)

‎src/google/adk/integrations/daytona/_daytona_environment.py‎

Lines changed: 2 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,7 @@
2626

2727
from ...environment._base_environment import BaseEnvironment
2828
from ...environment._base_environment import ExecutionResult
29+
from ...environment._base_environment import resolve_sandbox_path
2930
from ...features import experimental
3031
from ...features import FeatureName
3132

@@ -242,7 +243,4 @@ async def _ensure_sandbox(self) -> AsyncSandbox:
242243

243244
def _resolve_path(self, path: str | os.PathLike[str]) -> str:
244245
"""Resolve a relative path against the sandbox working directory."""
245-
pure = PurePosixPath(os.fspath(path))
246-
if pure.is_absolute():
247-
return str(pure)
248-
return str(PurePosixPath(_SANDBOX_HOME) / pure)
246+
return resolve_sandbox_path(path, _SANDBOX_HOME)

‎src/google/adk/integrations/e2b/_e2b_environment.py‎

Lines changed: 2 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -19,13 +19,13 @@
1919
import logging
2020
import os
2121
from pathlib import Path
22-
from pathlib import PurePosixPath
2322
from typing import TYPE_CHECKING
2423

2524
from typing_extensions import override
2625

2726
from ...environment._base_environment import BaseEnvironment
2827
from ...environment._base_environment import ExecutionResult
28+
from ...environment._base_environment import resolve_sandbox_path
2929
from ...features import experimental
3030
from ...features import FeatureName
3131

@@ -185,7 +185,4 @@ async def _ensure_sandbox(self) -> AsyncSandbox:
185185

186186
def _resolve_path(self, path: str | os.PathLike[str]) -> str:
187187
"""Resolve a relative path against the sandbox working directory."""
188-
pure = PurePosixPath(os.fspath(path))
189-
if pure.is_absolute():
190-
return str(pure)
191-
return str(PurePosixPath(_SANDBOX_HOME) / pure)
188+
return resolve_sandbox_path(path, _SANDBOX_HOME)

‎src/google/adk/integrations/opensandbox/_opensandbox_environment.py‎

Lines changed: 2 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,7 @@
3030

3131
from ...environment._base_environment import BaseEnvironment
3232
from ...environment._base_environment import ExecutionResult
33+
from ...environment._base_environment import resolve_sandbox_path
3334
from ...features import experimental
3435
from ...features import FeatureName
3536

@@ -384,10 +385,7 @@ def _command_renewal_timeout(self, timeout: float | None) -> timedelta:
384385

385386
@staticmethod
386387
def _resolve_path(path: str | os.PathLike[str]) -> str:
387-
pure = PurePosixPath(os.fspath(path))
388-
if pure.is_absolute():
389-
return str(pure)
390-
return str(PurePosixPath(_SANDBOX_HOME) / pure)
388+
return resolve_sandbox_path(path, _SANDBOX_HOME)
391389

392390
@staticmethod
393391
def _command_timeout(timeout: float | None) -> timedelta | None:

‎src/google/adk/integrations/vmaas/_agent_engine_environment.py‎

Lines changed: 2 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,7 @@
3333

3434
from ...environment._base_environment import BaseEnvironment
3535
from ...environment._base_environment import ExecutionResult
36+
from ...environment._base_environment import resolve_sandbox_path
3637
from ...features import experimental
3738
from ...features import FeatureName
3839
from ._sandbox_manager import SandboxManager
@@ -303,10 +304,7 @@ async def _execute_bash(
303304

304305
def _resolve_path(self, path: str | os.PathLike[str]) -> str:
305306
"""Resolves a relative path against the sandbox working directory."""
306-
pure = PurePosixPath(os.fspath(path))
307-
if pure.is_absolute():
308-
return str(pure)
309-
return str(PurePosixPath(_WORKSPACE) / pure)
307+
return resolve_sandbox_path(path, _WORKSPACE)
310308

311309

312310
def _is_timeout(error: APIError) -> bool:
Lines changed: 61 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,61 @@
1+
# Copyright 2026 Google LLC
2+
#
3+
# Licensed under the Apache License, Version 2.0 (the "License");
4+
# you may not use this file except in compliance with the License.
5+
# You may obtain a copy of the License at
6+
#
7+
# http://www.apache.org/licenses/LICENSE-2.0
8+
#
9+
# Unless required by applicable law or agreed to in writing, software
10+
# distributed under the License is distributed on an "AS IS" BASIS,
11+
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12+
# See the License for the specific language governing permissions and
13+
# limitations under the License.
14+
15+
"""Tests for the remote sandbox path resolver in BaseEnvironment."""
16+
17+
from pathlib import Path
18+
19+
from google.adk.environment._base_environment import resolve_sandbox_path
20+
import pytest
21+
22+
23+
@pytest.mark.parametrize(
24+
('path', 'expected'),
25+
[
26+
('notes.txt', '/workspace/notes.txt'),
27+
('sub/nested/file.txt', '/workspace/sub/nested/file.txt'),
28+
('./notes.txt', '/workspace/notes.txt'),
29+
# '..' segments that stay inside the workspace are normalized away.
30+
('sub/../notes.txt', '/workspace/notes.txt'),
31+
('.', '/workspace'),
32+
],
33+
)
34+
def test_resolve_sandbox_path_anchors_relative_paths(path, expected):
35+
assert resolve_sandbox_path(path, '/workspace') == expected
36+
37+
38+
@pytest.mark.parametrize(
39+
'path',
40+
[
41+
'..',
42+
'../outside.txt',
43+
'subdir/../../etc/passwd',
44+
'a/../../b',
45+
],
46+
)
47+
def test_resolve_sandbox_path_rejects_relative_escape(path):
48+
with pytest.raises(ValueError, match='escapes working directory'):
49+
resolve_sandbox_path(path, '/workspace')
50+
51+
52+
@pytest.mark.parametrize('path', ['/etc/passwd', '/workspace/../../etc/passwd'])
53+
def test_resolve_sandbox_path_passes_absolute_paths_through(path):
54+
"""Absolute paths follow the BaseEnvironment contract and pass through."""
55+
assert resolve_sandbox_path(path, '/workspace') == path
56+
57+
58+
def test_resolve_sandbox_path_accepts_pathlike():
59+
assert resolve_sandbox_path(Path('notes.txt'), '/workspace') == (
60+
'/workspace/notes.txt'
61+
)

‎tests/unittests/integrations/daytona/test_daytona_environment.py‎

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -245,3 +245,18 @@ async def test_read_file_raises_file_not_found_on_daytona_not_found(
245245

246246
with pytest.raises(FileNotFoundError):
247247
await env.read_file("missing.txt")
248+
249+
250+
async def test_resolve_path_rejects_relative_escape():
251+
"""Model-supplied '..' paths cannot resolve outside the sandbox workspace."""
252+
env = DaytonaEnvironment()
253+
254+
with pytest.raises(ValueError, match="escapes working directory"):
255+
env._resolve_path("../../etc/passwd")
256+
257+
258+
async def test_resolve_path_anchors_and_normalizes_relative_paths():
259+
env = DaytonaEnvironment()
260+
261+
assert env._resolve_path("sub/../notes.txt") == "/workspaces/notes.txt"
262+
assert env._resolve_path("/etc/passwd") == "/etc/passwd"

‎tests/unittests/integrations/e2b/test_e2b_environment.py‎

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -222,3 +222,18 @@ async def test_lazy_recreate_when_expired(sandbox):
222222
assert create.await_count == 2
223223
assert env._sandbox is fresh
224224
expired.set_timeout.assert_not_awaited()
225+
226+
227+
async def test_resolve_path_rejects_relative_escape():
228+
"""Model-supplied '..' paths cannot resolve outside the sandbox home."""
229+
env = E2BEnvironment()
230+
231+
with pytest.raises(ValueError, match='escapes working directory'):
232+
env._resolve_path('../../etc/passwd')
233+
234+
235+
async def test_resolve_path_anchors_and_normalizes_relative_paths():
236+
env = E2BEnvironment()
237+
238+
assert env._resolve_path('sub/../notes.txt') == '/home/user/notes.txt'
239+
assert env._resolve_path('/etc/passwd') == '/etc/passwd'

‎tests/unittests/integrations/opensandbox/test_opensandbox_environment.py‎

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -624,3 +624,17 @@ async def test_initialize_explains_missing_optional_dependency():
624624
with mock.patch.dict("sys.modules", {"opensandbox": None}):
625625
with pytest.raises(ImportError, match=r"google-adk\[opensandbox\]"):
626626
await env.initialize()
627+
628+
629+
def test_resolve_path_rejects_relative_escape():
630+
"""Model-supplied '..' paths cannot resolve outside the sandbox workspace."""
631+
with pytest.raises(ValueError, match="escapes working directory"):
632+
OpenSandboxEnvironment._resolve_path("../../etc/passwd")
633+
634+
635+
def test_resolve_path_anchors_and_normalizes_relative_paths():
636+
assert (
637+
OpenSandboxEnvironment._resolve_path("sub/../notes.txt")
638+
== "/workspace/notes.txt"
639+
)
640+
assert OpenSandboxEnvironment._resolve_path("/etc/passwd") == "/etc/passwd"

‎tests/unittests/integrations/vmaas/test_agent_engine_environment.py‎

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -465,3 +465,18 @@ async def test_working_dir_is_workspace():
465465
environment = await _make_environment()
466466

467467
assert str(environment.working_dir) == "/workspace"
468+
469+
470+
async def test_resolve_path_rejects_relative_escape():
471+
"""Model-supplied '..' paths cannot resolve outside the sandbox workspace."""
472+
environment = AgentEngineEnvironment(vertexai_client=_make_client())
473+
474+
with pytest.raises(ValueError, match="escapes working directory"):
475+
environment._resolve_path("../../etc/passwd")
476+
477+
478+
async def test_resolve_path_anchors_and_normalizes_relative_paths():
479+
environment = AgentEngineEnvironment(vertexai_client=_make_client())
480+
481+
assert environment._resolve_path("sub/../notes.txt") == "/workspace/notes.txt"
482+
assert environment._resolve_path("/etc/passwd") == "/etc/passwd"

0 commit comments

Comments
 (0)