mirror of
https://github.com/bytedance/deer-flow.git
synced 2026-10-26 21:26:39 +00:00
fix(sandbox): honor sandbox.environment on the local provider (#6463)
* fix(sandbox): honor sandbox.environment on the local provider LocalSandboxProvider ignored the documented sandbox.environment config field (only the AIO provider consumed it), leaving public generation skills (video-generation / image-generation) with no way to receive provider API keys on the default local sandbox: env inheritance is scrubbed by policy (#3861), the skills declare no required-secrets, and dashboard runs cannot supply config.context.secrets. Fixes #6461. - LocalSandbox accepts an operator environment; execute_command layers it through the existing authorized injection channel (build_sandbox_env), so injected entries survive the secret scrub while platform credentials still never leak. - Request-scoped env (declared required-secrets) wins on key collision. - LocalSandboxProvider resolves $VAR refs from the gateway process env, mirroring the AIO provider, and passes the mapping to every sandbox it yields; missing/unloadable config degrades to injecting nothing. * fix(sandbox): address review on local sandbox.environment - P1: operator-injected values join the bash-tool output redaction set (request-scoped values keep precedence on name collision), so an echoed sandbox.environment credential no longer flows back into the model-visible tool result. - P2: consume resolved SandboxConfig.environment values verbatim. The file-backed AppConfig loader already substitutes $VAR references; re-resolving corrupted credentials whose value legitimately starts with '$'. Drop the now-unneeded AIO-style resolver. - Validate operator environment keys at LocalSandbox construction (a typo'd key fails at startup, not on first command execution). - Tests: verbatim passthrough, happy-path config loading, acquire() wiring (generic + thread), key validation, and a P1 redaction regression through the real bash tool path. - Docs: SandboxConfig docstring and sandbox/AGENTS.md now describe the operator injection channel and its output masking. * fix(sandbox): redact effective local credentials in bash results * fix(ci): keep local sandbox guidance within inherited budget --------- Co-authored-by: vicfei <4058491+vicfei@users.noreply.github.com> Co-authored-by: Willem Jiang <willem.jiang@gmail.com>
This commit is contained in:
parent
b99ac916b6
commit
49a0e131b3
10
README.md
10
README.md
@ -1280,9 +1280,13 @@ The built-in `image-generation` skill supports Gemini, MiniMax, and
|
||||
OpenAI-compatible Images APIs. Select the latter with
|
||||
`IMAGE_GENERATION_PROVIDER=openai`, then configure
|
||||
`IMAGE_GENERATION_API_KEY`, `IMAGE_GENERATION_BASE_URL`, and
|
||||
`IMAGE_GENERATION_MODEL`. For a containerized sandbox, expose these variables
|
||||
through `sandbox.environment`; sandbox commands intentionally do not inherit
|
||||
API keys from the Gateway process.
|
||||
`IMAGE_GENERATION_MODEL`. For a containerized sandbox or an explicitly enabled
|
||||
local host shell, expose these variables through `sandbox.environment`; sandbox
|
||||
commands intentionally do not inherit API keys from the Gateway process.
|
||||
The local provider uses the resolved configuration values unchanged, and
|
||||
request-scoped secrets override operator values. Local bash output and command
|
||||
errors redact credential values while leaving benign settings such as model
|
||||
names and base URLs readable.
|
||||
|
||||
For `LocalSandboxProvider`, this is a managed tool-path boundary rather than host filesystem isolation. Explicit per-Agent skill policies are accepted only while host bash is disabled (the default), because a host subprocess can address canonical paths without using the provider's virtual-path mappings. Use Docker/AIO, the Kubernetes provisioner, or E2B when the filesystem boundary must remain enforceable alongside shell access.
|
||||
|
||||
|
||||
@ -174,13 +174,17 @@ class SandboxConfig(BaseModel):
|
||||
allow_host_bash: Enable host-side bash execution for LocalSandboxProvider.
|
||||
Dangerous and intended only for fully trusted local workflows.
|
||||
|
||||
AioSandboxProvider, BoxliteProvider, E2BSandboxProvider, and OpenSandboxProvider shared options:
|
||||
AioSandboxProvider, BoxliteProvider, E2BSandboxProvider, OpenSandboxProvider, and
|
||||
LocalSandboxProvider shared options:
|
||||
image: Sandbox image to use (Docker/AIO, BoxLite OCI, or OpenSandbox image)
|
||||
replicas: Positive provider capacity. E2B shares it across Gateway
|
||||
workers when ownership uses Redis; other modes/providers keep
|
||||
process-local accounting.
|
||||
idle_timeout: Idle timeout in seconds before released warm sandboxes/VMs are stopped (default: 600 = 10 minutes). Set to 0 to disable.
|
||||
environment: Environment variables to inject into the sandbox (values starting with $ are resolved from host env)
|
||||
environment: Environment variables to inject into the sandbox (values starting with $ are
|
||||
resolved from host env). Injected entries bypass the env-policy scrubber as
|
||||
operator-authorized values; LocalSandboxProvider masks injected credentials from bash
|
||||
tool output and command errors while preserving benign configuration values.
|
||||
|
||||
BoxliteProvider specific options:
|
||||
health_check_skip_seconds: Optional reclaim-time skip window in seconds for recently released warm VMs. Default behavior is 0.0 = always validate before reuse.
|
||||
@ -291,7 +295,7 @@ class SandboxConfig(BaseModel):
|
||||
)
|
||||
environment: dict[str, str] = Field(
|
||||
default_factory=dict,
|
||||
description="Environment variables to inject into the sandbox container. Values starting with $ will be resolved from host environment variables.",
|
||||
description="Environment variables to inject into sandbox commands (including the local provider). Values starting with $ will be resolved from host environment variables when config is loaded.",
|
||||
)
|
||||
network: SandboxNetworkConfig = Field(
|
||||
default_factory=SandboxNetworkConfig,
|
||||
|
||||
@ -14,7 +14,7 @@ cancellation before propagating it.
|
||||
**Shared components** (RFC #4741): remote IDs use `derive_sandbox_scope_token` (`sandbox/identity.py`); preserve its keyword-only SHA-256/16-hex contract to avoid orphaning containers. `AcquireSerializer` (`sandbox/acquire_serialization.py`) serializes selected acquire/release transitions with a bounded, refcounted per-key `threading.Lock` table and dedicated bounded executor (no event-loop/default-executor blocking). Workers own cancellation cleanup without waiting for cancelled tasks to resume; provider `shutdown()`/`reset()` calls idempotent `close()`. Keys: AIO `(user_id, thread_id)`, E2B `(user_id, thread_id, skills_root)`, BoxLite/Tenki/OpenSandbox derived id. Random-UUID `thread_id=None` acquires bypass serialization.
|
||||
**Execution leases** (`sandbox/lease.py`, #5128): cross-instance ownership selects which Gateway may reap a container; process-local `SandboxLeaseManager` tracks a client's concurrent lead, subagent, Gateway-request, and channel-upload users. Runs have ephemeral owners. Restore persisted sandboxes only after `get_scoped(user_id, thread_id)` succeeds; otherwise canonical `acquire` replaces the id. Last holder performs pending `SandboxProvider.release`. External run/state boundaries reject server-owned `sandbox`. Outer lifecycle fences repeat idempotent release after each graph/tool/request batch; per-tool terminal `Command` wrappers never release while siblings run. Fork-restored children and upload syncs hold without requesting release: server-created forks may borrow the parent client; ordinary checkpoint ids stay identity-scoped. Serialize persisted lookup and retention by `(user_id, thread_id)`; stale or mismatched bindings reacquire, and a post-acquire miss rolls back then raises. Provider I/O runs outside the metadata lock. Repeated cancellation cannot interrupt acquire/rollback/release reconciliation or let `to_thread` work outlive its execution/request holder; failures are logged without replacing the original cancellation. Lease/scope context IDs are server-owned: Gateway/worker scrub caller values; only internal subagents assign task IDs. Managers key by provider object identity (not hash/equality), so unhashable custom providers work. Subagent owners double as `sandbox_command_scope_id`: AIO gives each scope an ordered persistent shell, replaces it after `ErrorObservation` or a missing-session 404, and cleans it on lease release. Revalidate registry identity after each scope-lock wait so queued commands cannot resurrect released sessions. Env-bearing commands use fresh `bash.exec` sessions so secrets do not persist.
|
||||
**Authorization gate** (`sandbox:execute`, RFC #4063 Phase 3): every sandbox-backed tool call passes through the gate in `deerflow/authz/sandbox_authz.py` - a binary `authorize(principal, "sandbox", "execute", target="*")` check before either reusing a persisted sandbox id or calling `provider.acquire`. Rechecking reuse is required because authorization config and user roles can change while the sandbox remains cached. Sync tool invocations call `authorize_sandbox_execution`; async tool invocations await `authorize_sandbox_execution_async` exactly once. A task-local `ContextVar` scopes that single decision across the complete composed tool invocation, including `ReadBeforeWriteMiddleware`'s pre-write inspection, tool body, and post-read mark; the value is copied into `asyncio.to_thread` workers. Authorization denial is converted to the normal error `ToolMessage` at the composed middleware boundary and is explicitly excluded from the gate's generic fail-open handlers. Async config loading and provider class discovery/import are offloaded before `aauthorize()` so reused sandbox calls do not hash config files or import custom modules on the event loop; provider construction remains on the running event loop because async providers may initialize loop-affine clients. The gate lives at the single tool initialization entry point (`ensure_sandbox_initialized` / `ensure_sandbox_initialized_async` in `tools.py`), while `SandboxMiddleware.before_agent` / `abefore_agent` apply the matching sync/async check to eager acquisition. Deny raises `SandboxAuthorizationError` (`sandbox/exceptions.py`), which propagates out of ordinary tool execution as a friendly error `ToolMessage` ("sandbox execution is not permitted for your role") - the eager path catches it and skips acquisition instead, deferring the deny to the first sandbox-touching tool call so both paths share the same semantics. Provider errors (authorization calls and provider resolution) follow `authorization.fail_closed` / `fail_open`; no readable `config.yaml` or `authorization.enabled: false` makes the gate a no-op (`safe_app_config` tolerates missing config). Gateway upload/artifact sync calls `try_acquire_sandbox_for_request` (`app/gateway/authz.py`), which gates, returns a request lease, and skips sync on deny while preserving the primary operation. Callers release after their last sandbox operation; artifacts request normal parking, uploads do not. Tests: `tests/test_sandbox_authorization.py` and `tests/blocking_io/test_sandbox_authorization.py`.
|
||||
**Environment policy** (`sandbox/env_policy.py`): `execute_command` no longer inherits the full `os.environ`. `build_sandbox_env()` scrubs secret-looking names (`*KEY*`/`*SECRET*`/`*TOKEN*`/`*PASS*`/`*CREDENTIAL*`) from the inherited environment before layering injected request secrets on top, so platform credentials (e.g. `OPENAI_API_KEY`) never leak into skill subprocesses. Benign vars (`PATH`, `HOME`, `LANG`, `VIRTUAL_ENV`, ...) are preserved.
|
||||
**Environment policy** (`sandbox/env_policy.py`): `build_sandbox_env()` scrubs inherited credential names via `is_blocked_env_name`, preserving benign vars (`PATH`, `HOME`, `LANG`, `VIRTUAL_ENV`). Explicit `sandbox.environment` injection survives scrubbing; request secrets override it. Local sandboxes validate keys at construction; generic and per-thread instances receive resolved config values verbatim. Local `bash` masks effective operator credentials and request secrets in output and command errors, preserving benign config values.
|
||||
**Implementations**:
|
||||
- `LocalSandboxProvider` - Local filesystem execution. `acquire(thread_id)` returns a per-user/thread `LocalSandbox` (id `local:{user_id}:{thread_id}`) whose `path_mappings` resolve `/mnt/user-data/{workspace,uploads,outputs}` and `/mnt/acp-workspace` to that thread's host directories, so the public `Sandbox` API honours the `/mnt/user-data` contract uniformly with AIO. `acquire()` / `acquire(None)` keeps the legacy generic singleton (id `local`) for callers without a thread context. Per-thread sandboxes are held in an LRU cache (default 256 entries) guarded by a `threading.Lock`. Shared runs use category mappings; a policy-scoped run replaces them with one `/mnt/skills` root mapping to the coherent thread view, so structured file tools resolve through one managed boundary. This is not a host filesystem security boundary: an enabled host `bash` subprocess can use canonical paths without `PathMapping`, so `supports_agent_skill_isolation` is dynamic and explicit Agent policies fail closed while host bash is enabled; unrestricted runs then get the shared mappings, never a leftover thread view. Host-to-virtual output masking scans dynamic per-user/per-thread roots directly instead of compiling path-specific regexes, so evicted thread IDs do not remain in Python's global regex caches; a separate 256-entry root cache prevents repeated `realpath()` walks for every glob/grep match while bounding dynamic-path retention, and only the small process-stable skill/integration source set uses a bounded compiled cache. The shared `path_patterns.py` tail stops at `:`, so `$PATH`-style lists mask every entry; a mount symlink resolving outside all mounts keeps its mount path. On Windows, Git Bash/MSYS argument-conversion exclusions are limited to safe non-root virtual path prefixes; do not restore a blanket conversion disable, because host-native CLI launchers need normal MSYS path conversion for their own installation paths.
|
||||
PowerShell fallback pairs guarded console UTF-8 setters with explicit UTF-8 pipe decoding; cmd/MSYS retain locale decoding. Encoding regressions include real-pipe probes and Windows-only PowerShell roundtrips with and without a console.
|
||||
|
||||
@ -212,7 +212,12 @@ class LocalSandbox(Sandbox):
|
||||
except OSError:
|
||||
return False
|
||||
|
||||
def __init__(self, id: str, path_mappings: list[PathMapping] | None = None):
|
||||
def __init__(
|
||||
self,
|
||||
id: str,
|
||||
path_mappings: list[PathMapping] | None = None,
|
||||
environment: dict[str, str] | None = None,
|
||||
):
|
||||
"""
|
||||
Initialize local sandbox with optional path mappings.
|
||||
|
||||
@ -220,9 +225,22 @@ class LocalSandbox(Sandbox):
|
||||
id: Sandbox identifier
|
||||
path_mappings: List of path mappings with optional read-only flag.
|
||||
Skills directory is read-only by default.
|
||||
environment: Operator-authorized variables (``sandbox.environment``
|
||||
in config.yaml, ``$VAR`` refs already resolved) layered
|
||||
into every subprocess even when the env-policy scrubber
|
||||
would drop them from inherited ``os.environ`` — the
|
||||
same injection channel as request-scoped secrets, so
|
||||
an entry here is trusted like a declared
|
||||
``required-secrets`` value.
|
||||
"""
|
||||
super().__init__(id)
|
||||
self.path_mappings = path_mappings or []
|
||||
environment = dict(environment) if environment else {}
|
||||
# Config-derived keys flow into every subprocess's Popen(env=...); a
|
||||
# bad name (``"MY=KEY"``, empty) must fail at construction — a clear
|
||||
# startup error — instead of on the first command execution.
|
||||
_validate_extra_env(environment)
|
||||
self.environment: dict[str, str] = environment
|
||||
# Track files written through write_file so read_file only
|
||||
# reverse-resolves paths in agent-authored content.
|
||||
self._agent_written_paths: set[str] = set()
|
||||
@ -516,10 +534,13 @@ class LocalSandbox(Sandbox):
|
||||
if timeout is None:
|
||||
timeout = DEFAULT_COMMAND_TIMEOUT_SECONDS
|
||||
|
||||
# Inherit os.environ minus platform secrets, then layer any injected
|
||||
# request-scoped secrets on top (#3861). An explicit env is always passed
|
||||
# so platform credentials never leak into skill subprocesses.
|
||||
sandbox_env = build_sandbox_env(env)
|
||||
# Inherit os.environ minus platform secrets, then layer injected
|
||||
# request-scoped secrets on top (#3861). Operator-configured
|
||||
# ``sandbox.environment`` entries ride the same authorized injection
|
||||
# channel (and lose to request-scoped values on key collision), so
|
||||
# platform credentials still never leak into skill subprocesses.
|
||||
injected = {**self.environment, **(env or {})}
|
||||
sandbox_env = build_sandbox_env(injected)
|
||||
timed_out = False
|
||||
if os.name == "nt":
|
||||
if self._is_powershell(shell):
|
||||
|
||||
@ -97,6 +97,27 @@ class LocalSandboxProvider(SandboxProvider):
|
||||
self._thread_sandboxes: OrderedDict[tuple[str, str], LocalSandbox] = OrderedDict()
|
||||
self._max_cached_threads = max_cached_threads
|
||||
self._lock = threading.Lock()
|
||||
self._environment = self._load_sandbox_environment()
|
||||
|
||||
def _load_sandbox_environment(self) -> dict[str, str]:
|
||||
"""Load operator-configured ``sandbox.environment`` for local sandboxes.
|
||||
|
||||
The file-backed AppConfig loader has already substituted every ``$VAR``
|
||||
reference in field values before the parsed config reaches here, so the
|
||||
values are consumed verbatim — re-resolving would corrupt a credential
|
||||
that legitimately starts with ``$`` (e.g. host ``APP_PASSWORD='$s3cret'``
|
||||
would be re-expanded into a different variable or the empty string).
|
||||
Missing/unloadable config yields an empty mapping (nothing injected;
|
||||
scrubbing alone applies).
|
||||
"""
|
||||
try:
|
||||
from deerflow.config import get_app_config
|
||||
|
||||
sandbox_config = get_app_config().sandbox
|
||||
except Exception:
|
||||
return {}
|
||||
env_config = getattr(sandbox_config, "environment", None) or {}
|
||||
return dict(env_config)
|
||||
|
||||
def _setup_path_mappings(self) -> list[PathMapping]:
|
||||
"""
|
||||
@ -455,7 +476,7 @@ class LocalSandboxProvider(SandboxProvider):
|
||||
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)
|
||||
self._generic_sandbox = LocalSandbox("local", path_mappings=mappings, environment=self._environment)
|
||||
_singleton = self._generic_sandbox
|
||||
return self._generic_sandbox.id
|
||||
|
||||
@ -506,6 +527,7 @@ class LocalSandboxProvider(SandboxProvider):
|
||||
replacement = LocalSandbox(
|
||||
self._sandbox_id_for_thread(thread_id, effective_user_id),
|
||||
path_mappings=new_mappings,
|
||||
environment=self._environment,
|
||||
)
|
||||
if cached is not None:
|
||||
replacement._agent_written_paths.update(cached._agent_written_paths)
|
||||
|
||||
@ -26,6 +26,7 @@ from deerflow.config.paths import VIRTUAL_PATH_PREFIX
|
||||
from deerflow.constants import DEFAULT_SKILLS_CONTAINER_PATH
|
||||
from deerflow.runtime.secret_context import read_active_secrets
|
||||
from deerflow.runtime.user_context import resolve_runtime_user_id
|
||||
from deerflow.sandbox.env_policy import is_blocked_env_name
|
||||
from deerflow.sandbox.exceptions import (
|
||||
SandboxError,
|
||||
SandboxNotFoundError,
|
||||
@ -2400,6 +2401,7 @@ def bash_tool(runtime: Runtime, command: str, description: str = "") -> str:
|
||||
command: The bash command to execute. Always use absolute paths for files and directories.
|
||||
description: Optional short explanation of this command shown in the UI.
|
||||
"""
|
||||
redaction_env = None
|
||||
try:
|
||||
sandbox = ensure_sandbox_initialized(runtime)
|
||||
# Request-scoped secrets resolved for the active skill (#3861), plus a
|
||||
@ -2415,7 +2417,13 @@ def bash_tool(runtime: Runtime, command: str, description: str = "") -> str:
|
||||
injected_env = {**(injected_env or {}), **github_env}
|
||||
if lark_cli_env:
|
||||
injected_env = {**(injected_env or {}), **lark_cli_env}
|
||||
redaction_env = injected_env
|
||||
if is_local_sandbox(runtime):
|
||||
# Match the credential-name policy used for inherited host env.
|
||||
# Keep benign operator settings readable and redact effective
|
||||
# credentials before execution, including any exception output.
|
||||
redaction_env = {name: value for name, value in (getattr(sandbox, "environment", None) or {}).items() if is_blocked_env_name(name)}
|
||||
redaction_env.update(injected_env or {})
|
||||
if not is_host_bash_allowed():
|
||||
return f"Error: {LOCAL_HOST_BASH_DISABLED_MESSAGE}"
|
||||
ensure_thread_directories_exist(runtime)
|
||||
@ -2455,7 +2463,7 @@ def bash_tool(runtime: Runtime, command: str, description: str = "") -> str:
|
||||
timeout=command_timeout,
|
||||
)
|
||||
return _truncate_bash_output(
|
||||
mask_secret_values(mask_local_paths_in_output(output, thread_data), injected_env),
|
||||
mask_secret_values(mask_local_paths_in_output(output, thread_data), redaction_env),
|
||||
max_chars,
|
||||
)
|
||||
ensure_thread_directories_exist(runtime)
|
||||
@ -2481,11 +2489,11 @@ def bash_tool(runtime: Runtime, command: str, description: str = "") -> str:
|
||||
max_chars,
|
||||
)
|
||||
except SandboxError as e:
|
||||
return f"Error: {e}"
|
||||
return mask_secret_values(f"Error: {e}", redaction_env)
|
||||
except PermissionError as e:
|
||||
return f"Error: {e}"
|
||||
return mask_secret_values(f"Error: {e}", redaction_env)
|
||||
except Exception as e:
|
||||
return f"Error: Unexpected error executing command: {_sanitize_error(e, runtime)}"
|
||||
return mask_secret_values(f"Error: Unexpected error executing command: {_sanitize_error(e, runtime)}", redaction_env)
|
||||
|
||||
|
||||
async def _bash_tool_async(runtime: Runtime, command: str, description: str = "") -> str:
|
||||
|
||||
@ -93,6 +93,169 @@ class TestLocalSandboxEnvInjection:
|
||||
assert "scoped-value" in out
|
||||
|
||||
|
||||
class TestLocalSandboxOperatorEnvironment:
|
||||
"""``sandbox.environment`` (operator config) rides the authorized injection channel.
|
||||
|
||||
The env-policy scrubber drops secret-looking names from inherited os.environ,
|
||||
and dashboard-driven runs cannot supply request-scoped ``context.secrets``
|
||||
(there is no UI for it), so ``sandbox.environment`` is the only operator-owned
|
||||
path for e.g. ``MINIMAX_API_KEY`` to reach public generation skills on the
|
||||
local provider — matching the AIO provider, which already honors the field.
|
||||
"""
|
||||
|
||||
def test_operator_environment_survives_scrub(self):
|
||||
sandbox = LocalSandbox(id="local", environment={"MINIMAX_API_KEY": "operator-value"})
|
||||
out = sandbox.execute_command(_echo_env_probe("MINIMAX_API_KEY"))
|
||||
assert "operator-value" in out
|
||||
|
||||
def test_request_scoped_env_wins_over_operator_environment(self):
|
||||
"""A request-scoped value (declared ``required-secrets``) overrides the
|
||||
shared operator value — the per-user-key-overrides-shared-key case."""
|
||||
sandbox = LocalSandbox(id="local", environment={"SHARED_API_KEY": "operator-value"})
|
||||
out = sandbox.execute_command(
|
||||
_echo_env_probe("SHARED_API_KEY"),
|
||||
env={"SHARED_API_KEY": "request-scoped-value"},
|
||||
)
|
||||
assert "request-scoped-value" in out
|
||||
assert "operator-value" not in out
|
||||
|
||||
def test_no_environment_config_keeps_pure_scrubbing(self, monkeypatch):
|
||||
"""Omitting the parameter must not weaken the baseline: a platform secret
|
||||
in os.environ still never reaches the subprocess."""
|
||||
monkeypatch.setenv("MINIMAX_API_KEY", "platform-key-should-vanish")
|
||||
sandbox = LocalSandbox(id="local")
|
||||
out = sandbox.execute_command(_echo_env_probe("MINIMAX_API_KEY"))
|
||||
assert "platform-key-should-vanish" not in out
|
||||
|
||||
def test_provider_consumes_config_values_verbatim(self, monkeypatch):
|
||||
"""Values from the file-backed AppConfig loader arrive pre-resolved
|
||||
($VAR substitution happens during config load), so a credential that
|
||||
legitimately starts with ``$`` must pass through untouched — re-running
|
||||
$-resolution would expand it into a different variable or the empty
|
||||
string."""
|
||||
from types import SimpleNamespace
|
||||
|
||||
from deerflow.sandbox.local import local_sandbox_provider as provider_module
|
||||
|
||||
monkeypatch.setattr(
|
||||
"deerflow.config.get_app_config",
|
||||
lambda: SimpleNamespace(sandbox=SimpleNamespace(environment={"APP_PASSWORD": "$s3cret-value"})),
|
||||
raising=False,
|
||||
)
|
||||
provider = provider_module.LocalSandboxProvider()
|
||||
assert provider._environment == {"APP_PASSWORD": "$s3cret-value"}
|
||||
|
||||
def test_provider_missing_config_yields_empty_environment(self, monkeypatch):
|
||||
"""A missing/unloadable config must degrade to 'inject nothing', not crash."""
|
||||
from deerflow.sandbox.local import local_sandbox_provider as provider_module
|
||||
|
||||
def _raise():
|
||||
raise RuntimeError("no config on this host")
|
||||
|
||||
monkeypatch.setattr("deerflow.config.get_app_config", _raise, raising=False)
|
||||
provider = provider_module.LocalSandboxProvider()
|
||||
assert provider._environment == {}
|
||||
|
||||
def test_acquire_wires_environment_into_sandboxes(self, monkeypatch):
|
||||
"""Both ``acquire()`` call sites must hand the resolved mapping to the
|
||||
LocalSandbox instances they yield — generic singleton and per-thread
|
||||
alike (either construction site could silently drop the kwarg)."""
|
||||
from deerflow.sandbox.local import local_sandbox_provider as provider_module
|
||||
|
||||
environment = {"MINIMAX_API_KEY": "operator-value", "APP_PASSWORD": "$s3cret-value"}
|
||||
monkeypatch.setattr(
|
||||
"deerflow.config.get_app_config",
|
||||
lambda: SimpleNamespace(sandbox=SimpleNamespace(environment=environment)),
|
||||
)
|
||||
provider = provider_module.LocalSandboxProvider()
|
||||
|
||||
for thread_id in (None, "thread-env-wiring"):
|
||||
sandbox = provider.get(provider.acquire(thread_id))
|
||||
assert sandbox.environment == environment
|
||||
for name, value in environment.items():
|
||||
assert value in sandbox.execute_command(_echo_env_probe(name))
|
||||
|
||||
@pytest.mark.parametrize("name", ["MY=KEY", "", "MY-KEY", "MY KEY"])
|
||||
def test_operator_environment_keys_validated_at_construction(self, name):
|
||||
"""A typo'd key (``MY=KEY``) fails at construction instead of on the
|
||||
first command execution."""
|
||||
with pytest.raises(ValueError):
|
||||
LocalSandbox(id="local", environment={name: "v"})
|
||||
|
||||
@pytest.mark.parametrize("override", [False, True])
|
||||
@pytest.mark.parametrize("result_kind", ["output", "sandbox_error", "permission_error", "unexpected_error"])
|
||||
def test_operator_environment_masked_from_bash_output(self, override, result_kind):
|
||||
"""[P1 regression] An operator-injected credential must not flow back
|
||||
into model-visible tool output when a script echoes its environment —
|
||||
the bash tool's redaction set includes the sandbox's operator
|
||||
environment, with request-scoped values winning on name collision."""
|
||||
from deerflow.sandbox import tools as tools_mod
|
||||
from deerflow.sandbox.exceptions import SandboxRuntimeError
|
||||
|
||||
operator_secret = "operator-secret-value-123456"
|
||||
request_secret = "request-secret-value-987654321"
|
||||
database_secret = "database-credential-value-123456"
|
||||
errors = {
|
||||
"sandbox_error": SandboxRuntimeError,
|
||||
"permission_error": PermissionError,
|
||||
"unexpected_error": RuntimeError,
|
||||
}
|
||||
|
||||
class FakeSandbox:
|
||||
environment = {
|
||||
"MINIMAX_API_KEY": operator_secret,
|
||||
"DATABASE_URL": database_secret,
|
||||
"IMAGE_GENERATION_MODEL": "readable-model-name",
|
||||
"IMAGE_GENERATION_BASE_URL": "https://images.example.test",
|
||||
"REGION": "readable-region-name",
|
||||
}
|
||||
seen_env = None
|
||||
|
||||
def execute_command(self, command, env=None, timeout=None):
|
||||
self.seen_env = env
|
||||
effective_env = {**self.environment, **(env or {})}
|
||||
output = "\n".join(f"{key}={value}" for key, value in effective_env.items())
|
||||
if result_kind in errors:
|
||||
raise errors[result_kind](output)
|
||||
return output
|
||||
|
||||
active_secrets = {"ERP_TOKEN": request_secret} if override else {}
|
||||
if override:
|
||||
active_secrets["MINIMAX_API_KEY"] = request_secret
|
||||
sandbox = FakeSandbox()
|
||||
runtime = SimpleNamespace(
|
||||
context={"__active_skill_secrets": active_secrets, "user_id": "u-local"},
|
||||
state={"sandbox": {"sandbox_id": "local:1"}},
|
||||
)
|
||||
thread_data = {"workspace_path": "/tmp/ws", "cwd": "/mnt/user-data/workspace"}
|
||||
fake_cfg = SimpleNamespace(sandbox=SimpleNamespace(bash_output_max_chars=20000, bash_command_timeout=42))
|
||||
with (
|
||||
patch.object(tools_mod, "ensure_sandbox_initialized", return_value=sandbox),
|
||||
patch.object(tools_mod, "is_local_sandbox", return_value=True),
|
||||
patch.object(tools_mod, "is_host_bash_allowed", return_value=True),
|
||||
patch.object(tools_mod, "ensure_thread_directories_exist", return_value=None),
|
||||
patch.object(tools_mod, "get_thread_data", return_value=thread_data),
|
||||
patch.object(tools_mod, "validate_local_bash_command_paths", return_value=None),
|
||||
patch.object(tools_mod, "replace_virtual_paths_in_command", side_effect=lambda command, td: command),
|
||||
patch.object(tools_mod, "_apply_cwd_prefix", side_effect=lambda command, td: command),
|
||||
patch.object(tools_mod, "_is_windows", return_value=False),
|
||||
patch("deerflow.config.app_config.get_app_config", return_value=fake_cfg),
|
||||
):
|
||||
out = tools_mod.bash_tool.func(runtime=runtime, command="env", description="dump env")
|
||||
|
||||
assert sandbox.seen_env == (active_secrets or None)
|
||||
assert operator_secret not in out
|
||||
assert request_secret not in out
|
||||
assert database_secret not in out
|
||||
assert "MINIMAX_API_KEY=[redacted]" in out
|
||||
assert "DATABASE_URL=[redacted]" in out
|
||||
if override:
|
||||
assert "ERP_TOKEN=[redacted]" in out
|
||||
assert "IMAGE_GENERATION_MODEL=readable-model-name" in out
|
||||
assert "IMAGE_GENERATION_BASE_URL=https://images.example.test" in out
|
||||
assert "REGION=readable-region-name" in out
|
||||
|
||||
|
||||
class TestAioSandboxEnvInjection:
|
||||
@pytest.fixture
|
||||
def sandbox(self):
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user