fix: Harden YAML builder tmp save/cleanup

- Add path-safe helpers so all builder filesystem operations stay under <agents_dir>/<app_name> and reject traversal/invalid upload paths.
- Rework /builder/save to support tmp=true writes under <app>/tmp/<app>, promote tmp → app root on final save (preserving tools.py/tools/), then clean up tmp on success.
- Simplify /builder/app/{app_name}/cancel to best-effort delete tmp; update GET /builder/app/{app_name}?tmp=true to auto-recreate tmp from the app root and safely serve requested files.

Co-authored-by: George Weale <gweale@google.com>
PiperOrigin-RevId: 852366567
This commit is contained in:
George Weale
2026-01-05 10:47:12 -08:00
committed by Copybara-Service
parent 3ec7ae3b8d
commit 6f259f08b3
2 changed files with 418 additions and 95 deletions
+229 -82
View File
@@ -211,75 +211,214 @@ def get_fast_api_app(
**extra_fast_api_args, **extra_fast_api_args,
) )
agents_base_path = (Path.cwd() / agents_dir).resolve()
def _get_app_root(app_name: str) -> Path:
if app_name in ("", ".", ".."):
raise ValueError(f"Invalid app name: {app_name!r}")
if Path(app_name).name != app_name or "\\" in app_name:
raise ValueError(f"Invalid app name: {app_name!r}")
app_root = (agents_base_path / app_name).resolve()
if not app_root.is_relative_to(agents_base_path):
raise ValueError(f"Invalid app name: {app_name!r}")
return app_root
def _normalize_relative_path(path: str) -> str:
return path.replace("\\", "/").lstrip("/")
def _has_parent_reference(path: str) -> bool:
return any(part == ".." for part in path.split("/"))
def _parse_upload_filename(filename: Optional[str]) -> tuple[str, str]:
if not filename:
raise ValueError("Upload filename is missing.")
filename = _normalize_relative_path(filename)
if "/" not in filename:
raise ValueError(f"Invalid upload filename: {filename!r}")
app_name, rel_path = filename.split("/", 1)
if not app_name or not rel_path:
raise ValueError(f"Invalid upload filename: {filename!r}")
if rel_path.startswith("/"):
raise ValueError(f"Absolute upload path rejected: {filename!r}")
if _has_parent_reference(rel_path):
raise ValueError(f"Path traversal rejected: {filename!r}")
return app_name, rel_path
def _parse_file_path(file_path: str) -> str:
file_path = _normalize_relative_path(file_path)
if not file_path:
raise ValueError("file_path is missing.")
if file_path.startswith("/"):
raise ValueError(f"Absolute file_path rejected: {file_path!r}")
if _has_parent_reference(file_path):
raise ValueError(f"Path traversal rejected: {file_path!r}")
return file_path
def _resolve_under_dir(root_dir: Path, rel_path: str) -> Path:
file_path = root_dir / rel_path
resolved_root_dir = root_dir.resolve()
resolved_file_path = file_path.resolve()
if not resolved_file_path.is_relative_to(resolved_root_dir):
raise ValueError(f"Path escapes root_dir: {rel_path!r}")
return file_path
def _get_tmp_agent_root(app_root: Path, app_name: str) -> Path:
tmp_agent_root = app_root / "tmp" / app_name
resolved_tmp_agent_root = tmp_agent_root.resolve()
if not resolved_tmp_agent_root.is_relative_to(app_root):
raise ValueError(f"Invalid tmp path for app: {app_name!r}")
return tmp_agent_root
def copy_dir_contents(source_dir: Path, dest_dir: Path) -> None:
dest_dir.mkdir(parents=True, exist_ok=True)
for source_path in source_dir.iterdir():
if source_path.name == "tmp":
continue
dest_path = dest_dir / source_path.name
if source_path.is_dir():
if dest_path.exists() and dest_path.is_file():
dest_path.unlink()
shutil.copytree(source_path, dest_path, dirs_exist_ok=True)
elif source_path.is_file():
if dest_path.exists() and dest_path.is_dir():
shutil.rmtree(dest_path)
shutil.copy2(source_path, dest_path)
def cleanup_tmp(app_name: str) -> bool:
try:
app_root = _get_app_root(app_name)
except ValueError as exc:
logger.exception("Error in cleanup_tmp: %s", exc)
return False
try:
tmp_agent_root = _get_tmp_agent_root(app_root, app_name)
except ValueError as exc:
logger.exception("Error in cleanup_tmp: %s", exc)
return False
try:
shutil.rmtree(tmp_agent_root)
except FileNotFoundError:
pass
except OSError as exc:
logger.exception("Error deleting tmp agent root: %s", exc)
return False
tmp_dir = app_root / "tmp"
resolved_tmp_dir = tmp_dir.resolve()
if not resolved_tmp_dir.is_relative_to(app_root):
logger.error(
"Refusing to delete tmp outside app_root: %s", resolved_tmp_dir
)
return False
try:
tmp_dir.rmdir()
except OSError:
pass
return True
def ensure_tmp_exists(app_name: str) -> bool:
try:
app_root = _get_app_root(app_name)
except ValueError as exc:
logger.exception("Error in ensure_tmp_exists: %s", exc)
return False
if not app_root.is_dir():
return False
try:
tmp_agent_root = _get_tmp_agent_root(app_root, app_name)
except ValueError as exc:
logger.exception("Error in ensure_tmp_exists: %s", exc)
return False
if tmp_agent_root.exists():
return True
try:
tmp_agent_root.mkdir(parents=True, exist_ok=True)
copy_dir_contents(app_root, tmp_agent_root)
except OSError as exc:
logger.exception("Error in ensure_tmp_exists: %s", exc)
return False
return True
@app.post("/builder/save", response_model_exclude_none=True) @app.post("/builder/save", response_model_exclude_none=True)
async def builder_build( async def builder_build(
files: list[UploadFile], tmp: Optional[bool] = False files: list[UploadFile], tmp: Optional[bool] = False
) -> bool: ) -> bool:
base_path = Path.cwd() / agents_dir try:
for file in files: if tmp:
if not file.filename: app_names = set()
logger.exception("Agent name is missing in the input files") uploads = []
return False for file in files:
agent_name, filename = file.filename.split("/") app_name, rel_path = _parse_upload_filename(file.filename)
agent_dir = os.path.join(base_path, agent_name) app_names.add(app_name)
try: uploads.append((rel_path, file))
# File name format: {app_name}/{agent_name}.yaml
if tmp: if len(app_names) != 1:
agent_dir = os.path.join(agent_dir, "tmp/" + agent_name) logger.error(
os.makedirs(agent_dir, exist_ok=True) "Exactly one app name is required, found: %s", sorted(app_names)
file_path = os.path.join(agent_dir, filename) )
with open(file_path, "wb") as buffer: return False
app_name = next(iter(app_names))
app_root = _get_app_root(app_name)
tmp_agent_root = _get_tmp_agent_root(app_root, app_name)
tmp_agent_root.mkdir(parents=True, exist_ok=True)
for rel_path, file in uploads:
destination_path = _resolve_under_dir(tmp_agent_root, rel_path)
destination_path.parent.mkdir(parents=True, exist_ok=True)
with destination_path.open("wb") as buffer:
shutil.copyfileobj(file.file, buffer) shutil.copyfileobj(file.file, buffer)
else: return True
source_dir = os.path.join(agent_dir, "tmp/" + agent_name)
destination_dir = agent_dir app_names = set()
for item in os.listdir(source_dir): uploads = []
source_item = os.path.join(source_dir, item) for file in files:
destination_item = os.path.join(destination_dir, item) app_name, rel_path = _parse_upload_filename(file.filename)
if os.path.isdir(source_item): app_names.add(app_name)
shutil.copytree(source_item, destination_item, dirs_exist_ok=True) uploads.append((rel_path, file))
# Check if the item is a file
elif os.path.isfile(source_item): if len(app_names) != 1:
shutil.copy2(source_item, destination_item) logger.error(
except Exception as e: "Exactly one app name is required, found: %s", sorted(app_names)
logger.exception("Error in builder_build: %s", e) )
return False return False
return True app_name = next(iter(app_names))
app_root = _get_app_root(app_name)
app_root.mkdir(parents=True, exist_ok=True)
tmp_agent_root = _get_tmp_agent_root(app_root, app_name)
if tmp_agent_root.is_dir():
copy_dir_contents(tmp_agent_root, app_root)
for rel_path, file in uploads:
destination_path = _resolve_under_dir(app_root, rel_path)
destination_path.parent.mkdir(parents=True, exist_ok=True)
with destination_path.open("wb") as buffer:
shutil.copyfileobj(file.file, buffer)
return cleanup_tmp(app_name)
except ValueError as exc:
logger.exception("Error in builder_build: %s", exc)
return False
except OSError as exc:
logger.exception("Error in builder_build: %s", exc)
return False
@app.post("/builder/app/{app_name}/cancel", response_model_exclude_none=True) @app.post("/builder/app/{app_name}/cancel", response_model_exclude_none=True)
async def builder_cancel(app_name: str) -> bool: async def builder_cancel(app_name: str) -> bool:
base_path = Path.cwd() / agents_dir return cleanup_tmp(app_name)
agent_dir = os.path.join(base_path, app_name)
destination_dir = os.path.join(agent_dir, "tmp/" + app_name)
source_dir = agent_dir
source_items = set(os.listdir(source_dir))
try:
for item in os.listdir(destination_dir):
if item in source_items:
continue
# If it doesn't exist in the source, delete it from the destination
item_path = os.path.join(destination_dir, item)
if os.path.isdir(item_path):
shutil.rmtree(item_path)
elif os.path.isfile(item_path):
os.remove(item_path)
for item in os.listdir(source_dir):
source_item = os.path.join(source_dir, item)
destination_item = os.path.join(destination_dir, item)
if item == "tmp" and os.path.isdir(source_item):
continue
if os.path.isdir(source_item):
shutil.copytree(source_item, destination_item, dirs_exist_ok=True)
# Check if the item is a file
elif os.path.isfile(source_item):
shutil.copy2(source_item, destination_item)
except Exception as e:
logger.exception("Error in builder_build: %s", e)
return False
return True
@app.get( @app.get(
"/builder/app/{app_name}", "/builder/app/{app_name}",
@@ -291,34 +430,42 @@ def get_fast_api_app(
file_path: Optional[str] = None, file_path: Optional[str] = None,
tmp: Optional[bool] = False, tmp: Optional[bool] = False,
): ):
base_path = Path.cwd() / agents_dir try:
agent_dir = base_path / app_name app_root = _get_app_root(app_name)
except ValueError as exc:
logger.exception("Error in get_agent_builder: %s", exc)
return ""
agent_dir = app_root
if tmp: if tmp:
agent_dir = agent_dir / "tmp" if not ensure_tmp_exists(app_name):
agent_dir = agent_dir / app_name return ""
agent_dir = app_root / "tmp" / app_name
if not file_path: if not file_path:
file_name = "root_agent.yaml" rel_path = "root_agent.yaml"
root_file_path = agent_dir / file_name
if not root_file_path.is_file():
return ""
else:
return FileResponse(
path=root_file_path,
media_type="application/x-yaml",
filename="${app_name}.yaml",
headers={"Cache-Control": "no-store"},
)
else: else:
agent_file_path = agent_dir / file_path try:
if not agent_file_path.is_file(): rel_path = _parse_file_path(file_path)
except ValueError as exc:
logger.exception("Error in get_agent_builder: %s", exc)
return "" return ""
else:
return FileResponse( try:
path=agent_file_path, agent_file_path = _resolve_under_dir(agent_dir, rel_path)
media_type="application/x-yaml", except ValueError as exc:
filename=file_path, logger.exception("Error in get_agent_builder: %s", exc)
headers={"Cache-Control": "no-store"}, return ""
)
if not agent_file_path.is_file():
return ""
return FileResponse(
path=agent_file_path,
media_type="application/x-yaml",
filename=file_path or f"{app_name}.yaml",
headers={"Cache-Control": "no-store"},
)
if a2a: if a2a:
from a2a.server.apps import A2AStarletteApplication from a2a.server.apps import A2AStarletteApplication
+189 -13
View File
@@ -17,6 +17,7 @@ import json
import logging import logging
import os import os
from pathlib import Path from pathlib import Path
import signal
import sys import sys
import tempfile import tempfile
import time import time
@@ -31,6 +32,7 @@ from google.adk.agents.base_agent import BaseAgent
from google.adk.agents.run_config import RunConfig from google.adk.agents.run_config import RunConfig
from google.adk.apps.app import App from google.adk.apps.app import App
from google.adk.artifacts.base_artifact_service import ArtifactVersion from google.adk.artifacts.base_artifact_service import ArtifactVersion
from google.adk.cli import fast_api as fast_api_module
from google.adk.cli.fast_api import get_fast_api_app from google.adk.cli.fast_api import get_fast_api_app
from google.adk.errors.input_validation_error import InputValidationError from google.adk.errors.input_validation_error import InputValidationError
from google.adk.evaluation.eval_case import EvalCase from google.adk.evaluation.eval_case import EvalCase
@@ -414,29 +416,41 @@ def test_app(
# Patch multiple services and signal handlers # Patch multiple services and signal handlers
with ( with (
patch("signal.signal", return_value=None), patch.object(signal, "signal", autospec=True, return_value=None),
patch( patch.object(
"google.adk.cli.fast_api.create_session_service_from_options", fast_api_module,
"create_session_service_from_options",
autospec=True,
return_value=mock_session_service, return_value=mock_session_service,
), ),
patch( patch.object(
"google.adk.cli.fast_api.create_artifact_service_from_options", fast_api_module,
"create_artifact_service_from_options",
autospec=True,
return_value=mock_artifact_service, return_value=mock_artifact_service,
), ),
patch( patch.object(
"google.adk.cli.fast_api.create_memory_service_from_options", fast_api_module,
"create_memory_service_from_options",
autospec=True,
return_value=mock_memory_service, return_value=mock_memory_service,
), ),
patch( patch.object(
"google.adk.cli.fast_api.AgentLoader", fast_api_module,
"AgentLoader",
autospec=True,
return_value=mock_agent_loader, return_value=mock_agent_loader,
), ),
patch( patch.object(
"google.adk.cli.fast_api.LocalEvalSetsManager", fast_api_module,
"LocalEvalSetsManager",
autospec=True,
return_value=mock_eval_sets_manager, return_value=mock_eval_sets_manager,
), ),
patch( patch.object(
"google.adk.cli.fast_api.LocalEvalSetResultsManager", fast_api_module,
"LocalEvalSetResultsManager",
autospec=True,
return_value=mock_eval_set_results_manager, return_value=mock_eval_set_results_manager,
), ),
): ):
@@ -459,6 +473,70 @@ def test_app(
return client return client
@pytest.fixture
def builder_test_client(
tmp_path,
mock_session_service,
mock_artifact_service,
mock_memory_service,
mock_agent_loader,
mock_eval_sets_manager,
mock_eval_set_results_manager,
):
"""Return a TestClient rooted in a temporary agents directory."""
with (
patch.object(signal, "signal", autospec=True, return_value=None),
patch.object(
fast_api_module,
"create_session_service_from_options",
autospec=True,
return_value=mock_session_service,
),
patch.object(
fast_api_module,
"create_artifact_service_from_options",
autospec=True,
return_value=mock_artifact_service,
),
patch.object(
fast_api_module,
"create_memory_service_from_options",
autospec=True,
return_value=mock_memory_service,
),
patch.object(
fast_api_module,
"AgentLoader",
autospec=True,
return_value=mock_agent_loader,
),
patch.object(
fast_api_module,
"LocalEvalSetsManager",
autospec=True,
return_value=mock_eval_sets_manager,
),
patch.object(
fast_api_module,
"LocalEvalSetResultsManager",
autospec=True,
return_value=mock_eval_set_results_manager,
),
):
app = get_fast_api_app(
agents_dir=str(tmp_path),
web=True,
session_service_uri="",
artifact_service_uri="",
memory_service_uri="",
allow_origins=["*"],
a2a=False,
host="127.0.0.1",
port=8000,
)
return TestClient(app)
@pytest.fixture @pytest.fixture
async def create_test_session( async def create_test_session(
test_app, test_session_info, mock_session_service test_app, test_session_info, mock_session_service
@@ -1175,5 +1253,103 @@ def test_patch_memory(test_app, create_test_session, mock_memory_service):
logger.info("Add session to memory test completed successfully") logger.info("Add session to memory test completed successfully")
def test_builder_final_save_preserves_tools_and_cleans_tmp(
builder_test_client, tmp_path
):
files = [
("files", ("app/__init__.py", b"from . import agent\n", "text/plain")),
("files", ("app/tools.py", b"def tool():\n return 1\n", "text/plain")),
(
"files",
("app/root_agent.yaml", b"name: app\n", "application/x-yaml"),
),
]
response = builder_test_client.post("/builder/save?tmp=true", files=files)
assert response.status_code == 200
assert response.json() is True
response = builder_test_client.post(
"/builder/save",
files=[(
"files",
(
"app/root_agent.yaml",
b"name: app_updated\n",
"application/x-yaml",
),
)],
)
assert response.status_code == 200
assert response.json() is True
assert (tmp_path / "app" / "tools.py").is_file()
assert not (tmp_path / "app" / "tmp" / "app").exists()
tmp_dir = tmp_path / "app" / "tmp"
assert not tmp_dir.exists() or not any(tmp_dir.iterdir())
def test_builder_cancel_deletes_tmp_idempotent(builder_test_client, tmp_path):
tmp_agent_root = tmp_path / "app" / "tmp" / "app"
tmp_agent_root.mkdir(parents=True, exist_ok=True)
(tmp_agent_root / "root_agent.yaml").write_text("name: app\n")
response = builder_test_client.post("/builder/app/app/cancel")
assert response.status_code == 200
assert response.json() is True
assert not (tmp_path / "app" / "tmp").exists()
response = builder_test_client.post("/builder/app/app/cancel")
assert response.status_code == 200
assert response.json() is True
assert not (tmp_path / "app" / "tmp").exists()
def test_builder_get_tmp_true_recreates_tmp(builder_test_client, tmp_path):
app_root = tmp_path / "app"
app_root.mkdir(parents=True, exist_ok=True)
(app_root / "root_agent.yaml").write_text("name: app\n")
nested_dir = app_root / "nested"
nested_dir.mkdir(parents=True, exist_ok=True)
(nested_dir / "nested.yaml").write_text("nested: true\n")
assert not (app_root / "tmp").exists()
response = builder_test_client.get("/builder/app/app?tmp=true")
assert response.status_code == 200
assert response.text == "name: app\n"
tmp_agent_root = app_root / "tmp" / "app"
assert (tmp_agent_root / "root_agent.yaml").is_file()
assert (tmp_agent_root / "nested" / "nested.yaml").is_file()
response = builder_test_client.get(
"/builder/app/app?tmp=true&file_path=nested/nested.yaml"
)
assert response.status_code == 200
assert response.text == "nested: true\n"
def test_builder_get_tmp_true_missing_app_returns_empty(
builder_test_client, tmp_path
):
response = builder_test_client.get("/builder/app/missing?tmp=true")
assert response.status_code == 200
assert response.text == ""
assert not (tmp_path / "missing").exists()
def test_builder_save_rejects_traversal(builder_test_client, tmp_path):
response = builder_test_client.post(
"/builder/save?tmp=true",
files=[(
"files",
("app/../escape.yaml", b"nope\n", "application/x-yaml"),
)],
)
assert response.status_code == 200
assert response.json() is False
assert not (tmp_path / "escape.yaml").exists()
assert not (tmp_path / "app" / "tmp" / "escape.yaml").exists()
if __name__ == "__main__": if __name__ == "__main__":
pytest.main(["-xvs", __file__]) pytest.main(["-xvs", __file__])