Skip to content

Commit 4471419

Browse files
committed
feat: add tool script safety guard
1 parent da30710 commit 4471419

13 files changed

Lines changed: 1436 additions & 0 deletions

File tree

Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,47 @@
1+
# Tool Safety Guard
2+
3+
This example shows how to add a static safety scan before a tool or code executor runs script-like input.
4+
5+
## Tool Filter
6+
7+
```python
8+
from trpc_agent_sdk.tools import FunctionTool
9+
from trpc_agent_sdk.tools.safety import ToolSafetyFilter
10+
11+
12+
def run_command(command: str):
13+
return {"ran": command}
14+
15+
16+
tool = FunctionTool(
17+
run_command,
18+
filters=[
19+
ToolSafetyFilter(policy_path="examples/tool_safety_guard/tool_safety_policy.yaml"),
20+
],
21+
)
22+
```
23+
24+
If you prefer `filters_name=["tool_safety_guard"]`, import `trpc_agent_sdk.tools.safety` first so the registered filter is available.
25+
26+
## Code Executor Wrapper
27+
28+
```python
29+
from trpc_agent_sdk.code_executors.local import UnsafeLocalCodeExecutor
30+
from trpc_agent_sdk.tools.safety import SafetyGuardedCodeExecutor
31+
32+
33+
code_executor = SafetyGuardedCodeExecutor(
34+
delegate=UnsafeLocalCodeExecutor(),
35+
policy_path="examples/tool_safety_guard/tool_safety_policy.yaml",
36+
)
37+
```
38+
39+
## What It Checks
40+
41+
The guard scans Python and Bash-like inputs for dangerous file operations, secret file access, non-whitelisted network egress, subprocess and shell patterns, dependency installation, resource abuse, and sensitive output.
42+
43+
Reports include `decision`, `risk_level`, `rule_id`, `evidence`, and `recommendation`. Audit events are written as JSONL and the filter sets current OpenTelemetry span attributes such as `tool.safety.decision` and `tool.safety.rule_ids`.
44+
45+
## Limits
46+
47+
This guard is a pre-execution static scan. It can have false positives, false negatives, and bypasses through obfuscation or dynamic code construction. It does not replace sandboxing, container isolation, OS permissions, network controls, timeouts, or resource limits. Use it as an early policy and observability layer before a properly isolated runtime.
Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
{"timestamp":"2026-06-30T00:00:00+00:00","tool_name":"run_command","decision":"deny","risk_level":"critical","rule_ids":["FILE_RECURSIVE_DELETE"],"elapsed_ms":0.42,"redacted":false,"blocked":true,"language":"bash","cwd":"","metadata":{}}
Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,27 @@
1+
mode: standard
2+
fail_closed: false
3+
block_on_review: true
4+
allowed_domains:
5+
- api.example.com
6+
- "*.trusted.internal"
7+
allowed_commands:
8+
- curl
9+
- wget
10+
- echo
11+
- cat
12+
- grep
13+
- python
14+
- python3
15+
- pip
16+
denied_paths:
17+
- "~/.ssh"
18+
- ".env"
19+
- "/etc"
20+
- "/var/secrets"
21+
max_timeout_seconds: 300
22+
max_output_bytes: 10000
23+
audit_log_path: tool_safety_audit.jsonl
24+
rules:
25+
DEPENDENCY_INSTALL:
26+
enabled: true
27+
decision: needs_human_review
Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,24 @@
1+
{
2+
"decision": "deny",
3+
"risk_level": "critical",
4+
"findings": [
5+
{
6+
"rule_id": "FILE_RECURSIVE_DELETE",
7+
"risk_type": "file_operation",
8+
"risk_level": "critical",
9+
"decision": "deny",
10+
"message": "Dangerous recursive delete detected.",
11+
"evidence": "rm -rf /tmp/build-output",
12+
"recommendation": "Avoid recursive deletion or restrict it to an explicitly approved workspace path.",
13+
"line": null,
14+
"column": null
15+
}
16+
],
17+
"elapsed_ms": 0.42,
18+
"redacted": false,
19+
"blocked": true,
20+
"language": "bash",
21+
"tool_name": "run_command",
22+
"scanner_version": "1",
23+
"error": null
24+
}

tests/tools/safety/samples.yaml

Lines changed: 89 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,89 @@
1+
- name: safe_python
2+
language: python
3+
expected_decision: allow
4+
expected_rule_ids: []
5+
content: |
6+
total = sum([1, 2, 3])
7+
print(total)
8+
- name: dangerous_delete
9+
language: bash
10+
expected_decision: deny
11+
expected_rule_ids:
12+
- FILE_RECURSIVE_DELETE
13+
content: |
14+
rm -rf /tmp/build-output
15+
- name: read_secrets
16+
language: python
17+
expected_decision: deny
18+
expected_rule_ids:
19+
- FILE_SECRET_READ
20+
content: |
21+
with open(".env") as f:
22+
print(f.read())
23+
- name: network_egress
24+
language: python
25+
expected_decision: deny
26+
expected_rule_ids:
27+
- NET_NON_WHITELIST_EGRESS
28+
content: |
29+
import requests
30+
requests.get("https://evil.example/data")
31+
- name: whitelist_network
32+
language: bash
33+
expected_decision: allow
34+
expected_rule_ids: []
35+
content: |
36+
curl https://api.example.com/status
37+
- name: subprocess_call
38+
language: python
39+
expected_decision: needs_human_review
40+
expected_rule_ids:
41+
- PROC_SUBPROCESS
42+
content: |
43+
import subprocess
44+
subprocess.run(["ls", "-la"])
45+
- name: shell_injection
46+
language: bash
47+
expected_decision: needs_human_review
48+
expected_rule_ids:
49+
- SHELL_INJECTION
50+
content: |
51+
echo $(cat user_input.txt)
52+
- name: dependency_install
53+
language: bash
54+
expected_decision: needs_human_review
55+
expected_rule_ids:
56+
- DEPENDENCY_INSTALL
57+
content: |
58+
pip install suspicious-package
59+
- name: infinite_loop
60+
language: python
61+
expected_decision: needs_human_review
62+
expected_rule_ids:
63+
- RESOURCE_INFINITE_LOOP
64+
content: |
65+
while True:
66+
pass
67+
- name: sensitive_output
68+
language: python
69+
expected_decision: deny
70+
expected_rule_ids:
71+
- SENSITIVE_OUTPUT
72+
content: |
73+
api_key = "sk-test-secret-value"
74+
print(api_key)
75+
- name: bash_pipeline
76+
language: bash
77+
expected_decision: needs_human_review
78+
expected_rule_ids:
79+
- SHELL_PIPELINE
80+
content: |
81+
cat access.log | grep token
82+
- name: human_review_mixed
83+
language: python
84+
expected_decision: needs_human_review
85+
expected_rule_ids:
86+
- NET_CLIENT_USAGE
87+
content: |
88+
import socket
89+
socket.create_connection((host, 443))
Lines changed: 141 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,141 @@
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 Apache-2.0.
6+
"""Tests for tool safety filter, audit, and executor wrapper."""
7+
8+
from __future__ import annotations
9+
10+
import json
11+
from unittest.mock import MagicMock
12+
13+
from opentelemetry import trace
14+
15+
from trpc_agent_sdk.code_executors import BaseCodeExecutor
16+
from trpc_agent_sdk.code_executors import CodeBlock
17+
from trpc_agent_sdk.code_executors import CodeExecutionInput
18+
from trpc_agent_sdk.code_executors import CodeExecutionResult
19+
from trpc_agent_sdk.code_executors import create_code_execution_result
20+
from trpc_agent_sdk.context import AgentContext
21+
from trpc_agent_sdk.context import InvocationContext
22+
from trpc_agent_sdk.tools import FunctionTool
23+
from trpc_agent_sdk.tools.safety import SafetyDecision
24+
from trpc_agent_sdk.tools.safety import SafetyGuardedCodeExecutor
25+
from trpc_agent_sdk.tools.safety import SafetyPolicy
26+
from trpc_agent_sdk.tools.safety import ToolSafetyFilter
27+
28+
29+
class DummyCodeExecutor(BaseCodeExecutor):
30+
calls: int = 0
31+
32+
async def execute_code(
33+
self,
34+
invocation_context: InvocationContext,
35+
code_execution_input: CodeExecutionInput,
36+
) -> CodeExecutionResult:
37+
self.calls += 1
38+
return create_code_execution_result(stdout="ok")
39+
40+
41+
def _mock_invocation_context() -> InvocationContext:
42+
ctx = MagicMock(spec=InvocationContext)
43+
ctx.agent = MagicMock()
44+
ctx.agent.parallel_tool_calls = False
45+
ctx.agent.before_tool_callback = None
46+
ctx.agent.after_tool_callback = None
47+
ctx.agent_context = AgentContext()
48+
return ctx
49+
50+
51+
async def test_filter_blocks_dangerous_tool_and_writes_audit(tmp_path):
52+
def run_command(command: str) -> dict:
53+
return {"ran": command}
54+
55+
audit_path = tmp_path / "audit.jsonl"
56+
policy = SafetyPolicy(audit_log_path=str(audit_path))
57+
tool = FunctionTool(run_command, filters=[ToolSafetyFilter(policy=policy)])
58+
59+
result = await tool.run_async(tool_context=_mock_invocation_context(), args={"command": "rm -rf /tmp/out"})
60+
61+
assert result["error"] == "TOOL_SAFETY_BLOCKED"
62+
report = result["safety_report"]
63+
assert report["decision"] == SafetyDecision.DENY.value
64+
assert "FILE_RECURSIVE_DELETE" in report["findings"][0]["rule_id"]
65+
66+
lines = audit_path.read_text(encoding="utf-8").splitlines()
67+
assert len(lines) == 1
68+
event = json.loads(lines[0])
69+
assert event["tool_name"] == "run_command"
70+
assert event["blocked"] is True
71+
assert event["decision"] == SafetyDecision.DENY.value
72+
73+
74+
async def test_filter_allows_safe_tool(tmp_path):
75+
def run_command(command: str) -> dict:
76+
return {"ran": command}
77+
78+
policy = SafetyPolicy(audit_log_path=str(tmp_path / "audit.jsonl"))
79+
tool = FunctionTool(run_command, filters=[ToolSafetyFilter(policy=policy)])
80+
81+
result = await tool.run_async(tool_context=_mock_invocation_context(), args={"command": "echo hello"})
82+
83+
assert result == {"ran": "echo hello"}
84+
85+
86+
def test_filter_writes_otel_span_attributes(monkeypatch, tmp_path):
87+
span = MagicMock()
88+
monkeypatch.setattr(trace, "get_current_span", lambda: span)
89+
filter_instance = ToolSafetyFilter(policy=SafetyPolicy(audit_log_path=str(tmp_path / "audit.jsonl")))
90+
tool = MagicMock()
91+
tool.name = "exec"
92+
93+
async def run_filter():
94+
from trpc_agent_sdk.abc import FilterResult
95+
from trpc_agent_sdk.tools._context_var import reset_tool_var
96+
from trpc_agent_sdk.tools._context_var import set_tool_var
97+
98+
token = set_tool_var(tool)
99+
try:
100+
rsp = FilterResult()
101+
await filter_instance._before(AgentContext(), {"command": "rm -rf /tmp/out"}, rsp)
102+
return rsp
103+
finally:
104+
reset_tool_var(token)
105+
106+
import asyncio
107+
108+
rsp = asyncio.run(run_filter())
109+
assert rsp.is_continue is False
110+
span.set_attribute.assert_any_call("tool.safety.decision", SafetyDecision.DENY.value)
111+
span.set_attribute.assert_any_call("tool.safety.blocked", True)
112+
113+
114+
async def test_code_executor_wrapper_blocks_before_delegate(tmp_path):
115+
delegate = DummyCodeExecutor()
116+
executor = SafetyGuardedCodeExecutor(
117+
delegate=delegate,
118+
policy=SafetyPolicy(audit_log_path=str(tmp_path / "audit.jsonl")),
119+
)
120+
121+
result = await executor.execute_code(
122+
MagicMock(spec=InvocationContext),
123+
CodeExecutionInput(code_blocks=[CodeBlock(language="python", code='open(".env").read()')]),
124+
)
125+
126+
assert "TOOL_SAFETY_BLOCKED" in result.output
127+
assert delegate.calls == 0
128+
129+
130+
async def test_code_executor_wrapper_delegates_safe_code(tmp_path):
131+
delegate = DummyCodeExecutor()
132+
executor = SafetyGuardedCodeExecutor(
133+
delegate=delegate,
134+
policy=SafetyPolicy(audit_log_path=str(tmp_path / "audit.jsonl")),
135+
)
136+
input_data = CodeExecutionInput(code_blocks=[CodeBlock(language="python", code="print('ok')")])
137+
138+
result = await executor.execute_code(MagicMock(spec=InvocationContext), input_data)
139+
140+
assert "ok" in result.output
141+
assert delegate.calls == 1

0 commit comments

Comments
 (0)