mirror of
https://github.com/openswarm-ai/openswarm.git
synced 2026-08-20 11:42:23 +02:00
74 lines
3.3 KiB
Python
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
|