mirror of
https://github.com/bytedance/deer-flow.git
synced 2026-07-28 08:56:13 +00:00
* 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.
159 lines
6.2 KiB
Python
159 lines
6.2 KiB
Python
"""Regression tests for blocking-command timeout handling in LocalSandbox.
|
|
|
|
These pin the fix for the "starting a server hangs the whole turn" bug:
|
|
a backgrounded long-lived process must not keep the bash tool blocked until
|
|
the timeout, and a genuinely blocking foreground command must be terminated
|
|
(process group and all) once it exceeds the timeout.
|
|
|
|
The POSIX cases exercise real subprocess/process-group semantics, so they are
|
|
skipped on Windows. Windows keeps the ``subprocess.run`` path, but timeout
|
|
errors still use the same user-facing notice.
|
|
"""
|
|
|
|
import os
|
|
import shlex
|
|
import sys
|
|
import time
|
|
from pathlib import Path
|
|
|
|
import pytest
|
|
|
|
from deerflow.config.sandbox_config import SandboxConfig
|
|
from deerflow.sandbox.local import local_sandbox
|
|
from deerflow.sandbox.local.local_sandbox import LocalSandbox
|
|
|
|
posix_only = pytest.mark.skipif(os.name == "nt", reason="POSIX process-group semantics")
|
|
linux_proc_fd_only = pytest.mark.skipif(not Path("/proc/self/fd").exists(), reason="requires Linux /proc fd links")
|
|
|
|
|
|
@posix_only
|
|
def test_backgrounded_process_returns_promptly():
|
|
"""A backgrounded long-lived process (e.g. a dev server started with `&`)
|
|
must return as soon as the foreground command finishes, instead of
|
|
blocking the bash tool until the timeout because it inherited the
|
|
captured pipe."""
|
|
sandbox = LocalSandbox("t")
|
|
start = time.monotonic()
|
|
output = sandbox.execute_command("sleep 5 & echo serving", timeout=10)
|
|
elapsed = time.monotonic() - start
|
|
|
|
assert elapsed < 3, f"expected prompt return, took {elapsed:.1f}s"
|
|
assert "serving" in output
|
|
|
|
|
|
@posix_only
|
|
@linux_proc_fd_only
|
|
def test_backgrounded_process_does_not_inherit_deleted_temp_capture(tmp_path):
|
|
"""A backgrounded process that forgets to redirect output must not inherit
|
|
an anonymous deleted temp file for fd 1. That would be an invisible,
|
|
unbounded disk leak for long-lived processes that keep writing."""
|
|
marker = tmp_path / "fd1"
|
|
script = f"import os, pathlib, time; pathlib.Path({str(marker)!r}).write_text(os.readlink('/proc/self/fd/1')); time.sleep(2)"
|
|
sandbox = LocalSandbox("t")
|
|
|
|
output = sandbox.execute_command(f"{shlex.quote(sys.executable)} -c {shlex.quote(script)} & echo launched", timeout=10)
|
|
|
|
assert "launched" in output
|
|
for _ in range(50):
|
|
if marker.exists():
|
|
break
|
|
time.sleep(0.1)
|
|
assert marker.exists()
|
|
assert " (deleted)" not in marker.read_text()
|
|
|
|
|
|
@posix_only
|
|
def test_foreground_blocking_command_times_out_with_notice():
|
|
"""A foreground command that never exits is terminated at the timeout and
|
|
the agent receives an explanatory notice instead of a generic error."""
|
|
sandbox = LocalSandbox("t")
|
|
start = time.monotonic()
|
|
output = sandbox.execute_command("while true; do sleep 0.2; done", timeout=1)
|
|
elapsed = time.monotonic() - start
|
|
|
|
assert elapsed < 5, f"timeout not enforced, took {elapsed:.1f}s"
|
|
assert "timed out" in output.lower()
|
|
|
|
|
|
def test_timeout_notice_formats_fractional_and_singular_timeouts(monkeypatch):
|
|
monkeypatch.setattr(LocalSandbox, "_get_shell", lambda self: "/bin/sh")
|
|
monkeypatch.setattr(LocalSandbox, "_run_posix_command", staticmethod(lambda args, timeout, env=None: ("", "", 0, True)))
|
|
|
|
assert "after 1.5 seconds" in LocalSandbox("t").execute_command("wait", timeout=1.5)
|
|
assert "after 1 second" in LocalSandbox("t").execute_command("wait", timeout=1)
|
|
|
|
|
|
def test_windows_timeout_expired_returns_notice(monkeypatch):
|
|
def fake_run(*args, **kwargs):
|
|
raise local_sandbox.subprocess.TimeoutExpired(cmd=args[0], timeout=kwargs["timeout"], output="partial out", stderr="partial err")
|
|
|
|
monkeypatch.setattr(local_sandbox.os, "name", "nt")
|
|
monkeypatch.setattr(LocalSandbox, "_get_shell", lambda self: "cmd.exe")
|
|
monkeypatch.setattr(local_sandbox.subprocess, "run", fake_run)
|
|
|
|
output = LocalSandbox("t").execute_command("wait", timeout=1.5)
|
|
|
|
assert "partial out" in output
|
|
assert "Std Error:" in output
|
|
assert "partial err" in output
|
|
assert "after 1.5 seconds" in output
|
|
assert "Unexpected error" not in output
|
|
|
|
|
|
@posix_only
|
|
def test_foreground_timeout_kills_whole_process_group(tmp_path):
|
|
"""On timeout the entire process group is killed, not just the direct
|
|
child, so child processes spawned by the command do not survive."""
|
|
marker = tmp_path / "alive"
|
|
sandbox = LocalSandbox("t")
|
|
sandbox.execute_command(f"while true; do touch {marker}; sleep 0.2; done", timeout=1)
|
|
|
|
assert marker.exists()
|
|
first_mtime = marker.stat().st_mtime
|
|
time.sleep(1.5)
|
|
assert marker.stat().st_mtime == first_mtime, "process group survived the timeout"
|
|
|
|
|
|
@posix_only
|
|
def test_command_reading_stdin_does_not_block():
|
|
"""stdin is redirected from /dev/null, so a command that reads stdin gets
|
|
immediate EOF instead of blocking until the timeout."""
|
|
sandbox = LocalSandbox("t")
|
|
start = time.monotonic()
|
|
output = sandbox.execute_command("read x; echo got", timeout=10)
|
|
elapsed = time.monotonic() - start
|
|
|
|
assert elapsed < 3, f"stdin read blocked, took {elapsed:.1f}s"
|
|
assert "got" in output
|
|
|
|
|
|
@posix_only
|
|
def test_normal_command_output_exit_code_and_stderr():
|
|
"""Ordinary commands keep their existing output contract: stdout,
|
|
appended Std Error section, and a non-zero Exit Code line."""
|
|
sandbox = LocalSandbox("t")
|
|
|
|
assert "hello" in sandbox.execute_command("echo hello")
|
|
assert "Exit Code: 3" in sandbox.execute_command("exit 3")
|
|
|
|
combined = sandbox.execute_command("echo out; echo oops >&2")
|
|
assert "out" in combined
|
|
assert "Std Error:" in combined
|
|
assert "oops" in combined
|
|
|
|
|
|
def test_sandbox_config_exposes_command_timeout_default():
|
|
cfg = SandboxConfig(use="deerflow.sandbox.local:LocalSandboxProvider")
|
|
assert cfg.bash_command_timeout == 600
|
|
|
|
|
|
def test_bash_tool_description_guides_backgrounding_long_lived_processes():
|
|
"""The bash tool description (seen by the model) must tell it to background
|
|
long-lived processes like servers, so it doesn't block the turn in the
|
|
foreground. This is the prompt-side half of the server-hang fix."""
|
|
from deerflow.sandbox.tools import bash_tool
|
|
|
|
description = bash_tool.description.lower()
|
|
assert "background" in description
|
|
assert "server" in description
|