[eric] browser: agents can attach a file to an upload field without the OS picker; the path is allow-listed backend-side so a hostile page cannot turn it into exfiltration (ENG-47)

This commit is contained in:
ciregenz
2026-08-11 20:10:36 -07:00
parent a28268bab9
commit ba97d141ee
5 changed files with 281 additions and 2 deletions
+10 -1
View File
@@ -211,6 +211,7 @@ def render_app_controls(describe_value: object) -> tuple[str, str] | None:
# both BrowserPressKey({key}) and a batch's {"type":"press_key","params":{key}}.
P_SINGLE_ACTION_TYPE = {
"BrowserClick": "click", "BrowserClickIndex": "click_index",
"BrowserUploadFile": "upload_file",
"BrowserClickByName": "click_name", "BrowserType": "type",
"BrowserPressKey": "press_key", "BrowserScroll": "scroll",
"BrowserNavigate": "navigate", "BrowserClickPoint": "click_point",
@@ -415,6 +416,14 @@ async def p_execute_browser_tool(
return {"error": f"Unknown browser tool: {tool_name}"}
params = {k: v for k, v in tool_input.items()}
# Resolve the upload path HERE, before the command crosses to the renderer, so the only string
# the page-driven agent can turn into bytes-on-the-wire is one that already passed the allow-list.
if action == "upload_file":
from backend.apps.agents.browser.resolve_upload_path import resolve_upload_path, UploadPathRefused
try:
params["path"] = resolve_upload_path(str(params.get("path") or ""))
except UploadPathRefused as p_refused:
return {"error": str(p_refused)}
# Self-healing click toggle + click-effect metric. Threaded for solo clicks AND batches (most clicks are batched, so gating on click_index alone misses them). handleBatch propagates these into its click_index sub-actions.
if action in ("click_index", "batch"):
params["selfheal"] = os.environ.get("OSW_SELFHEAL_CLICK", "1") != "0"
@@ -560,7 +569,7 @@ def format_tool_result(result: dict, tool_name: str) -> list[dict]:
# Mutating tools whose results get fresh page state attached (the browser-use loop shape: act, settle, see), so acting and seeing are one turn, not two.
P_AUTO_STATE_TOOLS = {
"BrowserNavigate", "BrowserClick", "BrowserClickIndex", "BrowserClickByName",
"BrowserNavigate", "BrowserClick", "BrowserClickIndex", "BrowserClickByName", "BrowserUploadFile",
"BrowserType", "BrowserPressKey", "BrowserScroll", "BrowserBatch",
}
# Matches the frontend's DEFAULT_INTERACTIVE_CAP (interactiveRanking.ts): a shorter cap here silently hid rows 36-60 that an explicit BrowserListInteractives would show, forcing the model to re-list the very elements it just acted on. Delta compression keeps the common attach small, so the worst case (a full 60-row attach) is bounded and rare.
@@ -392,6 +392,40 @@ BROWSER_TOOLS_SCHEMA = [
"required": ["index"],
},
},
{
"name": "BrowserUploadFile",
"description": (
"Attach a local file to a file-upload field (resume, photo, document). ALWAYS use this "
"instead of clicking an Upload / Choose File / Attach button: clicking one opens the "
"operating system's file picker, which is outside the page and which you cannot see or "
"control, so the run dead-ends there. This tool fills the field directly, no dialog "
"opens, and the page's own change handlers fire exactly as if a human had picked the "
"file.\n"
"It finds the file input for you even when the site hides it behind a styled button, "
"which is the usual design. Pass `index` only to disambiguate when a page has several "
"upload fields and the first one is not the one you want.\n"
"`path` must be a file the user attached to this chat or one an agent created in its "
"workspace; anything else is refused. Verify the result: it reports back the filename "
"the page actually received."
),
"input_schema": {
"type": "object",
"properties": {
"path": {
"type": "string",
"description": "Absolute path of the local file to attach.",
},
"index": {
"type": "integer",
"description": (
"Optional. 1-based index from BrowserListInteractives, when the page has "
"more than one upload field. Omit to use the page's first one."
),
},
},
"required": ["path"],
},
},
{
"name": "BrowserActVerified",
"description": (
@@ -807,6 +841,7 @@ ACTION_MAP = {
"BrowserPressKey": "press_key",
"BrowserListInteractives": "list_interactives",
"BrowserClickIndex": "click_index",
"BrowserUploadFile": "upload_file",
"BrowserClickPoint": "click_point",
"BrowserBatch": "batch",
"BrowserDetectWebMCP": "detect_webmcp",
@@ -1221,6 +1256,7 @@ ACTION_TOOLS_REQUIRING_REPORT = {
"BrowserScroll",
"BrowserEvaluate",
"BrowserClickIndex", # Phase 3
"BrowserUploadFile",
"BrowserClickPoint", # app mode: tap a canvas/game at a screen point
"BrowserBatch", # Phase 4
"BrowserActVerified", # verified-step sequence (mutates state like a batch)
@@ -0,0 +1,69 @@
"""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 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.
roots = [
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()
if not any(target == r or target.startswith(r + os.sep) for r in roots):
raise UploadPathRefused(
f"Refused: {raw} is outside the folders this agent may upload from. "
"Uploadable files are the ones the user attached to the chat and the ones agents "
"created in their workspace. Copy the file into the workspace first, then upload it."
)
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
+90
View File
@@ -0,0 +1,90 @@
"""ENG-47: the upload tool is the first browser tool that can move bytes off the machine, and the
browser sub-agent takes its instructions from the page it is driving. These pin the containment
guard, including the case that matters most: a symlink planted INSIDE an allowed root."""
import os
import pytest
from backend.apps.agents.browser.resolve_upload_path import (
resolve_upload_path,
allowed_upload_roots,
UploadPathRefused,
MAX_UPLOAD_BYTES,
)
@pytest.fixture
def uploads_dir():
root = allowed_upload_roots()[0]
os.makedirs(root, exist_ok=True)
return root
def test_a_file_the_user_attached_is_uploadable(uploads_dir):
f = os.path.join(uploads_dir, "resume.pdf")
with open(f, "w", encoding="utf-8") as fh:
fh.write("cv")
assert resolve_upload_path(f) == os.path.realpath(f)
@pytest.mark.parametrize("hostile", ["~/.ssh/id_rsa", "/etc/passwd", "/etc/hosts", ""])
def test_paths_outside_every_allowed_root_are_refused(hostile):
with pytest.raises(UploadPathRefused):
resolve_upload_path(hostile)
def test_a_symlink_inside_an_allowed_root_cannot_smuggle_a_file_out(uploads_dir):
# String math on the path would pass this: it really does live under the uploads root.
link = os.path.join(uploads_dir, "innocent.txt")
if os.path.lexists(link):
os.remove(link)
os.symlink("/etc/hosts", link)
try:
with pytest.raises(UploadPathRefused):
resolve_upload_path(link)
finally:
os.remove(link)
def test_a_sibling_root_with_a_shared_prefix_is_not_inside_it(uploads_dir):
# Without the trailing separator, root `self-swarm-uploads` would own `self-swarm-uploads-evil`.
evil = uploads_dir + "-evil"
os.makedirs(evil, exist_ok=True)
f = os.path.join(evil, "x.txt")
with open(f, "w", encoding="utf-8") as fh:
fh.write("x")
with pytest.raises(UploadPathRefused):
resolve_upload_path(f)
def test_a_directory_is_not_a_file(uploads_dir):
with pytest.raises(UploadPathRefused):
resolve_upload_path(uploads_dir)
def test_an_oversized_file_is_refused(uploads_dir, monkeypatch):
f = os.path.join(uploads_dir, "huge.bin")
with open(f, "w", encoding="utf-8") as fh:
fh.write("x")
monkeypatch.setattr(os.path, "getsize", lambda p: MAX_UPLOAD_BYTES + 1)
with pytest.raises(UploadPathRefused):
resolve_upload_path(f)
def test_the_dispatcher_refuses_before_the_command_leaves_the_backend(monkeypatch):
"""The guard has to sit in front of the WS hop, not inside the renderer."""
import asyncio
import backend.apps.agents.core.ws_manager as ws_mod
from backend.apps.agents.browser import browser_agent
sent = []
async def spy(*args, **kwargs):
sent.append(args)
return {"text": "should never happen"}
monkeypatch.setattr(ws_mod.ws_manager, "send_browser_command", spy, raising=True)
out = asyncio.run(browser_agent.execute_browser_tool(
"BrowserUploadFile", {"path": "/etc/passwd"}, "browser-1"))
assert "Refused" in str(out.get("error", ""))
assert sent == [], "a refused path must never reach the renderer"