deer-flow/backend/tests/test_sandbox_middleware.py
Xinmin Zeng 09988caf95
feat(skills): request-scoped secrets for skills (closes #3861) (#3871)
* feat(sandbox): per-call env injection + platform-secret scrubbing for skills

Add an env parameter to Sandbox.execute_command (abstract + local + AIO) so request-scoped secrets can be injected into skill subprocesses, and scrub platform credentials (*KEY*/*SECRET*/*TOKEN*/*PASSWORD*/*CREDENTIAL*) from the inherited environment by default so scoped injection is not security theatre. LocalSandbox always passes an explicit scrubbed env; AioSandbox routes env-bearing commands through bash.exec(env=) on a fresh session and leaves the legacy persistent-shell path unchanged. Part of #3861. BEHAVIOR CHANGE: execute_command no longer inherits the full os.environ; Windows encoding tests updated to assert the scrubbed dict.

* feat(skills): parse required-secrets frontmatter declaration

Add SecretRequirement and Skill.required_secrets, and parse the required-secrets SKILL.md frontmatter field (a string list or {name, optional} mappings), dropping malformed entries with a warning so one bad declaration does not invalidate the skill. The declared name is both the context.secrets key and the env var injected at activation. Part of #3861.

* feat(runtime): request-scoped secret carrier (context.secrets)

Add SECRETS_CONTEXT_KEY + extract_request_secrets, centralising the context.secrets carrier contract. The existing context passthrough (build_run_config -> _build_runtime_context) already carries the sub-key to runtime.context without mirroring it into configurable; characterization tests lock that behaviour. Part of #3861.

* feat(skills): inject declared secrets at slash-activation into bash env

Binding point A: when a skill is slash-activated, SkillActivationMiddleware resolves its declared required-secrets against the request's context.secrets and writes the per-run injection set to runtime.context. The bash tool forwards that set to execute_command(env=). A skill cannot harvest a host platform credential (is_host_platform_secret guard, cf. GHSA-rhgp-j443-p4rf), and injected values are redacted from bash output (mask_secret_values) so an echoed secret never re-enters the prompt/trace. Part of #3861.

* test(skills): lock the five secret leak surfaces + add trace redaction helper

Regression tests assert the secret value is absent from all five surfaces: prompt (activation message), checkpoint (graph state vs context separation), audit (journal records names only), trace (metadata builder never copies context; never mirrored to configurable), and stdout (mask_secret_values). Add redact_secret_context_keys as a defensive helper for any context serialization. Part of #3861.

* docs(backend): document request-scoped secrets for skills

Add Request-Scoped Secrets subsection (Skills) + env policy note (Sandbox) and the execute_command(env=) signature change, per the doc-sync policy. Part of #3861.

* fix(skills): close gaps found by end-to-end verification of request-scoped secrets

Real-gateway e2e + independent review of #3861 surfaced three defects, now fixed:

1. Slash activation never fired in the live chain. InputSanitizationMiddleware
   wraps user input in BEGIN/END markers before SkillActivationMiddleware sees it,
   and the original text was only preserved when an upload or IM channel set it.
   For a plain text message the slash command became undetectable, so no secret
   was ever resolved. Fix: the sanitizer now setdefaults the pre-wrap text into
   ORIGINAL_USER_CONTENT_KEY (additive; sanitization behaviour unchanged), so
   slash activation works for all messages. Pre-existing latent bug surfaced here.

2. The raw request config (with context.secrets) was persisted to runs.kwargs_json
   and echoed by the run API (RunResponse.kwargs). Fix: redact_config_secrets()
   strips secret-bearing context keys from the persisted/echoed copy in start_run;
   the live config that drives the run keeps them. build_run_config now also sets
   configurable.thread_id on the context path (the checkpointer requires it).

3. Connection-string credentials (DATABASE_URL, REDIS_URL, SENTRY_DSN, GH_PAT, ...)
   were not scrubbed from the inherited sandbox env. Fix: env_policy adds a *DSN*
   pattern plus an explicit connection-string denylist (no blanket *URL* — benign
   service URLs stay readable).

Verified end-to-end via a real gateway run (real LLM + skill activation + bash):
the secret reaches the sandbox subprocess and appears in NONE of prompt, trace,
checkpoint, audit, stdout, runs.kwargs_json, or the run API. Part of #3861.

* docs(backend): document the env scrub, persistence redaction, and sanitizer interaction

Sync the Request-Scoped Secrets section with the verification-driven fixes: inherited-env scrub (incl. connection-string denylist), run-record/run-API redaction as the 6th sealed leak surface, and the sanitizer preserving original content so slash activation fires. Part of #3861.

* fix(skills): inject caller secret over scrubbed host value; drop redundant host-name guard

A real-world demo (a skill calling a third-party cloud API with a request-scoped
key) exposed that the is_host_platform_secret guard was both wrong and harmful:
it refused to inject a caller-supplied secret whenever a same-named variable
existed in the Gateway env — which is exactly the #3861 use case (a per-user key
overriding a shared platform key). The guard was also redundant: build_sandbox_env
already scrubs secret-looking names from the inherited env before injection, so a
skill can never read a host credential — it only ever receives the caller's value.

Remove the guard; the injected (caller) value simply wins over the scrubbed host
value. Verified end-to-end: the agent called the real cloud API successfully with
the caller's key, the host's same-named key was scrubbed and never used, and the
caller's key leaked to none of the surfaces. Part of #3861.

* fix(skills): address review on request-scoped secrets (#3861)

Review fixes from PR #3871:

- E2BSandbox.execute_command now accepts env/timeout and routes them to
  commands.run(envs=, timeout=). The bash tool passes env= unconditionally,
  so the prior signature (command only) raised TypeError on every e2b bash
  call and broke e2b deployments entirely. env=None stays backward-compatible.
- SkillActivationMiddleware clears the active-secret set before resolving each
  activation, so a later skill in the same run never inherits an earlier
  skill's injection set (the #3861 contract: a skill only receives what the
  caller supplied AND that skill declared).
- AioSandbox env path uses a dedicated _DEFAULT_HARD_TIMEOUT — bash.exec exposes
  no idle/no-change timeout, so the prior reuse of the legacy idle constant
  conflated wall-clock vs idle semantics. The env path also retries on the
  ErrorObservation signature now, sharing the legacy persistent-shell recovery
  contract.
- mask_secret_values skips values below a minimum length floor so a short
  declared secret (e.g. "42") cannot shred unrelated bytes (exit codes,
  timestamps, sizes) of tool output. The secret is still injected into the
  subprocess; only the output mask skips it.

session_id reuse on the env path is intentionally NOT added: a shared session
could let request-scoped secrets ride the session env into later commands,
which the SDK does not contractually forbid. The fresh-session choice matches
the LocalSandbox model (each call is a fresh subprocess); the trade-off
(consecutive env-bearing calls do not share cwd/venv/exports) is documented on
_execute_with_env.
2026-07-03 07:51:22 +08:00

432 lines
14 KiB
Python

from __future__ import annotations
import asyncio
from typing import get_type_hints
import pytest
from langchain.agents.middleware import AgentMiddleware
from langchain.tools import ToolRuntime
from langchain_core.messages import ToolMessage
from langgraph.prebuilt.tool_node import ToolCallRequest
from langgraph.runtime import Runtime
from langgraph.types import Command
from deerflow.agents.thread_state import ThreadState
from deerflow.sandbox.middleware import SandboxMiddleware, SandboxMiddlewareState
from deerflow.sandbox.sandbox import Sandbox
from deerflow.sandbox.sandbox_provider import SandboxProvider, reset_sandbox_provider, set_sandbox_provider
from deerflow.sandbox.search import GrepMatch
from deerflow.sandbox.tools import ls_tool
class _SyncProvider(SandboxProvider):
def __init__(self) -> None:
self.thread_ids: list[str | None] = []
self.user_ids: list[str | None] = []
def acquire(self, thread_id: str | None = None, *, user_id: str | None = None) -> str:
self.thread_ids.append(thread_id)
self.user_ids.append(user_id)
return "sync-sandbox"
def get(self, sandbox_id: str) -> Sandbox | None:
return None
def release(self, sandbox_id: str) -> None:
return None
class _SandboxStub(Sandbox):
def execute_command(
self,
command: str,
env: dict[str, str] | None = None,
timeout: float | None = None,
) -> str:
del env, timeout
return "OK"
def read_file(self, path: str) -> str:
return "content"
def download_file(self, path: str) -> bytes:
return b"content"
def list_dir(self, path: str, max_depth: int = 2) -> list[str]:
return ["/mnt/user-data/workspace/file.txt"]
def write_file(self, path: str, content: str, append: bool = False) -> None:
return None
def glob(self, path: str, pattern: str, *, include_dirs: bool = False, max_results: int = 200) -> tuple[list[str], bool]:
return [], False
def grep(
self,
path: str,
pattern: str,
*,
glob: str | None = None,
literal: bool = False,
case_sensitive: bool = False,
max_results: int = 100,
) -> tuple[list[GrepMatch], bool]:
return [], False
def update_file(self, path: str, content: bytes) -> None:
return None
class _AsyncOnlyProvider(SandboxProvider):
def __init__(self) -> None:
self.thread_ids: list[str | None] = []
self.user_ids: list[str | None] = []
self.released_ids: list[str] = []
self.sandbox = _SandboxStub("async-sandbox")
def acquire(self, thread_id: str | None = None, *, user_id: str | None = None) -> str:
del user_id
raise AssertionError("async middleware should not call sync acquire")
async def acquire_async(self, thread_id: str | None = None, *, user_id: str | None = None) -> str:
self.thread_ids.append(thread_id)
self.user_ids.append(user_id)
return "async-sandbox"
def get(self, sandbox_id: str) -> Sandbox | None:
if sandbox_id == "async-sandbox":
return self.sandbox
return None
def release(self, sandbox_id: str) -> None:
self.released_ids.append(sandbox_id)
return None
def test_sandbox_middleware_state_matches_thread_state_sandbox_field() -> None:
"""Middleware-local schema must not drift from ThreadState.sandbox."""
middleware_hints = get_type_hints(SandboxMiddlewareState, include_extras=True)
thread_hints = get_type_hints(ThreadState, include_extras=True)
assert middleware_hints["sandbox"] == thread_hints["sandbox"]
@pytest.mark.anyio
async def test_provider_default_acquire_async_offloads_sync_acquire(monkeypatch: pytest.MonkeyPatch) -> None:
provider = _SyncProvider()
calls: list[tuple[object, tuple[object, ...]]] = []
async def fake_to_thread(func, /, *args, **kwargs):
calls.append((func, args, kwargs))
return func(*args, **kwargs)
monkeypatch.setattr(asyncio, "to_thread", fake_to_thread)
sandbox_id = await provider.acquire_async("thread-1")
assert sandbox_id == "sync-sandbox"
assert provider.thread_ids == ["thread-1"]
assert provider.user_ids == [None]
assert calls == [(provider.acquire, ("thread-1",), {"user_id": None})]
@pytest.mark.anyio
async def test_abefore_agent_uses_async_provider_acquire() -> None:
provider = _AsyncOnlyProvider()
set_sandbox_provider(provider)
try:
middleware = SandboxMiddleware(lazy_init=False)
result = await middleware.abefore_agent({}, Runtime(context={"thread_id": "thread-2", "user_id": "owner-2"}))
finally:
reset_sandbox_provider()
assert result == {"sandbox": {"sandbox_id": "async-sandbox"}}
assert provider.thread_ids == ["thread-2"]
assert provider.user_ids == ["owner-2"]
@pytest.mark.anyio
@pytest.mark.parametrize(
("middleware", "state", "runtime"),
[
(SandboxMiddleware(lazy_init=True), {}, Runtime(context={"thread_id": "thread-lazy"})),
(SandboxMiddleware(lazy_init=False), {}, Runtime(context={})),
(SandboxMiddleware(lazy_init=False), {"sandbox": {"sandbox_id": "existing"}}, Runtime(context={"thread_id": "thread-existing"})),
],
)
async def test_abefore_agent_delegates_to_super_when_not_acquiring(
monkeypatch: pytest.MonkeyPatch,
middleware: SandboxMiddleware,
state: dict,
runtime: Runtime,
) -> None:
calls: list[tuple[dict, Runtime]] = []
async def fake_super_abefore_agent(self, state_arg, runtime_arg):
calls.append((state_arg, runtime_arg))
return {"delegated": True}
monkeypatch.setattr(AgentMiddleware, "abefore_agent", fake_super_abefore_agent)
result = await middleware.abefore_agent(state, runtime)
assert result == {"delegated": True}
assert calls == [(state, runtime)]
@pytest.mark.anyio
async def test_default_lazy_tool_acquisition_uses_async_provider() -> None:
provider = _AsyncOnlyProvider()
set_sandbox_provider(provider)
try:
runtime = ToolRuntime(
state={},
context={"thread_id": "thread-lazy", "user_id": "owner-lazy"},
config={"configurable": {}},
stream_writer=lambda _: None,
tools=[],
tool_call_id="call-1",
store=None,
)
result = await ls_tool.ainvoke({"runtime": runtime, "description": "list workspace", "path": "/mnt/user-data/workspace"})
finally:
reset_sandbox_provider()
assert result == "/mnt/user-data/workspace/file.txt"
assert provider.thread_ids == ["thread-lazy"]
assert provider.user_ids == ["owner-lazy"]
assert runtime.state["sandbox"] == {"sandbox_id": "async-sandbox"}
assert runtime.context["sandbox_id"] == "async-sandbox"
@pytest.mark.anyio
@pytest.mark.parametrize(
("state", "runtime", "expected_sandbox_id"),
[
({"sandbox": {"sandbox_id": "state-sandbox"}}, Runtime(context={}), "state-sandbox"),
({}, Runtime(context={"sandbox_id": "context-sandbox"}), "context-sandbox"),
],
)
async def test_aafter_agent_releases_sandbox_off_thread(
monkeypatch: pytest.MonkeyPatch,
state: dict,
runtime: Runtime,
expected_sandbox_id: str,
) -> None:
provider = _AsyncOnlyProvider()
to_thread_calls: list[tuple[object, tuple[object, ...]]] = []
async def fake_to_thread(func, /, *args):
to_thread_calls.append((func, args))
return func(*args)
monkeypatch.setattr(asyncio, "to_thread", fake_to_thread)
set_sandbox_provider(provider)
try:
result = await SandboxMiddleware().aafter_agent(state, runtime)
finally:
reset_sandbox_provider()
assert result is None
assert provider.released_ids == [expected_sandbox_id]
assert to_thread_calls == [(provider.release, (expected_sandbox_id,))]
@pytest.mark.anyio
async def test_aafter_agent_delegates_to_super_when_no_sandbox(monkeypatch: pytest.MonkeyPatch) -> None:
calls: list[tuple[dict, Runtime]] = []
async def fake_super_aafter_agent(self, state_arg, runtime_arg):
calls.append((state_arg, runtime_arg))
return {"delegated": True}
monkeypatch.setattr(AgentMiddleware, "aafter_agent", fake_super_aafter_agent)
state = {}
runtime = Runtime(context={})
result = await SandboxMiddleware().aafter_agent(state, runtime)
assert result == {"delegated": True}
assert calls == [(state, runtime)]
# ---------------------------------------------------------------------------
# wrap_tool_call / awrap_tool_call: persistent sandbox state via Command
# ---------------------------------------------------------------------------
def _make_tool_call_request(state: dict) -> ToolCallRequest:
"""Build a minimal ToolCallRequest backed by a real ToolRuntime."""
runtime = ToolRuntime(
state=state,
context={},
config={"configurable": {}},
stream_writer=lambda _: None,
tools=[],
tool_call_id="call-1",
store=None,
)
return ToolCallRequest(
tool_call={"id": "call-1", "name": "bash", "args": {}},
tool=None,
state=state,
runtime=runtime,
)
def test_wrap_tool_call_emits_command_when_lazy_init_happens() -> None:
middleware = SandboxMiddleware()
state: dict = {}
request = _make_tool_call_request(state)
def handler(req: ToolCallRequest) -> ToolMessage:
# Simulate ensure_sandbox_initialized() mutating runtime.state in-place.
req.runtime.state["sandbox"] = {"sandbox_id": "new-sandbox"}
return ToolMessage(content="ok", tool_call_id="call-1", name="bash")
result = middleware.wrap_tool_call(request, handler)
assert isinstance(result, Command)
assert isinstance(result.update, dict)
assert result.update["sandbox"] == {"sandbox_id": "new-sandbox"}
messages = result.update["messages"]
assert len(messages) == 1
assert messages[0].content == "ok"
assert messages[0].tool_call_id == "call-1"
def test_wrap_tool_call_passthrough_when_sandbox_already_in_state() -> None:
middleware = SandboxMiddleware()
state: dict = {"sandbox": {"sandbox_id": "existing"}}
request = _make_tool_call_request(state)
original = ToolMessage(content="ok", tool_call_id="call-1", name="bash")
def handler(req: ToolCallRequest) -> ToolMessage:
return original
result = middleware.wrap_tool_call(request, handler)
assert result is original
def test_wrap_tool_call_passthrough_when_handler_did_not_initialize_sandbox() -> None:
middleware = SandboxMiddleware()
state: dict = {}
request = _make_tool_call_request(state)
original = ToolMessage(content="ok", tool_call_id="call-1", name="bash")
def handler(req: ToolCallRequest) -> ToolMessage:
return original
result = middleware.wrap_tool_call(request, handler)
assert result is original
def test_wrap_tool_call_merges_with_existing_command_update() -> None:
middleware = SandboxMiddleware()
state: dict = {}
request = _make_tool_call_request(state)
tool_msg = ToolMessage(content="ok", tool_call_id="call-1", name="bash")
def handler(req: ToolCallRequest) -> Command:
req.runtime.state["sandbox"] = {"sandbox_id": "new-sandbox"}
return Command(
update={
"messages": [tool_msg],
"viewed_images": {"a.png": {"base64": "x", "mime_type": "image/png"}},
},
goto="next-node",
)
result = middleware.wrap_tool_call(request, handler)
assert isinstance(result, Command)
assert result.goto == "next-node"
assert isinstance(result.update, dict)
assert result.update["messages"] == [tool_msg]
assert result.update["viewed_images"] == {"a.png": {"base64": "x", "mime_type": "image/png"}}
assert result.update["sandbox"] == {"sandbox_id": "new-sandbox"}
def test_wrap_tool_call_does_not_override_non_dict_update() -> None:
middleware = SandboxMiddleware()
state: dict = {}
request = _make_tool_call_request(state)
cmd = Command(update=[("messages", [ToolMessage(content="x", tool_call_id="c", name="bash")])])
def handler(req: ToolCallRequest) -> Command:
req.runtime.state["sandbox"] = {"sandbox_id": "new-sandbox"}
return cmd
result = middleware.wrap_tool_call(request, handler)
# Non-dict update is left untouched to avoid silent data loss.
assert result is cmd
@pytest.mark.anyio
async def test_awrap_tool_call_emits_command_when_lazy_init_happens() -> None:
middleware = SandboxMiddleware()
state: dict = {}
request = _make_tool_call_request(state)
async def handler(req: ToolCallRequest) -> ToolMessage:
req.runtime.state["sandbox"] = {"sandbox_id": "async-new"}
return ToolMessage(content="ok", tool_call_id="call-1", name="bash")
result = await middleware.awrap_tool_call(request, handler)
assert isinstance(result, Command)
assert isinstance(result.update, dict)
assert result.update["sandbox"] == {"sandbox_id": "async-new"}
messages = result.update["messages"]
assert len(messages) == 1
assert messages[0].content == "ok"
@pytest.mark.anyio
async def test_awrap_tool_call_passthrough_when_sandbox_already_in_state() -> None:
middleware = SandboxMiddleware()
state: dict = {"sandbox": {"sandbox_id": "existing"}}
request = _make_tool_call_request(state)
original = ToolMessage(content="ok", tool_call_id="call-1", name="bash")
async def handler(req: ToolCallRequest) -> ToolMessage:
return original
result = await middleware.awrap_tool_call(request, handler)
assert result is original
def test_wrap_tool_call_preserves_existing_command_fields_when_merging() -> None:
"""Regression: when merging sandbox_update into an existing Command,
all other Command fields (e.g. graph, goto, resume) must be preserved.
"""
middleware = SandboxMiddleware()
state: dict = {}
request = _make_tool_call_request(state)
def handler(req: ToolCallRequest) -> Command:
req.runtime.state["sandbox"] = {"sandbox_id": "sbx-merge"}
return Command(
update={"existing_key": "existing_value"},
graph="parent",
goto="next_node",
resume="resume-token",
)
result = middleware.wrap_tool_call(request, handler)
assert isinstance(result, Command)
assert result.update == {
"existing_key": "existing_value",
"sandbox": {"sandbox_id": "sbx-merge"},
}
# Critical: other Command fields must NOT be dropped by the merge.
assert result.graph == "parent"
assert result.goto == "next_node"
assert result.resume == "resume-token"