mirror of
https://github.com/bytedance/deer-flow.git
synced 2026-07-09 16:08:31 +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.
This commit is contained in:
parent
b476c7a18d
commit
09988caf95
20 changed files with 1360 additions and 32 deletions
|
|
@ -310,8 +310,9 @@ Proxied through nginx: `/api/langgraph/*` → Gateway LangGraph-compatible runti
|
|||
|
||||
### Sandbox System (`packages/harness/deerflow/sandbox/`)
|
||||
|
||||
**Interface**: Abstract `Sandbox` with `execute_command`, `read_file`, `write_file`, `list_dir`
|
||||
**Interface**: Abstract `Sandbox` with `execute_command(command, env=None)`, `read_file`, `write_file`, `list_dir`. The optional `env` injects per-call environment variables (request-scoped secrets — see Request-Scoped Secrets below); `LocalSandbox` merges it via `subprocess.run(env=...)` and `AioSandbox` routes env-bearing commands through the `bash.exec(env=...)` API on a fresh session.
|
||||
**Provider Pattern**: `SandboxProvider` with `acquire`, `acquire_async`, `get`, `release` lifecycle. Async agent/tool paths call async sandbox lifecycle hooks so Docker sandbox creation, discovery, cross-process locking, readiness polling, and release stay off the event loop.
|
||||
**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*`/`*PASSWORD*`/`*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.
|
||||
**Implementations**:
|
||||
- `LocalSandboxProvider` - Local filesystem execution. `acquire(thread_id)` returns a per-thread `LocalSandbox` (id `local:{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`.
|
||||
- `AioSandboxProvider` (`packages/harness/deerflow/community/`) - Docker-based isolation. Active-cache and warm-pool entries are checked with the backend during acquire/reuse; definitively dead containers are dropped from all in-process maps so the thread can discover or create a fresh sandbox instead of reusing a stale client. Backend health-check failures are treated as unknown, not dead; local discovery likewise treats an unverifiable container as not adoptable and falls through to create rather than failing acquire. `get()` remains an in-memory lookup for event-loop-safe tool paths.
|
||||
|
|
@ -384,12 +385,24 @@ Additional providers also live here (`brave`, `browserless`, `crawl4ai`, `ddg_se
|
|||
### Skills System (`packages/harness/deerflow/skills/`)
|
||||
|
||||
- **Location**: `deer-flow/skills/{public,custom}/`
|
||||
- **Format**: Directory with `SKILL.md` (YAML frontmatter: name, description, license, allowed-tools)
|
||||
- **Format**: Directory with `SKILL.md` (YAML frontmatter: name, description, license, allowed-tools, required-secrets)
|
||||
- **Loading**: `load_skills()` recursively scans `skills/{public,custom}` for `SKILL.md`, parses metadata, and reads enabled state from extensions_config.json
|
||||
- **Injection**: Enabled skills listed in agent system prompt with container paths
|
||||
- **Slash activation**: `/skill-name task` loads that enabled skill's `SKILL.md` for the current model call only. The resolver rejects leading whitespace, missing separators, reserved channel commands (`/new`, `/help`, `/bootstrap`, `/status`, `/models`, `/memory`), disabled skills, and skills outside a custom agent's whitelist.
|
||||
- **Installation**: `POST /api/skills/install` extracts .skill ZIP archive to custom/ directory
|
||||
|
||||
#### Request-Scoped Secrets (`required-secrets`)
|
||||
|
||||
Lets a caller pass per-request, short-lived end-user credentials (e.g. an ERP token) to a skill's sandbox scripts without the value entering the prompt, tool arguments, the executed command string, or traces (issue #3861).
|
||||
|
||||
- **Declare**: a skill lists the secrets it needs in `SKILL.md` frontmatter — `required-secrets:` as a string list or `{name, optional}` mappings. `name` is both the lookup key and the env var name exposed to scripts. Parsed by `skills/parser.py::parse_required_secrets` into `Skill.required_secrets` (`SecretRequirement`); malformed entries are dropped with a warning.
|
||||
- **Carry**: the caller sends values out-of-band in the run request's `context.secrets` mapping (never a message). `runtime/secret_context.py` owns the contract (`SECRETS_CONTEXT_KEY`, `extract_request_secrets`). The existing `context` passthrough carries it to `runtime.context` without mirroring into `configurable`. `build_run_config` still sets `configurable.thread_id` on the context path — the checkpointer requires it.
|
||||
- **Bind (point A)**: on slash-activation, `SkillActivationMiddleware._apply_skill_secrets` resolves the activated skill's declared secrets against `context.secrets` and writes the per-run injection set to `runtime.context[__active_skill_secrets]`. Slash activation reads the genuine user text via `get_original_user_content_text`; `InputSanitizationMiddleware` preserves it (`ORIGINAL_USER_CONTENT_KEY`) when it wraps input in BEGIN/END markers, so activation fires even after sanitization. The injected value always comes from the caller's request, never the host environment (scrubbed first — see below), so a declared name that also exists in the host env is safe: the caller's value wins and the host value is dropped (the #3861 per-user-key-overrides-shared-key case). Missing required secrets are logged, not injected.
|
||||
- **Inject**: `bash_tool` reads the injection set and passes it as `execute_command(env=...)`. Scope is the activation turn/run only — a run without `/skill` activation injects nothing.
|
||||
- **Inherited-env scrub**: `execute_command` no longer leaks the Gateway's `os.environ` to skill subprocesses — `env_policy.build_sandbox_env` drops secret-looking names (`*KEY*`/`*SECRET*`/`*TOKEN*`/`*PASSWORD*`/`*CREDENTIAL*`/`*DSN*` + a connection-string denylist like `DATABASE_URL`/`REDIS_URL`/`GH_PAT`) so platform credentials never reach a skill; a skill that needs one must declare it.
|
||||
- **Leak surfaces sealed** (verified by a real-gateway e2e run — secret reaches the sandbox but none of these): prompt (value never in a message), trace (`tracing/metadata.py` never copies `context`), checkpoint (secrets live on `runtime.context`, not graph state), audit (journal records names only), stdout (`tools.py::mask_secret_values` redacts injected values from bash output), and **run-record persistence + run API** (`services.py::start_run` stores `redact_config_secrets(body.config)` so `runs.kwargs_json` and `RunResponse.kwargs` never carry the secret).
|
||||
- **Scope / non-goals**: only `/slash`-activated skills receive secrets (autonomously invoked enabled skills do not); no persistence/vaulting; the MCP per-user-credential gap (#3322) is a sibling, not covered here. Tests: `tests/test_skill_request_scoped_secrets.py`.
|
||||
|
||||
### Model Factory (`packages/harness/deerflow/models/factory.py`)
|
||||
|
||||
- `create_chat_model(name, thinking_enabled)` instantiates LLM from config via reflection
|
||||
|
|
|
|||
|
|
@ -37,6 +37,7 @@ from deerflow.runtime import (
|
|||
run_agent,
|
||||
)
|
||||
from deerflow.runtime.runs.naming import resolve_root_run_name
|
||||
from deerflow.runtime.secret_context import redact_config_secrets
|
||||
from deerflow.runtime.user_context import reset_current_user, set_current_user
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
|
@ -290,6 +291,12 @@ def build_run_config(
|
|||
raise ValueError("request config 'context' must be a mapping or null.")
|
||||
context["thread_id"] = thread_id
|
||||
config["context"] = context
|
||||
# The checkpointer always scopes state by configurable["thread_id"],
|
||||
# regardless of whether the caller drives the run via context (e.g.
|
||||
# request-scoped secrets, #3861). thread_id comes from the URL path,
|
||||
# not caller config, so mirror it here while keeping secret-bearing
|
||||
# context keys out of configurable.
|
||||
config["configurable"] = {"thread_id": thread_id}
|
||||
else:
|
||||
configurable = {"thread_id": thread_id}
|
||||
configurable.update(request_config.get("configurable", {}))
|
||||
|
|
@ -476,7 +483,11 @@ async def start_run(
|
|||
body.assistant_id,
|
||||
on_disconnect=disconnect,
|
||||
metadata=body.metadata or {},
|
||||
kwargs={"input": body.input, "config": body.config},
|
||||
# Persist a secret-redacted copy of the config: the run record is
|
||||
# written to runs.kwargs_json and echoed by the run API, so a
|
||||
# request-scoped secret (#3861) must not ride along. The live
|
||||
# config built below keeps the secrets for the actual run.
|
||||
kwargs={"input": body.input, "config": redact_config_secrets(body.config)},
|
||||
multitask_strategy=body.multitask_strategy,
|
||||
model_name=model_name,
|
||||
user_id=owner_user_id,
|
||||
|
|
|
|||
|
|
@ -31,6 +31,8 @@ from langchain.agents.middleware.types import (
|
|||
from langchain_core.messages import HumanMessage
|
||||
from langgraph.errors import GraphBubbleUp
|
||||
|
||||
from deerflow.utils.messages import ORIGINAL_USER_CONTENT_KEY, message_content_to_text
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
_SUMMARY_MESSAGE_NAME = "summary"
|
||||
|
|
@ -233,11 +235,17 @@ class InputSanitizationMiddleware(AgentMiddleware[AgentState]):
|
|||
else:
|
||||
new_content = processed
|
||||
|
||||
# Preserve the pre-sanitization user text so downstream consumers that
|
||||
# must see the genuine input (slash skill activation, regenerate) can
|
||||
# recover it after the BEGIN/END wrapping. setdefault keeps an existing
|
||||
# value (e.g. set by UploadsMiddleware or an IM channel) authoritative.
|
||||
preserved_kwargs = dict(msg.additional_kwargs or {})
|
||||
preserved_kwargs.setdefault(ORIGINAL_USER_CONTENT_KEY, message_content_to_text(content))
|
||||
messages[i] = HumanMessage(
|
||||
content=new_content,
|
||||
id=msg.id,
|
||||
name=msg.name,
|
||||
additional_kwargs=msg.additional_kwargs,
|
||||
additional_kwargs=preserved_kwargs,
|
||||
)
|
||||
logger.debug(
|
||||
"InputSanitizationMiddleware: original=%r -> processed=%r",
|
||||
|
|
|
|||
|
|
@ -16,10 +16,11 @@ from langchain.agents.middleware import AgentMiddleware
|
|||
from langchain.agents.middleware.types import ModelRequest, ModelResponse
|
||||
from langchain_core.messages import AIMessage, HumanMessage
|
||||
|
||||
from deerflow.runtime.secret_context import ACTIVE_SECRETS_CONTEXT_KEY, extract_request_secrets
|
||||
from deerflow.skills.slash import parse_slash_skill_reference, resolve_slash_skill
|
||||
from deerflow.skills.storage import get_or_new_skill_storage
|
||||
from deerflow.skills.storage.skill_storage import SkillStorage
|
||||
from deerflow.skills.types import SKILL_MD_FILE
|
||||
from deerflow.skills.types import SKILL_MD_FILE, SecretRequirement
|
||||
from deerflow.utils.messages import get_original_user_content_text
|
||||
|
||||
if TYPE_CHECKING:
|
||||
|
|
@ -40,6 +41,7 @@ class _Activation:
|
|||
skill_content: str
|
||||
content_hash: str
|
||||
remaining_text: str
|
||||
required_secrets: tuple[SecretRequirement, ...] = ()
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
|
|
@ -134,6 +136,7 @@ class SkillActivationMiddleware(AgentMiddleware):
|
|||
skill_content=skill_content,
|
||||
content_hash=content_hash,
|
||||
remaining_text=resolved.remaining_text,
|
||||
required_secrets=tuple(resolved.skill.required_secrets or ()),
|
||||
)
|
||||
)
|
||||
|
||||
|
|
@ -242,11 +245,60 @@ Follow this skill before choosing a general workflow. Load supporting resources
|
|||
activation.content_hash,
|
||||
)
|
||||
self._record_activation(request, activation, hook=hook)
|
||||
self._apply_skill_secrets(request, activation)
|
||||
activation_msg = self._make_activation_message(target, self._build_activation_reminder(activation))
|
||||
messages = list(request.messages)
|
||||
messages.insert(target_index, activation_msg)
|
||||
return request.override(messages=messages)
|
||||
|
||||
@staticmethod
|
||||
def _apply_skill_secrets(request: ModelRequest, activation: _Activation) -> None:
|
||||
"""Resolve the activated skill's declared secrets into the per-run injection
|
||||
set (binding point A, issue #3861).
|
||||
|
||||
For each declared secret present in the request's ``context.secrets``,
|
||||
record its value in the injection set stored under
|
||||
``ACTIVE_SECRETS_CONTEXT_KEY`` on the shared run context, so the bash tool
|
||||
can build the subprocess env for this turn. The injected value always comes
|
||||
from the caller's request — never from the host environment, which is
|
||||
scrubbed of secret-looking names by ``env_policy.build_sandbox_env`` before
|
||||
injection. A skill can therefore never harvest a host platform credential
|
||||
(it only ever receives what the caller explicitly supplied), so a declared
|
||||
name that also exists in the host env is fine: the caller's value wins and
|
||||
the host value is dropped. Secret *values* are never logged.
|
||||
"""
|
||||
runtime = getattr(request, "runtime", None)
|
||||
context = getattr(runtime, "context", None)
|
||||
if not isinstance(context, dict):
|
||||
return
|
||||
# Unconditionally clear any active-secret set a previous activation in
|
||||
# the same run may have written, before this turn's resolution decides
|
||||
# what (if anything) to install. Otherwise a later skill that declares
|
||||
# no secrets, or whose required secrets the caller did not supply, would
|
||||
# inherit the previous skill's injection set and the bash tool would
|
||||
# inject those values into a subprocess that never declared them (#3861).
|
||||
context.pop(ACTIVE_SECRETS_CONTEXT_KEY, None)
|
||||
if not activation.required_secrets:
|
||||
return
|
||||
|
||||
request_secrets = extract_request_secrets(context)
|
||||
injected: dict[str, str] = {}
|
||||
missing: list[str] = []
|
||||
for req in activation.required_secrets:
|
||||
if req.name in request_secrets:
|
||||
injected[req.name] = request_secrets[req.name]
|
||||
elif not req.optional:
|
||||
missing.append(req.name)
|
||||
|
||||
if injected:
|
||||
context[ACTIVE_SECRETS_CONTEXT_KEY] = injected
|
||||
if missing:
|
||||
logger.warning(
|
||||
"Skill %s activated but required secrets are missing from the request context: %s",
|
||||
activation.skill_name,
|
||||
", ".join(sorted(missing)),
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def _make_activation_message(target: HumanMessage, activation_content: str) -> HumanMessage:
|
||||
stable_id = target.id or str(uuid.uuid4())
|
||||
|
|
|
|||
|
|
@ -110,7 +110,22 @@ class AioSandbox(Sandbox):
|
|||
# default.
|
||||
_DEFAULT_NO_CHANGE_TIMEOUT = 600
|
||||
|
||||
def execute_command(self, command: str) -> str:
|
||||
# Wall-clock hard timeout for env-bearing commands routed through bash.exec.
|
||||
# The bash.exec API exposes no idle/no-change timeout (unlike
|
||||
# shell.exec_command's ``no_change_timeout`` on the legacy path), so
|
||||
# env-bearing commands are bounded by total elapsed wall-clock time, not
|
||||
# time-since-last-output. Kept at the same numeric value as the legacy idle
|
||||
# budget so the two paths broadly agree on how long a single command may
|
||||
# run; a future SDK that exposes an idle timeout on bash.exec should switch
|
||||
# this call site to it.
|
||||
_DEFAULT_HARD_TIMEOUT = 600.0
|
||||
|
||||
def execute_command(
|
||||
self,
|
||||
command: str,
|
||||
env: dict[str, str] | None = None,
|
||||
timeout: float | None = None,
|
||||
) -> str:
|
||||
"""Execute a shell command in the sandbox.
|
||||
|
||||
Uses a lock to serialize concurrent requests. The AIO sandbox
|
||||
|
|
@ -122,10 +137,23 @@ class AioSandbox(Sandbox):
|
|||
|
||||
Args:
|
||||
command: The command to execute.
|
||||
env: Optional per-call environment variables (request-scoped secrets,
|
||||
issue #3861). When provided, the command runs via the ``bash.exec``
|
||||
API (which supports per-command env) on a fresh auto-created session
|
||||
so the secrets are scoped to this single command and never persist;
|
||||
secret values travel in the structured ``env`` field, never in the
|
||||
command string. When ``None`` the legacy persistent-shell path runs
|
||||
unchanged.
|
||||
timeout: Optional per-call timeout. The current sandbox SDK does not
|
||||
expose a command-level timeout distinct from its client/request
|
||||
timeout, so DeerFlow keeps using the backend's default here.
|
||||
|
||||
Returns:
|
||||
The output of the command.
|
||||
"""
|
||||
del timeout
|
||||
if env:
|
||||
return self._execute_with_env(command, env)
|
||||
with self._lock:
|
||||
try:
|
||||
result = self._client.shell.exec_command(command=command, no_change_timeout=self._DEFAULT_NO_CHANGE_TIMEOUT)
|
||||
|
|
@ -154,6 +182,57 @@ class AioSandbox(Sandbox):
|
|||
logger.error(f"Failed to execute command in sandbox: {e}")
|
||||
return f"Error: {e}"
|
||||
|
||||
def _execute_with_env(self, command: str, env: dict[str, str]) -> str:
|
||||
"""Execute a command with per-call environment variables injected.
|
||||
|
||||
The persistent-shell ``shell.exec_command`` API has no env parameter, so
|
||||
injected commands use the ``bash.exec`` API which accepts per-command env.
|
||||
Each call lets the sandbox auto-create a fresh session (no ``session_id``),
|
||||
so injected request-scoped secrets are scoped to this command and never
|
||||
persist across calls. Secret values travel in the structured ``env`` field,
|
||||
never in the command string.
|
||||
|
||||
Trade-off of the fresh-session choice: consecutive env-bearing bash calls
|
||||
within the same skill do not share session state (cwd, sourced venv,
|
||||
exported variables). This mirrors the LocalSandbox model (each call is a
|
||||
fresh subprocess) and is intentional — a shared session_id would let
|
||||
request-scoped secrets ride the session env into later commands, which the
|
||||
SDK does not contractually forbid. Skills that need setup must fold it into
|
||||
a single command (e.g. ``cd /mnt/user-data/workspace && source .venv/bin/activate && python run.py``).
|
||||
|
||||
The ``_ERROR_OBSERVATION_SIGNATURE`` recovery contract is shared with the
|
||||
legacy persistent-shell path: if the (unlikely, since each call is a fresh
|
||||
session) corruption marker shows up, the call is retried on another fresh
|
||||
session rather than returned verbatim.
|
||||
"""
|
||||
output = self._run_bash_exec(command, env)
|
||||
if output and _ERROR_OBSERVATION_SIGNATURE in output:
|
||||
logger.warning("ErrorObservation detected in bash.exec output, retrying on a fresh session")
|
||||
retried = self._run_bash_exec(command, env)
|
||||
if retried and _ERROR_OBSERVATION_SIGNATURE not in retried:
|
||||
return retried
|
||||
return output
|
||||
|
||||
def _run_bash_exec(self, command: str, env: dict[str, str]) -> str:
|
||||
"""Single bash.exec invocation with injected env (one fresh session)."""
|
||||
with self._lock:
|
||||
try:
|
||||
result = self._client.bash.exec(
|
||||
command=command,
|
||||
env=env,
|
||||
hard_timeout=self._DEFAULT_HARD_TIMEOUT,
|
||||
)
|
||||
data = result.data if result else None
|
||||
stdout = (data.stdout or "") if data else ""
|
||||
stderr = (data.stderr or "") if data else ""
|
||||
output = stdout
|
||||
if stderr:
|
||||
output += f"\nStd Error:\n{stderr}" if output else stderr
|
||||
return output if output else "(no output)"
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to execute command with injected env in sandbox: {e}")
|
||||
return f"Error: {e}"
|
||||
|
||||
def read_file(self, path: str) -> str:
|
||||
"""Read the content of a file in the sandbox.
|
||||
|
||||
|
|
|
|||
|
|
@ -118,12 +118,25 @@ class E2BSandbox(Sandbox):
|
|||
return f"{self._home_dir}/{tail}".rstrip("/") if tail else self._home_dir
|
||||
return normalised
|
||||
|
||||
def execute_command(self, command: str) -> str:
|
||||
def execute_command(
|
||||
self,
|
||||
command: str,
|
||||
env: dict[str, str] | None = None,
|
||||
timeout: float | None = None,
|
||||
) -> str:
|
||||
"""Execute a shell command via ``sandbox.commands.run``.
|
||||
|
||||
Returns the combined stdout/stderr.
|
||||
The lock serialises concurrent calls on the same instance
|
||||
because the e2b SDK shares a single HTTP/2 connection per sandbox.
|
||||
|
||||
Args:
|
||||
command: The command to execute.
|
||||
env: Optional per-call environment variables (request-scoped secrets,
|
||||
issue #3861). Passed through to e2b as ``envs``, which are scoped
|
||||
to this command only and never placed in the command string.
|
||||
timeout: Optional per-call command timeout in seconds. ``None`` keeps
|
||||
the e2b SDK default (60s).
|
||||
"""
|
||||
with self._lock:
|
||||
client = self._client
|
||||
|
|
@ -132,7 +145,12 @@ class E2BSandbox(Sandbox):
|
|||
if self._dead:
|
||||
return "Error: e2b sandbox has been reaped by the control plane (idle timeout or explicit pause). The provider will rebuild a fresh sandbox on the next tool call."
|
||||
try:
|
||||
result = client.commands.run(command)
|
||||
kwargs: dict[str, object] = {}
|
||||
if env is not None:
|
||||
kwargs["envs"] = env
|
||||
if timeout is not None:
|
||||
kwargs["timeout"] = timeout
|
||||
result = client.commands.run(command, **kwargs)
|
||||
stdout = getattr(result, "stdout", "") or ""
|
||||
stderr = getattr(result, "stderr", "") or ""
|
||||
exit_code = getattr(result, "exit_code", 0)
|
||||
|
|
|
|||
88
backend/packages/harness/deerflow/runtime/secret_context.py
Normal file
88
backend/packages/harness/deerflow/runtime/secret_context.py
Normal file
|
|
@ -0,0 +1,88 @@
|
|||
"""Request-scoped secret carrier in the run context (issue #3861).
|
||||
|
||||
Callers pass per-request secrets out-of-band in ``config.context.secrets`` — a
|
||||
mapping of name -> value. The value never enters the prompt, tool arguments, or
|
||||
the executed command string; it is injected as an environment variable into a
|
||||
skill's sandbox subprocess only when an activated skill declares it via the
|
||||
``required-secrets`` frontmatter field.
|
||||
|
||||
This module centralises the reserved key name and safe extraction so the carrier
|
||||
contract lives in one place, consumed by the skill-activation middleware (to
|
||||
build the per-turn injection set) and the tracing redactor (to strip it from
|
||||
trace payloads).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any
|
||||
|
||||
# Reserved sub-key of the run context that holds request-scoped secrets supplied
|
||||
# by the caller. Source of truth for what a skill *may* receive.
|
||||
SECRETS_CONTEXT_KEY = "secrets"
|
||||
|
||||
# Reserved sub-key holding the secrets resolved for the currently activated skill
|
||||
# (binding point A). Written by the skill-activation middleware, read by the bash
|
||||
# tool. Both reserved keys are stripped from trace payloads (see tracing redactor).
|
||||
ACTIVE_SECRETS_CONTEXT_KEY = "__active_skill_secrets"
|
||||
|
||||
|
||||
def _string_pairs(raw: Any) -> dict[str, str]:
|
||||
if not isinstance(raw, dict):
|
||||
return {}
|
||||
return {key: value for key, value in raw.items() if isinstance(key, str) and isinstance(value, str)}
|
||||
|
||||
|
||||
def extract_request_secrets(context: Any) -> dict[str, str]:
|
||||
"""Return the caller-supplied request-scoped secrets mapping, or ``{}``.
|
||||
|
||||
Only string-keyed, string-valued entries are kept; anything else is ignored
|
||||
so a malformed carrier can never crash secret resolution or injection.
|
||||
"""
|
||||
if not isinstance(context, dict):
|
||||
return {}
|
||||
return _string_pairs(context.get(SECRETS_CONTEXT_KEY))
|
||||
|
||||
|
||||
def read_active_secrets(context: Any) -> dict[str, str]:
|
||||
"""Return the secrets resolved for the active skill (the per-run injection
|
||||
set), or ``{}``. Read by the bash tool to build the subprocess env."""
|
||||
if not isinstance(context, dict):
|
||||
return {}
|
||||
return _string_pairs(context.get(ACTIVE_SECRETS_CONTEXT_KEY))
|
||||
|
||||
|
||||
# Run-context keys whose values are request-scoped secrets and must be stripped
|
||||
# before a context mapping is serialized anywhere observable (traces, logs).
|
||||
REDACTED_CONTEXT_KEYS = frozenset({SECRETS_CONTEXT_KEY, ACTIVE_SECRETS_CONTEXT_KEY})
|
||||
|
||||
|
||||
def redact_secret_context_keys(context: Any) -> Any:
|
||||
"""Return a shallow copy of ``context`` with secret-bearing keys removed.
|
||||
|
||||
Defensive helper for any code path that serializes the run context into an
|
||||
observable surface. DeerFlow's own trace-metadata builder never copies the
|
||||
context, so this is belt-and-suspenders for future call sites and custom
|
||||
tracer configurations.
|
||||
"""
|
||||
if not isinstance(context, dict):
|
||||
return context
|
||||
return {key: value for key, value in context.items() if key not in REDACTED_CONTEXT_KEYS}
|
||||
|
||||
|
||||
def redact_config_secrets(config: Any) -> Any:
|
||||
"""Return a copy of a run config safe to persist or echo back to clients.
|
||||
|
||||
The request config (``body.config``) is stored verbatim on the run record
|
||||
(``runs.kwargs_json``) and echoed by the run API. Strip the secret-bearing
|
||||
keys from its ``context`` so a request-scoped secret is never persisted or
|
||||
returned, while the live config that drives the run (built separately) keeps
|
||||
them. Non-dict / context-less configs pass through unchanged.
|
||||
"""
|
||||
if not isinstance(config, dict):
|
||||
return config
|
||||
context = config.get("context")
|
||||
if not isinstance(context, dict):
|
||||
return config
|
||||
redacted = dict(config)
|
||||
redacted["context"] = redact_secret_context_keys(context)
|
||||
return redacted
|
||||
82
backend/packages/harness/deerflow/sandbox/env_policy.py
Normal file
82
backend/packages/harness/deerflow/sandbox/env_policy.py
Normal file
|
|
@ -0,0 +1,82 @@
|
|||
"""Environment-variable policy for sandbox command execution (issue #3861).
|
||||
|
||||
Skill scripts run as sandbox subprocesses. By default a subprocess inherits the
|
||||
Gateway process's entire ``os.environ`` — which holds platform credentials
|
||||
(``OPENAI_API_KEY``, tracing keys, community-provider keys, ...). That makes any
|
||||
scoped request-secret injection pointless: a script could simply read those
|
||||
inherited platform secrets. This module scrubs secret-looking variables from the
|
||||
inherited environment before request-scoped secrets are layered on top.
|
||||
|
||||
The pattern set mirrors codex's ``*KEY*/*SECRET*/*TOKEN*`` default excludes and
|
||||
hermes's fixed provider blocklist; unlike codex (which defaults the exclude
|
||||
*off*), DeerFlow scrubs by default — security first.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import fnmatch
|
||||
import os
|
||||
|
||||
# Case-insensitive wildcard patterns for secret-looking variable names. Matched
|
||||
# against the upper-cased variable name. Benign system vars (PATH, HOME, SHELL,
|
||||
# LANG, PWD, TMPDIR, VIRTUAL_ENV, PYTHONPATH, ...) contain none of these tokens
|
||||
# and are therefore preserved.
|
||||
_SECRET_NAME_PATTERNS: tuple[str, ...] = (
|
||||
"*KEY*",
|
||||
"*SECRET*",
|
||||
"*TOKEN*",
|
||||
"*PASSWORD*",
|
||||
"*PASSWD*",
|
||||
"*CREDENTIAL*",
|
||||
"*DSN*", # data source name — almost always a connection string with a password
|
||||
)
|
||||
|
||||
# Connection-string / credential-bearing variable names that carry no
|
||||
# KEY/SECRET/TOKEN/DSN substring but routinely embed a password (e.g.
|
||||
# ``postgresql://user:pw@host/db``). A blanket ``*URL*`` block is intentionally
|
||||
# avoided — it would strip benign service URLs a skill may legitimately read.
|
||||
# A skill that genuinely needs one of these must declare it via required-secrets
|
||||
# (the caller then supplies it through context.secrets, and injection wins).
|
||||
_BLOCKED_EXACT_NAMES: frozenset[str] = frozenset(
|
||||
{
|
||||
"DATABASE_URL",
|
||||
"DATABASE_URI",
|
||||
"REDIS_URL",
|
||||
"MONGODB_URI",
|
||||
"MONGO_URL",
|
||||
"AMQP_URL",
|
||||
"RABBITMQ_URL",
|
||||
"POSTGRES_URL",
|
||||
"POSTGRESQL_URL",
|
||||
"MYSQL_URL",
|
||||
"CLICKHOUSE_URL",
|
||||
"CONNECTION_STRING",
|
||||
"CONN_STR",
|
||||
"GH_PAT",
|
||||
"GITHUB_PAT",
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
def is_blocked_env_name(name: str) -> bool:
|
||||
"""Return True if ``name`` looks like a credential that must not be inherited
|
||||
by a sandbox subprocess."""
|
||||
upper = name.upper()
|
||||
if upper in _BLOCKED_EXACT_NAMES:
|
||||
return True
|
||||
return any(fnmatch.fnmatchcase(upper, pattern) for pattern in _SECRET_NAME_PATTERNS)
|
||||
|
||||
|
||||
def build_sandbox_env(injected: dict[str, str] | None = None) -> dict[str, str]:
|
||||
"""Build the environment dict for a sandbox subprocess.
|
||||
|
||||
Inherits ``os.environ`` minus any secret-looking variables, then layers the
|
||||
explicitly injected request-scoped secrets on top. An injected secret wins
|
||||
even if its name matches a blocked pattern, because injection is authorized
|
||||
upstream (the skill declared it and the value came from the request, not from
|
||||
the host environment).
|
||||
"""
|
||||
env = {key: value for key, value in os.environ.items() if not is_blocked_env_name(key)}
|
||||
if injected:
|
||||
env.update(injected)
|
||||
return env
|
||||
|
|
@ -13,6 +13,7 @@ from pathlib import Path
|
|||
from typing import NamedTuple
|
||||
|
||||
from deerflow.config.paths import VIRTUAL_PATH_PREFIX
|
||||
from deerflow.sandbox.env_policy import build_sandbox_env
|
||||
from deerflow.sandbox.local.list_dir import list_dir
|
||||
from deerflow.sandbox.sandbox import Sandbox
|
||||
from deerflow.sandbox.search import GrepMatch, find_glob_matches, find_grep_matches
|
||||
|
|
@ -433,16 +434,24 @@ class LocalSandbox(Sandbox):
|
|||
|
||||
raise RuntimeError("No suitable shell executable found. Tried /bin/zsh, /bin/bash, /bin/sh, and `sh` on PATH.")
|
||||
|
||||
def execute_command(self, command: str, timeout: float | None = None) -> str:
|
||||
def execute_command(
|
||||
self,
|
||||
command: str,
|
||||
env: dict[str, str] | None = None,
|
||||
timeout: float | None = None,
|
||||
) -> str:
|
||||
# Resolve container paths in command before execution
|
||||
resolved_command = self._resolve_paths_in_command(command)
|
||||
shell = self._get_shell()
|
||||
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)
|
||||
timed_out = False
|
||||
if os.name == "nt":
|
||||
env = None
|
||||
if self._is_powershell(shell):
|
||||
args = [shell, "-NoProfile", "-Command", resolved_command]
|
||||
elif self._is_cmd_shell(shell):
|
||||
|
|
@ -450,8 +459,8 @@ class LocalSandbox(Sandbox):
|
|||
else:
|
||||
args = [shell, "-c", resolved_command]
|
||||
if self._is_msys_shell(shell):
|
||||
env = {
|
||||
**os.environ,
|
||||
sandbox_env = {
|
||||
**sandbox_env,
|
||||
"MSYS_NO_PATHCONV": "1",
|
||||
"MSYS2_ARG_CONV_EXCL": "*",
|
||||
}
|
||||
|
|
@ -463,7 +472,7 @@ class LocalSandbox(Sandbox):
|
|||
capture_output=True,
|
||||
text=True,
|
||||
timeout=timeout,
|
||||
env=env,
|
||||
env=sandbox_env,
|
||||
)
|
||||
stdout, stderr, returncode = result.stdout, result.stderr, result.returncode
|
||||
except subprocess.TimeoutExpired as exc:
|
||||
|
|
@ -473,7 +482,7 @@ class LocalSandbox(Sandbox):
|
|||
returncode = 0
|
||||
else:
|
||||
args = [shell, "-c", resolved_command]
|
||||
stdout, stderr, returncode, timed_out = self._run_posix_command(args, timeout)
|
||||
stdout, stderr, returncode, timed_out = self._run_posix_command(args, timeout, sandbox_env)
|
||||
|
||||
output = stdout
|
||||
if stderr:
|
||||
|
|
@ -489,7 +498,11 @@ class LocalSandbox(Sandbox):
|
|||
return self._reverse_resolve_paths_in_output(final_output)
|
||||
|
||||
@staticmethod
|
||||
def _run_posix_command(args: list[str], timeout: float) -> tuple[str, str, int, bool]:
|
||||
def _run_posix_command(
|
||||
args: list[str],
|
||||
timeout: float,
|
||||
env: dict[str, str] | None = None,
|
||||
) -> tuple[str, str, int, bool]:
|
||||
"""Run a command on POSIX with bounded pipe capture.
|
||||
|
||||
``subprocess.communicate()`` cannot be used here: a backgrounded
|
||||
|
|
@ -517,6 +530,7 @@ class LocalSandbox(Sandbox):
|
|||
stdout=stdout_write_fd,
|
||||
stderr=stderr_write_fd,
|
||||
start_new_session=True,
|
||||
env=env,
|
||||
)
|
||||
except Exception:
|
||||
for fd in (stdout_read_fd, stdout_write_fd, stderr_read_fd, stderr_write_fd):
|
||||
|
|
|
|||
|
|
@ -16,11 +16,27 @@ class Sandbox(ABC):
|
|||
return self._id
|
||||
|
||||
@abstractmethod
|
||||
def execute_command(self, command: str) -> str:
|
||||
def execute_command(
|
||||
self,
|
||||
command: str,
|
||||
env: dict[str, str] | None = None,
|
||||
timeout: float | None = None,
|
||||
) -> str:
|
||||
"""Execute bash command in sandbox.
|
||||
|
||||
Args:
|
||||
command: The command to execute.
|
||||
env: Optional per-call environment variables to inject into the
|
||||
command's process. Used to pass request-scoped secrets (e.g. a
|
||||
short-lived end-user token) to skill scripts without placing them
|
||||
in the prompt, tool arguments, or the command string (issue #3861).
|
||||
When ``None`` the sandbox uses its default environment.
|
||||
timeout: Optional per-call wall-clock timeout in seconds. Local
|
||||
sandboxes use this to bound host bash commands so long-lived
|
||||
foreground processes cannot hang a turn indefinitely. Remote/AIO
|
||||
implementations may ignore it when their backend does not expose
|
||||
an equivalent command-timeout control separate from its own API
|
||||
timeouts.
|
||||
|
||||
Returns:
|
||||
The standard or error output of the command.
|
||||
|
|
|
|||
|
|
@ -12,6 +12,7 @@ from langchain.tools import tool
|
|||
from deerflow.agents.thread_state import ThreadDataState
|
||||
from deerflow.config import get_app_config
|
||||
from deerflow.config.paths import VIRTUAL_PATH_PREFIX
|
||||
from deerflow.runtime.secret_context import read_active_secrets
|
||||
from deerflow.runtime.user_context import resolve_runtime_user_id
|
||||
from deerflow.sandbox.exceptions import (
|
||||
SandboxError,
|
||||
|
|
@ -1309,6 +1310,37 @@ def ensure_thread_directories_exist(runtime: Runtime | None) -> None:
|
|||
runtime.state["thread_directories_created"] = True
|
||||
|
||||
|
||||
_SECRET_REDACTION = "[redacted]"
|
||||
|
||||
# Values shorter than this are not redacted from bash output. A short secret
|
||||
# value (a 2-char region code, a numeric id, a PIN) would otherwise shred
|
||||
# unrelated bytes of tool output — exit codes, timestamps, sizes, paths —
|
||||
# corrupting the result the model reads back. The redaction of a value this
|
||||
# short is more likely noise than genuine leak protection; the secret is still
|
||||
# injected into the subprocess, only the output mask skips it.
|
||||
_MIN_MASK_LENGTH = 8
|
||||
|
||||
|
||||
def mask_secret_values(output: str, injected_env: dict[str, str] | None) -> str:
|
||||
"""Redact injected secret values from bash output before it re-enters context.
|
||||
|
||||
Skill scripts receive request-scoped secrets as env vars (#3861). If a script
|
||||
echoes one (debugging, ``set -x``, an error dump), the value would otherwise
|
||||
flow into the tool result — and thus into the prompt and the trace. This is
|
||||
the skill-specific fifth leak surface (the bash tool returns subprocess stdout,
|
||||
unlike MCP tools). Replace each non-empty secret value with a redaction marker.
|
||||
Longest values first so a value that is a substring of another is not partially
|
||||
revealed. Values shorter than ``_MIN_MASK_LENGTH`` are skipped — a redacted
|
||||
3-char token is more likely to corrupt unrelated output than to protect a
|
||||
real secret.
|
||||
"""
|
||||
if not injected_env or not output:
|
||||
return output
|
||||
for value in sorted((v for v in injected_env.values() if v and len(v) >= _MIN_MASK_LENGTH), key=len, reverse=True):
|
||||
output = output.replace(value, _SECRET_REDACTION)
|
||||
return output
|
||||
|
||||
|
||||
def _truncate_bash_output(output: str, max_chars: int) -> str:
|
||||
"""Middle-truncate bash output, preserving head and tail (50/50 split).
|
||||
|
||||
|
|
@ -1404,6 +1436,9 @@ def bash_tool(runtime: Runtime, description: str, command: str) -> str:
|
|||
"""
|
||||
try:
|
||||
sandbox = ensure_sandbox_initialized(runtime)
|
||||
# Request-scoped secrets resolved for the active skill (#3861); injected as
|
||||
# per-call env into the subprocess, never placed in the command string.
|
||||
injected_env = read_active_secrets(getattr(runtime, "context", None)) or None
|
||||
if is_local_sandbox(runtime):
|
||||
if not is_host_bash_allowed():
|
||||
return f"Error: {LOCAL_HOST_BASH_DISABLED_MESSAGE}"
|
||||
|
|
@ -1421,8 +1456,11 @@ def bash_tool(runtime: Runtime, description: str, command: str) -> str:
|
|||
except Exception:
|
||||
max_chars = 20000
|
||||
command_timeout = None
|
||||
output = sandbox.execute_command(command, timeout=command_timeout)
|
||||
return _truncate_bash_output(mask_local_paths_in_output(output, thread_data), max_chars)
|
||||
output = sandbox.execute_command(command, env=injected_env, timeout=command_timeout)
|
||||
return _truncate_bash_output(
|
||||
mask_secret_values(mask_local_paths_in_output(output, thread_data), injected_env),
|
||||
max_chars,
|
||||
)
|
||||
ensure_thread_directories_exist(runtime)
|
||||
try:
|
||||
from deerflow.config.app_config import get_app_config
|
||||
|
|
@ -1431,7 +1469,7 @@ def bash_tool(runtime: Runtime, description: str, command: str) -> str:
|
|||
max_chars = sandbox_cfg.bash_output_max_chars if sandbox_cfg else 20000
|
||||
except Exception:
|
||||
max_chars = 20000
|
||||
return _truncate_bash_output(sandbox.execute_command(command), max_chars)
|
||||
return _truncate_bash_output(mask_secret_values(sandbox.execute_command(command, env=injected_env), injected_env), max_chars)
|
||||
except SandboxError as e:
|
||||
return f"Error: {e}"
|
||||
except PermissionError as e:
|
||||
|
|
|
|||
|
|
@ -4,10 +4,13 @@ from pathlib import Path
|
|||
|
||||
import yaml
|
||||
|
||||
from .types import SKILL_MD_FILE, Skill, SkillCategory
|
||||
from .types import SKILL_MD_FILE, SecretRequirement, Skill, SkillCategory
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# Valid POSIX environment-variable name.
|
||||
_ENV_VAR_NAME_RE = re.compile(r"^[A-Za-z_][A-Za-z0-9_]*$")
|
||||
|
||||
|
||||
def _format_yaml_error(skill_file: Path, exc: yaml.YAMLError, source: str) -> str:
|
||||
"""Render a developer-friendly explanation of a YAML front-matter error."""
|
||||
|
|
@ -63,6 +66,43 @@ def parse_allowed_tools(raw: object, skill_file: Path) -> list[str] | None:
|
|||
return allowed_tools
|
||||
|
||||
|
||||
def parse_required_secrets(raw: object, skill_file: Path) -> list[SecretRequirement]:
|
||||
"""Parse the optional required-secrets frontmatter field (issue #3861).
|
||||
|
||||
Accepts a YAML sequence whose items are either a string (the secret / env
|
||||
variable name) or a mapping (``{name, optional}``). Returns an empty list
|
||||
when the field is omitted. Entries whose name is missing or is not a valid
|
||||
environment-variable name are dropped with a warning, so one malformed
|
||||
declaration does not invalidate the whole skill. Raises ValueError only when
|
||||
the field is present but is not a list.
|
||||
"""
|
||||
if raw is None:
|
||||
return []
|
||||
if not isinstance(raw, list):
|
||||
raise ValueError(f"required-secrets in {skill_file} must be a list")
|
||||
|
||||
secrets: list[SecretRequirement] = []
|
||||
seen: set[str] = set()
|
||||
for item in raw:
|
||||
if isinstance(item, str):
|
||||
name, optional = item.strip(), False
|
||||
elif isinstance(item, dict):
|
||||
name = str(item.get("name") or "").strip()
|
||||
optional = bool(item.get("optional", False))
|
||||
else:
|
||||
logger.warning("Ignoring malformed required-secrets entry in %s: %r", skill_file, item)
|
||||
continue
|
||||
|
||||
if not _ENV_VAR_NAME_RE.match(name):
|
||||
logger.warning("Ignoring required-secrets entry with invalid env var name in %s: %r", skill_file, name)
|
||||
continue
|
||||
if name in seen:
|
||||
continue
|
||||
seen.add(name)
|
||||
secrets.append(SecretRequirement(name=name, optional=optional))
|
||||
return secrets
|
||||
|
||||
|
||||
def parse_skill_file(skill_file: Path, category: SkillCategory, relative_path: Path | None = None) -> Skill | None:
|
||||
"""Parse a SKILL.md file and extract metadata.
|
||||
|
||||
|
|
@ -124,6 +164,12 @@ def parse_skill_file(skill_file: Path, category: SkillCategory, relative_path: P
|
|||
logger.error("Invalid allowed-tools in %s: %s", skill_file, exc)
|
||||
return None
|
||||
|
||||
try:
|
||||
required_secrets = parse_required_secrets(metadata.get("required-secrets"), skill_file)
|
||||
except ValueError as exc:
|
||||
logger.error("Invalid required-secrets in %s: %s", skill_file, exc)
|
||||
return None
|
||||
|
||||
return Skill(
|
||||
name=name,
|
||||
description=description,
|
||||
|
|
@ -134,6 +180,7 @@ def parse_skill_file(skill_file: Path, category: SkillCategory, relative_path: P
|
|||
category=category,
|
||||
allowed_tools=allowed_tools,
|
||||
enabled=True, # Actual state comes from the extensions config file.
|
||||
required_secrets=required_secrets,
|
||||
)
|
||||
|
||||
except Exception:
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
from dataclasses import dataclass
|
||||
from dataclasses import dataclass, field
|
||||
from enum import StrEnum
|
||||
from pathlib import Path
|
||||
|
||||
|
|
@ -16,6 +16,19 @@ class SkillCategory(StrEnum):
|
|||
CUSTOM = "custom"
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class SecretRequirement:
|
||||
"""A request-scoped secret a skill declares it needs (issue #3861).
|
||||
|
||||
``name`` is both the key looked up in the request's ``context.secrets`` and
|
||||
the environment variable name injected into the skill's sandbox subprocess
|
||||
when the skill is activated.
|
||||
"""
|
||||
|
||||
name: str
|
||||
optional: bool = False
|
||||
|
||||
|
||||
@dataclass
|
||||
class Skill:
|
||||
"""Represents a skill with its metadata and file path"""
|
||||
|
|
@ -29,6 +42,7 @@ class Skill:
|
|||
category: SkillCategory # 'public' or 'custom'
|
||||
allowed_tools: list[str] | None = None
|
||||
enabled: bool = False # Whether this skill is enabled
|
||||
required_secrets: list[SecretRequirement] = field(default_factory=list)
|
||||
|
||||
@property
|
||||
def skill_path(self) -> str:
|
||||
|
|
|
|||
|
|
@ -7,6 +7,7 @@ import threading
|
|||
from collections import OrderedDict
|
||||
from types import SimpleNamespace
|
||||
from typing import Any
|
||||
from unittest.mock import MagicMock
|
||||
|
||||
from deerflow.config.paths import Paths
|
||||
|
||||
|
|
@ -257,6 +258,42 @@ def test_execute_command_does_not_mark_dead_on_unrelated_error():
|
|||
assert sb.is_dead is False
|
||||
|
||||
|
||||
def test_execute_command_forwards_env_and_timeout_to_commands_run():
|
||||
"""execute_command(env=..., timeout=...) routes env as ``envs`` and the
|
||||
timeout through to ``commands.run`` so request-scoped secrets (#3861) reach
|
||||
the e2b subprocess without entering the command string. Regression for the
|
||||
signature mismatch that broke bash for every e2b user."""
|
||||
commands = MagicMock()
|
||||
commands.run.return_value = SimpleNamespace(stdout="ok\n", stderr="", exit_code=0)
|
||||
client = FakeClient(commands=commands)
|
||||
sb = _make_sandbox(client)
|
||||
|
||||
out = sb.execute_command("echo $TOK", env={"TOK": "secret-v"}, timeout=120)
|
||||
|
||||
assert out.rstrip() == "ok"
|
||||
args, kwargs = commands.run.call_args
|
||||
assert args == ("echo $TOK",)
|
||||
assert kwargs["envs"] == {"TOK": "secret-v"}
|
||||
assert kwargs["timeout"] == 120
|
||||
# The secret must not be smuggled into the command string.
|
||||
assert "secret-v" not in args[0]
|
||||
|
||||
|
||||
def test_execute_command_env_none_passes_no_envs_kwarg():
|
||||
"""env=None is fully backward-compatible — ``commands.run`` is called with no
|
||||
``envs``/``timeout`` kwargs, so existing (non-secret) callers are unaffected."""
|
||||
commands = MagicMock()
|
||||
commands.run.return_value = SimpleNamespace(stdout="ok\n", stderr="", exit_code=0)
|
||||
client = FakeClient(commands=commands)
|
||||
sb = _make_sandbox(client)
|
||||
|
||||
sb.execute_command("echo hi")
|
||||
|
||||
_, kwargs = commands.run.call_args
|
||||
assert "envs" not in kwargs
|
||||
assert "timeout" not in kwargs
|
||||
|
||||
|
||||
def test_ping_returns_false_when_sandbox_gone():
|
||||
client = FakeClient(commands=FakeCommandsAPI([FakeCommandsAPI.GONE]))
|
||||
sb = _make_sandbox(client)
|
||||
|
|
|
|||
|
|
@ -208,6 +208,21 @@ def test_build_run_config_with_overrides():
|
|||
assert config["metadata"]["user"] == "alice"
|
||||
|
||||
|
||||
def test_build_run_config_context_path_still_sets_configurable_thread_id(_stub_app_config):
|
||||
"""A caller-supplied context (e.g. request-scoped secrets, #3861) must not
|
||||
deprive the checkpointer of configurable.thread_id, which it always needs to
|
||||
scope checkpoints. Secrets stay in context; thread_id is mirrored into
|
||||
configurable for the checkpointer."""
|
||||
from app.gateway.services import build_run_config
|
||||
|
||||
config = build_run_config("thread-1", {"context": {"secrets": {"ERP_TOKEN": "v"}}}, None)
|
||||
assert config["context"]["secrets"] == {"ERP_TOKEN": "v"}
|
||||
assert config["context"]["thread_id"] == "thread-1"
|
||||
assert config["configurable"]["thread_id"] == "thread-1"
|
||||
# Secrets must NOT be mirrored into configurable.
|
||||
assert "secrets" not in config["configurable"]
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# recursion_limit clamping: the Gateway must not trust a client-supplied
|
||||
# recursion_limit verbatim (runaway LLM cost / DoS). See build_run_config.
|
||||
|
|
@ -853,7 +868,8 @@ def test_build_run_config_with_context():
|
|||
assert "context" in config
|
||||
assert config["context"]["user_id"] == "u-42"
|
||||
assert config["context"]["thread_id"] == "thread-1"
|
||||
assert "configurable" not in config
|
||||
# configurable carries thread_id for the checkpointer; user context stays in context.
|
||||
assert config["configurable"] == {"thread_id": "thread-1"}
|
||||
assert config["recursion_limit"] == 100
|
||||
|
||||
|
||||
|
|
@ -869,7 +885,7 @@ def test_build_run_config_context_injects_thread_id():
|
|||
assert config["context"]["user_id"] == "u-1"
|
||||
assert config["context"]["thinking_enabled"] is True
|
||||
assert config["context"]["thread_id"] == "T-deadbeef-42"
|
||||
assert "configurable" not in config
|
||||
assert config["configurable"] == {"thread_id": "T-deadbeef-42"}
|
||||
|
||||
|
||||
def test_build_run_config_null_context_becomes_empty_context():
|
||||
|
|
@ -879,7 +895,7 @@ def test_build_run_config_null_context_becomes_empty_context():
|
|||
config = build_run_config("thread-1", {"context": None}, None)
|
||||
|
||||
assert config["context"] == {"thread_id": "thread-1"}
|
||||
assert "configurable" not in config
|
||||
assert config["configurable"] == {"thread_id": "thread-1"}
|
||||
|
||||
|
||||
def test_build_run_config_rejects_non_mapping_context():
|
||||
|
|
@ -921,7 +937,10 @@ def test_build_run_config_context_plus_configurable_warns(caplog):
|
|||
)
|
||||
assert "context" in config
|
||||
assert config["context"]["user_id"] == "u-42"
|
||||
assert "configurable" not in config
|
||||
# context wins: caller's configurable (model_name) is dropped, but thread_id is
|
||||
# still set for the checkpointer.
|
||||
assert config["configurable"] == {"thread_id": "thread-1"}
|
||||
assert "model_name" not in config["configurable"]
|
||||
assert any("both 'context' and 'configurable'" in r.message for r in caplog.records)
|
||||
|
||||
|
||||
|
|
@ -935,7 +954,7 @@ def test_build_run_config_context_passthrough_other_keys():
|
|||
None,
|
||||
)
|
||||
assert config["context"]["thread_id"] == "thread-1"
|
||||
assert "configurable" not in config
|
||||
assert config["configurable"] == {"thread_id": "thread-1"}
|
||||
assert config["tags"] == ["prod"]
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -77,7 +77,7 @@ def test_foreground_blocking_command_times_out_with_notice():
|
|||
|
||||
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: ("", "", 0, True)))
|
||||
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)
|
||||
|
|
|
|||
|
|
@ -86,12 +86,16 @@ def test_execute_command_uses_powershell_command_mode_on_windows(monkeypatch):
|
|||
return SimpleNamespace(stdout="ok", stderr="", returncode=0)
|
||||
|
||||
monkeypatch.setattr(local_sandbox.os, "name", "nt")
|
||||
monkeypatch.setattr(local_sandbox.os, "environ", {"PATH": r"C:\Windows", "OPENAI_API_KEY": "should-not-leak"})
|
||||
monkeypatch.setattr(LocalSandbox, "_get_shell", staticmethod(lambda: r"C:\Windows\System32\WindowsPowerShell\v1.0\powershell.exe"))
|
||||
monkeypatch.setattr(local_sandbox.subprocess, "run", fake_run)
|
||||
|
||||
output = LocalSandbox("t").execute_command("Write-Output hello")
|
||||
|
||||
assert output == "ok"
|
||||
# Platform secrets are scrubbed from the inherited environment even on the
|
||||
# Windows PowerShell path (#3861); benign PATH is preserved and the env is an
|
||||
# explicit scrubbed dict, no longer None.
|
||||
assert calls == [
|
||||
(
|
||||
[
|
||||
|
|
@ -105,7 +109,7 @@ def test_execute_command_uses_powershell_command_mode_on_windows(monkeypatch):
|
|||
"capture_output": True,
|
||||
"text": True,
|
||||
"timeout": 600,
|
||||
"env": None,
|
||||
"env": {"PATH": r"C:\Windows"},
|
||||
},
|
||||
)
|
||||
]
|
||||
|
|
@ -152,13 +156,17 @@ def test_execute_command_does_not_set_msys_env_for_non_msys_posix_shell_on_windo
|
|||
return SimpleNamespace(stdout="ok", stderr="", returncode=0)
|
||||
|
||||
monkeypatch.setattr(local_sandbox.os, "name", "nt")
|
||||
monkeypatch.setattr(local_sandbox.os, "environ", {"PATH": r"C:\tools"})
|
||||
monkeypatch.setattr(LocalSandbox, "_get_shell", staticmethod(lambda: r"C:\tools\busybox\sh.exe"))
|
||||
monkeypatch.setattr(local_sandbox.subprocess, "run", fake_run)
|
||||
|
||||
output = LocalSandbox("t").execute_command("echo /mnt/skills/demo")
|
||||
|
||||
assert output == "ok"
|
||||
assert calls[0][1]["env"] is None
|
||||
# Non-MSYS posix shell adds no MSYS_* vars; the env is the scrubbed inherited
|
||||
# environment, not None (#3861).
|
||||
assert calls[0][1]["env"] == {"PATH": r"C:\tools"}
|
||||
assert "MSYS_NO_PATHCONV" not in calls[0][1]["env"]
|
||||
|
||||
|
||||
def test_execute_command_uses_cmd_command_mode_on_windows(monkeypatch):
|
||||
|
|
@ -169,12 +177,15 @@ def test_execute_command_uses_cmd_command_mode_on_windows(monkeypatch):
|
|||
return SimpleNamespace(stdout="ok", stderr="", returncode=0)
|
||||
|
||||
monkeypatch.setattr(local_sandbox.os, "name", "nt")
|
||||
monkeypatch.setattr(local_sandbox.os, "environ", {"PATH": r"C:\Windows", "GITHUB_TOKEN": "should-not-leak"})
|
||||
monkeypatch.setattr(LocalSandbox, "_get_shell", staticmethod(lambda: r"C:\Windows\System32\cmd.exe"))
|
||||
monkeypatch.setattr(local_sandbox.subprocess, "run", fake_run)
|
||||
|
||||
output = LocalSandbox("t").execute_command("echo hello")
|
||||
|
||||
assert output == "ok"
|
||||
# Platform secrets are scrubbed even on the Windows cmd path (#3861); the env
|
||||
# is an explicit scrubbed dict, no longer None.
|
||||
assert calls == [
|
||||
(
|
||||
[r"C:\Windows\System32\cmd.exe", "/c", "echo hello"],
|
||||
|
|
@ -183,7 +194,7 @@ def test_execute_command_uses_cmd_command_mode_on_windows(monkeypatch):
|
|||
"capture_output": True,
|
||||
"text": True,
|
||||
"timeout": 600,
|
||||
"env": None,
|
||||
"env": {"PATH": r"C:\Windows"},
|
||||
},
|
||||
)
|
||||
]
|
||||
|
|
|
|||
|
|
@ -37,7 +37,13 @@ class _SyncProvider(SandboxProvider):
|
|||
|
||||
|
||||
class _SandboxStub(Sandbox):
|
||||
def execute_command(self, command: str) -> str:
|
||||
def execute_command(
|
||||
self,
|
||||
command: str,
|
||||
env: dict[str, str] | None = None,
|
||||
timeout: float | None = None,
|
||||
) -> str:
|
||||
del env, timeout
|
||||
return "OK"
|
||||
|
||||
def read_file(self, path: str) -> str:
|
||||
|
|
|
|||
769
backend/tests/test_skill_request_scoped_secrets.py
Normal file
769
backend/tests/test_skill_request_scoped_secrets.py
Normal file
|
|
@ -0,0 +1,769 @@
|
|||
"""Tests for request-scoped secret injection into skills (issue #3861).
|
||||
|
||||
Covers the full feature surface:
|
||||
- Slice 1: ``Sandbox.execute_command(command, env=...)`` per-call env injection
|
||||
on both the local and AIO backends.
|
||||
- Slice 2: ``SKILL.md`` ``requires-secrets`` frontmatter parsing.
|
||||
- Slice 3: gateway carrier (``context.secrets``) and runtime-context passthrough.
|
||||
- Slice 4: activation-turn binding + ``bash`` tool injection.
|
||||
- Slice 5: the five leak surfaces (prompt / trace / checkpoint / audit / stdout).
|
||||
"""
|
||||
|
||||
from pathlib import Path
|
||||
from types import SimpleNamespace
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
import pytest
|
||||
from langchain.agents.middleware.types import ModelRequest
|
||||
from langchain_core.messages import AIMessage, HumanMessage
|
||||
|
||||
from deerflow.sandbox.local.local_sandbox import LocalSandbox
|
||||
from deerflow.skills.types import SecretRequirement, Skill, SkillCategory
|
||||
|
||||
|
||||
class TestLocalSandboxEnvInjection:
|
||||
"""LocalSandbox.execute_command(env=...) injects per-call env into the subprocess."""
|
||||
|
||||
def test_injected_env_visible_to_command(self):
|
||||
sandbox = LocalSandbox(id="local")
|
||||
out = sandbox.execute_command(
|
||||
"echo $DEERFLOW_TEST_SECRET",
|
||||
env={"DEERFLOW_TEST_SECRET": "s3cret-value"},
|
||||
)
|
||||
assert "s3cret-value" in out
|
||||
|
||||
def test_env_none_keeps_inherited_environment(self, monkeypatch):
|
||||
"""env=None preserves the legacy inherited-os.environ behaviour."""
|
||||
monkeypatch.setenv("DEERFLOW_INHERITED_VAR", "inherited-value")
|
||||
sandbox = LocalSandbox(id="local")
|
||||
out = sandbox.execute_command("echo $DEERFLOW_INHERITED_VAR")
|
||||
assert "inherited-value" in out
|
||||
|
||||
def test_injected_env_is_per_call_only(self):
|
||||
"""Injected env must not leak into a subsequent call that does not pass it."""
|
||||
sandbox = LocalSandbox(id="local")
|
||||
sandbox.execute_command("true", env={"DEERFLOW_EPHEMERAL": "leaky"})
|
||||
out = sandbox.execute_command("echo [$DEERFLOW_EPHEMERAL]")
|
||||
assert "leaky" not in out
|
||||
|
||||
def test_platform_secret_scrubbed_from_inherited_env(self, monkeypatch):
|
||||
"""A platform credential present in os.environ must NOT reach the sandbox
|
||||
subprocess (the baseline-env leak surface). Without this, scoped injection
|
||||
is security theatre — a skill script could simply read $OPENAI_API_KEY."""
|
||||
monkeypatch.setenv("OPENAI_API_KEY", "sk-platform-should-not-leak")
|
||||
sandbox = LocalSandbox(id="local")
|
||||
out = sandbox.execute_command("echo [$OPENAI_API_KEY]")
|
||||
assert "sk-platform-should-not-leak" not in out
|
||||
|
||||
def test_benign_env_still_inherited_after_scrub(self, monkeypatch):
|
||||
"""Scrubbing platform secrets must not strip harmless vars that skills rely on."""
|
||||
monkeypatch.setenv("DEERFLOW_PLAIN_VAR", "harmless-value")
|
||||
sandbox = LocalSandbox(id="local")
|
||||
out = sandbox.execute_command("echo [$DEERFLOW_PLAIN_VAR]")
|
||||
assert "harmless-value" in out
|
||||
|
||||
def test_injected_secret_survives_scrub(self, monkeypatch):
|
||||
"""An explicitly injected secret must win even if its name matches a blocked
|
||||
pattern — injection happens after scrubbing the inherited environment."""
|
||||
sandbox = LocalSandbox(id="local")
|
||||
out = sandbox.execute_command(
|
||||
"echo [$INJECTED_API_KEY]",
|
||||
env={"INJECTED_API_KEY": "scoped-value"},
|
||||
)
|
||||
assert "scoped-value" in out
|
||||
|
||||
|
||||
class TestAioSandboxEnvInjection:
|
||||
@pytest.fixture
|
||||
def sandbox(self):
|
||||
with patch("deerflow.community.aio_sandbox.aio_sandbox.AioSandboxClient"):
|
||||
from deerflow.community.aio_sandbox.aio_sandbox import AioSandbox
|
||||
|
||||
return AioSandbox(id="test-sandbox", base_url="http://localhost:8080")
|
||||
|
||||
def test_env_none_uses_legacy_shell_path(self, sandbox):
|
||||
"""No injected env → unchanged shell.exec_command path (backward compat)."""
|
||||
sandbox._client.shell.exec_command = MagicMock(return_value=SimpleNamespace(data=SimpleNamespace(output="hello")))
|
||||
sandbox._client.bash.exec = MagicMock()
|
||||
out = sandbox.execute_command("echo hello")
|
||||
sandbox._client.shell.exec_command.assert_called_once()
|
||||
sandbox._client.bash.exec.assert_not_called()
|
||||
assert "hello" in out
|
||||
|
||||
def test_injected_env_uses_bash_exec_with_env_dict(self, sandbox):
|
||||
"""Injected env → bash.exec(env=...) carries the dict; secret stays out of the command string."""
|
||||
sandbox._client.bash.exec = MagicMock(return_value=SimpleNamespace(data=SimpleNamespace(stdout="hello", stderr=None)))
|
||||
sandbox._client.shell.exec_command = MagicMock()
|
||||
out = sandbox.execute_command("echo $TOK", env={"TOK": "secret-v"})
|
||||
sandbox._client.bash.exec.assert_called_once()
|
||||
_, kwargs = sandbox._client.bash.exec.call_args
|
||||
assert kwargs["env"] == {"TOK": "secret-v"}
|
||||
# Secret must NOT be smuggled into the command string (audit / ps safety).
|
||||
assert "secret-v" not in kwargs["command"]
|
||||
sandbox._client.shell.exec_command.assert_not_called()
|
||||
assert "hello" in out
|
||||
|
||||
def test_env_path_uses_hard_timeout_not_no_change_timeout(self, sandbox):
|
||||
"""The env path routes through bash.exec which exposes no idle/no-change
|
||||
timeout; it must use the dedicated wall-clock ``_DEFAULT_HARD_TIMEOUT``,
|
||||
not the legacy idle constant (same numeric value today, but distinct
|
||||
semantics so a future change to one does not silently alter the other)."""
|
||||
from deerflow.community.aio_sandbox.aio_sandbox import AioSandbox
|
||||
|
||||
sandbox._client.bash.exec = MagicMock(return_value=SimpleNamespace(data=SimpleNamespace(stdout="ok", stderr=None)))
|
||||
sandbox.execute_command("echo hi", env={"X": "1"})
|
||||
_, kwargs = sandbox._client.bash.exec.call_args
|
||||
assert kwargs["hard_timeout"] == AioSandbox._DEFAULT_HARD_TIMEOUT
|
||||
assert AioSandbox._DEFAULT_HARD_TIMEOUT != AioSandbox._DEFAULT_NO_CHANGE_TIMEOUT or (
|
||||
# Same numeric value is fine today; the contract is that they are
|
||||
# named independently so the two call sites evolve independently.
|
||||
AioSandbox._DEFAULT_HARD_TIMEOUT == AioSandbox._DEFAULT_NO_CHANGE_TIMEOUT
|
||||
)
|
||||
|
||||
def test_env_path_retries_on_error_observation_signature(self, sandbox):
|
||||
"""The env path shares the legacy persistent-shell recovery contract: if
|
||||
the (unlikely, fresh-session) corruption marker appears, the call is
|
||||
retried rather than returned verbatim."""
|
||||
from deerflow.community.aio_sandbox.aio_sandbox import _ERROR_OBSERVATION_SIGNATURE
|
||||
|
||||
corrupted = SimpleNamespace(data=SimpleNamespace(stdout=_ERROR_OBSERVATION_SIGNATURE, stderr=None))
|
||||
clean = SimpleNamespace(data=SimpleNamespace(stdout="recovered", stderr=None))
|
||||
sandbox._client.bash.exec = MagicMock(side_effect=[corrupted, clean])
|
||||
out = sandbox.execute_command("script", env={"TOK": "v"})
|
||||
assert sandbox._client.bash.exec.call_count == 2
|
||||
assert "recovered" in out
|
||||
assert _ERROR_OBSERVATION_SIGNATURE not in out
|
||||
|
||||
|
||||
class TestEnvPolicy:
|
||||
"""Platform-secret scrubbing policy for sandbox subprocesses (delta 1)."""
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"name",
|
||||
[
|
||||
"OPENAI_API_KEY",
|
||||
"ANTHROPIC_API_KEY",
|
||||
"LANGFUSE_SECRET_KEY",
|
||||
"GITHUB_TOKEN",
|
||||
"AWS_SECRET_ACCESS_KEY",
|
||||
"DB_PASSWORD",
|
||||
"MY_SERVICE_CREDENTIAL",
|
||||
"api_key",
|
||||
"Some_Token_Here",
|
||||
# Connection-string credentials (no KEY/SECRET/TOKEN substring) — these
|
||||
# routinely embed a password, e.g. postgresql://user:pw@host/db.
|
||||
"DATABASE_URL",
|
||||
"REDIS_URL",
|
||||
"MONGODB_URI",
|
||||
"AMQP_URL",
|
||||
"SENTRY_DSN",
|
||||
"POSTGRES_DSN",
|
||||
"CONN_STR",
|
||||
"GH_PAT",
|
||||
],
|
||||
)
|
||||
def test_secret_like_names_are_blocked(self, name):
|
||||
from deerflow.sandbox.env_policy import is_blocked_env_name
|
||||
|
||||
assert is_blocked_env_name(name) is True
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"name",
|
||||
[
|
||||
"PATH",
|
||||
"HOME",
|
||||
"SHELL",
|
||||
"USER",
|
||||
"LANG",
|
||||
"LC_ALL",
|
||||
"PWD",
|
||||
"TMPDIR",
|
||||
"VIRTUAL_ENV",
|
||||
"PYTHONPATH",
|
||||
"DEERFLOW_PLAIN_VAR",
|
||||
# Not a blanket *URL* block: a benign service URL a skill may legitimately
|
||||
# read is not treated as a credential.
|
||||
"NEXT_PUBLIC_BASE_URL",
|
||||
"SERVICE_ENDPOINT",
|
||||
],
|
||||
)
|
||||
def test_benign_names_are_allowed(self, name):
|
||||
from deerflow.sandbox.env_policy import is_blocked_env_name
|
||||
|
||||
assert is_blocked_env_name(name) is False
|
||||
|
||||
def test_build_sandbox_env_scrubs_inherited_and_layers_injected(self, monkeypatch):
|
||||
from deerflow.sandbox.env_policy import build_sandbox_env
|
||||
|
||||
monkeypatch.setenv("OPENAI_API_KEY", "platform-key-should-vanish")
|
||||
monkeypatch.setenv("HARMLESS_PLAIN", "ok")
|
||||
env = build_sandbox_env(injected={"SCOPED_TOKEN": "v"})
|
||||
assert "OPENAI_API_KEY" not in env # platform secret scrubbed
|
||||
assert env.get("HARMLESS_PLAIN") == "ok" # benign preserved
|
||||
assert env.get("SCOPED_TOKEN") == "v" # injected layered on top
|
||||
assert env.get("PATH") # core var preserved
|
||||
|
||||
def test_build_sandbox_env_none_injection_still_scrubs(self, monkeypatch):
|
||||
from deerflow.sandbox.env_policy import build_sandbox_env
|
||||
|
||||
monkeypatch.setenv("ANTHROPIC_API_KEY", "leak")
|
||||
env = build_sandbox_env()
|
||||
assert "ANTHROPIC_API_KEY" not in env
|
||||
|
||||
|
||||
class TestRequiredSecretsParsing:
|
||||
"""SKILL.md ``required-secrets`` frontmatter parsing (Slice 2)."""
|
||||
|
||||
def _write_skill(self, tmp_path, frontmatter_body: str):
|
||||
skill_dir = tmp_path / "erp-report"
|
||||
skill_dir.mkdir()
|
||||
skill_file = skill_dir / "SKILL.md"
|
||||
skill_file.write_text(f"---\n{frontmatter_body}\n---\n# body\n", encoding="utf-8")
|
||||
return skill_file
|
||||
|
||||
def test_absent_field_defaults_to_empty(self, tmp_path):
|
||||
from deerflow.skills.parser import parse_skill_file
|
||||
from deerflow.skills.types import SkillCategory
|
||||
|
||||
skill_file = self._write_skill(tmp_path, "name: erp-report\ndescription: Pull an ERP report")
|
||||
skill = parse_skill_file(skill_file, SkillCategory.CUSTOM)
|
||||
assert skill is not None
|
||||
assert skill.required_secrets == []
|
||||
|
||||
def test_string_list_form(self, tmp_path):
|
||||
from deerflow.skills.parser import parse_skill_file
|
||||
from deerflow.skills.types import SkillCategory
|
||||
|
||||
skill_file = self._write_skill(
|
||||
tmp_path,
|
||||
"name: erp-report\ndescription: d\nrequired-secrets:\n - ERP_TOKEN\n - OTHER_TOKEN",
|
||||
)
|
||||
skill = parse_skill_file(skill_file, SkillCategory.CUSTOM)
|
||||
assert [s.name for s in skill.required_secrets] == ["ERP_TOKEN", "OTHER_TOKEN"]
|
||||
assert all(s.optional is False for s in skill.required_secrets)
|
||||
|
||||
def test_object_list_with_optional(self, tmp_path):
|
||||
from deerflow.skills.parser import parse_skill_file
|
||||
from deerflow.skills.types import SkillCategory
|
||||
|
||||
skill_file = self._write_skill(
|
||||
tmp_path,
|
||||
"name: erp-report\ndescription: d\nrequired-secrets:\n - name: ERP_TOKEN\n optional: true\n - name: REQUIRED_ONE",
|
||||
)
|
||||
skill = parse_skill_file(skill_file, SkillCategory.CUSTOM)
|
||||
by_name = {s.name: s for s in skill.required_secrets}
|
||||
assert by_name["ERP_TOKEN"].optional is True
|
||||
assert by_name["REQUIRED_ONE"].optional is False
|
||||
|
||||
def test_invalid_env_name_entry_is_dropped(self, tmp_path):
|
||||
from deerflow.skills.parser import parse_skill_file
|
||||
from deerflow.skills.types import SkillCategory
|
||||
|
||||
skill_file = self._write_skill(
|
||||
tmp_path,
|
||||
'name: erp-report\ndescription: d\nrequired-secrets:\n - "bad name!"\n - GOOD_TOKEN',
|
||||
)
|
||||
skill = parse_skill_file(skill_file, SkillCategory.CUSTOM)
|
||||
# The malformed entry is dropped; the valid one survives — one bad
|
||||
# declaration must not nuke the whole skill.
|
||||
assert [s.name for s in skill.required_secrets] == ["GOOD_TOKEN"]
|
||||
|
||||
|
||||
class TestSecretCarrier:
|
||||
"""Request-scoped secret carrier: context.secrets → runtime.context (Slice 3)."""
|
||||
|
||||
def test_build_run_config_keeps_secrets_in_context_not_configurable(self):
|
||||
from app.gateway.services import build_run_config
|
||||
|
||||
config = build_run_config("thread-1", {"context": {"secrets": {"ERP_TOKEN": "v"}}}, None)
|
||||
assert config["context"]["secrets"] == {"ERP_TOKEN": "v"}
|
||||
# Secrets must never be mirrored into configurable (which legacy readers
|
||||
# and some trace backends surface).
|
||||
assert "secrets" not in config.get("configurable", {})
|
||||
|
||||
def test_runtime_context_carries_secrets(self):
|
||||
from deerflow.runtime.runs.worker import _build_runtime_context
|
||||
|
||||
ctx = _build_runtime_context("t", "r", {"secrets": {"ERP_TOKEN": "v"}})
|
||||
assert ctx["secrets"] == {"ERP_TOKEN": "v"}
|
||||
|
||||
def test_extract_request_secrets_filters_non_string_pairs(self):
|
||||
from deerflow.runtime.secret_context import extract_request_secrets
|
||||
|
||||
assert extract_request_secrets({"secrets": {"A": "x", "B": 123, 4: "y"}}) == {"A": "x"}
|
||||
|
||||
def test_extract_request_secrets_missing_or_malformed(self):
|
||||
from deerflow.runtime.secret_context import extract_request_secrets
|
||||
|
||||
assert extract_request_secrets({}) == {}
|
||||
assert extract_request_secrets({"secrets": "not-a-dict"}) == {}
|
||||
assert extract_request_secrets(None) == {}
|
||||
|
||||
|
||||
def _make_secret_skill(tmp_path: Path, name: str, required_secrets):
|
||||
skill_dir = tmp_path / name
|
||||
skill_dir.mkdir()
|
||||
skill_file = skill_dir / "SKILL.md"
|
||||
skill_file.write_text(f"# {name}\n", encoding="utf-8")
|
||||
return Skill(
|
||||
name=name,
|
||||
description=f"Description for {name}",
|
||||
license="MIT",
|
||||
skill_dir=skill_dir,
|
||||
skill_file=skill_file,
|
||||
relative_path=Path(name),
|
||||
category=SkillCategory.CUSTOM,
|
||||
enabled=True,
|
||||
required_secrets=required_secrets,
|
||||
)
|
||||
|
||||
|
||||
class TestActivationBindsSecrets:
|
||||
"""Binding point A: activation turn resolves declared secrets into the per-run injection set."""
|
||||
|
||||
def _activate(self, tmp_path, monkeypatch, skill, context):
|
||||
from deerflow.agents.middlewares import skill_activation_middleware as mw
|
||||
from deerflow.agents.middlewares.skill_activation_middleware import SkillActivationMiddleware
|
||||
|
||||
storage = SimpleNamespace(
|
||||
load_skills=lambda *, enabled_only: [skill],
|
||||
get_container_root=lambda: "/mnt/skills",
|
||||
get_skills_root_path=lambda: tmp_path,
|
||||
)
|
||||
monkeypatch.setattr(mw, "get_or_new_skill_storage", lambda **kwargs: storage)
|
||||
middleware = SkillActivationMiddleware()
|
||||
request = ModelRequest(
|
||||
model=object(),
|
||||
messages=[HumanMessage(content=f"/{skill.name} do it", id="m1")],
|
||||
state={"messages": []},
|
||||
runtime=SimpleNamespace(context=context),
|
||||
)
|
||||
middleware.wrap_model_call(request, lambda r: AIMessage(content="ok"))
|
||||
|
||||
def test_declared_secret_resolved_into_active_set(self, tmp_path, monkeypatch):
|
||||
from deerflow.runtime.secret_context import read_active_secrets
|
||||
|
||||
skill = _make_secret_skill(tmp_path, "erp-report", [SecretRequirement("ERP_TOKEN")])
|
||||
context = {"secrets": {"ERP_TOKEN": "tok-123", "UNUSED": "x"}}
|
||||
self._activate(tmp_path, monkeypatch, skill, context)
|
||||
# Only the declared secret is injected — not the whole secrets bag.
|
||||
assert read_active_secrets(context) == {"ERP_TOKEN": "tok-123"}
|
||||
|
||||
def test_skill_without_declaration_gets_no_injection(self, tmp_path, monkeypatch):
|
||||
from deerflow.runtime.secret_context import read_active_secrets
|
||||
|
||||
skill = _make_secret_skill(tmp_path, "plain", [])
|
||||
context = {"secrets": {"ERP_TOKEN": "tok-123"}}
|
||||
self._activate(tmp_path, monkeypatch, skill, context)
|
||||
assert read_active_secrets(context) == {}
|
||||
|
||||
def test_missing_required_secret_not_injected(self, tmp_path, monkeypatch):
|
||||
from deerflow.runtime.secret_context import read_active_secrets
|
||||
|
||||
skill = _make_secret_skill(tmp_path, "erp-report", [SecretRequirement("ERP_TOKEN")])
|
||||
context = {"secrets": {}} # caller provided none
|
||||
self._activate(tmp_path, monkeypatch, skill, context)
|
||||
assert read_active_secrets(context) == {}
|
||||
|
||||
def test_caller_secret_wins_over_host_value_of_same_name(self, tmp_path, monkeypatch):
|
||||
"""A skill may declare a name that also exists in the host env (e.g. a
|
||||
per-user key overriding a shared platform key — the #3861 use case). The
|
||||
skill receives the CALLER's value (from context.secrets), never the host's:
|
||||
the inherited host value is scrubbed and the caller's value is injected on
|
||||
top. There is therefore no host-credential harvest to guard against."""
|
||||
from deerflow.runtime.secret_context import read_active_secrets
|
||||
from deerflow.sandbox.env_policy import build_sandbox_env
|
||||
|
||||
monkeypatch.setenv("MEMOS_API_KEY", "host-shared-key-MUST-NOT-LEAK")
|
||||
skill = _make_secret_skill(tmp_path, "memos", [SecretRequirement("MEMOS_API_KEY")])
|
||||
context = {"secrets": {"MEMOS_API_KEY": "caller-per-user-key"}}
|
||||
self._activate(tmp_path, monkeypatch, skill, context)
|
||||
|
||||
injected = read_active_secrets(context)
|
||||
assert injected == {"MEMOS_API_KEY": "caller-per-user-key"} # caller's value injected
|
||||
|
||||
# The subprocess env gets the caller's value; the host's value is scrubbed.
|
||||
env = build_sandbox_env(injected)
|
||||
assert env["MEMOS_API_KEY"] == "caller-per-user-key"
|
||||
assert "host-shared-key-MUST-NOT-LEAK" not in str(env.values())
|
||||
|
||||
def test_undeclared_host_secret_is_scrubbed_not_harvested(self, tmp_path, monkeypatch):
|
||||
"""If a skill does NOT declare a host credential, the inherited value is
|
||||
scrubbed — a skill can never read a platform credential it wasn't given."""
|
||||
from deerflow.sandbox.env_policy import build_sandbox_env
|
||||
|
||||
monkeypatch.setenv("OPENAI_API_KEY", "host-key-do-not-harvest")
|
||||
env = build_sandbox_env(None)
|
||||
assert "OPENAI_API_KEY" not in env
|
||||
|
||||
def test_activation_fires_after_input_sanitization_wrapping(self, tmp_path, monkeypatch):
|
||||
"""Integration: in the real chain InputSanitizationMiddleware wraps the user
|
||||
message in ``--- BEGIN USER INPUT ---`` markers before SkillActivationMiddleware
|
||||
sees it. Slash activation (and therefore secret resolution) must still fire — it
|
||||
relies on the original content being recoverable. Regression for the gateway
|
||||
path where no upload preserved it."""
|
||||
from deerflow.agents.middlewares import skill_activation_middleware as mw
|
||||
from deerflow.agents.middlewares.input_sanitization_middleware import InputSanitizationMiddleware
|
||||
from deerflow.agents.middlewares.skill_activation_middleware import SkillActivationMiddleware
|
||||
from deerflow.config.app_config import AppConfig, reset_app_config, set_app_config
|
||||
from deerflow.runtime.secret_context import read_active_secrets
|
||||
|
||||
skill = _make_secret_skill(tmp_path, "erp-report", [SecretRequirement("ERP_TOKEN")])
|
||||
storage = SimpleNamespace(
|
||||
load_skills=lambda *, enabled_only: [skill],
|
||||
get_container_root=lambda: "/mnt/skills",
|
||||
get_skills_root_path=lambda: tmp_path,
|
||||
)
|
||||
monkeypatch.setattr(mw, "get_or_new_skill_storage", lambda **kwargs: storage)
|
||||
|
||||
context = {"secrets": {"ERP_TOKEN": "tok-xyz"}}
|
||||
request = ModelRequest(
|
||||
model=object(),
|
||||
messages=[HumanMessage(content="/erp-report pull it", id="m1")],
|
||||
state={"messages": []},
|
||||
runtime=SimpleNamespace(context=context),
|
||||
)
|
||||
# The sanitizer loads enabled skills during wrap, so keep a stub app config
|
||||
# in place for the whole composed call.
|
||||
set_app_config(AppConfig.model_validate({"sandbox": {"use": "deerflow.sandbox.local:LocalSandboxProvider"}}))
|
||||
try:
|
||||
sanitizer = InputSanitizationMiddleware()
|
||||
skill_mw = SkillActivationMiddleware()
|
||||
|
||||
# Compose in real order: sanitizer (outer) -> skill activation (inner) -> model.
|
||||
def skill_layer(req):
|
||||
return skill_mw.wrap_model_call(req, lambda r: AIMessage(content="ok"))
|
||||
|
||||
sanitizer.wrap_model_call(request, skill_layer)
|
||||
finally:
|
||||
reset_app_config()
|
||||
|
||||
assert read_active_secrets(context) == {"ERP_TOKEN": "tok-xyz"}
|
||||
|
||||
def test_prior_activation_secrets_cleared_when_next_skill_declares_none(self, tmp_path, monkeypatch):
|
||||
"""A later skill in the same run never inherits an earlier skill's secrets.
|
||||
Turn 1 activates /skill-a (declares A_TOKEN, caller supplies it) → injected.
|
||||
Turn 2 activates /skill-b (declares nothing) → A_TOKEN must be cleared so
|
||||
bash in skill-b's turn cannot receive a value it never declared."""
|
||||
from deerflow.agents.middlewares import skill_activation_middleware as mw
|
||||
from deerflow.agents.middlewares.skill_activation_middleware import SkillActivationMiddleware
|
||||
from deerflow.runtime.secret_context import read_active_secrets
|
||||
|
||||
skill_a = _make_secret_skill(tmp_path, "skill-a", [SecretRequirement("A_TOKEN")])
|
||||
skill_b = _make_secret_skill(tmp_path, "skill-b", [])
|
||||
|
||||
def _storage(skills):
|
||||
return SimpleNamespace(
|
||||
load_skills=lambda *, enabled_only: skills,
|
||||
get_container_root=lambda: "/mnt/skills",
|
||||
get_skills_root_path=lambda: tmp_path,
|
||||
)
|
||||
|
||||
context = {"secrets": {"A_TOKEN": "v-a"}}
|
||||
|
||||
monkeypatch.setattr(mw, "get_or_new_skill_storage", lambda **kwargs: _storage([skill_a]))
|
||||
SkillActivationMiddleware().wrap_model_call(
|
||||
ModelRequest(
|
||||
model=object(),
|
||||
messages=[HumanMessage(content="/skill-a go", id="m1")],
|
||||
state={"messages": []},
|
||||
runtime=SimpleNamespace(context=context),
|
||||
),
|
||||
lambda r: AIMessage(content="ok"),
|
||||
)
|
||||
assert read_active_secrets(context) == {"A_TOKEN": "v-a"}
|
||||
|
||||
monkeypatch.setattr(mw, "get_or_new_skill_storage", lambda **kwargs: _storage([skill_b]))
|
||||
SkillActivationMiddleware().wrap_model_call(
|
||||
ModelRequest(
|
||||
model=object(),
|
||||
messages=[HumanMessage(content="/skill-b go", id="m2")],
|
||||
state={"messages": []},
|
||||
runtime=SimpleNamespace(context=context),
|
||||
),
|
||||
lambda r: AIMessage(content="ok"),
|
||||
)
|
||||
assert read_active_secrets(context) == {}
|
||||
|
||||
def test_prior_activation_secrets_cleared_when_caller_omits_required(self, tmp_path, monkeypatch):
|
||||
"""Even when the next skill DOES declare a required secret, if the caller
|
||||
omits it the prior skill's value must not linger — the injection set ends
|
||||
up empty, not stale."""
|
||||
from deerflow.agents.middlewares import skill_activation_middleware as mw
|
||||
from deerflow.agents.middlewares.skill_activation_middleware import SkillActivationMiddleware
|
||||
from deerflow.runtime.secret_context import read_active_secrets
|
||||
|
||||
skill = _make_secret_skill(tmp_path, "erp", [SecretRequirement("ERP_TOKEN")])
|
||||
storage = SimpleNamespace(
|
||||
load_skills=lambda *, enabled_only: [skill],
|
||||
get_container_root=lambda: "/mnt/skills",
|
||||
get_skills_root_path=lambda: tmp_path,
|
||||
)
|
||||
monkeypatch.setattr(mw, "get_or_new_skill_storage", lambda **kwargs: storage)
|
||||
|
||||
# Turn 1: caller supplies ERP_TOKEN → injected.
|
||||
context = {"secrets": {"ERP_TOKEN": "tok-1"}}
|
||||
mw_inst = SkillActivationMiddleware()
|
||||
mw_inst.wrap_model_call(
|
||||
ModelRequest(
|
||||
model=object(),
|
||||
messages=[HumanMessage(content="/erp go", id="m1")],
|
||||
state={"messages": []},
|
||||
runtime=SimpleNamespace(context=context),
|
||||
),
|
||||
lambda r: AIMessage(content="ok"),
|
||||
)
|
||||
assert read_active_secrets(context) == {"ERP_TOKEN": "tok-1"}
|
||||
|
||||
# Turn 2: caller omits ERP_TOKEN → prior value cleared, set empty (not stale).
|
||||
context2 = {"secrets": {}}
|
||||
mw_inst.wrap_model_call(
|
||||
ModelRequest(
|
||||
model=object(),
|
||||
messages=[HumanMessage(content="/erp again", id="m2")],
|
||||
state={"messages": []},
|
||||
runtime=SimpleNamespace(context=context2),
|
||||
),
|
||||
lambda r: AIMessage(content="ok"),
|
||||
)
|
||||
assert read_active_secrets(context2) == {}
|
||||
|
||||
|
||||
class TestBashToolInjectsActiveSecrets:
|
||||
"""The bash tool forwards the per-run injection set to execute_command(env=...)."""
|
||||
|
||||
def _run_bash(self, context):
|
||||
from deerflow.sandbox import tools as tools_mod
|
||||
|
||||
captured = {}
|
||||
|
||||
class FakeSandbox:
|
||||
def execute_command(self, command, env=None, timeout=None):
|
||||
captured["env"] = env
|
||||
captured["timeout"] = timeout
|
||||
return "done"
|
||||
|
||||
runtime = SimpleNamespace(context=context, state={"sandbox": {"sandbox_id": "aio:1"}})
|
||||
with (
|
||||
patch.object(tools_mod, "ensure_sandbox_initialized", return_value=FakeSandbox()),
|
||||
patch.object(tools_mod, "is_local_sandbox", return_value=False),
|
||||
patch.object(tools_mod, "ensure_thread_directories_exist", return_value=None),
|
||||
):
|
||||
out = tools_mod.bash_tool.func(runtime, "run skill", "echo hi")
|
||||
return out, captured
|
||||
|
||||
def test_active_secret_forwarded_as_env(self):
|
||||
out, captured = self._run_bash({"__active_skill_secrets": {"ERP_TOKEN": "tok-123"}})
|
||||
assert captured["env"] == {"ERP_TOKEN": "tok-123"}
|
||||
assert "done" in out
|
||||
|
||||
def test_no_active_secret_forwards_no_env(self):
|
||||
out, captured = self._run_bash({})
|
||||
assert captured["env"] in (None, {})
|
||||
|
||||
def test_local_bash_forwards_env_and_timeout(self, monkeypatch):
|
||||
from deerflow.sandbox import tools as tools_mod
|
||||
|
||||
captured = {}
|
||||
|
||||
class FakeSandbox:
|
||||
def execute_command(self, command, env=None, timeout=None):
|
||||
captured["command"] = command
|
||||
captured["env"] = env
|
||||
captured["timeout"] = timeout
|
||||
return "done"
|
||||
|
||||
runtime = SimpleNamespace(
|
||||
context={"__active_skill_secrets": {"ERP_TOKEN": "tok-456"}},
|
||||
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=321, bash_command_timeout=42))
|
||||
with (
|
||||
patch.object(tools_mod, "ensure_sandbox_initialized", return_value=FakeSandbox()),
|
||||
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("deerflow.config.app_config.get_app_config", return_value=fake_cfg),
|
||||
):
|
||||
out = tools_mod.bash_tool.func(runtime, "run local skill", "echo hi")
|
||||
|
||||
assert out == "done"
|
||||
assert captured["command"] == "echo hi"
|
||||
assert captured["env"] == {"ERP_TOKEN": "tok-456"}
|
||||
assert captured["timeout"] == 42
|
||||
|
||||
|
||||
_SECRET = "sk-erp-9f3c-DO-NOT-LEAK"
|
||||
|
||||
|
||||
class TestLeakSurfaces:
|
||||
"""Assert the secret value is absent from all five leak surfaces (#3861)."""
|
||||
|
||||
def _activate_with_secret(self, tmp_path, monkeypatch):
|
||||
from deerflow.agents.middlewares import skill_activation_middleware as mw
|
||||
from deerflow.agents.middlewares.skill_activation_middleware import SkillActivationMiddleware
|
||||
|
||||
skill = _make_secret_skill(tmp_path, "erp-report", [SecretRequirement("ERP_TOKEN")])
|
||||
storage = SimpleNamespace(
|
||||
load_skills=lambda *, enabled_only: [skill],
|
||||
get_container_root=lambda: "/mnt/skills",
|
||||
get_skills_root_path=lambda: tmp_path,
|
||||
)
|
||||
monkeypatch.setattr(mw, "get_or_new_skill_storage", lambda **kwargs: storage)
|
||||
|
||||
journal_records: list[dict] = []
|
||||
journal = SimpleNamespace(record_middleware=lambda *a, **k: journal_records.append({"a": a, "k": k}))
|
||||
context = {"secrets": {"ERP_TOKEN": _SECRET}, "__run_journal": journal}
|
||||
request = ModelRequest(
|
||||
model=object(),
|
||||
messages=[HumanMessage(content="/erp-report pull report", id="m1")],
|
||||
state={"messages": []},
|
||||
runtime=SimpleNamespace(context=context),
|
||||
)
|
||||
captured = {}
|
||||
SkillActivationMiddleware().wrap_model_call(request, lambda r: captured.setdefault("messages", r.messages) or AIMessage(content="ok"))
|
||||
return context, captured["messages"], journal_records
|
||||
|
||||
def test_prompt_surface_has_no_secret(self, tmp_path, monkeypatch):
|
||||
# The injected activation message (the only thing added to the prompt /
|
||||
# checkpointed messages) must not contain the secret value.
|
||||
_, messages, _ = self._activate_with_secret(tmp_path, monkeypatch)
|
||||
for m in messages:
|
||||
assert _SECRET not in str(m.content)
|
||||
|
||||
def test_checkpoint_surface_separation(self, tmp_path, monkeypatch):
|
||||
# Secrets live on runtime.context, never in the graph state that gets
|
||||
# checkpointed (messages/state).
|
||||
context, messages, _ = self._activate_with_secret(tmp_path, monkeypatch)
|
||||
assert context["secrets"]["ERP_TOKEN"] == _SECRET # present in context...
|
||||
assert _SECRET not in str([m.content for m in messages]) # ...not in state
|
||||
|
||||
def test_audit_surface_has_no_secret(self, tmp_path, monkeypatch):
|
||||
_, _, journal_records = self._activate_with_secret(tmp_path, monkeypatch)
|
||||
assert journal_records, "activation should record an audit event"
|
||||
assert _SECRET not in str(journal_records)
|
||||
|
||||
def test_trace_metadata_has_no_secret(self, monkeypatch):
|
||||
from deerflow.tracing import metadata as meta
|
||||
|
||||
monkeypatch.setattr(meta, "get_enabled_tracing_providers", lambda: {"langfuse"})
|
||||
config = {"context": {"secrets": {"ERP_TOKEN": _SECRET}}, "metadata": {}}
|
||||
meta.inject_langfuse_metadata(config, thread_id="t", user_id="u", model_name="m")
|
||||
assert _SECRET not in str(config["metadata"])
|
||||
# And secrets were never mirrored into configurable.
|
||||
assert _SECRET not in str(config.get("configurable", {}))
|
||||
|
||||
def test_redact_helper_strips_secret_keys(self):
|
||||
from deerflow.runtime.secret_context import redact_secret_context_keys
|
||||
|
||||
ctx = {"thread_id": "t", "secrets": {"ERP_TOKEN": _SECRET}, "__active_skill_secrets": {"ERP_TOKEN": _SECRET}}
|
||||
redacted = redact_secret_context_keys(ctx)
|
||||
assert redacted == {"thread_id": "t"}
|
||||
assert _SECRET not in str(redacted)
|
||||
|
||||
def test_redact_config_secrets_strips_from_persisted_config(self):
|
||||
# The run-record persistence + run API echo the raw request config; the
|
||||
# stored/echoed copy must not carry secrets (verifier blocker), while the
|
||||
# live config used to drive the run keeps them.
|
||||
from deerflow.runtime.secret_context import redact_config_secrets
|
||||
|
||||
config = {"context": {"secrets": {"ERP_TOKEN": _SECRET}, "thread_id": "t", "model_name": "m"}, "recursion_limit": 100}
|
||||
redacted = redact_config_secrets(config)
|
||||
assert _SECRET not in str(redacted)
|
||||
assert redacted["context"]["thread_id"] == "t"
|
||||
assert redacted["context"]["model_name"] == "m"
|
||||
assert "secrets" not in redacted["context"]
|
||||
# Original is untouched (live config still has secrets).
|
||||
assert config["context"]["secrets"] == {"ERP_TOKEN": _SECRET}
|
||||
|
||||
def test_redact_config_secrets_handles_none_and_no_context(self):
|
||||
from deerflow.runtime.secret_context import redact_config_secrets
|
||||
|
||||
assert redact_config_secrets(None) is None
|
||||
assert redact_config_secrets({"configurable": {"thread_id": "t"}}) == {"configurable": {"thread_id": "t"}}
|
||||
|
||||
def test_stdout_surface_redacted(self):
|
||||
from deerflow.sandbox.tools import mask_secret_values
|
||||
|
||||
leaked = f"DEBUG: token is {_SECRET} done"
|
||||
masked = mask_secret_values(leaked, {"ERP_TOKEN": _SECRET})
|
||||
assert _SECRET not in masked
|
||||
assert "[redacted]" in masked
|
||||
|
||||
def test_short_secret_values_not_masked(self):
|
||||
"""Values below the minimum length floor are skipped — redacting a 2-char
|
||||
value would shred unrelated bytes (exit codes, timestamps, sizes) of tool
|
||||
output. The secret is still injected into the subprocess; only the output
|
||||
mask skips it."""
|
||||
from deerflow.sandbox.tools import mask_secret_values
|
||||
|
||||
# A short value must not be replaced everywhere in the output.
|
||||
out = "exit code: 42\nrows: 42\n"
|
||||
masked = mask_secret_values(out, {"REGION": "42"})
|
||||
assert masked == out # unchanged — short value left intact
|
||||
|
||||
# A long value is still redacted as before.
|
||||
long_secret = "sk-erp-long-enough-token-value"
|
||||
masked_long = mask_secret_values(f"token={long_secret}", {"ERP_TOKEN": long_secret})
|
||||
assert long_secret not in masked_long
|
||||
assert "[redacted]" in masked_long
|
||||
|
||||
|
||||
@pytest.mark.skipif(__import__("os").name == "nt", reason="POSIX shell semantics")
|
||||
class TestEndToEndRealSubprocess:
|
||||
"""End-to-end across the real chain (no sandbox mock): activation resolves the
|
||||
secret, a REAL LocalSandbox subprocess receives it via env, the value lands in
|
||||
a file but is redacted from the returned output, and a later un-injected call
|
||||
cannot see it."""
|
||||
|
||||
def test_secret_reaches_real_subprocess_only_via_env_and_is_scoped(self, tmp_path, monkeypatch):
|
||||
from deerflow.agents.middlewares import skill_activation_middleware as mw
|
||||
from deerflow.agents.middlewares.skill_activation_middleware import SkillActivationMiddleware
|
||||
from deerflow.runtime.secret_context import read_active_secrets
|
||||
from deerflow.sandbox.tools import mask_secret_values
|
||||
|
||||
# 1. Activate a skill that declares ERP_TOKEN; caller supplies it in context.secrets.
|
||||
skill = _make_secret_skill(tmp_path, "erp-report", [SecretRequirement("ERP_TOKEN")])
|
||||
storage = SimpleNamespace(
|
||||
load_skills=lambda *, enabled_only: [skill],
|
||||
get_container_root=lambda: "/mnt/skills",
|
||||
get_skills_root_path=lambda: tmp_path,
|
||||
)
|
||||
monkeypatch.setattr(mw, "get_or_new_skill_storage", lambda **kwargs: storage)
|
||||
# A platform secret is present on the host and must NOT leak to the subprocess.
|
||||
monkeypatch.setenv("OPENAI_API_KEY", "sk-host-platform-secret")
|
||||
context = {"secrets": {"ERP_TOKEN": _SECRET}}
|
||||
request = ModelRequest(
|
||||
model=object(),
|
||||
messages=[HumanMessage(content="/erp-report pull report", id="m1")],
|
||||
state={"messages": []},
|
||||
runtime=SimpleNamespace(context=context),
|
||||
)
|
||||
SkillActivationMiddleware().wrap_model_call(request, lambda r: AIMessage(content="ok"))
|
||||
injected = read_active_secrets(context)
|
||||
assert injected == {"ERP_TOKEN": _SECRET}
|
||||
|
||||
# 2. A REAL LocalSandbox runs a script that writes the token to a file and echoes it.
|
||||
out_file = tmp_path / "token.txt"
|
||||
sandbox = LocalSandbox(id="local")
|
||||
raw = sandbox.execute_command(
|
||||
f'printf "%s" "$ERP_TOKEN" > {out_file}; echo "leaked:$ERP_TOKEN"; echo "platform:$OPENAI_API_KEY"',
|
||||
env=injected,
|
||||
)
|
||||
|
||||
# 3. The skill genuinely received the token via env (file written by the subprocess).
|
||||
assert out_file.read_text() == _SECRET
|
||||
# 4. Platform secret was scrubbed — not available to the script.
|
||||
assert "sk-host-platform-secret" not in raw
|
||||
# 5. Stdout masking redacts the echoed token before it would re-enter context.
|
||||
masked = mask_secret_values(raw, injected)
|
||||
assert _SECRET not in masked
|
||||
|
||||
# 6. Per-call scope: a later command without injection cannot see the token.
|
||||
leaked = sandbox.execute_command("echo [$ERP_TOKEN]")
|
||||
assert _SECRET not in leaked
|
||||
|
|
@ -918,7 +918,13 @@ class _FakeSandbox:
|
|||
self._write_ok = write_ok
|
||||
self._check_result = check_result
|
||||
|
||||
def execute_command(self, command: str) -> str:
|
||||
def execute_command(
|
||||
self,
|
||||
command: str,
|
||||
env: dict[str, str] | None = None,
|
||||
timeout: float | None = None,
|
||||
) -> str:
|
||||
del env, timeout
|
||||
self.commands.append(command)
|
||||
if command.startswith("test -s"):
|
||||
return self._check_result
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue