feat(guardrails): 添加工具调用安全中间件,强化了调用前检查和隐私保护

This commit is contained in:
肖应宇 2026-05-06 10:33:34 +08:00
parent ab9555255a
commit 9b8bc09414
8 changed files with 359 additions and 2 deletions

View File

@ -112,6 +112,34 @@ guardrails:
3. Ask the agent: "Use bash to run echo hello" 3. Ask the agent: "Use bash to run echo hello"
4. The agent sees: `Guardrail denied: tool 'bash' was blocked (oap.tool_not_allowed)` 4. The agent sees: `Guardrail denied: tool 'bash' was blocked (oap.tool_not_allowed)`
### Option 1.5: Built-in SensitiveDataProvider (Strict Secret Blocking)
For secret-leak prevention, DeerFlow also ships `SensitiveDataProvider`, which
blocks tool calls targeting sensitive file patterns (for example `.env`,
`.env.*`, `*.pem`, `*.key`, `id_rsa*`, `secrets.*`, `credentials.*`).
This provider is strict: it blocks matching access even when the user
explicitly asks the agent to reveal those files.
**config.yaml:**
```yaml
guardrails:
enabled: true
fail_closed: true
provider:
use: deerflow.guardrails.builtin:SensitiveDataProvider
config:
protected_tools: ["read_file", "write_file", "str_replace", "ls", "glob", "grep", "bash"]
deny_basenames: [".env"]
deny_globs: [".env.*", "*.pem", "*.key", "id_rsa*", "secrets.*", "credentials.*"]
block_skills_env: true
```
**Behavior summary:**
- `read_file / ls / glob / grep / bash` attempting sensitive path access are denied
- `write_file / str_replace` touching sensitive targets are denied
- Denials are logged as structured audit events (tool/reason/thread/timestamp)
### Option 2: OAP Passport Provider (Policy-Based) ### Option 2: OAP Passport Provider (Policy-Based)
For policy enforcement based on the [Open Agent Passport (OAP)](https://github.com/aporthq/aport-spec) open standard. An OAP passport is a JSON document that declares an agent's identity, capabilities, and operational limits. Any provider that reads an OAP passport and returns OAP-compliant decisions works with DeerFlow. For policy enforcement based on the [Open Agent Passport (OAP)](https://github.com/aporthq/aport-spec) open standard. An OAP passport is a JSON document that declares an agent's identity, capabilities, and operational limits. Any provider that reads an OAP passport and returns OAP-compliant decisions works with DeerFlow.

View File

@ -0,0 +1,64 @@
"""Redact sensitive values from tool outputs before they re-enter the model context."""
from __future__ import annotations
import re
from collections.abc import Awaitable, Callable
from typing import override
from langchain.agents import AgentState
from langchain.agents.middleware import AgentMiddleware
from langchain_core.messages import ToolMessage
from langgraph.prebuilt.tool_node import ToolCallRequest
from langgraph.types import Command
_SIMPLE_REDACTION = "[REDACTED]"
_PATTERNS = [
re.compile(r"(?im)\b([A-Z][A-Z0-9_]{1,64})\s*=\s*([^\s\"'`]{6,})"),
re.compile(r"(?i)\b(bearer\s+)[A-Za-z0-9._\-+/=]{8,}"),
re.compile(r"(?i)\b(sk-[A-Za-z0-9]{12,})\b"),
re.compile(r"(?i)\b(eyJ[A-Za-z0-9_\-]{10,}\.[A-Za-z0-9_\-]{10,}\.[A-Za-z0-9_\-]{10,})\b"),
re.compile(r"(?is)-----BEGIN [A-Z ]*PRIVATE KEY-----.*?-----END [A-Z ]*PRIVATE KEY-----"),
]
def _redact_text(text: str) -> str:
value = text
# Preserve var name for KEY=VALUE style output.
value = _PATTERNS[0].sub(lambda m: f"{m.group(1)}={_SIMPLE_REDACTION}", value)
value = _PATTERNS[1].sub(lambda m: f"{m.group(1)}{_SIMPLE_REDACTION}", value)
for pattern in _PATTERNS[2:]:
value = pattern.sub(_SIMPLE_REDACTION, value)
return value
class SensitiveOutputRedactionMiddleware(AgentMiddleware[AgentState]):
"""Redact secrets from tool outputs."""
def _redact_tool_result(self, result: ToolMessage | Command) -> ToolMessage | Command:
if not isinstance(result, ToolMessage):
return result
if isinstance(result.content, str):
redacted = _redact_text(result.content)
if redacted != result.content:
return ToolMessage(content=redacted, tool_call_id=result.tool_call_id, name=result.name, status=result.status)
return result
return result
@override
def wrap_tool_call(
self,
request: ToolCallRequest,
handler: Callable[[ToolCallRequest], ToolMessage | Command],
) -> ToolMessage | Command:
return self._redact_tool_result(handler(request))
@override
async def awrap_tool_call(
self,
request: ToolCallRequest,
handler: Callable[[ToolCallRequest], Awaitable[ToolMessage | Command]],
) -> ToolMessage | Command:
return self._redact_tool_result(await handler(request))

View File

@ -127,8 +127,10 @@ def _build_runtime_middlewares(
middlewares.append(GuardrailMiddleware(provider, fail_closed=guardrails_config.fail_closed, passport=guardrails_config.passport)) middlewares.append(GuardrailMiddleware(provider, fail_closed=guardrails_config.fail_closed, passport=guardrails_config.passport))
from deerflow.agents.middlewares.sandbox_audit_middleware import SandboxAuditMiddleware from deerflow.agents.middlewares.sandbox_audit_middleware import SandboxAuditMiddleware
from deerflow.agents.middlewares.sensitive_output_redaction_middleware import SensitiveOutputRedactionMiddleware
middlewares.append(SandboxAuditMiddleware()) middlewares.append(SandboxAuditMiddleware())
middlewares.append(SensitiveOutputRedactionMiddleware())
middlewares.append(ToolErrorHandlingMiddleware()) middlewares.append(ToolErrorHandlingMiddleware())
return middlewares return middlewares

View File

@ -1,11 +1,12 @@
"""Pre-tool-call authorization middleware.""" """Pre-tool-call authorization middleware."""
from deerflow.guardrails.builtin import AllowlistProvider from deerflow.guardrails.builtin import AllowlistProvider, SensitiveDataProvider
from deerflow.guardrails.middleware import GuardrailMiddleware from deerflow.guardrails.middleware import GuardrailMiddleware
from deerflow.guardrails.provider import GuardrailDecision, GuardrailProvider, GuardrailReason, GuardrailRequest from deerflow.guardrails.provider import GuardrailDecision, GuardrailProvider, GuardrailReason, GuardrailRequest
__all__ = [ __all__ = [
"AllowlistProvider", "AllowlistProvider",
"SensitiveDataProvider",
"GuardrailDecision", "GuardrailDecision",
"GuardrailMiddleware", "GuardrailMiddleware",
"GuardrailProvider", "GuardrailProvider",

View File

@ -1,7 +1,20 @@
"""Built-in guardrail providers that ship with DeerFlow.""" """Built-in guardrail providers that ship with DeerFlow."""
from __future__ import annotations
import fnmatch
import json
import logging
import re
import shlex
from datetime import UTC, datetime
from pathlib import PurePosixPath
from typing import Any
from deerflow.guardrails.provider import GuardrailDecision, GuardrailReason, GuardrailRequest from deerflow.guardrails.provider import GuardrailDecision, GuardrailReason, GuardrailRequest
logger = logging.getLogger(__name__)
class AllowlistProvider: class AllowlistProvider:
"""Simple allowlist/denylist provider. No external dependencies.""" """Simple allowlist/denylist provider. No external dependencies."""
@ -21,3 +34,138 @@ class AllowlistProvider:
async def aevaluate(self, request: GuardrailRequest) -> GuardrailDecision: async def aevaluate(self, request: GuardrailRequest) -> GuardrailDecision:
return self.evaluate(request) return self.evaluate(request)
class SensitiveDataProvider:
"""Block tool calls that may access sensitive files such as .env and keys."""
name = "sensitive-data"
_DEFAULT_PROTECTED_TOOLS = {
"read_file",
"write_file",
"str_replace",
"ls",
"glob",
"grep",
"bash",
}
_DEFAULT_DENY_BASENAMES = {".env"}
_DEFAULT_DENY_GLOBS = {
".env.*",
"*.pem",
"*.key",
"id_rsa*",
"secrets.*",
"credentials.*",
}
def __init__(
self,
*,
protected_tools: list[str] | None = None,
deny_basenames: list[str] | None = None,
deny_globs: list[str] | None = None,
block_skills_env: bool = True,
**_: Any,
):
self._protected_tools = {t.lower() for t in (protected_tools or list(self._DEFAULT_PROTECTED_TOOLS))}
self._deny_basenames = {n.lower() for n in (deny_basenames or list(self._DEFAULT_DENY_BASENAMES))}
self._deny_globs = {p.lower() for p in (deny_globs or list(self._DEFAULT_DENY_GLOBS))}
self._block_skills_env = block_skills_env
def _normalize_candidate(self, raw: str | None) -> str:
if not raw:
return ""
return str(raw).strip().strip("\"'")
def _looks_sensitive_path(self, raw_path: str) -> bool:
value = self._normalize_candidate(raw_path)
if not value:
return False
lowered = value.lower()
if self._block_skills_env and "/mnt/skills/" in lowered:
basename = PurePosixPath(lowered).name
if basename == ".env" or basename.startswith(".env."):
return True
basename = PurePosixPath(lowered).name
if basename in self._deny_basenames:
return True
return any(fnmatch.fnmatch(basename, pat) for pat in self._deny_globs)
def _extract_bash_candidates(self, command: str) -> list[str]:
candidates: list[str] = []
if not command:
return candidates
try:
tokens = shlex.split(command)
except ValueError:
tokens = command.split()
for token in tokens:
t = token.strip()
if not t:
continue
# Path-like tokens
if "/" in t or t.startswith("."):
candidates.append(t)
# file.env style arguments may not contain slash
if t.lower().startswith(".env"):
candidates.append(t)
return candidates
def _collect_candidates(self, request: GuardrailRequest) -> list[str]:
args = request.tool_input if isinstance(request.tool_input, dict) else {}
tool = request.tool_name
candidates: list[str] = []
if tool in {"read_file", "write_file", "str_replace", "ls"}:
path = args.get("path")
if isinstance(path, str):
candidates.append(path)
elif tool in {"glob", "grep"}:
path = args.get("path")
if isinstance(path, str):
candidates.append(path)
glob_pat = args.get("glob")
if isinstance(glob_pat, str):
candidates.append(glob_pat)
elif tool == "bash":
command = str(args.get("command") or "")
candidates.extend(self._extract_bash_candidates(command))
# Fast-path for common secret exposure commands
if re.search(r"\b(printenv|env)\b", command, flags=re.IGNORECASE):
candidates.append(".env")
return candidates
def _audit(self, request: GuardrailRequest, decision: GuardrailDecision) -> None:
if decision.allow:
return
code = decision.reasons[0].code if decision.reasons else "oap.blocked_pattern"
rec = {
"timestamp": datetime.now(UTC).isoformat(),
"provider": self.name,
"tool_name": request.tool_name,
"reason_code": code,
"thread_id": request.thread_id,
"agent_id": request.agent_id,
}
logger.warning("[SensitiveDataGuardrail] %s", json.dumps(rec, ensure_ascii=False))
def evaluate(self, request: GuardrailRequest) -> GuardrailDecision:
tool = (request.tool_name or "").lower()
if tool not in self._protected_tools:
return GuardrailDecision(allow=True, reasons=[GuardrailReason(code="oap.allowed")])
candidates = self._collect_candidates(request)
if any(self._looks_sensitive_path(c) for c in candidates):
decision = GuardrailDecision(
allow=False,
reasons=[GuardrailReason(code="oap.blocked_pattern", message="sensitive path access is blocked by policy")],
policy_id="sensitive-data.v1",
)
self._audit(request, decision)
return decision
return GuardrailDecision(allow=True, reasons=[GuardrailReason(code="oap.allowed")])
async def aevaluate(self, request: GuardrailRequest) -> GuardrailDecision:
return self.evaluate(request)

View File

@ -8,7 +8,7 @@ from unittest.mock import MagicMock
import pytest import pytest
from langgraph.errors import GraphBubbleUp from langgraph.errors import GraphBubbleUp
from deerflow.guardrails.builtin import AllowlistProvider from deerflow.guardrails.builtin import AllowlistProvider, SensitiveDataProvider
from deerflow.guardrails.middleware import GuardrailMiddleware from deerflow.guardrails.middleware import GuardrailMiddleware
from deerflow.guardrails.provider import GuardrailDecision, GuardrailReason, GuardrailRequest from deerflow.guardrails.provider import GuardrailDecision, GuardrailReason, GuardrailRequest
@ -105,6 +105,46 @@ class TestAllowlistProvider:
assert decision.allow is False assert decision.allow is False
class TestSensitiveDataProvider:
def test_denies_reading_env_file(self):
provider = SensitiveDataProvider()
req = GuardrailRequest(tool_name="read_file", tool_input={"path": "/tmp/.env"})
decision = provider.evaluate(req)
assert decision.allow is False
assert decision.reasons[0].code == "oap.blocked_pattern"
def test_allows_normal_source_file(self):
provider = SensitiveDataProvider()
req = GuardrailRequest(tool_name="read_file", tool_input={"path": "/workspace/app/main.py"})
decision = provider.evaluate(req)
assert decision.allow is True
def test_denies_skills_env(self):
provider = SensitiveDataProvider()
req = GuardrailRequest(tool_name="read_file", tool_input={"path": "/mnt/skills/public/foo/.env.local"})
decision = provider.evaluate(req)
assert decision.allow is False
def test_denies_glob_or_grep_targeting_env(self):
provider = SensitiveDataProvider()
req = GuardrailRequest(tool_name="grep", tool_input={"path": "/workspace", "glob": "**/.env.*"})
decision = provider.evaluate(req)
assert decision.allow is False
def test_denies_bash_cat_env(self):
provider = SensitiveDataProvider()
req = GuardrailRequest(tool_name="bash", tool_input={"command": "cat /workspace/.env"})
decision = provider.evaluate(req)
assert decision.allow is False
def test_denies_case_variant_and_key_material(self):
provider = SensitiveDataProvider()
req1 = GuardrailRequest(tool_name="read_file", tool_input={"path": "/workspace/.ENV"})
req2 = GuardrailRequest(tool_name="read_file", tool_input={"path": "/workspace/id_rsa.pub"})
assert provider.evaluate(req1).allow is False
assert provider.evaluate(req2).allow is False
# --- GuardrailMiddleware tests --- # --- GuardrailMiddleware tests ---

View File

@ -0,0 +1,62 @@
from __future__ import annotations
import asyncio
from unittest.mock import MagicMock
from langchain_core.messages import ToolMessage
from deerflow.agents.middlewares.sensitive_output_redaction_middleware import SensitiveOutputRedactionMiddleware
def _request(name: str = "bash", args: dict | None = None):
req = MagicMock()
req.tool_call = {"name": name, "args": args or {}, "id": "call_1"}
return req
def test_redacts_key_value_and_bearer():
mw = SensitiveOutputRedactionMiddleware()
req = _request()
handler = MagicMock(
return_value=ToolMessage(
content="OPENAI_API_KEY=sk-abc123456789\nAuthorization: Bearer abcdefghijklmnop",
tool_call_id="call_1",
name="bash",
status="success",
)
)
result = mw.wrap_tool_call(req, handler)
assert "OPENAI_API_KEY=[REDACTED]" in result.content
assert "Bearer [REDACTED]" in result.content
def test_redacts_private_key_block():
mw = SensitiveOutputRedactionMiddleware()
req = _request()
handler = MagicMock(
return_value=ToolMessage(
content="-----BEGIN PRIVATE KEY-----\nabc\n-----END PRIVATE KEY-----",
tool_call_id="call_1",
name="bash",
status="success",
)
)
result = mw.wrap_tool_call(req, handler)
assert result.content == "[REDACTED]"
def test_async_path_redacts_jwt():
mw = SensitiveOutputRedactionMiddleware()
req = _request()
async def handler(_):
return ToolMessage(
content="eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.abcde12345.zyxwv98765",
tool_call_id="call_1",
name="bash",
status="success",
)
result = asyncio.run(mw.awrap_tool_call(req, handler))
assert result.content == "[REDACTED]"

View File

@ -832,3 +832,15 @@ checkpointer:
# use: my_package:MyGuardrailProvider # use: my_package:MyGuardrailProvider
# config: # config:
# key: value # key: value
# --- Option 4: SensitiveDataProvider (strict secret-file blocking) ---
# guardrails:
# enabled: true
# fail_closed: true
# provider:
# use: deerflow.guardrails.builtin:SensitiveDataProvider
# config:
# protected_tools: ["read_file", "write_file", "str_replace", "ls", "glob", "grep", "bash"]
# deny_basenames: [".env"]
# deny_globs: [".env.*", "*.pem", "*.key", "id_rsa*", "secrets.*", "credentials.*"]
# block_skills_env: true