diff --git a/README.md b/README.md index cd6b60086f..9df9278fdd 100644 --- a/README.md +++ b/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. diff --git a/backend/packages/harness/deerflow/config/sandbox_config.py b/backend/packages/harness/deerflow/config/sandbox_config.py index 0531eb275a..c0c944004f 100644 --- a/backend/packages/harness/deerflow/config/sandbox_config.py +++ b/backend/packages/harness/deerflow/config/sandbox_config.py @@ -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, diff --git a/backend/packages/harness/deerflow/sandbox/AGENTS.md b/backend/packages/harness/deerflow/sandbox/AGENTS.md index 33f940e88e..3eac775285 100644 --- a/backend/packages/harness/deerflow/sandbox/AGENTS.md +++ b/backend/packages/harness/deerflow/sandbox/AGENTS.md @@ -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. diff --git a/backend/packages/harness/deerflow/sandbox/local/local_sandbox.py b/backend/packages/harness/deerflow/sandbox/local/local_sandbox.py index 854130fee0..cbfa76eab1 100644 --- a/backend/packages/harness/deerflow/sandbox/local/local_sandbox.py +++ b/backend/packages/harness/deerflow/sandbox/local/local_sandbox.py @@ -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): diff --git a/backend/packages/harness/deerflow/sandbox/local/local_sandbox_provider.py b/backend/packages/harness/deerflow/sandbox/local/local_sandbox_provider.py index 38d389505b..1365dc88bb 100644 --- a/backend/packages/harness/deerflow/sandbox/local/local_sandbox_provider.py +++ b/backend/packages/harness/deerflow/sandbox/local/local_sandbox_provider.py @@ -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) diff --git a/backend/packages/harness/deerflow/sandbox/tools.py b/backend/packages/harness/deerflow/sandbox/tools.py index 6655be443e..a5eee65d29 100644 --- a/backend/packages/harness/deerflow/sandbox/tools.py +++ b/backend/packages/harness/deerflow/sandbox/tools.py @@ -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: diff --git a/backend/tests/test_skill_request_scoped_secrets.py b/backend/tests/test_skill_request_scoped_secrets.py index 060dde5937..822167d62d 100644 --- a/backend/tests/test_skill_request_scoped_secrets.py +++ b/backend/tests/test_skill_request_scoped_secrets.py @@ -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):