Skip to content

Commit c547c28

Browse files
committed
feat(tools): add ToolSafetyFilter for filter chain integration
1 parent 7991a56 commit c547c28

2 files changed

Lines changed: 210 additions & 0 deletions

File tree

tests/tools/safety/test_filter.py

Lines changed: 118 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,118 @@
1+
# Tencent is pleased to support the open source community by making tRPC-Agent-Python available.
2+
#
3+
# Copyright (C) 2026 Tencent. All rights reserved.
4+
#
5+
# tRPC-Agent-Python is licensed under Apache-2.0.
6+
"""Tests for ToolSafetyFilter integration."""
7+
8+
from unittest.mock import AsyncMock
9+
from unittest.mock import MagicMock
10+
from unittest.mock import patch
11+
12+
import pytest
13+
14+
from trpc_agent_sdk.abc._filter import FilterResult
15+
from trpc_agent_sdk.tools.safety._filter import ToolSafetyFilter
16+
from trpc_agent_sdk.tools.safety._types import Decision
17+
from trpc_agent_sdk.tools.safety._types import RiskLevel
18+
from trpc_agent_sdk.tools.safety._types import RiskType
19+
from trpc_agent_sdk.tools.safety._types import RuleFinding
20+
from trpc_agent_sdk.tools.safety._types import ScanReport
21+
22+
23+
@pytest.fixture
24+
def mock_scanner():
25+
scanner = MagicMock()
26+
scanner.scan = AsyncMock()
27+
scanner.hash_script = MagicMock(return_value="abc123")
28+
return scanner
29+
30+
31+
@pytest.fixture
32+
def mock_audit_logger():
33+
logger = MagicMock()
34+
return logger
35+
36+
37+
@pytest.fixture
38+
def filter_instance(mock_scanner, mock_audit_logger):
39+
return ToolSafetyFilter(scanner=mock_scanner, audit_logger=mock_audit_logger)
40+
41+
42+
class TestToolSafetyFilter:
43+
async def test_filter_blocks_on_deny(self, filter_instance, mock_scanner):
44+
mock_scanner.scan.return_value = ScanReport(
45+
decision=Decision.DENY,
46+
risk_level=RiskLevel.CRITICAL,
47+
findings=[
48+
RuleFinding(
49+
rule_id="DANGEROUS_DELETE_001",
50+
risk_type=RiskType.DANGEROUS_FILE_OP,
51+
risk_level=RiskLevel.CRITICAL,
52+
evidence="rm -rf /",
53+
message="dangerous",
54+
recommendation="stop",
55+
),
56+
],
57+
scan_duration_ms=10.0,
58+
)
59+
60+
ctx = MagicMock()
61+
req = MagicMock()
62+
req.args = {"command": "rm -rf /"}
63+
req.tool_name = "bash_tool"
64+
rsp = FilterResult()
65+
66+
await filter_instance._before(ctx, req, rsp)
67+
68+
assert rsp.is_continue is False
69+
assert rsp.error is not None
70+
71+
async def test_filter_passes_on_allow(self, filter_instance, mock_scanner):
72+
mock_scanner.scan.return_value = ScanReport(
73+
decision=Decision.ALLOW,
74+
findings=[],
75+
scan_duration_ms=1.0,
76+
)
77+
78+
ctx = MagicMock()
79+
req = MagicMock()
80+
req.args = {"command": "ls -la"}
81+
req.tool_name = "bash_tool"
82+
rsp = FilterResult()
83+
84+
await filter_instance._before(ctx, req, rsp)
85+
86+
assert rsp.is_continue is True
87+
assert rsp.error is None
88+
89+
async def test_filter_with_no_script_content_passes(self, filter_instance, mock_scanner):
90+
mock_scanner.scan = AsyncMock()
91+
92+
ctx = MagicMock()
93+
req = MagicMock()
94+
req.args = {}
95+
req.tool_name = "todo_tool"
96+
rsp = FilterResult()
97+
98+
await filter_instance._before(ctx, req, rsp)
99+
100+
assert rsp.is_continue is True
101+
mock_scanner.scan.assert_not_called()
102+
103+
async def test_audit_logger_called_on_scan(self, filter_instance, mock_scanner, mock_audit_logger):
104+
mock_scanner.scan.return_value = ScanReport(
105+
decision=Decision.ALLOW,
106+
findings=[],
107+
scan_duration_ms=1.0,
108+
)
109+
110+
ctx = MagicMock()
111+
req = MagicMock()
112+
req.args = {"script": "print('hello')"}
113+
req.tool_name = "python_tool"
114+
rsp = FilterResult()
115+
116+
await filter_instance._before(ctx, req, rsp)
117+
118+
mock_audit_logger.log.assert_called_once()
Lines changed: 92 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,92 @@
1+
# Tencent is pleased to support the open source community by making tRPC-Agent-Python available.
2+
#
3+
# Copyright (C) 2026 Tencent. All rights reserved.
4+
#
5+
# tRPC-Agent-Python is licensed under Apache-2.0.
6+
"""Tool safety filter for the TRPC Agent filter pipeline.
7+
8+
Integrates the ToolSafetyScanner as a BaseFilter that runs in _before()
9+
to inspect tool arguments and block dangerous scripts before execution.
10+
"""
11+
12+
from __future__ import annotations
13+
14+
from typing import Any
15+
from typing import Optional
16+
17+
from trpc_agent_sdk.abc import FilterType
18+
from trpc_agent_sdk.filter import BaseFilter
19+
from trpc_agent_sdk.filter import FilterResult
20+
from trpc_agent_sdk.context import AgentContext
21+
22+
from ._audit import SafetyAuditLogger
23+
from ._scanner import ToolSafetyScanner
24+
from ._telemetry import set_safety_span_attrs
25+
from ._types import Decision
26+
27+
28+
class ToolSafetyFilter(BaseFilter):
29+
"""A BaseFilter that scans tool script arguments before execution.
30+
31+
Plugs into the existing tool filter chain via FilterRunner._run_filters().
32+
Blocks execution if the safety scanner returns DENY. Writes audit events
33+
and sets OpenTelemetry span attributes on every scan.
34+
"""
35+
36+
def __init__(
37+
self,
38+
*,
39+
scanner: ToolSafetyScanner,
40+
audit_logger: Optional[SafetyAuditLogger] = None,
41+
):
42+
super().__init__()
43+
self._type = FilterType.TOOL
44+
self._name = "tool_safety"
45+
self._scanner = scanner
46+
self._audit_logger = audit_logger or SafetyAuditLogger()
47+
48+
async def _before(self, ctx: AgentContext, req: Any, rsp: FilterResult):
49+
script = self._extract_script(req)
50+
if not script:
51+
return
52+
53+
tool_name = getattr(req, "tool_name", "unknown")
54+
args = getattr(req, "args", None)
55+
env_vars = getattr(req, "env_vars", None)
56+
57+
report = await self._scanner.scan(
58+
script=script,
59+
tool_name=tool_name,
60+
args=args,
61+
env_vars=env_vars,
62+
)
63+
64+
set_safety_span_attrs(report)
65+
66+
script_hash = self._scanner.hash_script(script)
67+
self._audit_logger.log(report, tool_name=tool_name, script_hash=script_hash)
68+
69+
if report.decision == Decision.DENY:
70+
findings_text = "; ".join(
71+
f"{f.rule_id}: {f.message}" for f in report.findings
72+
)
73+
rsp.error = Exception(
74+
f"Tool execution blocked by safety guard: {findings_text}"
75+
)
76+
rsp.is_continue = False
77+
78+
@staticmethod
79+
def _extract_script(req: Any) -> Optional[str]:
80+
args = getattr(req, "args", None)
81+
if not args or not isinstance(args, dict):
82+
return None
83+
84+
for key in ("command", "script", "code", "text", "content"):
85+
if key in args and isinstance(args[key], str) and args[key].strip():
86+
return args[key]
87+
88+
str_values = [v for v in args.values() if isinstance(v, str) and len(v) > 10]
89+
if str_values:
90+
return "\n".join(str_values)
91+
92+
return None

0 commit comments

Comments
 (0)