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:
Fei.Chen 2026-10-08 22:50:45 +08:00 committed by GitHub
parent b99ac916b6
commit 49a0e131b3
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
7 changed files with 239 additions and 17 deletions

View File

@ -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.

View File

@ -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,

View File

@ -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.

View File

@ -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):

View File

@ -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)

View File

@ -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:

View File

@ -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):