feat: refact agent shell output (#39190)

Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com>
This commit is contained in:
Yunlu Wen 2026-07-17 16:27:00 +08:00 committed by GitHub
parent 0e84ae7338
commit 6e5fc1081b
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
9 changed files with 250 additions and 4 deletions

View File

@ -94,6 +94,7 @@ class DifyShellLayerConfig(LayerConfig):
env: list[DifyShellEnvVarConfig] = Field(default_factory=list) env: list[DifyShellEnvVarConfig] = Field(default_factory=list)
secret_refs: list[DifyShellSecretRefConfig] = Field(default_factory=list) secret_refs: list[DifyShellSecretRefConfig] = Field(default_factory=list)
sandbox: DifyShellSandboxConfig | None = None sandbox: DifyShellSandboxConfig | None = None
redact_patterns: list[str] = Field(default_factory=list)
__all__ = [ __all__ = [

View File

@ -33,7 +33,7 @@ import logging
import re import re
import secrets import secrets
import time import time
from dataclasses import dataclass from dataclasses import dataclass, field
from typing import ClassVar, Literal, NotRequired, Protocol, TypedDict, runtime_checkable from typing import ClassVar, Literal, NotRequired, Protocol, TypedDict, runtime_checkable
from pydantic import BaseModel, ConfigDict, Field, NonNegativeInt, field_validator, model_validator from pydantic import BaseModel, ConfigDict, Field, NonNegativeInt, field_validator, model_validator
@ -57,6 +57,7 @@ from dify_agent.adapters.shell.protocols import (
ShellProviderProtocol, ShellProviderProtocol,
ShellResourceProtocol, ShellResourceProtocol,
) )
from dify_agent.agent_stub.protocol import AGENT_STUB_AUTH_JWE_ENV_VAR
from dify_agent.agent_stub.shell_env import ShellAgentStubTokenFactory, build_shell_agent_stub_env from dify_agent.agent_stub.shell_env import ShellAgentStubTokenFactory, build_shell_agent_stub_env
from dify_agent.layers.execution_context import DifyExecutionContextLayerConfig from dify_agent.layers.execution_context import DifyExecutionContextLayerConfig
from dify_agent.layers.shell.configs import DIFY_SHELL_LAYER_TYPE_ID, DifyShellLayerConfig from dify_agent.layers.shell.configs import DIFY_SHELL_LAYER_TYPE_ID, DifyShellLayerConfig
@ -251,6 +252,7 @@ class DifyShellLayer(PydanticAILayer[DifyShellLayerDeps, object, DifyShellLayerC
config: DifyShellLayerConfig config: DifyShellLayerConfig
shell_provider: ShellProviderProtocol shell_provider: ShellProviderProtocol
shell_home_root: str = "/home" shell_home_root: str = "/home"
shell_redact_patterns: list[str] = field(default_factory=list)
agent_stub_api_base_url: str | None = None agent_stub_api_base_url: str | None = None
agent_stub_token_factory: ShellAgentStubTokenFactory | None = None agent_stub_token_factory: ShellAgentStubTokenFactory | None = None
_shell_resource: ShellResourceProtocol | None = None _shell_resource: ShellResourceProtocol | None = None
@ -269,6 +271,7 @@ class DifyShellLayer(PydanticAILayer[DifyShellLayerDeps, object, DifyShellLayerC
*, *,
shell_provider: ShellProviderProtocol | None, shell_provider: ShellProviderProtocol | None,
shell_home_root: str = "/home", shell_home_root: str = "/home",
shell_redact_patterns: list[str] | None = None,
agent_stub_api_base_url: str | None = None, agent_stub_api_base_url: str | None = None,
agent_stub_token_factory: ShellAgentStubTokenFactory | None = None, agent_stub_token_factory: ShellAgentStubTokenFactory | None = None,
) -> Self: ) -> Self:
@ -278,6 +281,7 @@ class DifyShellLayer(PydanticAILayer[DifyShellLayerDeps, object, DifyShellLayerC
config=config, config=config,
shell_provider=shell_provider, shell_provider=shell_provider,
shell_home_root=_normalize_shell_home_root(shell_home_root), shell_home_root=_normalize_shell_home_root(shell_home_root),
shell_redact_patterns=shell_redact_patterns or [],
agent_stub_api_base_url=agent_stub_api_base_url, agent_stub_api_base_url=agent_stub_api_base_url,
agent_stub_token_factory=agent_stub_token_factory, agent_stub_token_factory=agent_stub_token_factory,
) )
@ -417,7 +421,7 @@ class DifyShellLayer(PydanticAILayer[DifyShellLayerDeps, object, DifyShellLayerC
exit_code=result.exit_code, exit_code=result.exit_code,
output_path=observation.output_path, output_path=observation.output_path,
), ),
observation.text, self._redact_output(observation.text),
) )
except (RuntimeError, ValueError) as exc: except (RuntimeError, ValueError) as exc:
return _tool_error_from_exception(exc) return _tool_error_from_exception(exc)
@ -443,7 +447,7 @@ class DifyShellLayer(PydanticAILayer[DifyShellLayerDeps, object, DifyShellLayerC
exit_code=result.exit_code, exit_code=result.exit_code,
output_path=observation.output_path, output_path=observation.output_path,
), ),
observation.text, self._redact_output(observation.text),
) )
except (RuntimeError, ValueError) as exc: except (RuntimeError, ValueError) as exc:
return _tool_error_from_exception(exc, job_id=job_id) return _tool_error_from_exception(exc, job_id=job_id)
@ -469,7 +473,7 @@ class DifyShellLayer(PydanticAILayer[DifyShellLayerDeps, object, DifyShellLayerC
exit_code=result.exit_code, exit_code=result.exit_code,
output_path=observation.output_path, output_path=observation.output_path,
), ),
observation.text, self._redact_output(observation.text),
) )
except (RuntimeError, ValueError) as exc: except (RuntimeError, ValueError) as exc:
return _tool_error_from_exception(exc, job_id=job_id) return _tool_error_from_exception(exc, job_id=job_id)
@ -718,6 +722,30 @@ class DifyShellLayer(PydanticAILayer[DifyShellLayerDeps, object, DifyShellLayerC
env.update(agent_stub_env) env.update(agent_stub_env)
return env return env
def _redact_output(self, text: str) -> str:
"""Redact sensitive content from shell output before the model sees it.
Two layers of redaction are applied:
1. **Built-in token redaction** the actual Agent Stub JWE token value
is always replaced with ``***``. This is unconditional and cannot be
disabled.
2. **Pattern redaction** regex patterns from both server-level
``shell_redact_patterns`` and per-agent ``config.redact_patterns``
are applied via ``re.sub`` to mask additional secrets.
"""
if not text:
return text
# Built-in: always redact the JWE token value.
env = self._build_shell_command_env(include_agent_stub_env=True)
jwe_value = env.get(AGENT_STUB_AUTH_JWE_ENV_VAR)
if jwe_value and len(jwe_value) > 8:
text = text.replace(jwe_value, "***")
# Server-level + per-agent regex patterns.
for pattern in (*self.shell_redact_patterns, *self.config.redact_patterns):
text = re.sub(pattern, "***", text)
return text
async def execute_complete_with_commands( async def execute_complete_with_commands(
commands: ShellCommandProtocol, commands: ShellCommandProtocol,

View File

@ -69,6 +69,7 @@ def create_default_layer_providers(
inner_api_key: str = "", inner_api_key: str = "",
shell_provider: ShellProviderProtocol | None = None, shell_provider: ShellProviderProtocol | None = None,
shell_home_root: str = "/home", shell_home_root: str = "/home",
shell_redact_patterns: list[str] | None = None,
agent_stub_api_base_url: str | None = None, agent_stub_api_base_url: str | None = None,
agent_stub_token_factory: ShellAgentStubTokenFactory | None = None, agent_stub_token_factory: ShellAgentStubTokenFactory | None = None,
) -> tuple[DifyAgentLayerProvider, ...]: ) -> tuple[DifyAgentLayerProvider, ...]:
@ -94,6 +95,7 @@ def create_default_layer_providers(
DifyShellLayerConfig.model_validate(config), DifyShellLayerConfig.model_validate(config),
shell_provider=shell_provider, shell_provider=shell_provider,
shell_home_root=shell_home_root, shell_home_root=shell_home_root,
shell_redact_patterns=shell_redact_patterns or [],
agent_stub_api_base_url=agent_stub_api_base_url, agent_stub_api_base_url=agent_stub_api_base_url,
agent_stub_token_factory=agent_stub_token_factory, agent_stub_token_factory=agent_stub_token_factory,
), ),

View File

@ -67,6 +67,7 @@ def create_app(settings: ServerSettings | None = None) -> FastAPI:
inner_api_key=resolved_settings.inner_api_key or "", inner_api_key=resolved_settings.inner_api_key or "",
shell_provider=shell_provider, shell_provider=shell_provider,
shell_home_root=resolved_settings.shell_home_root, shell_home_root=resolved_settings.shell_home_root,
shell_redact_patterns=resolved_settings.get_shell_redact_patterns(),
agent_stub_api_base_url=resolved_settings.agent_stub_api_base_url, agent_stub_api_base_url=resolved_settings.agent_stub_api_base_url,
agent_stub_token_factory=agent_stub_token_factory, agent_stub_token_factory=agent_stub_token_factory,
) )

View File

@ -52,6 +52,7 @@ class ServerSettings(BaseSettings):
agent_stub_api_base_url: str | None = Field(default=None, validation_alias="DIFY_AGENT_STUB_API_BASE_URL") agent_stub_api_base_url: str | None = Field(default=None, validation_alias="DIFY_AGENT_STUB_API_BASE_URL")
agent_stub_grpc_bind_address: str | None = Field(default=None, validation_alias="DIFY_AGENT_STUB_GRPC_BIND_ADDRESS") agent_stub_grpc_bind_address: str | None = Field(default=None, validation_alias="DIFY_AGENT_STUB_GRPC_BIND_ADDRESS")
server_secret_key: str | None = None server_secret_key: str | None = None
shell_redact_patterns: str = ""
outbound_http_connect_timeout: float = Field(default=10.0, ge=0) outbound_http_connect_timeout: float = Field(default=10.0, ge=0)
outbound_http_read_timeout: float = Field(default=600.0, ge=0) outbound_http_read_timeout: float = Field(default=600.0, ge=0)
outbound_http_write_timeout: float = Field(default=30.0, ge=0) outbound_http_write_timeout: float = Field(default=30.0, ge=0)
@ -126,6 +127,18 @@ class ServerSettings(BaseSettings):
stripped = value.strip() stripped = value.strip()
return stripped or None return stripped or None
def get_shell_redact_patterns(self) -> list[str]:
"""Parse the JSON array from shell_redact_patterns; empty/blank → empty list."""
stripped = self.shell_redact_patterns.strip()
if not stripped:
return []
import json as _json
parsed = _json.loads(stripped)
if not isinstance(parsed, list):
raise ValueError("DIFY_AGENT_SHELL_REDACT_PATTERNS must be a JSON array of strings")
return [str(p) for p in parsed]
@field_validator("shell_home_root") @field_validator("shell_home_root")
@classmethod @classmethod
def normalize_shell_home_root(cls, value: str) -> str: def normalize_shell_home_root(cls, value: str) -> str:

View File

@ -34,6 +34,7 @@ def test_shell_layer_config_defaults_and_forbids_unknown_fields() -> None:
"env": [], "env": [],
"secret_refs": [], "secret_refs": [],
"sandbox": None, "sandbox": None,
"redact_patterns": [],
} }
with pytest.raises(ValidationError): with pytest.raises(ValidationError):

View File

@ -1378,3 +1378,161 @@ def test_resource_context_reraises_non_expired_attach_error() -> None:
with pytest.raises(ShellProviderError, match="some other error"): with pytest.raises(ShellProviderError, match="some other error"):
asyncio.run(scenario()) asyncio.run(scenario())
# ---------------------------------------------------------------------------
# Output redaction tests
# ---------------------------------------------------------------------------
def _layer_with_redaction(
*,
commands: FakeCommands,
config: DifyShellLayerConfig | None = None,
shell_redact_patterns: list[str] | None = None,
token_value: str = "eyJhbGciOiJkaXIiLCJlbmMiOiJBMjU2R0NNIn0.fake-long-jwe-token-value",
) -> tuple[DifyShellLayer, FakeProvider]:
"""Create a layer with agent_stub env injection and optional redaction patterns."""
provider = FakeProvider(resource=FakeResource(commands=commands))
layer = DifyShellLayer.from_config_with_settings(
config or DifyShellLayerConfig(),
shell_provider=provider,
shell_home_root="/home",
shell_redact_patterns=shell_redact_patterns,
agent_stub_api_base_url="http://localhost:5050/agent-stub",
agent_stub_token_factory=lambda execution_context, session_id: token_value,
)
return layer, provider
def test_redact_output_replaces_jwe_token_value() -> None:
"""The JWE token value should always be redacted from shell output."""
token = "eyJhbGciOiJkaXIiLCJlbmMiOiJBMjU2R0NNIn0.super-secret-token-12345"
def run_handler(script: str, cwd: str | None, env: Mapping[str, str] | None, timeout: float) -> ShellCommandResult:
return _command_result(
"job-1",
status="exited",
done=True,
exit_code=0,
output=f"DIFY_AGENT_STUB_AUTH_JWE={token}\n",
offset=100,
)
commands = FakeCommands(
run_handler=run_handler,
tail_handler=lambda _: _command_result("job-1", done=True, exit_code=0, status="exited", offset=100),
)
layer, _provider = _layer_with_redaction(commands=commands, token_value=token)
_bind_execution_context(layer)
layer.runtime_state = _runtime_state()
tools = {tool.name: tool for tool in layer.tools}
async def scenario() -> None:
async with layer.resource_context():
result = await tools["shell_run"].function_schema.call({"script": "env"}, None) # pyright: ignore[reportArgumentType]
_, output = _parse_tagged_observation(result)
assert token not in output
assert "***" in output
asyncio.run(scenario())
def test_redact_output_applies_server_level_patterns() -> None:
"""Server-level regex patterns from env var should redact matching content."""
def run_handler(script: str, cwd: str | None, env: Mapping[str, str] | None, timeout: float) -> ShellCommandResult:
return _command_result(
"job-1",
status="exited",
done=True,
exit_code=0,
output="api_key=sk-proj-abc123xyz\n",
offset=30,
)
commands = FakeCommands(
run_handler=run_handler,
tail_handler=lambda _: _command_result("job-1", done=True, exit_code=0, status="exited", offset=30),
)
layer, _provider = _layer_with_redaction(
commands=commands,
shell_redact_patterns=[r"sk-proj-[A-Za-z0-9]+"],
)
_bind_execution_context(layer)
layer.runtime_state = _runtime_state()
tools = {tool.name: tool for tool in layer.tools}
async def scenario() -> None:
async with layer.resource_context():
result = await tools["shell_run"].function_schema.call({"script": "cat .env"}, None) # pyright: ignore[reportArgumentType]
_, output = _parse_tagged_observation(result)
assert "sk-proj-abc123xyz" not in output
assert "api_key=***" in output
asyncio.run(scenario())
def test_redact_output_applies_per_agent_config_patterns() -> None:
"""Per-agent redact_patterns from DifyShellLayerConfig should also apply."""
def run_handler(script: str, cwd: str | None, env: Mapping[str, str] | None, timeout: float) -> ShellCommandResult:
return _command_result(
"job-1",
status="exited",
done=True,
exit_code=0,
output="token: ghp_aBcDeFgHiJkLmNoPqRsTuVwXyZ1234567890\n",
offset=50,
)
commands = FakeCommands(
run_handler=run_handler,
tail_handler=lambda _: _command_result("job-1", done=True, exit_code=0, status="exited", offset=50),
)
config = DifyShellLayerConfig(redact_patterns=[r"ghp_[A-Za-z0-9]{36}"])
layer, _provider = _layer_with_redaction(commands=commands, config=config)
_bind_execution_context(layer)
layer.runtime_state = _runtime_state()
tools = {tool.name: tool for tool in layer.tools}
async def scenario() -> None:
async with layer.resource_context():
result = await tools["shell_run"].function_schema.call({"script": "echo $TOKEN"}, None) # pyright: ignore[reportArgumentType]
_, output = _parse_tagged_observation(result)
assert "ghp_" not in output
assert "token: ***" in output
asyncio.run(scenario())
def test_redact_output_skips_short_jwe_values() -> None:
"""JWE values ≤8 chars should not be redacted to avoid false positives."""
def run_handler(script: str, cwd: str | None, env: Mapping[str, str] | None, timeout: float) -> ShellCommandResult:
return _command_result(
"job-1",
status="exited",
done=True,
exit_code=0,
output="short\n",
offset=6,
)
commands = FakeCommands(
run_handler=run_handler,
tail_handler=lambda _: _command_result("job-1", done=True, exit_code=0, status="exited", offset=6),
)
# Token value is short — should NOT be redacted even if it appears in output.
layer, _provider = _layer_with_redaction(commands=commands, token_value="short")
_bind_execution_context(layer)
layer.runtime_state = _runtime_state()
tools = {tool.name: tool for tool in layer.tools}
async def scenario() -> None:
async with layer.resource_context():
result = await tools["shell_run"].function_schema.call({"script": "echo hi"}, None) # pyright: ignore[reportArgumentType]
_, output = _parse_tagged_observation(result)
assert "short" in output
asyncio.run(scenario())

View File

@ -306,3 +306,40 @@ def test_build_shell_provider_returns_none_when_enterprise_endpoint_is_unset(
def test_build_shell_provider_rejects_blank_shellctl_entrypoint() -> None: def test_build_shell_provider_rejects_blank_shellctl_entrypoint() -> None:
with pytest.raises(ValidationError, match="shellctl_entrypoint is required"): with pytest.raises(ValidationError, match="shellctl_entrypoint is required"):
_ = ServerSettings(shell_provider="shellctl", shellctl_entrypoint=" ").build_shell_provider() _ = ServerSettings(shell_provider="shellctl", shellctl_entrypoint=" ").build_shell_provider()
def test_server_settings_parses_shell_redact_patterns_json_array(monkeypatch: pytest.MonkeyPatch) -> None:
monkeypatch.setenv("DIFY_AGENT_SHELL_REDACT_PATTERNS", '["sk-[A-Za-z0-9]+","ghp_[A-Za-z0-9]{36}"]')
settings = ServerSettings()
assert settings.get_shell_redact_patterns() == ["sk-[A-Za-z0-9]+", "ghp_[A-Za-z0-9]{36}"]
def test_server_settings_shell_redact_patterns_empty_string_yields_empty_list(monkeypatch: pytest.MonkeyPatch) -> None:
monkeypatch.setenv("DIFY_AGENT_SHELL_REDACT_PATTERNS", "")
settings = ServerSettings()
assert settings.get_shell_redact_patterns() == []
def test_server_settings_shell_redact_patterns_defaults_to_empty_list(
monkeypatch: pytest.MonkeyPatch,
tmp_path: Path,
) -> None:
monkeypatch.delenv("DIFY_AGENT_SHELL_REDACT_PATTERNS", raising=False)
monkeypatch.chdir(tmp_path)
settings = ServerSettings()
assert settings.get_shell_redact_patterns() == []
def test_server_settings_rejects_non_array_shell_redact_patterns(monkeypatch: pytest.MonkeyPatch) -> None:
monkeypatch.setenv("DIFY_AGENT_SHELL_REDACT_PATTERNS", '{"key": "value"}')
settings = ServerSettings()
with pytest.raises(ValueError, match="must be a JSON array"):
settings.get_shell_redact_patterns()

View File

@ -29,3 +29,8 @@ DIFY_AGENT_STUB_API_BASE_URL=http://agent_backend:5050/agent-stub
# Replace this development default in production. # Replace this development default in production.
# Generate one with: python -c 'import secrets; print(secrets.token_urlsafe(32))' # Generate one with: python -c 'import secrets; print(secrets.token_urlsafe(32))'
DIFY_AGENT_SERVER_SECRET_KEY=MDEyMzQ1Njc4OWFiY2RlZjAxMjM0NTY3ODlhYmNkZWY DIFY_AGENT_SERVER_SECRET_KEY=MDEyMzQ1Njc4OWFiY2RlZjAxMjM0NTY3ODlhYmNkZWY
# JSON array of regex patterns to redact from shell output shown to the agent.
# The JWE token value is always redacted regardless of this setting.
# Example: DIFY_AGENT_SHELL_REDACT_PATTERNS=["sk-[A-Za-z0-9]+","ghp_[A-Za-z0-9]{36}"]
DIFY_AGENT_SHELL_REDACT_PATTERNS=