deer-flow/backend/packages/harness/deerflow/sandbox/local/local_sandbox_provider.py
Aari 317577e285
fix: enforce custom agent skill allowlists in sandboxes (#5077)
* fix: enforce agent skill allowlists in sandboxes

* fix: guard E2B skill projection resets

* fix: preserve agent skill isolation across delegation

* fix: close sandbox skill isolation bypasses

* fix(sandbox): close skill isolation review gaps

* fix(sandbox): harden skill isolation lifecycle
2026-08-31 14:35:08 +08:00

580 lines
27 KiB
Python

import logging
import threading
from collections import OrderedDict
from pathlib import Path
from deerflow.constants import DEFAULT_SKILLS_CONTAINER_PATH
from deerflow.sandbox.local.local_sandbox import LocalSandbox, PathMapping
from deerflow.sandbox.sandbox import Sandbox
from deerflow.sandbox.sandbox_provider import SandboxProvider
from deerflow.sandbox.security import is_host_bash_allowed
logger = logging.getLogger(__name__)
# Module-level alias kept for backward compatibility with older callers/tests
# that reach into ``local_sandbox_provider._singleton`` directly. New code reads
# the provider instance attributes (``_generic_sandbox`` / ``_thread_sandboxes``)
# instead.
_singleton: LocalSandbox | None = None
# Virtual prefixes that must be reserved by the per-thread mappings created in
# ``acquire`` — custom mounts from ``config.yaml`` may not overlap with these.
_USER_DATA_VIRTUAL_PREFIX = "/mnt/user-data"
_ACP_WORKSPACE_VIRTUAL_PREFIX = "/mnt/acp-workspace"
# Default upper bound on per-thread LocalSandbox instances retained in memory.
# Each cached instance is cheap (a small Python object with a list of
# PathMapping and a set of agent-written paths used for reverse resolve), but
# in a long-running gateway the number of distinct thread_ids is unbounded.
# When the cap is exceeded the least-recently-used entry is dropped; the next
# ``acquire(thread_id)`` for that thread simply rebuilds the sandbox at the
# cost of losing its accumulated ``_agent_written_paths`` (read_file falls
# back to no reverse resolution, which is the same behaviour as a fresh run).
DEFAULT_MAX_CACHED_THREAD_SANDBOXES = 256
class LocalSandboxProvider(SandboxProvider):
"""Local-filesystem sandbox provider with per-thread path scoping.
Earlier revisions of this provider returned a single process-wide
``LocalSandbox`` keyed by the literal id ``"local"``. That singleton could
not honour the documented ``/mnt/user-data/...`` contract at the public
``Sandbox`` API boundary because the corresponding host directory is
per-thread (``{base_dir}/users/{user_id}/threads/{thread_id}/user-data/``).
The provider now produces a fresh ``LocalSandbox`` per ``thread_id`` whose
``path_mappings`` include thread-scoped entries for
``/mnt/user-data/{workspace,uploads,outputs}`` and ``/mnt/acp-workspace``,
mirroring how :class:`AioSandboxProvider` bind-mounts those paths into its
docker container. The legacy ``acquire()`` / ``acquire(None)`` call still
returns a generic singleton with id ``"local"`` for callers (and tests)
that do not have a thread context.
Thread-safety: ``acquire``, ``get`` and ``reset`` may be invoked from
multiple threads (Gateway tool dispatch, subagent worker pools, the
background memory updater, …) so all cache state changes are serialised
through a provider-wide :class:`threading.Lock`. This matches the pattern
used by :class:`AioSandboxProvider`.
Memory bound: ``_thread_sandboxes`` is an LRU cache capped at
``max_cached_threads`` (default :data:`DEFAULT_MAX_CACHED_THREAD_SANDBOXES`).
When the cap is exceeded the least-recently-used entry is evicted on the
next ``acquire``; the evicted thread's next ``acquire`` rebuilds a fresh
sandbox (losing only its ``_agent_written_paths`` reverse-resolve hint,
which gracefully degrades read_file output).
The managed ``/mnt/skills`` projection is a logical boundary, not a host
filesystem security boundary. When host bash is enabled, a subprocess can
address canonical host paths without going through ``PathMapping``. The
provider therefore advertises Agent skill isolation only while host bash
remains disabled.
"""
uses_thread_data_mounts = True
needs_upload_permission_adjustment = False
@property
def supports_agent_skill_isolation(self) -> bool:
"""Whether the current tool surface can enforce the managed view."""
try:
return not is_host_bash_allowed()
except Exception:
# An unreadable config must not turn a host-process provider into
# an isolation boundary by accident.
return False
def __init__(self, max_cached_threads: int = DEFAULT_MAX_CACHED_THREAD_SANDBOXES):
"""Initialize the local sandbox provider with static path mappings.
Args:
max_cached_threads: Upper bound on per-thread sandboxes retained in
the LRU cache. When exceeded, the least-recently-used entry is
evicted on the next ``acquire``.
"""
self._skills_container_path = DEFAULT_SKILLS_CONTAINER_PATH
self._path_mappings = self._setup_path_mappings()
self._generic_sandbox: LocalSandbox | None = None
self._thread_sandboxes: OrderedDict[tuple[str, str], LocalSandbox] = OrderedDict()
self._max_cached_threads = max_cached_threads
self._lock = threading.Lock()
def _setup_path_mappings(self) -> list[PathMapping]:
"""
Setup static path mappings shared by every sandbox this provider yields.
Static mappings cover the **public** skills directory and any custom
mounts from ``config.yaml`` — both are process-wide and identical for
every thread. Per-thread ``/mnt/user-data/...``, ``/mnt/acp-workspace``
and ``/mnt/skills/custom`` mappings are appended inside
:meth:`_build_thread_path_mappings` because they depend on
``thread_id`` and the effective ``user_id``.
Returns:
List of static path mappings
"""
mappings: list[PathMapping] = []
# Map skills: split mount for public + legacy + custom
try:
from deerflow.config import get_app_config
config = get_app_config()
container_path = config.skills.container_path
self._skills_container_path = container_path.rstrip("/")
projection = self._ensure_skills_projection()
# Public skills: global, read-only — static, shared by all threads
public_skills_path = projection.public
if public_skills_path.exists():
mappings.append(
PathMapping(
container_path=f"{container_path}/public",
local_path=str(public_skills_path),
read_only=True,
)
)
# NOTE: Legacy skills mount is NOT included here because it must
# only be exposed to users who have no per-user custom skills yet
# (mirroring ``UserScopedSkillStorage._iter_skill_files`` which only
# surfaces SkillCategory.LEGACY to such users). Including it for
# every user would let users with per-user custom skills still
# ``read_file("/mnt/skills/legacy/<name>/SKILL.md")`` and read
# content the listing layer told them doesn't exist. See review
# feedback on PR #3889 — the legacy mount is now built in
# ``_build_thread_path_mappings`` after we know the user_id.
# NOTE: Custom skills mount is NOT included here because it is
# per-user and must be built dynamically per-thread inside
# ``_build_thread_path_mappings``. The static mount that previously
# bound ``get_effective_user_id()`` at init time was incorrect:
# every subsequent user's sandbox would resolve
# ``/mnt/skills/custom`` to the init-time user's directory.
# Map custom mounts from sandbox config
_RESERVED_CONTAINER_PATHS = {container_path}
_RESERVED_CONTAINER_PREFIXES = [
f"{container_path}/public",
f"{container_path}/custom",
f"{container_path}/integrations",
f"{container_path}/legacy",
_ACP_WORKSPACE_VIRTUAL_PREFIX,
_USER_DATA_VIRTUAL_PREFIX,
]
sandbox_config = config.sandbox
if sandbox_config and sandbox_config.mounts:
for mount in sandbox_config.mounts:
host_path = Path(mount.host_path)
container_path = mount.container_path.rstrip("/") or "/"
if not host_path.is_absolute():
logger.warning(
"Mount host_path must be absolute, skipping: %s -> %s",
mount.host_path,
mount.container_path,
)
continue
if not container_path.startswith("/"):
logger.warning(
"Mount container_path must be absolute, skipping: %s -> %s",
mount.host_path,
mount.container_path,
)
continue
# Reject mounts that conflict with reserved container paths
if container_path in _RESERVED_CONTAINER_PATHS or any(container_path == p or container_path.startswith(p + "/") for p in _RESERVED_CONTAINER_PREFIXES):
logger.warning(
"Mount container_path conflicts with reserved prefix, skipping: %s",
mount.container_path,
)
continue
# Ensure the host path exists before adding mapping.
#
# ``host_path`` is resolved against the filesystem of the
# process running this provider — for ``make dev`` that is
# the host machine, but for ``make up`` it is the
# ``deer-flow-gateway`` container, so any host path that
# isn't bind-mounted into the gateway image will be missing
# here. Skipping silently makes this a high-cost-to-debug
# silent failure (sandbox skill / tool reads an empty dir
# instead of the configured mount), so escalate to ERROR
# and include actionable guidance. See #3244.
if host_path.exists():
mappings.append(
PathMapping(
container_path=container_path,
local_path=str(host_path.resolve()),
read_only=mount.read_only,
)
)
else:
logger.error(
"sandbox.mounts entry %s -> %s ignored: host_path %s does not exist from the "
"perspective of the gateway process. In Docker deployments (make up / docker-compose), "
"this path must also be bind-mounted into the gateway container — add a matching "
"volume entry under services.gateway.volumes in docker/docker-compose.yaml (and use "
"the in-container path here), or run in local mode (make dev) where the gateway sees "
"the host filesystem directly.",
mount.host_path,
mount.container_path,
mount.host_path,
)
except Exception as e:
# Log but don't fail if config loading fails
logger.warning("Could not setup path mappings: %s", e, exc_info=True)
return mappings
@staticmethod
def _effective_acquire_user_id(user_id: str | None) -> str:
from deerflow.runtime.user_context import get_effective_user_id
return user_id or get_effective_user_id()
@staticmethod
def _thread_key(thread_id: str, user_id: str) -> tuple[str, str]:
return (user_id, thread_id)
@staticmethod
def _ensure_skills_projection(user_id: str | None = None, *, thread_id: str | None = None):
"""Best-effort: a projection failure must not fail sandbox acquire.
Mirrors the surrounding skill-mount setup, which has always logged
and continued rather than failing the whole acquire (e.g. missing
config.yaml in a test double). Callers see ``None`` and skip the
skill mounts for this acquire; the projection self-heals on a later
acquire once the underlying condition clears.
"""
from deerflow.config import get_app_config
from deerflow.skills.projection import (
ensure_skill_projections,
get_thread_skill_projection_paths,
thread_skill_projection_exists,
)
from deerflow.skills.storage import get_or_new_skill_storage, get_or_new_user_skill_storage
try:
config = get_app_config()
if user_id is None:
storage = get_or_new_skill_storage(app_config=config)
else:
storage = get_or_new_user_skill_storage(user_id, app_config=config)
if thread_id is not None and thread_skill_projection_exists(storage, thread_id):
return get_thread_skill_projection_paths(storage, thread_id)
return ensure_skill_projections(storage)
except Exception as exc:
logger.warning(
"Could not ensure skills projection for user/thread %s/%s: %s",
user_id,
thread_id,
exc,
exc_info=True,
)
return None
@staticmethod
def _append_public_skill_mapping(mappings: list[PathMapping], projection) -> None:
if projection is None:
return
try:
from deerflow.config import get_app_config
container_path = get_app_config().skills.container_path.rstrip("/")
public_container_path = f"{container_path}/public"
if any(mapping.container_path.rstrip("/") == public_container_path for mapping in mappings):
return
mappings.append(
PathMapping(
container_path=public_container_path,
local_path=str(projection.public),
read_only=True,
)
)
except Exception as exc:
logger.warning("Could not append public skill mapping: %s", exc, exc_info=True)
@staticmethod
def _sandbox_id_for_thread(thread_id: str, user_id: str) -> str:
return f"local:{user_id}:{thread_id}"
@staticmethod
def _key_from_sandbox_id(sandbox_id: str) -> tuple[str, str] | None:
if not sandbox_id.startswith("local:"):
return None
value = sandbox_id[len("local:") :]
user_id, separator, thread_id = value.partition(":")
if not separator or not user_id or not thread_id:
return None
return (user_id, thread_id)
@staticmethod
def _build_thread_path_mappings(thread_id: str, *, user_id: str | None = None, skill_projection=None) -> list[PathMapping]:
"""Build per-thread path mappings for /mnt/user-data, /mnt/acp-workspace,
and /mnt/skills/custom.
Uses the explicitly resolved user id when provided, falling back to
:func:`get_effective_user_id` for legacy callers. Custom skills are
mounted per-user (read-only) because agent writes custom skills via
``skill_manage_tool`` on the host filesystem, not inside the sandbox.
"""
from deerflow.config import get_app_config
from deerflow.config.paths import get_paths
paths = get_paths()
effective_user_id = LocalSandboxProvider._effective_acquire_user_id(user_id)
paths.ensure_thread_dirs(thread_id, user_id=effective_user_id)
mappings = [
# Aggregate parent mapping so ``ls /mnt/user-data`` and other
# parent-level operations behave the same as inside AIO (where the
# parent directory is real and contains the three subdirs). Longer
# subpath mappings below still win for ``/mnt/user-data/workspace/...``
# because ``_find_path_mapping`` sorts by container_path length.
PathMapping(
container_path=_USER_DATA_VIRTUAL_PREFIX,
local_path=str(paths.sandbox_user_data_dir(thread_id, user_id=effective_user_id)),
read_only=False,
),
PathMapping(
container_path=f"{_USER_DATA_VIRTUAL_PREFIX}/workspace",
local_path=str(paths.sandbox_work_dir(thread_id, user_id=effective_user_id)),
read_only=False,
),
PathMapping(
container_path=f"{_USER_DATA_VIRTUAL_PREFIX}/uploads",
local_path=str(paths.sandbox_uploads_dir(thread_id, user_id=effective_user_id)),
read_only=False,
),
PathMapping(
container_path=f"{_USER_DATA_VIRTUAL_PREFIX}/outputs",
local_path=str(paths.sandbox_outputs_dir(thread_id, user_id=effective_user_id)),
read_only=False,
),
PathMapping(
container_path=_ACP_WORKSPACE_VIRTUAL_PREFIX,
local_path=str(paths.acp_workspace_dir(thread_id, user_id=effective_user_id)),
read_only=False,
),
]
# Category mounts stay present for the sandbox lifetime. Their
# enabled-only contents change beneath these stable roots.
try:
config = get_app_config()
skills_container_path = config.skills.container_path
projection = skill_projection if skill_projection is not None else LocalSandboxProvider._ensure_skills_projection(effective_user_id)
if projection is not None:
thread_projection_root = paths.thread_skills_view_dir(
thread_id,
user_id=effective_user_id,
)
if projection.public.parent == thread_projection_root:
mappings.append(
PathMapping(
container_path=skills_container_path,
local_path=str(thread_projection_root),
read_only=True,
)
)
else:
mappings.extend(
[
PathMapping(
container_path=f"{skills_container_path}/public",
local_path=str(projection.public),
read_only=True,
),
PathMapping(
container_path=f"{skills_container_path}/custom",
local_path=str(projection.custom),
read_only=True,
),
PathMapping(
container_path=f"{skills_container_path}/legacy",
local_path=str(projection.legacy),
read_only=True,
),
PathMapping(
container_path=f"{skills_container_path}/integrations",
local_path=str(projection.integrations),
read_only=True,
),
]
)
except Exception as exc:
logger.warning("Could not setup per-thread skills projection mounts: %s", exc, exc_info=True)
return mappings
def _without_managed_skill_mappings(
self,
mappings: list[PathMapping],
*,
policy_scoped: bool,
) -> list[PathMapping]:
"""Drop mappings that would overlap the selected managed skill view.
Ordinary sandboxes retain operator-defined mounts elsewhere below the
configured skills root for backward compatibility. A policy-scoped
sandbox removes the entire subtree before installing its coherent root
mapping, so a nested custom mount cannot bypass the Agent allowlist.
"""
root = self._skills_container_path
managed_categories = tuple(f"{root}/{category}" for category in ("public", "custom", "legacy", "integrations"))
def conflicts(mapping: PathMapping) -> bool:
path = mapping.container_path.rstrip("/")
if path == root:
return True
prefixes = (root,) if policy_scoped else managed_categories
return any(path == prefix or path.startswith(prefix + "/") for prefix in prefixes)
return [mapping for mapping in mappings if not conflicts(mapping)]
def acquire(self, thread_id: str | None = None, *, user_id: str | None = None) -> str:
"""Return a sandbox id scoped to *thread_id* (or the generic singleton).
- ``thread_id=None`` keeps the legacy singleton with id ``"local"`` for
callers that have no thread context (e.g. legacy tests, scripts).
- ``thread_id="abc"`` yields a per-thread ``LocalSandbox`` with id
``"local:abc"`` whose ``path_mappings`` resolve ``/mnt/user-data/...``
to that thread's host directories.
Thread-safe under concurrent invocation: the cache check + insert is
guarded by ``self._lock`` so two callers racing on the same
``thread_id`` always observe the same LocalSandbox instance.
"""
global _singleton
if thread_id is None:
skill_projection = self._ensure_skills_projection()
with self._lock:
if self._generic_sandbox is None:
mappings = list(self._path_mappings)
self._append_public_skill_mapping(mappings, skill_projection)
self._generic_sandbox = LocalSandbox("local", path_mappings=mappings)
_singleton = self._generic_sandbox
return self._generic_sandbox.id
effective_user_id = self._effective_acquire_user_id(user_id)
# Runs on every acquire, including cache hits, to self-heal drift —
# cheap (~3-4 ms metadata walk) when the manifest is fresh. If another
# worker mutated this user's skills since the last check, this
# triggers a full rebuild (~400 ms measured locally) under the
# cross-process projection lock, serializing concurrent acquires and
# mutations for that user. Acceptable for an editing-frequency event.
skill_projection = self._ensure_skills_projection(
effective_user_id,
thread_id=thread_id,
)
key = self._thread_key(thread_id, effective_user_id)
# ``_build_thread_path_mappings`` touches the filesystem
# (``ensure_thread_dirs``); release the lock during I/O.
from deerflow.config.paths import get_paths
policy_scoped = bool(
skill_projection is not None
and skill_projection.public.parent
== get_paths().thread_skills_view_dir(
thread_id,
user_id=effective_user_id,
)
)
new_mappings = self._without_managed_skill_mappings(
list(self._path_mappings),
policy_scoped=policy_scoped,
)
new_mappings += self._build_thread_path_mappings(
thread_id,
user_id=effective_user_id,
skill_projection=skill_projection,
)
with self._lock:
cached = self._thread_sandboxes.get(key)
if cached is None or cached.path_mappings != new_mappings:
replacement = LocalSandbox(
self._sandbox_id_for_thread(thread_id, effective_user_id),
path_mappings=new_mappings,
)
if cached is not None:
replacement._agent_written_paths.update(cached._agent_written_paths)
cached = replacement
self._thread_sandboxes[key] = cached
self._evict_until_within_cap_locked()
else:
self._thread_sandboxes.move_to_end(key)
return cached.id
def _evict_until_within_cap_locked(self) -> None:
"""LRU-evict cached thread sandboxes once the cap is exceeded.
Caller MUST hold ``self._lock``.
"""
while len(self._thread_sandboxes) > self._max_cached_threads:
evicted_key, _ = self._thread_sandboxes.popitem(last=False)
logger.info(
"Evicting LocalSandbox cache entry for user/thread %s/%s (cap=%d)",
evicted_key[0],
evicted_key[1],
self._max_cached_threads,
)
def get(self, sandbox_id: str) -> Sandbox | None:
if sandbox_id == "local":
with self._lock:
generic = self._generic_sandbox
if generic is None:
self.acquire()
with self._lock:
return self._generic_sandbox
return generic
if isinstance(sandbox_id, str) and sandbox_id.startswith("local:"):
key = self._key_from_sandbox_id(sandbox_id)
if key is None:
return None
with self._lock:
cached = self._thread_sandboxes.get(key)
if cached is not None:
# Touching a thread via ``get`` (used by tools.py to look
# up the sandbox once per tool call) promotes it in LRU
# order so an active thread isn't evicted under load.
self._thread_sandboxes.move_to_end(key)
return cached
return None
def release(self, sandbox_id: str) -> None:
# LocalSandbox has no resources to release; keep the cached instance so
# that ``_agent_written_paths`` (used to reverse-resolve agent-authored
# file contents on read) survives between turns. LRU eviction in
# ``acquire`` and explicit ``reset()`` / ``shutdown()`` are the only
# paths that drop cached entries.
#
# Note: This method is intentionally not called by SandboxMiddleware
# to allow sandbox reuse across multiple turns in a thread.
pass
def reset(self) -> None:
"""Drop all cached LocalSandbox instances.
``reset_sandbox_provider()`` calls this to ensure config / mount
changes take effect on the next ``acquire()``. We also reset the
module-level ``_singleton`` alias so older callers/tests that reach
# into it see a fresh state.
"""
global _singleton
with self._lock:
self._generic_sandbox = None
self._thread_sandboxes.clear()
_singleton = None
def shutdown(self) -> None:
# LocalSandboxProvider has no extra resources beyond the cached
# ``LocalSandbox`` instances, so shutdown uses the same cleanup path
# as ``reset``.
self.reset()