Skip to content

Commit 0c5f8a8

Browse files
committed
test(safety): add types unit tests for tool safety scanner
16 tests covering Decision, RiskLevel, Finding, Report, AuditEvent, Policy, Request, and CodeBlock types. Enums with rank ordering. Mirrors trpc-agent-go/tool/safety/safety.go types. Signed-off-by: coder-mtj <coder-mtj@users.noreply.github.com>
1 parent 099b571 commit 0c5f8a8

4 files changed

Lines changed: 394 additions & 0 deletions

File tree

tests/tools/safety/__init__.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
# Tool safety guard tests.

tests/tools/safety/test_types.py

Lines changed: 221 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,221 @@
1+
# Tencent is pleased to support the open source community by making
2+
# tRPC-Agent-Python available.
3+
#
4+
# Copyright (C) 2026 Tencent. All rights reserved.
5+
#
6+
# tRPC-Agent-Python is licensed under Apache-2.0.
7+
8+
"""Unit tests for safety scanner type definitions.
9+
10+
Mirrors trpc-agent-go/tool/safety/safety.go types.
11+
"""
12+
13+
from __future__ import annotations
14+
15+
import dataclasses
16+
import json
17+
import pytest
18+
19+
20+
class TestDecision:
21+
def test_decision_constants(self):
22+
from trpc_agent_sdk.tools.safety._types import (
23+
Decision, DECISION_ALLOW, DECISION_DENY,
24+
DECISION_ASK, DECISION_NEEDS_HUMAN_REVIEW,
25+
)
26+
assert Decision(DECISION_ALLOW) == Decision("allow")
27+
assert Decision(DECISION_DENY) == Decision("deny")
28+
assert Decision(DECISION_ASK) == Decision("ask")
29+
assert Decision(DECISION_NEEDS_HUMAN_REVIEW) == Decision("needs_human_review")
30+
31+
def test_decision_rank_order(self):
32+
from trpc_agent_sdk.tools.safety._types import (
33+
DECISION_ALLOW, DECISION_ASK, DECISION_NEEDS_HUMAN_REVIEW,
34+
DECISION_DENY, decision_rank,
35+
)
36+
assert decision_rank(DECISION_DENY) > decision_rank(DECISION_NEEDS_HUMAN_REVIEW)
37+
assert decision_rank(DECISION_NEEDS_HUMAN_REVIEW) > decision_rank(DECISION_ASK)
38+
assert decision_rank(DECISION_ASK) > decision_rank(DECISION_ALLOW)
39+
40+
def test_decision_str_value(self):
41+
from trpc_agent_sdk.tools.safety._types import DECISION_ALLOW
42+
assert DECISION_ALLOW.value == "allow"
43+
44+
45+
class TestRiskLevel:
46+
def test_risk_level_constants(self):
47+
from trpc_agent_sdk.tools.safety._types import (
48+
RISK_LOW, RISK_MEDIUM, RISK_HIGH, RISK_CRITICAL,
49+
)
50+
assert RISK_LOW.value == "low"
51+
assert RISK_MEDIUM.value == "medium"
52+
assert RISK_HIGH.value == "high"
53+
assert RISK_CRITICAL.value == "critical"
54+
55+
def test_risk_rank_order(self):
56+
from trpc_agent_sdk.tools.safety._types import (
57+
RISK_LOW, RISK_MEDIUM, RISK_HIGH, RISK_CRITICAL, risk_rank,
58+
)
59+
assert risk_rank(RISK_CRITICAL) > risk_rank(RISK_HIGH)
60+
assert risk_rank(RISK_HIGH) > risk_rank(RISK_MEDIUM)
61+
assert risk_rank(RISK_MEDIUM) > risk_rank(RISK_LOW)
62+
63+
64+
class TestFinding:
65+
def test_finding_fields(self):
66+
from trpc_agent_sdk.tools.safety._types import Finding, DECISION_DENY, RISK_CRITICAL
67+
f = Finding(
68+
decision=DECISION_DENY,
69+
risk_level=RISK_CRITICAL,
70+
rule_id="test.rule",
71+
evidence=["/etc/passwd"],
72+
recommendation="Do not access system files.",
73+
)
74+
assert f.decision == DECISION_DENY
75+
assert f.rule_id == "test.rule"
76+
assert f.evidence == ["/etc/passwd"]
77+
78+
def test_finding_beats_by_decision(self):
79+
from trpc_agent_sdk.tools.safety._types import (
80+
Finding, finding_beats, DECISION_DENY, DECISION_ALLOW,
81+
RISK_LOW, RISK_CRITICAL,
82+
)
83+
bad = Finding(DECISION_DENY, RISK_LOW, "r1", ["ev"], "rec")
84+
good = Finding(DECISION_ALLOW, RISK_CRITICAL, "r2", ["ev"], "rec")
85+
assert finding_beats(bad, good), "deny should beat allow regardless of risk"
86+
87+
def test_finding_beats_by_risk_when_same_decision(self):
88+
from trpc_agent_sdk.tools.safety._types import (
89+
Finding, finding_beats, DECISION_DENY,
90+
RISK_LOW, RISK_CRITICAL,
91+
)
92+
critical = Finding(DECISION_DENY, RISK_CRITICAL, "r1", ["ev"], "rec")
93+
low = Finding(DECISION_DENY, RISK_LOW, "r2", ["ev"], "rec")
94+
assert finding_beats(critical, low), "critical should beat low for same decision"
95+
96+
97+
class TestReport:
98+
def test_report_serialization(self):
99+
from trpc_agent_sdk.tools.safety._types import (
100+
Report, Finding, DECISION_ALLOW, RISK_LOW,
101+
)
102+
r = Report(
103+
decision=DECISION_ALLOW,
104+
risk_level=RISK_LOW,
105+
recommendation="Safe.",
106+
tool_name="test_tool",
107+
command="echo hi",
108+
backend="workspaceexec",
109+
blocked=False,
110+
duration_ms=5,
111+
redacted=False,
112+
findings=[],
113+
)
114+
data = dataclasses.asdict(r)
115+
assert data["decision"] == "allow"
116+
assert data["blocked"] == False
117+
118+
def test_report_blocked_if_deny(self):
119+
from trpc_agent_sdk.tools.safety._types import (
120+
Report, DECISION_DENY, RISK_HIGH,
121+
)
122+
r = Report(
123+
decision=DECISION_DENY,
124+
risk_level=RISK_HIGH,
125+
recommendation="Blocked.",
126+
tool_name="test",
127+
command="rm -rf /",
128+
backend="workspaceexec",
129+
blocked=True,
130+
duration_ms=1,
131+
redacted=False,
132+
)
133+
assert r.blocked is True
134+
135+
def test_span_attributes(self):
136+
from trpc_agent_sdk.tools.safety._types import (
137+
Report, DECISION_DENY, RISK_CRITICAL,
138+
)
139+
r = Report(
140+
decision=DECISION_DENY,
141+
risk_level=RISK_CRITICAL,
142+
rule_id="dangerous.rm_rf",
143+
recommendation="Denied.",
144+
tool_name="ws",
145+
command="rm -rf /",
146+
backend="workspaceexec",
147+
blocked=True,
148+
duration_ms=2,
149+
redacted=False,
150+
)
151+
attrs = r.span_attributes()
152+
assert attrs["tool.safety.decision"] == "deny"
153+
assert attrs["tool.safety.risk_level"] == "critical"
154+
assert attrs["tool.safety.rule_id"] == "dangerous.rm_rf"
155+
156+
157+
class TestAuditEvent:
158+
def test_audit_event_jsonl(self):
159+
from trpc_agent_sdk.tools.safety._types import (
160+
AuditEvent, DECISION_DENY, RISK_HIGH,
161+
)
162+
import time
163+
evt = AuditEvent(
164+
timestamp=time.time(),
165+
tool_name="test",
166+
decision=DECISION_DENY,
167+
risk_level=RISK_HIGH,
168+
rule_id="r1",
169+
duration_ms=5,
170+
redacted=False,
171+
blocked=True,
172+
backend="workspaceexec",
173+
)
174+
line = json.dumps(dataclasses.asdict(evt), default=str)
175+
data = json.loads(line)
176+
assert data["decision"] == "deny"
177+
assert data["blocked"] == True
178+
179+
180+
class TestPolicy:
181+
def test_policy_defaults(self):
182+
from trpc_agent_sdk.tools.safety._types import Policy
183+
p = Policy()
184+
assert p.max_timeout_seconds == 0 # default before DefaultPolicy()
185+
assert p.max_output_bytes == 0
186+
187+
def test_policy_fields(self):
188+
from trpc_agent_sdk.tools.safety._types import Policy
189+
p = Policy(
190+
denied_commands=["rm", "sudo"],
191+
denied_paths=["/etc", ".ssh"],
192+
network_allowlist=["api.github.com"],
193+
max_timeout_seconds=300,
194+
)
195+
assert "rm" in p.denied_commands
196+
assert "/etc" in p.denied_paths
197+
assert "api.github.com" in p.network_allowlist
198+
199+
200+
class TestRequest:
201+
def test_request_fields(self):
202+
from trpc_agent_sdk.tools.safety._types import Request
203+
r = Request(
204+
tool_name="workspace_exec",
205+
command="go test ./...",
206+
cwd=".",
207+
backend="workspaceexec",
208+
timeout_seconds=30,
209+
)
210+
assert r.tool_name == "workspace_exec"
211+
assert r.command == "go test ./..."
212+
213+
def test_request_with_code_blocks(self):
214+
from trpc_agent_sdk.tools.safety._types import Request, CodeBlock
215+
r = Request(
216+
tool_name="execute_code",
217+
backend="codeexec",
218+
code_blocks=[CodeBlock(language="python", code="print(1)")],
219+
)
220+
assert len(r.code_blocks) == 1
221+
assert r.code_blocks[0].language == "python"
Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,4 @@
1+
# Package safety provides a pre-execution safety guard for command and
2+
# code-execution tools.
3+
#
4+
# Mirrors trpc-agent-go/tool/safety/.
Lines changed: 168 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,168 @@
1+
# Tencent is pleased to support the open source community by making
2+
# tRPC-Agent-Python available.
3+
#
4+
# Copyright (C) 2026 Tencent. All rights reserved.
5+
#
6+
# tRPC-Agent-Python is licensed under Apache-2.0.
7+
8+
"""Safety scanner data types. Mirrors trpc-agent-go/tool/safety/safety.go."""
9+
10+
from __future__ import annotations
11+
12+
import time
13+
from dataclasses import dataclass, field
14+
from enum import Enum
15+
16+
17+
# ---------------------------------------------------------------------------
18+
# Decision & RiskLevel
19+
# ---------------------------------------------------------------------------
20+
21+
class Decision(str, Enum):
22+
ALLOW = "allow"
23+
DENY = "deny"
24+
ASK = "ask"
25+
NEEDS_HUMAN_REVIEW = "needs_human_review"
26+
27+
28+
DECISION_ALLOW = Decision.ALLOW
29+
DECISION_DENY = Decision.DENY
30+
DECISION_ASK = Decision.ASK
31+
DECISION_NEEDS_HUMAN_REVIEW = Decision.NEEDS_HUMAN_REVIEW
32+
33+
34+
class RiskLevel(str, Enum):
35+
LOW = "low"
36+
MEDIUM = "medium"
37+
HIGH = "high"
38+
CRITICAL = "critical"
39+
40+
41+
RISK_LOW = RiskLevel.LOW
42+
RISK_MEDIUM = RiskLevel.MEDIUM
43+
RISK_HIGH = RiskLevel.HIGH
44+
RISK_CRITICAL = RiskLevel.CRITICAL
45+
46+
47+
def decision_rank(d: Decision) -> int:
48+
_map = {
49+
Decision.ALLOW: 1,
50+
Decision.ASK: 2,
51+
Decision.NEEDS_HUMAN_REVIEW: 3,
52+
Decision.DENY: 4,
53+
}
54+
return _map.get(d, 0)
55+
56+
57+
def risk_rank(level: RiskLevel) -> int:
58+
_map = {
59+
RiskLevel.LOW: 1,
60+
RiskLevel.MEDIUM: 2,
61+
RiskLevel.HIGH: 3,
62+
RiskLevel.CRITICAL: 4,
63+
}
64+
return _map.get(level, 0)
65+
66+
67+
# ---------------------------------------------------------------------------
68+
# Policy
69+
# ---------------------------------------------------------------------------
70+
71+
@dataclass
72+
class Policy:
73+
denied_commands: list[str] = field(default_factory=list)
74+
allowed_commands: list[str] = field(default_factory=list)
75+
denied_paths: list[str] = field(default_factory=list)
76+
network_allowlist: list[str] = field(default_factory=list)
77+
env_allowlist: list[str] = field(default_factory=list)
78+
review_commands: list[str] = field(default_factory=list)
79+
max_timeout_seconds: int = 0
80+
max_output_bytes: int = 0
81+
review_shell_pipelines: bool = True
82+
deny_on_parse_error: bool = True
83+
84+
85+
# ---------------------------------------------------------------------------
86+
# Request
87+
# ---------------------------------------------------------------------------
88+
89+
@dataclass
90+
class CodeBlock:
91+
language: str = ""
92+
code: str = ""
93+
94+
95+
@dataclass
96+
class Request:
97+
tool_name: str = ""
98+
command: str = ""
99+
args: list[str] = field(default_factory=list)
100+
cwd: str = ""
101+
env: dict[str, str] = field(default_factory=dict)
102+
backend: str = ""
103+
timeout_seconds: int = 0
104+
max_output_bytes: int = 0
105+
background: bool = False
106+
tty: bool = False
107+
code_blocks: list[CodeBlock] = field(default_factory=list)
108+
109+
110+
# ---------------------------------------------------------------------------
111+
# Finding & Report
112+
# ---------------------------------------------------------------------------
113+
114+
@dataclass
115+
class Finding:
116+
decision: Decision = Decision.ALLOW
117+
risk_level: RiskLevel = RiskLevel.LOW
118+
rule_id: str = ""
119+
evidence: list[str] = field(default_factory=list)
120+
recommendation: str = ""
121+
122+
123+
def finding_beats(a: Finding, b: Finding) -> bool:
124+
if decision_rank(a.decision) != decision_rank(b.decision):
125+
return decision_rank(a.decision) > decision_rank(b.decision)
126+
return risk_rank(a.risk_level) > risk_rank(b.risk_level)
127+
128+
129+
@dataclass
130+
class Report:
131+
decision: Decision = Decision.ALLOW
132+
risk_level: RiskLevel = RiskLevel.LOW
133+
rule_id: str = ""
134+
evidence: list[str] = field(default_factory=list)
135+
recommendation: str = ""
136+
tool_name: str = ""
137+
command: str = ""
138+
backend: str = ""
139+
blocked: bool = False
140+
redacted: bool = False
141+
duration_ms: int = 0
142+
safe_summary: str = ""
143+
findings: list[Finding] = field(default_factory=list)
144+
145+
def span_attributes(self) -> dict[str, str]:
146+
return {
147+
"tool.safety.decision": self.decision.value,
148+
"tool.safety.risk_level": self.risk_level.value,
149+
"tool.safety.rule_id": self.rule_id,
150+
"tool.safety.backend": self.backend,
151+
}
152+
153+
154+
# ---------------------------------------------------------------------------
155+
# AuditEvent
156+
# ---------------------------------------------------------------------------
157+
158+
@dataclass
159+
class AuditEvent:
160+
timestamp: float = field(default_factory=time.time)
161+
tool_name: str = ""
162+
decision: Decision = Decision.ALLOW
163+
risk_level: RiskLevel = RiskLevel.LOW
164+
rule_id: str = ""
165+
duration_ms: int = 0
166+
redacted: bool = False
167+
blocked: bool = False
168+
backend: str = ""

0 commit comments

Comments
 (0)