[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