mirror of
https://github.com/bytedance/deer-flow.git
synced 2026-09-25 22:16:19 +00:00
* fix(sandbox): enforce AIO shell command timeouts Map Sandbox.execute_command(timeout=T) onto the AIO legacy shell as a server-side hard_timeout=T plus a bounded host request (ceil(T+5)s, max_retries=0), preserve and render the upstream status (hard_timeout -> terminated + Exit Code: 124; no_change_timeout -> may still be running), keep no_change_timeout from preempting hard_timeout, and restrict ErrorObservation replay to completed results. * fix(sandbox): contain ambiguous AIO command outcomes Treat a transport timeout and statuses terminated/no_change_timeout/unknown as ambiguous: never replay the current command, fence the scoped or recovery session generation with bounded best-effort cleanup, and never adopt a replacement session for those statuses. hard_timeout and completed keep the session reusable. Align the env-bearing bash.exec path with the same contract and make its retry result authoritative. * fix(sandbox): wire AIO command timeout from provider config Restore bash_tool's non-local behaviour (other providers keep their own defaults) and let AioSandboxProvider read sandbox.bash_command_timeout as the sandbox's default_command_timeout at every construction site, with explicit per-call timeouts still winning. Widen the config field to float with allow_inf_nan=False and read the provider value with the module fallback so partial-config providers keep working. * docs(sandbox): document AIO command timeout semantics Document the provider-scoped deadline (LocalSandbox/AioSandbox/OpenSandbox wire it; other providers keep their defaults), the semver-vs-frozen-:latest image behaviour, the no-replay guarantees, and trim the sandbox guidance back under its size budget. * docs(sandbox): scope the wedge-resistance claim to command requests willem-bd's review on #5634 reproduced that list_dir and both create_session call sites still make unbounded SDK requests while holding the same sandbox lock, so the guide must not read as if every AIO request is bounded. State that the bounded T+5s host wait applies to command requests and name session-creation and file/list RPCs as not covered; bounding those remains a separate operation-deadline/control-plane follow-up. * fix(sandbox): make list_dir honor the shell generation fence list_dir previously always targeted the implicit persistent shell, so after an ambiguous command outcome fenced that generation (#5634) a listing could re-enter it. Route default-shell target selection through _ensure_default_shell_session_id so both execute_command and list_dir reuse the explicit recovery session, creating one only when the implicit shell is fenced. No list_dir deadline, hard_timeout, request budget, retry, or status semantics are added; those remain scoped to #5644.
1593 lines
71 KiB
Python
1593 lines
71 KiB
Python
"""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 httpx
|
|
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
|
|
|
|
_SLASH_SOURCE_OWNER_TOKEN = "test-slash-source-owner"
|
|
|
|
|
|
def _echo_env_probe(name: str) -> str:
|
|
"""Render an env-var echo probe in the syntax of the shell LocalSandbox picks.
|
|
|
|
The POSIX `$NAME` form expands under neither PowerShell nor cmd.exe, so on
|
|
Windows hosts the probe must use the resolved shell's own expansion syntax;
|
|
otherwise the variable echoes empty and the negative checks below silently
|
|
stop measuring anything (an unset PowerShell variable prints as a blank
|
|
line, which would let even a leaked secret pass).
|
|
"""
|
|
shell = LocalSandbox._get_shell()
|
|
if LocalSandbox._is_powershell(shell):
|
|
return f"echo [$env:{name}]"
|
|
if LocalSandbox._is_cmd_shell(shell):
|
|
return f"echo [%{name}%]"
|
|
return f"echo [${name}]"
|
|
|
|
|
|
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_env_probe("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_env_probe("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(_echo_env_probe("DEERFLOW_EPHEMERAL"), env={"DEERFLOW_EPHEMERAL": "leaky"})
|
|
out = sandbox.execute_command(_echo_env_probe("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_env_probe("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_env_probe("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_env_probe("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 uses the legacy shell path with bounded timeout/status handling."""
|
|
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_explicit_command_timeout_and_request_budget(self, sandbox):
|
|
sandbox._client.bash.exec = MagicMock(
|
|
return_value=SimpleNamespace(
|
|
data=SimpleNamespace(
|
|
stdout="ok",
|
|
stderr=None,
|
|
exit_code=0,
|
|
status="completed",
|
|
)
|
|
)
|
|
)
|
|
|
|
sandbox.execute_command(
|
|
"echo hi",
|
|
env={"X": "1"},
|
|
timeout=3,
|
|
)
|
|
|
|
_, kwargs = sandbox._client.bash.exec.call_args
|
|
assert kwargs["hard_timeout"] == 3
|
|
assert kwargs["request_options"] == {
|
|
"timeout_in_seconds": 8,
|
|
"max_retries": 0,
|
|
}
|
|
|
|
def test_env_path_uses_default_hard_timeout_when_timeout_is_none(self, sandbox):
|
|
sandbox._client.bash.exec = MagicMock(
|
|
return_value=SimpleNamespace(
|
|
data=SimpleNamespace(
|
|
stdout="ok",
|
|
stderr=None,
|
|
exit_code=0,
|
|
status="completed",
|
|
)
|
|
)
|
|
)
|
|
|
|
sandbox.execute_command("echo hi", env={"X": "1"})
|
|
|
|
_, kwargs = sandbox._client.bash.exec.call_args
|
|
assert kwargs["hard_timeout"] == sandbox._DEFAULT_HARD_TIMEOUT
|
|
|
|
def test_env_hard_timeout_is_rendered_and_not_retried(self, sandbox):
|
|
executions = 0
|
|
|
|
def bash_exec(**kwargs):
|
|
nonlocal executions
|
|
executions += 1
|
|
return SimpleNamespace(
|
|
data=SimpleNamespace(
|
|
stdout="partial",
|
|
stderr=None,
|
|
exit_code=-1,
|
|
status="timed_out",
|
|
)
|
|
)
|
|
|
|
sandbox._client.bash.exec = bash_exec
|
|
|
|
out = sandbox.execute_command(
|
|
"side-effect; sleep 30",
|
|
env={"X": "1"},
|
|
timeout=3,
|
|
)
|
|
|
|
assert executions == 1
|
|
assert "partial" in out
|
|
assert "Command timed out after 3 seconds and was terminated." in out
|
|
assert out.endswith("Exit Code: 124")
|
|
|
|
@pytest.mark.parametrize("status", ["timed_out", "killed"])
|
|
def test_env_interrupted_status_is_never_error_observation_retried(
|
|
self,
|
|
sandbox,
|
|
status,
|
|
):
|
|
executions = 0
|
|
|
|
def bash_exec(**kwargs):
|
|
nonlocal executions
|
|
executions += 1
|
|
return SimpleNamespace(
|
|
data=SimpleNamespace(
|
|
stdout="'ErrorObservation' object has no attribute 'exit_code'",
|
|
stderr=None,
|
|
exit_code=-1,
|
|
status=status,
|
|
)
|
|
)
|
|
|
|
sandbox._client.bash.exec = bash_exec
|
|
|
|
sandbox.execute_command(
|
|
"unsafe-to-repeat",
|
|
env={"X": "1"},
|
|
timeout=3,
|
|
)
|
|
|
|
assert executions == 1
|
|
|
|
def test_env_session_cleanup_is_bounded_and_does_not_mask_output(self, sandbox):
|
|
sandbox._client.bash.exec = MagicMock(
|
|
return_value=SimpleNamespace(
|
|
data=SimpleNamespace(
|
|
stdout="ok",
|
|
stderr="",
|
|
exit_code=0,
|
|
status="completed",
|
|
)
|
|
)
|
|
)
|
|
sandbox._client.bash.close_session = MagicMock(side_effect=RuntimeError("cleanup failed"))
|
|
|
|
assert (
|
|
sandbox.execute_command(
|
|
"echo $TOKEN",
|
|
env={"TOKEN": "secret"},
|
|
timeout=3,
|
|
)
|
|
== "ok"
|
|
)
|
|
|
|
_, kwargs = sandbox._client.bash.close_session.call_args
|
|
assert kwargs["request_options"] == {
|
|
"timeout_in_seconds": sandbox._CLEANUP_REQUEST_TIMEOUT_SECONDS,
|
|
"max_retries": 0,
|
|
}
|
|
|
|
@pytest.mark.parametrize(
|
|
("status", "notice"),
|
|
[
|
|
("killed", "Command was killed before completion."),
|
|
(
|
|
"running",
|
|
"Error: Sandbox command returned a non-terminal running status; command outcome is unknown and was not retried.",
|
|
),
|
|
],
|
|
)
|
|
def test_env_interrupted_status_is_rendered_without_exit_fallback(
|
|
self,
|
|
sandbox,
|
|
status,
|
|
notice,
|
|
):
|
|
sandbox._client.bash.exec = MagicMock(
|
|
return_value=SimpleNamespace(
|
|
data=SimpleNamespace(
|
|
stdout="partial",
|
|
stderr=None,
|
|
exit_code=-1,
|
|
status=status,
|
|
)
|
|
)
|
|
)
|
|
|
|
out = sandbox.execute_command(
|
|
"unsafe-to-repeat",
|
|
env={"X": "1"},
|
|
timeout=3,
|
|
)
|
|
|
|
assert notice in out
|
|
assert "Exit Code: -1" not in out
|
|
|
|
def test_env_transport_timeout_is_ambiguous_and_not_retried(self, sandbox):
|
|
executions = 0
|
|
|
|
def bash_exec(**kwargs):
|
|
nonlocal executions
|
|
executions += 1
|
|
raise httpx.ReadTimeout("response stalled")
|
|
|
|
sandbox._client.bash.exec = bash_exec
|
|
|
|
out = sandbox.execute_command(
|
|
"side-effect; sleep 30",
|
|
env={"X": "1"},
|
|
timeout=3,
|
|
)
|
|
|
|
assert executions == 1
|
|
assert "outcome is unknown" in out
|
|
assert "not retried" in out
|
|
_, kwargs = sandbox._client.bash.close_session.call_args
|
|
assert kwargs["request_options"] == {
|
|
"timeout_in_seconds": sandbox._CLEANUP_REQUEST_TIMEOUT_SECONDS,
|
|
"max_retries": 0,
|
|
}
|
|
|
|
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
|
|
|
|
@pytest.mark.parametrize(
|
|
("retry_result", "expected_fragments", "expected_suffix"),
|
|
[
|
|
pytest.param(
|
|
SimpleNamespace(
|
|
data=SimpleNamespace(
|
|
stdout="authoritative timeout output",
|
|
stderr=None,
|
|
exit_code=-1,
|
|
status="timed_out",
|
|
)
|
|
),
|
|
("Command timed out after 3 seconds and was terminated.",),
|
|
"Exit Code: 124",
|
|
id="timed-out",
|
|
),
|
|
pytest.param(
|
|
SimpleNamespace(
|
|
data=SimpleNamespace(
|
|
stdout="authoritative killed output",
|
|
stderr=None,
|
|
exit_code=-1,
|
|
status="killed",
|
|
)
|
|
),
|
|
("Command was killed before completion.",),
|
|
None,
|
|
id="killed",
|
|
),
|
|
pytest.param(
|
|
SimpleNamespace(
|
|
data=SimpleNamespace(
|
|
stdout="authoritative running output",
|
|
stderr=None,
|
|
exit_code=-1,
|
|
status="running",
|
|
)
|
|
),
|
|
(
|
|
"non-terminal running status",
|
|
"outcome is unknown",
|
|
"not retried",
|
|
),
|
|
None,
|
|
id="running",
|
|
),
|
|
pytest.param(
|
|
httpx.ReadTimeout("response stalled"),
|
|
("outcome is unknown", "not retried"),
|
|
None,
|
|
id="transport-timeout",
|
|
),
|
|
],
|
|
)
|
|
def test_env_error_observation_retry_returns_authoritative_outcome(
|
|
self,
|
|
sandbox,
|
|
retry_result,
|
|
expected_fragments,
|
|
expected_suffix,
|
|
):
|
|
from deerflow.community.aio_sandbox.aio_sandbox import _ERROR_OBSERVATION_SIGNATURE
|
|
|
|
corrupted = SimpleNamespace(
|
|
data=SimpleNamespace(
|
|
stdout=f"corrupted: {_ERROR_OBSERVATION_SIGNATURE}",
|
|
stderr=None,
|
|
exit_code=0,
|
|
status="completed",
|
|
)
|
|
)
|
|
sandbox._client.bash.exec = MagicMock(side_effect=[corrupted, retry_result])
|
|
|
|
out = sandbox.execute_command("unsafe-to-repeat", env={"X": "1"}, timeout=3)
|
|
|
|
assert sandbox._client.bash.exec.call_count == 2
|
|
assert "corrupted:" not in out
|
|
assert _ERROR_OBSERVATION_SIGNATURE not in out
|
|
for expected_fragment in expected_fragments:
|
|
assert expected_fragment in out
|
|
if expected_suffix is not None:
|
|
assert out.endswith(expected_suffix)
|
|
|
|
|
|
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",
|
|
# Password vars for services whose connection strings are already blocked
|
|
# above. These carry no KEY/SECRET/TOKEN/PASSWORD/PASSWD substring, and a
|
|
# blanket ``*PWD*`` / ``*AUTH*`` pattern would strip benign vars (``PWD``,
|
|
# ``OLDPWD``), so they need exact entries.
|
|
"MYSQL_PWD", # read directly by mysql / libmysqlclient
|
|
"REDISCLI_AUTH", # read directly by redis-cli
|
|
"REDIS_AUTH",
|
|
# Abbreviated ``_PASS`` password vars: value-bearing plaintext passwords
|
|
# that the full-spelling ``*PASSWORD*`` / ``*PASSWD*`` patterns miss.
|
|
"DB_PASS",
|
|
"SMTP_PASS",
|
|
"MYSQL_PASS",
|
|
"REDIS_PASS",
|
|
"FTP_PASS",
|
|
"MAIL_PASS",
|
|
# Postgres file-based credential sources read by libpq/psql with no flag,
|
|
# the direct analog of MYSQL_PWD/REDISCLI_AUTH above. PGPASSFILE names a
|
|
# .pgpass (host:port:db:user:password); PGSERVICEFILE names a
|
|
# pg_service.conf that may carry a password field.
|
|
"PGPASSFILE",
|
|
"PGSERVICEFILE",
|
|
# Credential *helpers*: each names a program that dispenses a credential
|
|
# on demand. Inheriting the pointer is the same leak class as inheriting
|
|
# the value, so ``*PASS*`` scrubbing them is intended. Pinned here so the
|
|
# behaviour is a deliberate decision rather than a side effect of the
|
|
# pattern's shape.
|
|
"GIT_ASKPASS",
|
|
"SSH_ASKPASS",
|
|
"SUDO_ASKPASS",
|
|
# ssh-agent socket: a credential pointer like the ASKPASS helpers —
|
|
# inheriting it lets the sandbox sign with every key the agent holds.
|
|
"SSH_AUTH_SOCK",
|
|
],
|
|
)
|
|
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",
|
|
"OLDPWD",
|
|
"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):
|
|
"""Names here must survive the scrub.
|
|
|
|
Note what this list does *not* contain: any name carrying a ``PASS``
|
|
substring. That is deliberate, not an oversight — ``*PASS*`` scrubs every
|
|
such name, including the ``*_ASKPASS`` credential helpers pinned in
|
|
``test_secret_like_names_are_blocked`` above. Over-scrubbing is this
|
|
module's fail-safe direction; a skill that needs a scrubbed name declares
|
|
it via ``required-secrets``. ``PWD``/``OLDPWD`` are the boundary this list
|
|
does pin: they carry no ``PASS`` substring and must never be stripped.
|
|
"""
|
|
from deerflow.sandbox.env_policy import is_blocked_env_name
|
|
|
|
assert is_blocked_env_name(name) is False
|
|
|
|
def test_db_password_vars_do_not_reach_the_subprocess_env(self, monkeypatch):
|
|
"""The URL forms are scrubbed; the password vars for the same services must be too.
|
|
|
|
``mysql`` reads ``MYSQL_PWD`` and ``redis-cli`` reads ``REDISCLI_AUTH`` as the
|
|
password with no further configuration, so inheriting them hands a skill
|
|
subprocess the credential the connection-string block already withholds.
|
|
"""
|
|
from deerflow.sandbox.env_policy import build_sandbox_env
|
|
|
|
monkeypatch.setenv("MYSQL_URL", "mysql://user:pw@host/db")
|
|
monkeypatch.setenv("PWD", "/repo") # POSIX hosts set PWD themselves; plant it so the survival check runs on Windows too
|
|
monkeypatch.setenv("MYSQL_PWD", "prod-db-password")
|
|
monkeypatch.setenv("REDISCLI_AUTH", "prod-redis-auth")
|
|
env = build_sandbox_env()
|
|
assert "MYSQL_URL" not in env
|
|
assert "MYSQL_PWD" not in env
|
|
assert "REDISCLI_AUTH" not in env
|
|
assert env.get("PWD") # the working directory must survive the added entries
|
|
|
|
def test_injection_still_wins_for_the_newly_blocked_names(self, monkeypatch):
|
|
"""``required-secrets`` stays the escape hatch for the names added here.
|
|
|
|
The request-scoped value must also override the host's, which is the
|
|
per-user-key-overrides-shared-key case from #3861.
|
|
"""
|
|
from deerflow.sandbox.env_policy import build_sandbox_env
|
|
|
|
monkeypatch.setenv("MYSQL_PWD", "host-value-must-not-leak")
|
|
env = build_sandbox_env(injected={"MYSQL_PWD": "request-scoped-value"})
|
|
assert env["MYSQL_PWD"] == "request-scoped-value"
|
|
|
|
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_build_run_config_strips_caller_dunder_context_keys(self):
|
|
"""Security (#3938): the harness writes private ``__``-prefixed keys into
|
|
``runtime.context`` (binding sources, active-secret set, run journal). A
|
|
caller must not be able to seed them via ``config.context`` and forge
|
|
internal state — they are stripped at the gateway boundary."""
|
|
from app.gateway.services import build_run_config
|
|
|
|
config = build_run_config(
|
|
"thread-1",
|
|
{
|
|
"context": {
|
|
"secrets": {"ERP_TOKEN": "v"},
|
|
"__slash_skill_secret_source": {"path": "x", "owner_token": "forged"},
|
|
"__active_skill_secrets": {"ADMIN": "stolen"},
|
|
"__skill_tool_policy_decision": {
|
|
"owner_token": "forged",
|
|
"allowed_names": None,
|
|
},
|
|
}
|
|
},
|
|
None,
|
|
)
|
|
assert config["context"]["secrets"] == {"ERP_TOKEN": "v"}
|
|
assert "__slash_skill_secret_source" not in config["context"]
|
|
assert "__active_skill_secrets" not in config["context"]
|
|
assert "__skill_tool_policy_decision" not in config["context"]
|
|
|
|
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 test_slash_skill_source_path_public_contract(self):
|
|
from deerflow.runtime.secret_context import read_slash_skill_source_path, write_slash_skill_source_path
|
|
|
|
context = {}
|
|
write_slash_skill_source_path(
|
|
context,
|
|
"/mnt/skills/public/reviewer/SKILL.md",
|
|
owner_token="middleware-owner",
|
|
)
|
|
|
|
assert read_slash_skill_source_path(context, owner_token="middleware-owner") == "/mnt/skills/public/reviewer/SKILL.md"
|
|
assert read_slash_skill_source_path(context, owner_token="caller-forged") is None
|
|
|
|
def test_slash_skill_source_path_rejects_malformed_shapes(self):
|
|
from deerflow.runtime.secret_context import read_slash_skill_source_path
|
|
|
|
malformed = [
|
|
None,
|
|
"path",
|
|
[],
|
|
{"path": None, "owner_token": "middleware-owner"},
|
|
{"path": "", "owner_token": "middleware-owner"},
|
|
{"path": 7, "owner_token": "middleware-owner"},
|
|
{"path": "/mnt/skills/public/reviewer/SKILL.md"},
|
|
{"path": "/mnt/skills/public/reviewer/SKILL.md", "owner_token": ""},
|
|
]
|
|
for value in malformed:
|
|
assert (
|
|
read_slash_skill_source_path(
|
|
{"__slash_skill_secret_source": value},
|
|
owner_token="middleware-owner",
|
|
)
|
|
is None
|
|
)
|
|
assert read_slash_skill_source_path({}, owner_token="middleware-owner") is None
|
|
assert read_slash_skill_source_path(None, owner_token="middleware-owner") is None
|
|
|
|
|
|
def _make_secret_skill(tmp_path: Path, name: str, required_secrets, *, enabled: bool = True, secrets_autonomous: bool = True):
|
|
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=enabled,
|
|
required_secrets=tuple(required_secrets),
|
|
secrets_autonomous=secrets_autonomous,
|
|
)
|
|
|
|
|
|
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(slash_source_owner_token=_SLASH_SOURCE_OWNER_TOKEN)
|
|
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(slash_source_owner_token=_SLASH_SOURCE_OWNER_TOKEN)
|
|
|
|
# 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(slash_source_owner_token=_SLASH_SOURCE_OWNER_TOKEN).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(slash_source_owner_token=_SLASH_SOURCE_OWNER_TOKEN).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(slash_source_owner_token=_SLASH_SOURCE_OWNER_TOKEN)
|
|
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) == {}
|
|
|
|
|
|
def _skill_context_entry(skill) -> dict:
|
|
return {
|
|
"name": skill.name,
|
|
"path": f"/mnt/skills/{skill.category}/{skill.name}/SKILL.md",
|
|
"description": skill.description,
|
|
"loaded_at": 0,
|
|
}
|
|
|
|
|
|
class TestInContextBindsSecrets:
|
|
"""Binding point A+ (issue #3914 gap 1): a skill the model loaded earlier in
|
|
the thread (tracked by ``ThreadState.skill_context``) keeps receiving its
|
|
declared secrets on later turns — without a fresh ``/slash`` — as long as
|
|
the caller supplies the values on the current request. Authorization stays
|
|
three-gated regardless of activation style: skill enabled by the operator,
|
|
values supplied per-request by the caller, names declared in frontmatter.
|
|
"""
|
|
|
|
def _run_call(self, tmp_path, monkeypatch, skills, *, context, skill_context=None, message="continue the report", available_skills=None, middleware=None, container_root="/mnt/skills"):
|
|
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: list(skills),
|
|
get_container_root=lambda: container_root,
|
|
get_skills_root_path=lambda: tmp_path,
|
|
)
|
|
monkeypatch.setattr(mw, "get_or_new_skill_storage", lambda **kwargs: storage)
|
|
mw_inst = middleware or SkillActivationMiddleware(slash_source_owner_token=_SLASH_SOURCE_OWNER_TOKEN, available_skills=available_skills)
|
|
mw_inst.wrap_model_call(
|
|
ModelRequest(
|
|
model=object(),
|
|
messages=[HumanMessage(content=message, id="m1")],
|
|
state={"messages": [], "skill_context": skill_context or []},
|
|
runtime=SimpleNamespace(context=context),
|
|
),
|
|
lambda r: AIMessage(content="ok"),
|
|
)
|
|
return mw_inst
|
|
|
|
def test_in_context_skill_binds_secrets_without_slash(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", "UNRELATED": "x"}}
|
|
self._run_call(tmp_path, monkeypatch, [skill], context=context, skill_context=[_skill_context_entry(skill)])
|
|
|
|
assert read_active_secrets(context) == {"ERP_TOKEN": "tok-123"}
|
|
|
|
def test_binding_clears_when_skill_evicted_from_context(self, tmp_path, monkeypatch):
|
|
"""Long-lived binding follows skill_context membership exactly: once the
|
|
entry is evicted (capacity) the injection disappears on the next call."""
|
|
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"}}
|
|
mw_inst = self._run_call(tmp_path, monkeypatch, [skill], context=context, skill_context=[_skill_context_entry(skill)])
|
|
assert read_active_secrets(context) == {"ERP_TOKEN": "tok-123"}
|
|
|
|
self._run_call(tmp_path, monkeypatch, [skill], context=context, skill_context=[], middleware=mw_inst)
|
|
assert read_active_secrets(context) == {}
|
|
|
|
def test_disabled_skill_in_context_not_bound(self, tmp_path, monkeypatch):
|
|
from deerflow.runtime.secret_context import read_active_secrets
|
|
|
|
skill = _make_secret_skill(tmp_path, "erp-report", [SecretRequirement("ERP_TOKEN")], enabled=False)
|
|
context = {"secrets": {"ERP_TOKEN": "tok-123"}}
|
|
self._run_call(tmp_path, monkeypatch, [skill], context=context, skill_context=[_skill_context_entry(skill)])
|
|
|
|
assert read_active_secrets(context) == {}
|
|
|
|
def test_skill_outside_agent_allowlist_not_bound(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"}}
|
|
self._run_call(
|
|
tmp_path,
|
|
monkeypatch,
|
|
[skill],
|
|
context=context,
|
|
skill_context=[_skill_context_entry(skill)],
|
|
available_skills={"some-other-skill"},
|
|
)
|
|
|
|
assert read_active_secrets(context) == {}
|
|
|
|
def test_secrets_autonomous_false_blocks_in_context_but_not_slash(self, tmp_path, monkeypatch):
|
|
"""The per-skill opt-out keeps explicit-activation ceremony available for
|
|
high-sensitivity skills: in-context binding is refused, slash still works."""
|
|
from deerflow.runtime.secret_context import read_active_secrets
|
|
|
|
skill = _make_secret_skill(tmp_path, "erp-report", [SecretRequirement("ERP_TOKEN")], secrets_autonomous=False)
|
|
|
|
context = {"secrets": {"ERP_TOKEN": "tok-123"}}
|
|
self._run_call(tmp_path, monkeypatch, [skill], context=context, skill_context=[_skill_context_entry(skill)])
|
|
assert read_active_secrets(context) == {}
|
|
|
|
slash_context = {"secrets": {"ERP_TOKEN": "tok-123"}}
|
|
self._run_call(tmp_path, monkeypatch, [skill], context=slash_context, message="/erp-report go")
|
|
assert read_active_secrets(slash_context) == {"ERP_TOKEN": "tok-123"}
|
|
|
|
def test_slash_and_in_context_sources_merge(self, tmp_path, monkeypatch):
|
|
from deerflow.runtime.secret_context import read_active_secrets
|
|
|
|
loaded = _make_secret_skill(tmp_path, "erp-report", [SecretRequirement("ERP_TOKEN")])
|
|
slashed = _make_secret_skill(tmp_path, "crm-sync", [SecretRequirement("CRM_TOKEN")])
|
|
context = {"secrets": {"ERP_TOKEN": "tok-erp", "CRM_TOKEN": "tok-crm"}}
|
|
self._run_call(
|
|
tmp_path,
|
|
monkeypatch,
|
|
[loaded, slashed],
|
|
context=context,
|
|
skill_context=[_skill_context_entry(loaded)],
|
|
message="/crm-sync push the numbers",
|
|
)
|
|
|
|
assert read_active_secrets(context) == {"ERP_TOKEN": "tok-erp", "CRM_TOKEN": "tok-crm"}
|
|
|
|
def test_forged_slash_source_cannot_bypass_gates(self, tmp_path, monkeypatch):
|
|
"""Security (#3938): `runtime.context` is caller-mergeable, so a client can
|
|
forge `__slash_skill_secret_source`. The slash source is re-validated
|
|
against the live registry (enabled + allowlist), so a forged source naming
|
|
a non-existent skill binds nothing — no gate bypass."""
|
|
from deerflow.runtime.secret_context import _SLASH_SECRET_SOURCE_KEY, read_active_secrets
|
|
|
|
context = {
|
|
"secrets": {"ADMIN_TOKEN": "stolen"},
|
|
_SLASH_SECRET_SOURCE_KEY: {"path": "/mnt/skills/custom/attacker/SKILL.md", "skill_name": "attacker", "requirements": [["ADMIN_TOKEN", False]]},
|
|
}
|
|
self._run_call(tmp_path, monkeypatch, [], context=context, message="no slash here")
|
|
assert read_active_secrets(context) == {}
|
|
|
|
def test_forged_slash_source_ignores_caller_requirements_and_allowlist(self, tmp_path, monkeypatch):
|
|
"""Even if a forged path resolves to a real skill, the caller's forged
|
|
requirements are ignored (only the registry skill's own declared secrets
|
|
bind) and the allowlist still applies."""
|
|
from deerflow.runtime.secret_context import _SLASH_SECRET_SOURCE_KEY, read_active_secrets
|
|
|
|
skill = _make_secret_skill(tmp_path, "erp-report", [SecretRequirement("ERP_TOKEN")])
|
|
context = {
|
|
"secrets": {"ADMIN_TOKEN": "stolen", "ERP_TOKEN": "ok"},
|
|
_SLASH_SECRET_SOURCE_KEY: {"path": "/mnt/skills/custom/erp-report/SKILL.md", "requirements": [["ADMIN_TOKEN", False]]},
|
|
}
|
|
self._run_call(tmp_path, monkeypatch, [skill], context=context, available_skills={"other"})
|
|
assert read_active_secrets(context) == {}
|
|
|
|
def test_malformed_slash_source_does_not_crash(self, tmp_path, monkeypatch):
|
|
"""Robustness (#3938): a forged malformed slash source must fail closed
|
|
(bind nothing), never raise and 500 the run."""
|
|
from deerflow.runtime.secret_context import _SLASH_SECRET_SOURCE_KEY, read_active_secrets
|
|
|
|
skill = _make_secret_skill(tmp_path, "erp-report", [SecretRequirement("ERP_TOKEN")])
|
|
for bad in ({"requirements": [["X"]]}, {"requirements": "abc"}, {"path": 123}, "not-a-dict", {"path": ["a"]}, {}):
|
|
context = {"secrets": {"ERP_TOKEN": "v"}, _SLASH_SECRET_SOURCE_KEY: bad}
|
|
self._run_call(tmp_path, monkeypatch, [skill], context=context, message="x")
|
|
assert read_active_secrets(context) == {}, f"bad={bad!r}"
|
|
|
|
def test_trailing_slash_container_root_still_binds(self, tmp_path, monkeypatch):
|
|
"""Latent bug (#3938): a non-canonical container_path (trailing slash) must
|
|
not silently disable in-context binding — paths are normalized both sides."""
|
|
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"}}
|
|
entry = {"name": "erp-report", "path": "/mnt/skills/custom/erp-report/SKILL.md", "description": "d", "loaded_at": 0}
|
|
self._run_call(tmp_path, monkeypatch, [skill], context=context, skill_context=[entry], container_root="/mnt/skills/")
|
|
assert read_active_secrets(context) == {"ERP_TOKEN": "tok-123"}
|
|
|
|
def test_shadowing_name_does_not_bind_unread_skill(self, tmp_path, monkeypatch):
|
|
"""Confused-deputy guard: a custom skill may shadow a same-named public
|
|
one (load_skills de-dupes by name, custom wins). A thread that read the
|
|
PUBLIC foo (no declared secrets) must NOT bind the CUSTOM foo's declared
|
|
secret — matching is by exact container path, never by name."""
|
|
from deerflow.runtime.secret_context import read_active_secrets
|
|
|
|
# Registry exposes only the custom foo (name de-dup, custom wins); the
|
|
# model read the public foo, whose path differs.
|
|
custom_foo = _make_secret_skill(tmp_path, "foo", [SecretRequirement("ERP_TOKEN")])
|
|
context = {"secrets": {"ERP_TOKEN": "tok-123"}}
|
|
entry = {
|
|
"name": "foo",
|
|
"path": "/mnt/skills/public/foo/SKILL.md", # the PUBLIC one the model actually read
|
|
"description": "d",
|
|
"loaded_at": 0,
|
|
}
|
|
self._run_call(tmp_path, monkeypatch, [custom_foo], context=context, skill_context=[entry])
|
|
|
|
assert read_active_secrets(context) == {}
|
|
|
|
def test_stale_path_does_not_fall_back_to_name(self, tmp_path, monkeypatch):
|
|
"""A skill_context path that no longer resolves must not degrade to a
|
|
name match — it simply does not bind."""
|
|
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"}}
|
|
entry = {
|
|
"name": "erp-report",
|
|
"path": "/mnt/skills/custom/erp-report-OLD-PATH/SKILL.md",
|
|
"description": "d",
|
|
"loaded_at": 0,
|
|
}
|
|
self._run_call(tmp_path, monkeypatch, [skill], context=context, skill_context=[entry])
|
|
|
|
assert read_active_secrets(context) == {}
|
|
|
|
def test_no_caller_secrets_means_no_binding(self, tmp_path, monkeypatch):
|
|
"""The supply gate: without caller-provided values on THIS request there
|
|
is nothing to inject, no matter what is in skill_context."""
|
|
from deerflow.runtime.secret_context import read_active_secrets
|
|
|
|
skill = _make_secret_skill(tmp_path, "erp-report", [SecretRequirement("ERP_TOKEN")])
|
|
context = {"secrets": {}}
|
|
self._run_call(tmp_path, monkeypatch, [skill], context=context, skill_context=[_skill_context_entry(skill)])
|
|
|
|
assert read_active_secrets(context) == {}
|
|
|
|
def test_binding_change_recorded_in_audit_journal_names_only(self, tmp_path, monkeypatch):
|
|
skill = _make_secret_skill(tmp_path, "erp-report", [SecretRequirement("ERP_TOKEN")])
|
|
journal = MagicMock()
|
|
context = {"secrets": {"ERP_TOKEN": "tok-secret-value"}, "__run_journal": journal}
|
|
self._run_call(tmp_path, monkeypatch, [skill], context=context, skill_context=[_skill_context_entry(skill)])
|
|
|
|
bind_calls = [call for call in journal.record_middleware.call_args_list if call.kwargs.get("action") == "bind_secrets"]
|
|
assert len(bind_calls) == 1
|
|
changes = bind_calls[0].kwargs["changes"]
|
|
assert changes["skills"] == ["erp-report"]
|
|
assert changes["secrets"] == ["ERP_TOKEN"]
|
|
# Values must never reach the audit journal.
|
|
assert "tok-secret-value" not in str(bind_calls[0])
|
|
|
|
def test_binding_audit_failure_warns_without_breaking_binding(self, tmp_path, monkeypatch, caplog):
|
|
from deerflow.runtime.secret_context import read_active_secrets
|
|
|
|
skill = _make_secret_skill(tmp_path, "erp-report", [SecretRequirement("ERP_TOKEN")])
|
|
journal = MagicMock()
|
|
journal.record_middleware.side_effect = RuntimeError("db down")
|
|
context = {"secrets": {"ERP_TOKEN": "tok-123"}, "__run_journal": journal}
|
|
|
|
with caplog.at_level("WARNING"):
|
|
self._run_call(
|
|
tmp_path,
|
|
monkeypatch,
|
|
[skill],
|
|
context=context,
|
|
skill_context=[_skill_context_entry(skill)],
|
|
)
|
|
|
|
assert read_active_secrets(context) == {"ERP_TOKEN": "tok-123"}
|
|
assert "Failed to record skill secret binding audit event" in caplog.text
|
|
|
|
def test_slash_binding_persists_across_model_calls_in_same_run(self, tmp_path, monkeypatch):
|
|
"""#3861 semantics preserved under per-call recompute: after the single
|
|
activation call, the tool loop issues more model calls without a fresh
|
|
slash — the binding must survive on the shared run context."""
|
|
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"}}
|
|
mw_inst = self._run_call(tmp_path, monkeypatch, [skill], context=context, message="/erp-report go")
|
|
assert read_active_secrets(context) == {"ERP_TOKEN": "tok-123"}
|
|
|
|
# Later model call in the SAME run (same context object): no slash in the
|
|
# latest message, skill never entered skill_context (slash injects the
|
|
# body directly, no read_file happens).
|
|
self._run_call(tmp_path, monkeypatch, [skill], context=context, message="tool loop continues", middleware=mw_inst)
|
|
assert read_active_secrets(context) == {"ERP_TOKEN": "tok-123"}
|
|
|
|
def test_unchanged_binding_not_re_recorded(self, tmp_path, monkeypatch):
|
|
skill = _make_secret_skill(tmp_path, "erp-report", [SecretRequirement("ERP_TOKEN")])
|
|
journal = MagicMock()
|
|
context = {"secrets": {"ERP_TOKEN": "tok-1"}, "__run_journal": journal}
|
|
mw_inst = self._run_call(tmp_path, monkeypatch, [skill], context=context, skill_context=[_skill_context_entry(skill)])
|
|
self._run_call(tmp_path, monkeypatch, [skill], context=context, skill_context=[_skill_context_entry(skill)], middleware=mw_inst)
|
|
|
|
bind_calls = [call for call in journal.record_middleware.call_args_list if call.kwargs.get("action") == "bind_secrets"]
|
|
assert len(bind_calls) == 1
|
|
|
|
|
|
class TestSecretsAutonomousParsing:
|
|
"""Frontmatter ``secrets-autonomous`` controls in-context (autonomous) binding."""
|
|
|
|
def _parse(self, tmp_path, frontmatter_extra: str):
|
|
from deerflow.skills.parser import parse_skill_file
|
|
from deerflow.skills.types import SkillCategory
|
|
|
|
skill_dir = tmp_path / "erp-report"
|
|
skill_dir.mkdir()
|
|
skill_file = skill_dir / "SKILL.md"
|
|
skill_file.write_text(
|
|
f"""---
|
|
name: erp-report
|
|
description: Pull an ERP report.
|
|
required-secrets:
|
|
- ERP_TOKEN
|
|
{frontmatter_extra}---
|
|
|
|
Body.
|
|
""",
|
|
encoding="utf-8",
|
|
)
|
|
return parse_skill_file(skill_file, SkillCategory.CUSTOM)
|
|
|
|
def test_defaults_to_true(self, tmp_path):
|
|
skill = self._parse(tmp_path, "")
|
|
assert skill is not None
|
|
assert skill.secrets_autonomous is True
|
|
|
|
def test_explicit_false(self, tmp_path):
|
|
skill = self._parse(tmp_path, "secrets-autonomous: false\n")
|
|
assert skill is not None
|
|
assert skill.secrets_autonomous is False
|
|
|
|
def test_malformed_value_fails_closed(self, tmp_path, caplog):
|
|
"""A non-boolean value disables autonomous binding (the safer direction)
|
|
instead of silently enabling it."""
|
|
skill = self._parse(tmp_path, 'secrets-autonomous: "yes please"\n')
|
|
assert skill is not None
|
|
assert skill.secrets_autonomous is False
|
|
|
|
|
|
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=runtime, command="echo hi", description="run skill")
|
|
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=runtime, command="echo hi", description="run local skill")
|
|
|
|
assert out == "done"
|
|
assert captured["command"] == "echo hi"
|
|
assert captured["env"] == {"ERP_TOKEN": "tok-456"}
|
|
assert captured["timeout"] == 42
|
|
|
|
def test_remote_bash_does_not_forward_shared_timeout(self):
|
|
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={},
|
|
state={"sandbox": {"sandbox_id": "aio:1"}},
|
|
)
|
|
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=False),
|
|
patch.object(
|
|
tools_mod,
|
|
"ensure_thread_directories_exist",
|
|
return_value=None,
|
|
),
|
|
patch(
|
|
"deerflow.config.app_config.get_app_config",
|
|
return_value=fake_cfg,
|
|
),
|
|
):
|
|
out = tools_mod.bash_tool.func(
|
|
runtime=runtime,
|
|
command="echo hi",
|
|
description="run remote",
|
|
)
|
|
|
|
assert out == "done"
|
|
assert captured["timeout"] is None
|
|
|
|
|
|
_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(slash_source_owner_token=_SLASH_SOURCE_OWNER_TOKEN).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 SKILL_TOOL_POLICY_DECISION_CONTEXT_KEY, redact_secret_context_keys
|
|
|
|
ctx = {
|
|
"thread_id": "t",
|
|
"secrets": {"ERP_TOKEN": _SECRET},
|
|
"__active_skill_secrets": {"ERP_TOKEN": _SECRET},
|
|
"__slash_skill_secret_source": {
|
|
"path": "/mnt/skills/public/reviewer/SKILL.md",
|
|
"owner_token": "slash-owner-token",
|
|
},
|
|
SKILL_TOOL_POLICY_DECISION_CONTEXT_KEY: {
|
|
"version": 1,
|
|
"owner_token": "policy-owner-token",
|
|
"active_paths": ["/mnt/skills/public/reviewer/SKILL.md"],
|
|
"allowed_names": None,
|
|
},
|
|
}
|
|
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 SKILL_TOOL_POLICY_DECISION_CONTEXT_KEY, redact_config_secrets
|
|
|
|
config = {
|
|
"context": {
|
|
"secrets": {
|
|
"ERP_TOKEN": _SECRET,
|
|
"nested": {"secondary": _SECRET},
|
|
},
|
|
"thread_id": "t",
|
|
"model_name": "m",
|
|
"__slash_skill_secret_source": {
|
|
"path": "/mnt/skills/public/reviewer/SKILL.md",
|
|
"owner_token": "slash-owner-token",
|
|
},
|
|
SKILL_TOOL_POLICY_DECISION_CONTEXT_KEY: {
|
|
"version": 1,
|
|
"owner_token": "forged-or-leaked-token",
|
|
"active_paths": ["/mnt/skills/public/reviewer/SKILL.md"],
|
|
"allowed_names": None,
|
|
},
|
|
},
|
|
"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"]
|
|
assert SKILL_TOOL_POLICY_DECISION_CONTEXT_KEY not in redacted["context"]
|
|
# Original is untouched (live config still has secrets).
|
|
assert config["context"]["secrets"] == {
|
|
"ERP_TOKEN": _SECRET,
|
|
"nested": {"secondary": _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(slash_source_owner_token=_SLASH_SOURCE_OWNER_TOKEN).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
|