Skip to content

Commit 26cab0a

Browse files
committed
test: raise tool safety coverage
1 parent 04bc75a commit 26cab0a

3 files changed

Lines changed: 265 additions & 3 deletions

File tree

tests/tools/safety/test_tool_safety_guard.py

Lines changed: 251 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,17 +17,28 @@
1717
from trpc_agent_sdk.tools import FunctionTool
1818
from trpc_agent_sdk.tools.safety import SafetyDecision
1919
from trpc_agent_sdk.tools.safety import SafetyGuardedCodeExecutor
20+
from trpc_agent_sdk.tools.safety import ToolSafetyBlockedError
21+
from trpc_agent_sdk.tools.safety import ToolSafetyFinding
2022
from trpc_agent_sdk.tools.safety import SafetyRiskLevel
2123
from trpc_agent_sdk.tools.safety import ToolSafetyAuditLogger
2224
from trpc_agent_sdk.tools.safety import ToolSafetyFilter
2325
from trpc_agent_sdk.tools.safety import ToolSafetyGuard
2426
from trpc_agent_sdk.tools.safety import ToolSafetyPolicy
27+
from trpc_agent_sdk.tools.safety import ToolSafetyReport
2528
from trpc_agent_sdk.tools.safety import ToolSafetyScanRequest
2629
from trpc_agent_sdk.tools.safety import ToolSafetyScanner
30+
from trpc_agent_sdk.tools.safety import apply_tool_safety_span_attributes
31+
from trpc_agent_sdk.tools.safety import extract_script_from_tool_args
32+
from trpc_agent_sdk.tools.safety import load_tool_safety_policy
2733
from trpc_agent_sdk.code_executors import CodeBlock
2834
from trpc_agent_sdk.code_executors import CodeExecutionInput
2935
from trpc_agent_sdk.code_executors import BaseCodeExecutor
3036
from trpc_agent_sdk.code_executors import create_code_execution_result
37+
from trpc_agent_sdk.filter import FilterResult
38+
from trpc_agent_sdk.tools.safety._guard import command_to_args
39+
from trpc_agent_sdk.tools.safety._guard import infer_language_from_key
40+
from trpc_agent_sdk.tools.safety._types import max_risk_level
41+
from trpc_agent_sdk.tools.safety._types import risk_level_value
3142
from scripts.tool_safety_check import main as tool_safety_check_main
3243

3344

@@ -236,6 +247,129 @@ def test_subprocess_output_capture_has_output_size_recommendation():
236247
assert "max_output_bytes" in finding.recommendation
237248

238249

250+
@pytest.mark.parametrize(
251+
("script", "language", "rule_id"),
252+
[
253+
("print(", "python", "TSG-PY-SYNTAX"),
254+
("password = 'abcdefghijklmnopqrstuvwxyz'", "python", "TSG-SECRETS-LITERAL"),
255+
("for i in range(100000):\n pass", "python", "TSG-RESOURCE-LARGE-LOOP"),
256+
("import pip", "python", "TSG-DEPENDENCY-INSTALL-API"),
257+
("from urllib import request", "python", "TSG-NETWORK-IMPORT"),
258+
("import os\nos.remove('/tmp/demo')", "python", "TSG-FILE-DANGEROUS-OP"),
259+
("open('/etc/passwd', 'w')", "python", "TSG-FILE-SYSTEM-WRITE"),
260+
("from pathlib import Path\nPath('/etc/app.conf').write_text('x')", "python", "TSG-FILE-SYSTEM-WRITE"),
261+
("from pathlib import Path\nPath('out.txt').write_text('" + ("x" * 120) + "')", "python", "TSG-RESOURCE-LARGE-WRITE"),
262+
("import time\ntime.sleep(99)", "python", "TSG-RESOURCE-LONG-SLEEP"),
263+
("import os\nos.fork()", "python", "TSG-RESOURCE-PROCESS-FANOUT"),
264+
("import asyncio\nasyncio.gather(" + ", ".join(f't{i}()' for i in range(40)) + ")", "python",
265+
"TSG-RESOURCE-PARALLELISM"),
266+
(":(){ :|:& };:", "bash", "TSG-RESOURCE-FORK-BOMB"),
267+
("while true; do echo x; done", "bash", "TSG-RESOURCE-INFINITE-LOOP"),
268+
("sleep 99", "bash", "TSG-RESOURCE-LONG-SLEEP"),
269+
("curl https://evil.example.net", "bash", "TSG-NETWORK-COMMAND"),
270+
("systemctl restart demo", "bash", "TSG-SHELL-DANGEROUS-COMMAND"),
271+
("cat ~/.ssh/id_rsa", "bash", "TSG-SECRETS-READ"),
272+
("dd if=/dev/zero of=big.bin bs=1M count=1024", "bash", "TSG-RESOURCE-LARGE-WRITE"),
273+
("echo $API_KEY", "bash", "TSG-SECRETS-SINK"),
274+
("tee /etc/app.conf", "bash", "TSG-FILE-SYSTEM-WRITE"),
275+
("echo x > ~/.ssh/config", "bash", "TSG-FILE-DENIED-PATH"),
276+
],
277+
)
278+
def test_scanner_additional_branches(script, language, rule_id):
279+
tuned_policy = policy(max_loop_iterations=10, max_sleep_seconds=10, max_literal_write_bytes=10, max_parallel_tasks=5)
280+
report = ToolSafetyScanner(tuned_policy).scan(ToolSafetyScanRequest(script=script, language=language))
281+
282+
assert any(finding.rule_id == rule_id for finding in report.findings)
283+
284+
285+
def test_scanner_unknown_language_and_env_secret_branches():
286+
report = ToolSafetyScanner(policy()).scan(
287+
ToolSafetyScanRequest(script="noop", language="ruby", env={"API_KEY": "secret-value"}))
288+
289+
assert report.decision == SafetyDecision.NEEDS_HUMAN_REVIEW
290+
assert any(finding.rule_id == "TSG-LANG-UNKNOWN" for finding in report.findings)
291+
assert any(finding.rule_id == "TSG-SECRETS-ENV" for finding in report.findings)
292+
assert report.redacted is True
293+
294+
295+
def test_scanner_command_arg_prefixes_and_duration_helpers():
296+
scripts = [
297+
("", "bash", ["sudo curl https://evil.example.net"], "TSG-NETWORK-COMMAND"),
298+
("", "bash", ["uv pip install requests"], "TSG-DEPENDENCY-INSTALL"),
299+
("", "bash", ["poetry add requests"], "TSG-DEPENDENCY-INSTALL"),
300+
("", "bash", ["parallel -j0 echo {}"], "TSG-RESOURCE-PARALLELISM"),
301+
("", "bash", ["xargs --max-procs=999"], "TSG-RESOURCE-PARALLELISM"),
302+
("", "bash", ["timeout -k 1s 10m python job.py"], "TSG-RESOURCE-LONG-TIMEOUT"),
303+
]
304+
scanner = ToolSafetyScanner(policy(max_timeout_seconds=30, max_parallel_tasks=8))
305+
306+
for script, language, command_args, rule_id in scripts:
307+
report = scanner.scan(ToolSafetyScanRequest(script=script, language=language, command_args=command_args))
308+
assert any(finding.rule_id == rule_id for finding in report.findings), [
309+
(finding.rule_id, finding.evidence) for finding in report.findings
310+
]
311+
312+
313+
def test_policy_loader_and_matching_branches(tmp_path):
314+
policy_file = tmp_path / "policy.yaml"
315+
policy_file.write_text(
316+
"""
317+
policy:
318+
name: nested
319+
allowed_domains:
320+
- "*.example.com"
321+
allowed_commands:
322+
- custom-tool
323+
denied_paths:
324+
- ""
325+
- .env
326+
audit_log_path: audit.jsonl
327+
extra_flag: yes
328+
""",
329+
encoding="utf-8",
330+
)
331+
332+
loaded = ToolSafetyPolicy.load(policy_file)
333+
334+
assert loaded.name == "nested"
335+
assert loaded.metadata == {"extra_flag": True}
336+
assert loaded.audit_log_path == "audit.jsonl"
337+
assert loaded.is_domain_allowed("api.example.com") is True
338+
assert loaded.is_domain_allowed("example.com") is True
339+
assert loaded.is_domain_allowed(None) is False
340+
assert loaded.is_command_allowed("custom-tool --flag") is True
341+
assert loaded.is_command_allowed(None) is False
342+
assert loaded.is_denied_path(None) is False
343+
assert loaded.is_system_write_path("/") is True
344+
assert loaded.is_system_write_path(None) is False
345+
assert load_tool_safety_policy(None).name == "default"
346+
347+
348+
def test_policy_load_rejects_non_mapping(tmp_path):
349+
policy_file = tmp_path / "policy.yaml"
350+
policy_file.write_text("- not-a-mapping\n", encoding="utf-8")
351+
352+
with pytest.raises(ValueError):
353+
ToolSafetyPolicy.load(policy_file)
354+
355+
356+
def test_report_and_type_helpers_cover_empty_and_fallback_paths():
357+
assert max_risk_level([]) == SafetyRiskLevel.NONE
358+
assert risk_level_value("unknown") == 0
359+
360+
report = ToolSafetyReport(
361+
decision="allow",
362+
risk_level="none",
363+
findings=[],
364+
duration_ms=1.2345,
365+
language="python",
366+
scanned_at="2026-07-05T00:00:00+00:00",
367+
)
368+
369+
assert report.is_allowed is True
370+
assert json.loads(report.to_json(indent=None))["risk_count"] == 0
371+
372+
239373
def test_audit_event_contains_required_fields(tmp_path):
240374
report = ToolSafetyScanner(policy()).scan(
241375
ToolSafetyScanRequest(script="rm -rf /tmp/demo", language="bash", tool_metadata={"name": "Bash"}))
@@ -258,6 +392,61 @@ def test_report_includes_telemetry_attributes():
258392
assert report.telemetry_attributes["tool.safety.rule_id"] == "TSG-RESOURCE-INFINITE-LOOP"
259393

260394

395+
def test_telemetry_handles_list_values_and_span_errors(monkeypatch):
396+
class Span:
397+
def __init__(self):
398+
self.attributes = {}
399+
400+
def set_attribute(self, key, value):
401+
self.attributes[key] = value
402+
403+
span = Span()
404+
report = ToolSafetyReport(
405+
decision=SafetyDecision.ALLOW,
406+
risk_level=SafetyRiskLevel.LOW,
407+
findings=[],
408+
duration_ms=0,
409+
language="python",
410+
scanned_at="2026-07-05T00:00:00+00:00",
411+
telemetry_attributes={
412+
"string": "value",
413+
"list": ["a", "b"],
414+
"object": {"x": 1},
415+
},
416+
)
417+
418+
monkeypatch.setattr("trpc_agent_sdk.tools.safety._telemetry.trace.get_current_span", lambda: span)
419+
apply_tool_safety_span_attributes(report)
420+
assert span.attributes["list"] == "a,b"
421+
assert span.attributes["object"] == "{'x': 1}"
422+
423+
class RaisingSpan:
424+
def set_attribute(self, key, value):
425+
raise RuntimeError("boom")
426+
427+
monkeypatch.setattr("trpc_agent_sdk.tools.safety._telemetry.trace.get_current_span", lambda: RaisingSpan())
428+
apply_tool_safety_span_attributes(report)
429+
430+
431+
@pytest.mark.asyncio
432+
async def test_guard_check_and_run_if_allowed_paths(tmp_path):
433+
audit_path = tmp_path / "audit.jsonl"
434+
guard = ToolSafetyGuard(policy=policy(audit_log_path=str(audit_path)), apply_telemetry=False)
435+
436+
report = guard.check(ToolSafetyScanRequest(script="print('ok')", language="python"))
437+
assert report.is_allowed
438+
assert audit_path.exists()
439+
440+
result = await guard.run_if_allowed(
441+
ToolSafetyScanRequest(script="print('ok')", language="python"),
442+
lambda: AsyncMock(return_value="ran")(),
443+
)
444+
assert result == "ran"
445+
446+
with pytest.raises(ToolSafetyBlockedError):
447+
guard.check(ToolSafetyScanRequest(script="rm -rf /tmp/demo", language="bash"))
448+
449+
261450
@pytest.mark.asyncio
262451
async def test_tool_filter_blocks_before_handle():
263452
filter_ = ToolSafetyFilter(guard=ToolSafetyGuard(policy=policy()))
@@ -286,6 +475,20 @@ async def test_tool_filter_block_writes_audit_event(tmp_path):
286475
assert event["blocked"] is True
287476

288477

478+
@pytest.mark.asyncio
479+
async def test_tool_filter_policy_path_and_passthrough_branches(tmp_path):
480+
policy_path = tmp_path / "policy.yaml"
481+
policy_path.write_text("allowed_commands:\n - echo\n", encoding="utf-8")
482+
filter_ = ToolSafetyFilter(policy_path=policy_path)
483+
484+
handle_result = FilterResult(rsp={"ok": True})
485+
result = await filter_.run(create_agent_context(), {"note": "not script"}, AsyncMock(return_value=handle_result))
486+
assert result is handle_result
487+
488+
result = await filter_.run(create_agent_context(), "not mapping", AsyncMock(return_value={"plain": True}))
489+
assert result.rsp == {"plain": True}
490+
491+
289492
@pytest.mark.asyncio
290493
async def test_tool_filter_allows_safe_command():
291494
filter_ = ToolSafetyFilter(guard=ToolSafetyGuard(policy=policy()))
@@ -318,6 +521,54 @@ async def execute_code(self, invocation_context, code_execution_input):
318521
assert delegate.called is False
319522

320523

524+
@pytest.mark.asyncio
525+
async def test_safety_guarded_code_executor_code_string_paths():
526+
class DelegateExecutor(BaseCodeExecutor):
527+
called: bool = False
528+
work_dir: str = "/workspace"
529+
530+
async def execute_code(self, invocation_context, code_execution_input):
531+
self.called = True
532+
return create_code_execution_result(stdout="executed")
533+
534+
delegate = DelegateExecutor()
535+
executor = SafetyGuardedCodeExecutor(delegate=delegate, guard=ToolSafetyGuard(policy=policy()))
536+
537+
blocked = await executor.execute_code(
538+
invocation_context=AsyncMock(),
539+
code_execution_input=CodeExecutionInput(code="while True:\n pass"),
540+
)
541+
assert "Tool safety guard blocked" in blocked.output
542+
assert delegate.called is False
543+
544+
allowed = await executor.execute_code(
545+
invocation_context=AsyncMock(),
546+
code_execution_input=CodeExecutionInput(code="print('ok')"),
547+
)
548+
assert "executed" in allowed.output
549+
assert delegate.called is True
550+
551+
552+
@pytest.mark.parametrize(
553+
("args", "expected"),
554+
[
555+
({"command": "echo 'unterminated", "cwd": "/tmp"}, ("echo 'unterminated", "bash", "/tmp", ["echo", "'unterminated"])),
556+
({"script": "#!/bin/bash\necho ok"}, ("#!/bin/bash\necho ok", "bash", None, [])),
557+
({"script": "set -e\necho ok"}, ("set -e\necho ok", "bash", None, [])),
558+
({"code": "print('ok')", "lang": "py"}, ("print('ok')", "python", None, [])),
559+
({"code_blocks": [{"code": "echo ok", "language": "sh"}]}, ("echo ok", "bash", None, [])),
560+
({"code_blocks": [{"language": "sh"}]}, None),
561+
],
562+
)
563+
def test_extract_script_from_tool_args_branches(args, expected):
564+
assert extract_script_from_tool_args(args) == expected
565+
566+
567+
def test_infer_language_from_key_default():
568+
assert infer_language_from_key("source", "anything") == "python"
569+
assert command_to_args("echo ok") == ["echo", "ok"]
570+
571+
321572
def test_scans_500_line_script_under_one_second():
322573
script = "\n".join(f'print("line {i}")' for i in range(500))
323574
scanner = ToolSafetyScanner(policy())

trpc_agent_sdk/tools/safety/_policy.py

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -161,13 +161,15 @@ def is_system_write_path(self, value: str | None) -> bool:
161161
return False
162162
normalized = self._normalize_path_text(value)
163163
for pattern in self.system_write_paths:
164-
normalized_pattern = self._normalize_path_text(str(pattern)).rstrip("/")
164+
normalized_pattern = self._normalize_path_text(str(pattern))
165165
if not normalized_pattern:
166166
continue
167167
if normalized_pattern == "/":
168168
if normalized in {"/", "/*", "/."}:
169169
return True
170-
elif normalized == normalized_pattern or normalized.startswith(f"{normalized_pattern}/"):
170+
continue
171+
normalized_pattern = normalized_pattern.rstrip("/")
172+
if normalized == normalized_pattern or normalized.startswith(f"{normalized_pattern}/"):
171173
return True
172174
return False
173175

trpc_agent_sdk/tools/safety/_scanner.py

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1208,8 +1208,17 @@ def timeout_seconds_from_tokens(tokens: Sequence[str]) -> float | None:
12081208
command = Path(tokens[0]).name.lower() if tokens else ""
12091209
if command != "timeout":
12101210
return None
1211-
for token in tokens[1:]:
1211+
index = 1
1212+
while index < len(tokens):
1213+
token = tokens[index]
1214+
if token in {"-k", "--kill-after"}:
1215+
index += 2
1216+
continue
1217+
if token.startswith("--kill-after="):
1218+
index += 1
1219+
continue
12121220
if token.startswith("-"):
1221+
index += 1
12131222
continue
12141223
return duration_token_seconds(token)
12151224
return None

0 commit comments

Comments
 (0)