Files
openswarm/backend/apps/agents/browser/resolve_upload_path.py

74 lines
3.3 KiB
Python

"""Containment guard for the one tool that hands a local file to a web page.
The browser sub-agent reads its instructions off the page it is driving, so a hostile page can ask
it for anything. Uploading is the first browser tool that can move bytes OFF the machine, which
makes it the only one where a prompt injection converts into exfiltration. So the path is resolved
against an allow-list here, on the backend, before the command ever reaches the renderer: the
agent can offer any string it likes and still cannot name `~/.ssh/id_rsa`.
"""
import os
from typing import List
from typeguard import typechecked
from backend.apps.settings.settings import legacy_upload_dir, upload_dir
from backend.config.paths import OUTPUTS_WORKSPACE_DIR, SKILLS_WORKSPACE_DIR
# Big enough for a portfolio PDF or a short video, small enough that a runaway loop can't post a disk image.
MAX_UPLOAD_BYTES = 100 * 1024 * 1024
class UploadPathRefused(Exception):
"""The requested file is outside every allowed root, missing, or too large."""
@typechecked
def allowed_upload_roots() -> List[str]:
"""Roots a file may be uploaded from: what the user attached, and what agents produce."""
# ~/.openswarm/workspaces is where a chat agent's own scratch cwd lives (AgentLaunch), so a file
# the agent just wrote and now wants to upload is covered without opening up the whole home dir.
# The legacy temp-dir root stays allowed: a file attached BEFORE the ENG-312 move keeps working.
roots = [
upload_dir(), legacy_upload_dir(), OUTPUTS_WORKSPACE_DIR, SKILLS_WORKSPACE_DIR,
os.path.join(os.path.expanduser("~"), ".openswarm", "workspaces"),
]
out: List[str] = []
for r in roots:
try:
out.append(os.path.realpath(r))
except OSError:
continue
return out
@typechecked
def resolve_upload_path(path: str) -> str:
"""Absolute real path of an uploadable file, or raise UploadPathRefused.
realpath both sides, then compare whole components: without the trailing separator a root named
`uploads` would also own `uploads-evil`, and plain string math walks straight through a symlink
planted inside an allowed root and pointing at the user's home.
"""
raw = (path or "").strip()
if not raw:
raise UploadPathRefused("No file path given.")
target = os.path.realpath(os.path.expanduser(raw))
roots = allowed_upload_roots()
staging = upload_dir()
if not any(target == r or target.startswith(r + os.sep) for r in roots):
# Name the fix, not just the rule. The PARENT agent can read anywhere and is not driven by
# page content, so staging the file is safe there and is the one move that unblocks this.
raise UploadPathRefused(
f"Refused: {raw} is outside the folders a page-driven agent may upload from. "
f"Ask the agent that sent you here to copy the file to {staging} first "
f"(e.g. `cp \"{raw}\" {staging}/`), then upload it from there."
)
if not os.path.isfile(target):
raise UploadPathRefused(f"Refused: {raw} is not a file that exists.")
size = os.path.getsize(target)
if size > MAX_UPLOAD_BYTES:
raise UploadPathRefused(
f"Refused: {raw} is {size // (1024 * 1024)}MB, over the {MAX_UPLOAD_BYTES // (1024 * 1024)}MB upload cap."
)
return target