Skip to content

Commit 306046f

Browse files
author
张志鹏
committed
test: harden tool safety guard acceptance coverage
1 parent 75b3ffb commit 306046f

3 files changed

Lines changed: 377 additions & 4 deletions

File tree

scripts/tool_safety_check.py

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,10 @@
77
import sys
88
from pathlib import Path
99

10+
ROOT_DIR = Path(__file__).resolve().parents[1]
11+
if str(ROOT_DIR) not in sys.path:
12+
sys.path.insert(0, str(ROOT_DIR))
13+
1014
from trpc_agent_sdk.tools.safety import ToolSafetyGuard
1115
from trpc_agent_sdk.tools.safety import ToolSafetyPolicy
1216
from trpc_agent_sdk.tools.safety import ToolSafetyScanRequest

tests/tools/safety/test_tool_safety_guard.py

Lines changed: 137 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@
66

77
from __future__ import annotations
88

9+
import json
910
import time
1011
from pathlib import Path
1112
from unittest.mock import AsyncMock
@@ -27,6 +28,7 @@
2728
from trpc_agent_sdk.code_executors import CodeExecutionInput
2829
from trpc_agent_sdk.code_executors import BaseCodeExecutor
2930
from trpc_agent_sdk.code_executors import create_code_execution_result
31+
from scripts.tool_safety_check import main as tool_safety_check_main
3032

3133

3234
EXAMPLE_DIR = Path(__file__).resolve().parents[3] / "examples" / "tool_safety_guard"
@@ -86,6 +88,82 @@ def test_issue_required_samples(sample, decision, rule_id):
8688
assert first["risk_level"]
8789

8890

91+
def test_public_samples_emit_structured_reports_and_meet_acceptance_rates():
92+
high_risk_samples = {
93+
"02_dangerous_delete.sh",
94+
"03_read_secret.py",
95+
"04_network_external.py",
96+
"07_shell_injection.py",
97+
"08_dependency_install.sh",
98+
"09_infinite_loop.py",
99+
"10_sensitive_output.py",
100+
}
101+
safe_samples = {"01_safe_python.py", "05_network_allowlist.py"}
102+
required_rule_by_sample = {
103+
"02_dangerous_delete.sh": "TSG-FILE-RECURSIVE-DELETE",
104+
"03_read_secret.py": "TSG-FILE-DENIED-PATH",
105+
"04_network_external.py": "TSG-NETWORK-NONALLOWLIST",
106+
}
107+
108+
sample_paths = sorted(SAMPLES.iterdir())
109+
reports = {path.name: scan_sample(path.name) for path in sample_paths}
110+
111+
assert len(reports) == 12
112+
for report in reports.values():
113+
payload = json.loads(report.to_json())
114+
assert payload["decision"]
115+
assert payload["risk_level"]
116+
if payload["decision"] != SafetyDecision.ALLOW.value:
117+
for finding in payload["findings"]:
118+
assert finding["rule_id"]
119+
assert finding["evidence"]
120+
assert finding["recommendation"]
121+
122+
detected_high_risk = sum(1 for name in high_risk_samples if reports[name].decision == SafetyDecision.DENY)
123+
false_positive_safe = sum(1 for name in safe_samples if reports[name].decision != SafetyDecision.ALLOW)
124+
assert detected_high_risk / len(high_risk_samples) >= 0.9
125+
assert false_positive_safe / len(safe_samples) <= 0.1
126+
127+
for sample, rule_id in required_rule_by_sample.items():
128+
report = reports[sample]
129+
assert report.decision == SafetyDecision.DENY
130+
assert any(finding.rule_id == rule_id for finding in report.findings)
131+
132+
133+
def test_cli_scans_public_samples_and_writes_structured_reports(tmp_path, monkeypatch):
134+
for sample in sorted(SAMPLES.iterdir()):
135+
report_path = tmp_path / f"{sample.name}.json"
136+
audit_path = tmp_path / "audit.jsonl"
137+
language = "bash" if sample.suffix == ".sh" else "python"
138+
monkeypatch.setattr(
139+
"sys.argv",
140+
[
141+
"tool_safety_check.py",
142+
str(sample),
143+
"--language",
144+
language,
145+
"--policy",
146+
str(EXAMPLE_DIR / "tool_safety_policy.yaml"),
147+
"--report-out",
148+
str(report_path),
149+
"--audit-out",
150+
str(audit_path),
151+
],
152+
)
153+
154+
exit_code = tool_safety_check_main()
155+
156+
payload = json.loads(report_path.read_text(encoding="utf-8"))
157+
assert exit_code in {0, 2}
158+
assert payload["decision"]
159+
assert payload["risk_level"]
160+
if payload["decision"] != SafetyDecision.ALLOW.value:
161+
assert payload["findings"]
162+
assert payload["findings"][0]["rule_id"]
163+
assert payload["findings"][0]["evidence"]
164+
assert payload["findings"][0]["recommendation"]
165+
166+
89167
def test_denied_path_policy_change_changes_result():
90168
script = 'open(".env", "r").read()'
91169
strict = ToolSafetyScanner(policy(denied_paths=[".env"]))
@@ -115,6 +193,49 @@ def test_allowed_command_policy_change_changes_result():
115193
assert relaxed_report.decision == SafetyDecision.ALLOW
116194

117195

196+
@pytest.mark.parametrize(
197+
("script", "language", "rule_id", "decision"),
198+
[
199+
("echo data > /etc/app.conf", "bash", "TSG-FILE-SYSTEM-WRITE", SafetyDecision.DENY),
200+
("python worker.py &", "bash", "TSG-PROCESS-BACKGROUND", SafetyDecision.NEEDS_HUMAN_REVIEW),
201+
("printf '%s\n' a b | xargs -P 0 -n1 echo", "bash", "TSG-RESOURCE-PARALLELISM", SafetyDecision.DENY),
202+
("timeout 999 python job.py", "bash", "TSG-RESOURCE-LONG-TIMEOUT", SafetyDecision.NEEDS_HUMAN_REVIEW),
203+
(
204+
"import socket\ns = socket.socket()\ns.connect(('evil.example.net', 443))",
205+
"python",
206+
"TSG-NETWORK-NONALLOWLIST",
207+
SafetyDecision.DENY,
208+
),
209+
(
210+
"import subprocess\nsubprocess.run(['python', 'job.py'], timeout=999, capture_output=True)",
211+
"python",
212+
"TSG-RESOURCE-LONG-TIMEOUT",
213+
SafetyDecision.NEEDS_HUMAN_REVIEW,
214+
),
215+
(
216+
"import concurrent.futures\nconcurrent.futures.ThreadPoolExecutor(max_workers=999)",
217+
"python",
218+
"TSG-RESOURCE-PARALLELISM",
219+
SafetyDecision.NEEDS_HUMAN_REVIEW,
220+
),
221+
],
222+
)
223+
def test_additional_required_risk_coverage(script, language, rule_id, decision):
224+
report = ToolSafetyScanner(policy()).scan(ToolSafetyScanRequest(script=script, language=language))
225+
226+
assert report.decision == decision
227+
assert any(finding.rule_id == rule_id for finding in report.findings)
228+
229+
230+
def test_subprocess_output_capture_has_output_size_recommendation():
231+
script = "import subprocess\nsubprocess.run(['python', 'job.py'], stdout=subprocess.PIPE)"
232+
report = ToolSafetyScanner(policy()).scan(ToolSafetyScanRequest(script=script, language="python"))
233+
234+
assert report.decision == SafetyDecision.NEEDS_HUMAN_REVIEW
235+
finding = next(item for item in report.findings if item.rule_id == "TSG-RESOURCE-OUTPUT-CAPTURE")
236+
assert "max_output_bytes" in finding.recommendation
237+
238+
118239
def test_audit_event_contains_required_fields(tmp_path):
119240
report = ToolSafetyScanner(policy()).scan(
120241
ToolSafetyScanRequest(script="rm -rf /tmp/demo", language="bash", tool_metadata={"name": "Bash"}))
@@ -149,6 +270,22 @@ async def test_tool_filter_blocks_before_handle():
149270
handle.assert_not_called()
150271

151272

273+
@pytest.mark.asyncio
274+
async def test_tool_filter_block_writes_audit_event(tmp_path):
275+
audit_path = tmp_path / "tool_safety_audit.jsonl"
276+
filter_ = ToolSafetyFilter(guard=ToolSafetyGuard(policy=policy(), audit_log_path=audit_path))
277+
handle = AsyncMock(return_value={"success": True})
278+
279+
result = await filter_.run(create_agent_context(), {"command": "rm -rf /tmp/demo"}, handle)
280+
281+
assert result.is_continue is False
282+
handle.assert_not_called()
283+
event = json.loads(audit_path.read_text(encoding="utf-8").strip())
284+
assert event["decision"] == "deny"
285+
assert event["rule_id"] == "TSG-FILE-RECURSIVE-DELETE"
286+
assert event["blocked"] is True
287+
288+
152289
@pytest.mark.asyncio
153290
async def test_tool_filter_allows_safe_command():
154291
filter_ = ToolSafetyFilter(guard=ToolSafetyGuard(policy=policy()))

0 commit comments

Comments
 (0)