1717from trpc_agent_sdk .tools import FunctionTool
1818from trpc_agent_sdk .tools .safety import SafetyDecision
1919from 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
2022from trpc_agent_sdk .tools .safety import SafetyRiskLevel
2123from trpc_agent_sdk .tools .safety import ToolSafetyAuditLogger
2224from trpc_agent_sdk .tools .safety import ToolSafetyFilter
2325from trpc_agent_sdk .tools .safety import ToolSafetyGuard
2426from trpc_agent_sdk .tools .safety import ToolSafetyPolicy
27+ from trpc_agent_sdk .tools .safety import ToolSafetyReport
2528from trpc_agent_sdk .tools .safety import ToolSafetyScanRequest
2629from 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
2733from trpc_agent_sdk .code_executors import CodeBlock
2834from trpc_agent_sdk .code_executors import CodeExecutionInput
2935from trpc_agent_sdk .code_executors import BaseCodeExecutor
3036from 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
3142from 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\n os.remove('/tmp/demo')" , "python" , "TSG-FILE-DANGEROUS-OP" ),
259+ ("open('/etc/passwd', 'w')" , "python" , "TSG-FILE-SYSTEM-WRITE" ),
260+ ("from pathlib import Path\n Path('/etc/app.conf').write_text('x')" , "python" , "TSG-FILE-SYSTEM-WRITE" ),
261+ ("from pathlib import Path\n Path('out.txt').write_text('" + ("x" * 120 ) + "')" , "python" , "TSG-RESOURCE-LARGE-WRITE" ),
262+ ("import time\n time.sleep(99)" , "python" , "TSG-RESOURCE-LONG-SLEEP" ),
263+ ("import os\n os.fork()" , "python" , "TSG-RESOURCE-PROCESS-FANOUT" ),
264+ ("import asyncio\n asyncio.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+
239373def 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
262451async 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
290493async 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\n echo ok" }, ("#!/bin/bash\n echo ok" , "bash" , None , [])),
557+ ({"script" : "set -e\n echo ok" }, ("set -e\n echo 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+
321572def 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 ())
0 commit comments