Skip to content

Commit 8da6f48

Browse files
committed
full_test
1 parent 28a4174 commit 8da6f48

19 files changed

Lines changed: 5315 additions & 0 deletions

tests/tools/safety/__init__.py

Whitespace-only changes.

tests/tools/safety/conftest.py

Lines changed: 134 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,134 @@
1+
"""Shared fixtures for tool-safety tests.
2+
3+
The fixtures here build minimal in-memory policies so tests do not touch
4+
the filesystem. They also expose the sample scripts shipped under
5+
``trpc_agent_sdk/tools/safety/examples/samples`` so integration tests can
6+
re-use them without copying the bodies.
7+
"""
8+
9+
from __future__ import annotations
10+
11+
from pathlib import Path
12+
from typing import Any
13+
14+
import pytest
15+
16+
from trpc_agent_sdk.tools.safety._policy import (
17+
POLICY_VERSION,
18+
load_safety_policy_dict,
19+
)
20+
from trpc_agent_sdk.tools.safety._models import (
21+
SafetyScanRequest,
22+
ScriptLanguage,
23+
ToolKind,
24+
)
25+
26+
27+
SAMPLES_DIR = Path(__file__).resolve().parents[3] \
28+
/ "trpc_agent_sdk" / "tools" / "safety" / "examples" / "samples"
29+
30+
31+
def _policy_dict(**overrides: Any) -> dict[str, Any]:
32+
data: dict[str, Any] = {
33+
"version": POLICY_VERSION,
34+
"network": {"allow_domains": ["api.example.com", "*.example.com"]},
35+
"commands": {
36+
"allow": ["python", "ls"],
37+
"deny": ["rm"],
38+
},
39+
"paths": {"deny": ["/etc/**", "~/.ssh/**", "/root/**"]},
40+
"limits": {
41+
"max_timeout_seconds": 60.0,
42+
"max_output_bytes": 1024,
43+
"max_script_bytes": 262144,
44+
"max_sleep_seconds": 30.0,
45+
"max_parallel_tasks": 16,
46+
"max_processes": 8,
47+
"max_file_write_bytes": 1024,
48+
},
49+
"defaults": {
50+
"unknown_construct": "needs_human_review",
51+
"guard_error": "deny",
52+
"human_review_blocks_execution": True,
53+
},
54+
"dependencies": {"decision": "deny"},
55+
"audit": {"enabled": False, "required": False},
56+
}
57+
for key, value in overrides.items():
58+
data[key] = value
59+
return data
60+
61+
62+
@pytest.fixture()
63+
def policy_dict() -> dict[str, Any]:
64+
return _policy_dict()
65+
66+
67+
@pytest.fixture()
68+
def policy_factory():
69+
"""Callable that builds a fresh policy from overrides."""
70+
71+
return load_safety_policy_dict
72+
73+
74+
@pytest.fixture()
75+
def make_policy():
76+
"""Build a policy with overrides applied."""
77+
78+
def _make(**overrides: Any):
79+
return load_safety_policy_dict(_policy_dict(**overrides))
80+
81+
return _make
82+
83+
84+
@pytest.fixture()
85+
def default_policy(make_policy):
86+
return make_policy()
87+
88+
89+
@pytest.fixture()
90+
def scan_request_factory():
91+
"""Build a SafetyScanRequest with sensible defaults."""
92+
93+
def _make(
94+
*,
95+
tool_name: str = "test_tool",
96+
tool_kind: ToolKind = ToolKind.UNKNOWN,
97+
language: ScriptLanguage = ScriptLanguage.UNKNOWN,
98+
script: str = "",
99+
argv: tuple[str, ...] = (),
100+
cwd: str | None = None,
101+
env: dict[str, str] | None = None,
102+
metadata: dict[str, Any] | None = None,
103+
requested_timeout_seconds: float | None = None,
104+
requested_output_bytes: int | None = None,
105+
) -> SafetyScanRequest:
106+
return SafetyScanRequest(
107+
tool_name=tool_name,
108+
tool_kind=tool_kind,
109+
language=language,
110+
script=script,
111+
argv=argv,
112+
cwd=cwd,
113+
env=env or {},
114+
metadata=metadata or {},
115+
requested_timeout_seconds=requested_timeout_seconds,
116+
requested_output_bytes=requested_output_bytes,
117+
)
118+
119+
return _make
120+
121+
122+
@pytest.fixture()
123+
def samples_dir() -> Path:
124+
return SAMPLES_DIR
125+
126+
127+
@pytest.fixture()
128+
def sample_script(samples_dir):
129+
"""Return the body of a sample script by file name."""
130+
131+
def _read(name: str) -> str:
132+
return (samples_dir / name).read_text(encoding="utf-8")
133+
134+
return _read

tests/tools/safety/test_audit.py

Lines changed: 107 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,107 @@
1+
"""Tests for trpc_agent_sdk.tools.safety._audit."""
2+
3+
from __future__ import annotations
4+
5+
import asyncio
6+
import json
7+
import os
8+
9+
import pytest
10+
11+
from trpc_agent_sdk.tools.safety._audit import (
12+
AuditSink,
13+
InMemoryAuditSink,
14+
JsonlAuditSink,
15+
NullAuditSink,
16+
)
17+
from trpc_agent_sdk.tools.safety._exceptions import SafetyAuditError
18+
from trpc_agent_sdk.tools.safety._models import (
19+
RiskLevel,
20+
SafetyAuditEvent,
21+
SafetyDecision,
22+
ToolKind,
23+
)
24+
25+
26+
def _make_event() -> SafetyAuditEvent:
27+
return SafetyAuditEvent(
28+
event_id="e1",
29+
timestamp="2024-01-01T00:00:00Z",
30+
report_id="r1",
31+
tool_name="t",
32+
tool_kind=ToolKind.UNKNOWN,
33+
decision=SafetyDecision.DENY,
34+
risk_level=RiskLevel.HIGH,
35+
rule_ids=("FILE001_RECURSIVE_DELETE",),
36+
duration_ms=0.5,
37+
redacted=False,
38+
execution_blocked=True,
39+
policy_hash="p",
40+
policy_version="1",
41+
script_sha256="s",
42+
)
43+
44+
45+
class TestInMemoryAuditSink:
46+
def test_protocol(self):
47+
sink = InMemoryAuditSink()
48+
assert isinstance(sink, AuditSink)
49+
50+
@pytest.mark.asyncio
51+
async def test_emit_and_read(self):
52+
sink = InMemoryAuditSink()
53+
await sink.emit(_make_event())
54+
events = sink.events
55+
assert len(events) == 1
56+
assert events[0].event_id == "e1"
57+
58+
@pytest.mark.asyncio
59+
async def test_clear(self):
60+
sink = InMemoryAuditSink()
61+
await sink.emit(_make_event())
62+
sink.clear()
63+
assert sink.events == ()
64+
65+
@pytest.mark.asyncio
66+
async def test_concurrent_emits_keep_order(self):
67+
sink = InMemoryAuditSink()
68+
events = [
69+
_make_event().model_copy(update={"event_id": f"e{i}"})
70+
for i in range(5)
71+
]
72+
await asyncio.gather(*(sink.emit(e) for e in events))
73+
ids = [e.event_id for e in sink.events]
74+
assert sorted(ids) == ["e0", "e1", "e2", "e3", "e4"]
75+
76+
77+
class TestJsonlAuditSink:
78+
@pytest.mark.asyncio
79+
async def test_append_writes_line(self, tmp_path):
80+
path = tmp_path / "audit.jsonl"
81+
sink = JsonlAuditSink(path)
82+
await sink.emit(_make_event())
83+
await sink.emit(_make_event().model_copy(update={"event_id": "e2"}))
84+
content = path.read_text(encoding="utf-8")
85+
lines = [ln for ln in content.splitlines() if ln.strip()]
86+
assert len(lines) == 2
87+
first = json.loads(lines[0])
88+
assert first["event_id"] == "e1"
89+
90+
@pytest.mark.asyncio
91+
async def test_oserror_raises_audit_error(self, tmp_path):
92+
# Pointing at a directory makes the open(..., "a") fail with
93+
# IsADirectoryError or PermissionError, both subclasses of OSError.
94+
sink = JsonlAuditSink(tmp_path)
95+
with pytest.raises(SafetyAuditError):
96+
await sink.emit(_make_event())
97+
98+
def test_accepts_pathlike_and_str(self, tmp_path):
99+
JsonlAuditSink(tmp_path / "a.jsonl")
100+
JsonlAuditSink(str(tmp_path / "b.jsonl"))
101+
102+
103+
class TestNullAuditSink:
104+
@pytest.mark.asyncio
105+
async def test_emit_noop(self):
106+
# Just verify it does not raise.
107+
await NullAuditSink().emit(_make_event())

0 commit comments

Comments
 (0)