240 lines
8.6 KiB
Python
240 lines
8.6 KiB
Python
"""Allowlist enforcement for automatic file upload during tool execution.
|
|
|
|
This is only consulted when
|
|
``dangerously_allow_auto_upload_download_files=True``. Manual upload APIs are
|
|
not subject to the allowlist (parity with the TypeScript SDK).
|
|
|
|
- A local path is accepted iff its symlink-resolved absolute path is inside one
|
|
of the allowed directories on a path-component boundary. ``/tmp/foo`` allows
|
|
``/tmp/foo/bar`` but NOT ``/tmp/foo-bar``.
|
|
- URLs never hit this check.
|
|
- User-provided ``file_upload_dirs`` fully REPLACES the default
|
|
``[~/.composio/temp]``.
|
|
- On Windows, path comparisons are case-insensitive.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import os
|
|
import sys
|
|
import typing as t
|
|
from pathlib import Path
|
|
|
|
from composio.exceptions import (
|
|
FileUploadPathNotAllowedError,
|
|
SDKFileNotFoundError,
|
|
)
|
|
|
|
UPLOAD_TEMP_DIRECTORY_NAME = "temp"
|
|
|
|
|
|
def get_default_upload_dir() -> t.Optional[Path]:
|
|
"""Absolute path of the default upload staging directory
|
|
(``<home>/.composio/temp``), or None when a home directory cannot be
|
|
determined."""
|
|
try:
|
|
home = Path.home()
|
|
except (RuntimeError, OSError):
|
|
return None
|
|
return (home / ".composio" / UPLOAD_TEMP_DIRECTORY_NAME).resolve()
|
|
|
|
|
|
def ensure_dir_exists(p: Path) -> None:
|
|
"""Best-effort directory creation. Allowlist enforcement still works if
|
|
creation fails (e.g. read-only filesystem)."""
|
|
try:
|
|
p.mkdir(parents=True, exist_ok=True)
|
|
except OSError:
|
|
pass
|
|
|
|
|
|
FileUploadDirs = t.Union[t.Sequence[str], t.Literal[False], None]
|
|
"""User-facing type for ``file_upload_dirs``.
|
|
|
|
- ``None`` (default) -> use ``[<home>/.composio/temp]``.
|
|
- ``False`` -> reject every local path during auto-upload (URLs / bytes still
|
|
work).
|
|
- ``[]`` -> same as ``False`` (kept as an alias; prefer ``False``).
|
|
- ``Sequence[str]`` (non-empty) -> allowlist replacing the default.
|
|
"""
|
|
|
|
|
|
def resolve_effective_upload_allowlist(
|
|
user_dirs: FileUploadDirs,
|
|
) -> t.List[Path]:
|
|
"""Resolve the effective allowlist.
|
|
|
|
- ``None`` -> ``[<default>]`` when available, else ``[]`` (fail-closed).
|
|
- ``False`` -> ``[]`` (explicit "reject all local paths"). URLs and
|
|
in-memory bytes still work because they aren't path-checked.
|
|
- ``Sequence[str]`` (including ``[]``) REPLACES the default and is returned
|
|
verbatim after ``~``-expansion and ``resolve()``. ``[]`` is treated as
|
|
equivalent to ``False``.
|
|
- Empty / blank / non-string entries are skipped.
|
|
- Duplicates are removed (case-insensitive on Windows).
|
|
"""
|
|
if user_dirs is None:
|
|
default = get_default_upload_dir()
|
|
return [default] if default is not None else []
|
|
if user_dirs is False:
|
|
return []
|
|
|
|
seen: t.Set[str] = set()
|
|
out: t.List[Path] = []
|
|
for raw in user_dirs:
|
|
if not isinstance(raw, (str, os.PathLike)):
|
|
continue
|
|
s = str(raw).strip()
|
|
if not s:
|
|
continue
|
|
abs_path = Path(s).expanduser().resolve()
|
|
key = str(abs_path).lower() if sys.platform == "win32" else str(abs_path)
|
|
if key in seen:
|
|
continue
|
|
seen.add(key)
|
|
out.append(abs_path)
|
|
return out
|
|
|
|
|
|
def _is_inside_dir(child: Path, parent: Path) -> bool:
|
|
"""True iff ``child`` equals ``parent`` or is nested inside on a component
|
|
boundary. Assumes both are absolute and normalized."""
|
|
try:
|
|
child_str = str(child)
|
|
parent_str = str(parent)
|
|
if sys.platform == "win32":
|
|
child_str = child_str.lower()
|
|
parent_str = parent_str.lower()
|
|
if child_str == parent_str:
|
|
return True
|
|
sep = os.sep
|
|
parent_with_sep = parent_str if parent_str.endswith(sep) else parent_str + sep
|
|
return child_str.startswith(parent_with_sep)
|
|
except OSError:
|
|
return False
|
|
|
|
|
|
def _format_allowlist(dirs: t.Sequence[Path]) -> str:
|
|
if not dirs:
|
|
return " (none configured — all local paths are blocked)"
|
|
return "\n".join(f" - {d}" for d in dirs)
|
|
|
|
|
|
def _build_help_footer(allowlist: t.Sequence[Path]) -> str:
|
|
return "\n".join(
|
|
[
|
|
"",
|
|
"Allowed upload directories (file_upload_dirs):",
|
|
_format_allowlist(allowlist),
|
|
"",
|
|
"Fix one of the following:",
|
|
"",
|
|
" 1. Recommended — move the file under an allowed directory, or add your",
|
|
" directory to the allowlist:",
|
|
" Composio(",
|
|
" file_upload_dirs=['/abs/path/to/uploads', '~/.composio/temp'],",
|
|
" )",
|
|
" Note: user-provided `file_upload_dirs` REPLACES the default",
|
|
" `~/.composio/temp`. Include it explicitly if you want staged uploads",
|
|
" to keep working.",
|
|
"",
|
|
" 2. Pass the file by URL (http://... / https://...) instead of a",
|
|
" filesystem path. URLs are never path-checked.",
|
|
"",
|
|
" 3. (Dangerous) Bypass the allowlist entirely by opting into automatic",
|
|
" file upload/download from any readable path:",
|
|
" Composio(dangerously_allow_auto_upload_download_files=True, ...)",
|
|
" WARNING: This lets a prompt-injected or misbehaving tool read ANY",
|
|
" file the process can access. Only enable it in trusted, sandboxed",
|
|
" environments. The built-in sensitive-path denylist (.ssh, .aws,",
|
|
" .env, private SSH keys, etc.) still applies unless you also set",
|
|
" `sensitive_file_upload_protection=False`.",
|
|
]
|
|
)
|
|
|
|
|
|
def assert_path_inside_upload_dirs(
|
|
file_path: t.Union[str, Path],
|
|
allowlist: t.Sequence[Path],
|
|
) -> None:
|
|
"""Raise an elaborate error if the path is missing or outside the allowlist.
|
|
|
|
:raises SDKFileNotFoundError: when ``file_path`` does not exist on disk.
|
|
:raises FileUploadPathNotAllowedError: when resolved path is outside every
|
|
entry of ``allowlist`` (including when allowlist is empty).
|
|
"""
|
|
attempted = str(file_path)
|
|
abs_path = Path(attempted).expanduser()
|
|
try:
|
|
abs_path = abs_path.resolve(strict=False)
|
|
except OSError:
|
|
pass
|
|
|
|
if not abs_path.exists():
|
|
cwd = Path.cwd()
|
|
parent = abs_path.parent
|
|
parent_exists = parent.exists()
|
|
raise SDKFileNotFoundError(
|
|
"\n".join(
|
|
[
|
|
f'Refusing to auto-upload "{attempted}": the file does not exist on disk.',
|
|
"",
|
|
f"Path attempted: {attempted}",
|
|
f"Resolved to: {abs_path}",
|
|
f"Process cwd: {cwd}",
|
|
f"Parent exists: {'yes (' + str(parent) + ')' if parent_exists else 'no (' + str(parent) + ')'}",
|
|
"",
|
|
"Common causes:",
|
|
" - Typo in the filename passed to the tool.",
|
|
" - Relative path resolved against the wrong working directory",
|
|
" (relative paths use os.getcwd() at the moment of upload).",
|
|
" - File was deleted between the tool being called and the upload starting.",
|
|
"",
|
|
_build_help_footer(allowlist),
|
|
]
|
|
)
|
|
)
|
|
|
|
try:
|
|
real_path = abs_path.resolve(strict=True)
|
|
except OSError:
|
|
real_path = abs_path
|
|
|
|
if not allowlist:
|
|
raise FileUploadPathNotAllowedError(
|
|
"\n".join(
|
|
[
|
|
f'Refusing to auto-upload "{attempted}": no upload directories are configured.',
|
|
"",
|
|
f"Path attempted: {attempted}",
|
|
f"Resolved to: {real_path}",
|
|
"",
|
|
"Automatic file upload during tool execution is locked down by default",
|
|
"to prevent a prompt-injected tool from exfiltrating server files",
|
|
"(source code, .env, SSH keys, etc.).",
|
|
_build_help_footer(allowlist),
|
|
]
|
|
)
|
|
)
|
|
|
|
for dir_entry in allowlist:
|
|
try:
|
|
real_dir = Path(dir_entry).expanduser().resolve(strict=False)
|
|
except OSError:
|
|
real_dir = Path(dir_entry)
|
|
if _is_inside_dir(real_path, real_dir):
|
|
return
|
|
|
|
raise FileUploadPathNotAllowedError(
|
|
"\n".join(
|
|
[
|
|
f'Refusing to auto-upload "{attempted}": resolved path is not inside any',
|
|
"directory in the configured `file_upload_dirs` allowlist.",
|
|
"",
|
|
f"Path attempted: {attempted}",
|
|
f"Resolved to: {real_path}",
|
|
_build_help_footer(allowlist),
|
|
]
|
|
)
|
|
)
|