Skip to content

Commit f8bd0f5

Browse files
committed
feat(tool-safety): add Tool Script Safety Guard with pluggable filter and scanner
1 parent 73655ab commit f8bd0f5

42 files changed

Lines changed: 4804 additions & 0 deletions

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

examples/tool_safety/README.md

Lines changed: 332 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,332 @@
1+
# Tool Script Safety Guard
2+
3+
A pluggable **pre-execution safety scanner** for tRPC-Agent Tool / Skill / CodeExecutor scripts.
4+
It performs static analysis on Python and Bash content **before** execution, emits
5+
`allow` / `deny` / `needs_human_review` decisions, and produces structured reports +
6+
audit events + OpenTelemetry span attributes.
7+
8+
> This is a **defense-in-depth** layer. It does **not** replace sandbox isolation —
9+
> see [Relationship with Sandbox / Filter / Telemetry / CodeExecutor](#relationship-with-sandbox--filter--telemetry--codeexecutor).
10+
11+
---
12+
13+
## 目录
14+
15+
- [快速开始](#快速开始)
16+
- [规则体系](#规则体系)
17+
- [策略文件](#策略文件)
18+
- [接入方式](#接入方式)
19+
- [结构化报告与审计](#结构化报告与审计)
20+
- [测试与验收](#测试与验收)
21+
- [已知限制与绕过风险](#已知限制与绕过风险)
22+
- [扩展新规则](#扩展新规则)
23+
- [与沙箱/Filter/Telemetry/CodeExecutor 的关系](#relationship-with-sandbox--filter--telemetry--codeexecutor)
24+
25+
---
26+
27+
## 快速开始
28+
29+
### 扫描单个脚本
30+
31+
```bash
32+
python examples/tool_safety/tool_safety_check.py --script examples/tool_safety/samples/02_dangerous_delete.sh
33+
```
34+
35+
### 扫描 12 条样本并生成报告 + 审计日志
36+
37+
```bash
38+
python examples/tool_safety/tool_safety_check.py \
39+
--samples examples/tool_safety/samples/ \
40+
--policy examples/tool_safety/tool_safety_policy.yaml \
41+
--report examples/tool_safety/tool_safety_report.json \
42+
--audit examples/tool_safety/tool_safety_audit.jsonl \
43+
--verbose
44+
```
45+
46+
预期输出:
47+
48+
```
49+
Summary: 12 scanned | 2 allow | 9 deny | 1 needs_review
50+
```
51+
52+
### 作为库使用
53+
54+
```python
55+
from examples.tool_safety.safety import PolicyConfig, SafetyScanner, ScanInput
56+
57+
policy = PolicyConfig.from_yaml("examples/tool_safety/tool_safety_policy.yaml")
58+
scanner = SafetyScanner(policy=policy)
59+
report = scanner.scan(ScanInput(script="rm -rf /", language="bash"))
60+
print(report.decision) # Decision.DENY
61+
print(report.rule_ids) # ['R001_dangerous_files', ...]
62+
```
63+
64+
---
65+
66+
## 规则体系
67+
68+
6 类内置规则,每条规则是一个独立的 `SafetyRule` 子类,可插拔、可禁用:
69+
70+
| 规则 ID | 规则名 | 风险类型 | 默认级别 | 覆盖范围 |
71+
|---|---|---|---|---|
72+
| `R001_dangerous_files` | Dangerous File Operation | dangerous_files | CRITICAL | `rm -rf``shutil.rmtree`、系统目录(`/etc` `/usr` `C:\Windows`)、`~/.ssh``.env``id_rsa``.aws/credentials`|
73+
| `R002_network_egress` | Network Egress | network | HIGH | `curl`/`wget`/`requests`/`aiohttp`/`socket`/`urllib` 访问非白名单域名;动态目标标记为需复核 |
74+
| `R003_process_system` | Process / System Command | process | HIGH/CRITICAL | `subprocess`/`os.system`/`os.popen``shell=True``eval`/`exec``sudo`/`su`、后台进程 `&`、嵌套命令替换 |
75+
| `R004_dependency_install` | Dependency Installation | dependency_install | HIGH | `pip install`/`npm install`/`apt install`/`yarn add`/`conda install` 等,含 Python 字符串字面量内嵌的安装命令 |
76+
| `R005_resource_abuse` | Resource Abuse | resource_abuse | HIGH | 无限循环(`while True` 无 break)、fork bomb、`dd``yes` 重定向、超长 `sleep`、高并发池 |
77+
| `R006_secret_leak` | Sensitive Information Leakage | secret_leak | CRITICAL | OpenAI/AWS/Slack/GitHub/JWT 密钥模式、`bearer` token、密钥变量传入 `print`/`logging`/`open` 等 sink |
78+
79+
### 判定逻辑
80+
81+
- 聚合所有命中的 finding,取最高 `risk_level`
82+
- `risk_level >= deny_risk_level`(默认 HIGH) → **DENY**
83+
- `risk_level >= review_risk_level`(默认 MEDIUM) → **NEEDS_HUMAN_REVIEW**
84+
- 否则 → **ALLOW**
85+
- 不确定情况(如动态网络目标)**不会**直接放行,而是标记为 `needs_human_review`
86+
87+
---
88+
89+
## 策略文件
90+
91+
[`tool_safety_policy.yaml`](tool_safety_policy.yaml) 控制全部行为,**修改后无需改代码**:
92+
93+
```yaml
94+
whitelisted_domains: # 网络白名单(后缀匹配),空列表 = 全部拒绝(fail-closed)
95+
- api.github.com
96+
- pypi.org
97+
forbidden_paths: # 禁止访问的路径子串
98+
- ~/.ssh
99+
- .env
100+
allowed_commands: # 允许的 bash 命令(仍会扫描参数)
101+
- ls
102+
- git
103+
max_timeout_seconds: 300 # 超时上限,用于判定长 sleep
104+
max_output_bytes: 10485760
105+
deny_risk_level: high # HIGH/CRITICAL → DENY
106+
review_risk_level: medium # MEDIUM → NEEDS_HUMAN_REVIEW
107+
secret_patterns: # 额外密钥正则(补充内置默认)
108+
- '(?i)sk-[A-Za-z0-9]{20,}'
109+
disabled_rules: [] # 要跳过的 rule_id 列表
110+
```
111+
112+
热更新示例见 [`tests/tool_safety/test_policy.py`](../../tests/tool_safety/test_policy.py):
113+
修改 YAML 后重新 `PolicyConfig.from_yaml(...)` 即可改变白名单域名、禁止路径、允许命令的判定行为。
114+
115+
---
116+
117+
## 接入方式
118+
119+
### 方式 1:作为 Tool Filter 接入 SDK 执行链路(推荐)
120+
121+
`ToolSafetyFilter` 继承 SDK 的 `BaseFilter`,在 `_before` 钩子里扫描 tool 参数,
122+
DENY 时设置 `is_continue=False` 阻断 `_run_async_impl`,并写审计日志:
123+
124+
```python
125+
from examples.tool_safety.safety import ToolSafetyFilter, PolicyConfig
126+
from trpc_agent_sdk.tools.file_tools import BashTool
127+
128+
policy = PolicyConfig.from_yaml("examples/tool_safety/tool_safety_policy.yaml")
129+
safety_filter = ToolSafetyFilter(policy=policy, audit_path="audit.jsonl")
130+
tool = BashTool(filters=[safety_filter])
131+
```
132+
133+
### 方式 2:wrapper 包装现有 tool / executor
134+
135+
```python
136+
from examples.tool_safety.safety import wrap_tool, PolicyConfig
137+
138+
policy = PolicyConfig.from_yaml("...")
139+
tool = wrap_tool(existing_tool, policy, audit_path="audit.jsonl")
140+
```
141+
142+
### 方式 3:独立 CLI 扫描
143+
144+
见 [快速开始](#快速开始),适合 CI 流水线或人工预检。
145+
146+
接入点说明:SDK 的 `BaseTool.run_async` 在调用 `_run_async_impl` 前会先跑 `_run_filters`,
147+
本 Filter 正是挂在这个前置位置,因此能在执行前拦截高危脚本。
148+
149+
---
150+
151+
## 结构化报告与审计
152+
153+
### SafetyReport 字段(issue 验收标准 5)
154+
155+
每条扫描产出 `SafetyReport`,序列化为 JSON,包含:
156+
157+
| 字段 | 说明 |
158+
|---|---|
159+
| `decision` | `allow` / `deny` / `needs_human_review` |
160+
| `risk_level` | 聚合最高风险级别 |
161+
| `rule_ids` | 命中规则 ID 列表 |
162+
| `findings[].rule_id` / `evidence` / `line` / `recommendation` | 单条证据 |
163+
| `scan_duration_ms` | 扫描耗时 |
164+
| `sanitized` | 证据是否已脱敏(始终为 true) |
165+
| `blocked` | 是否拦截(decision==deny) |
166+
167+
示例输出见 [`tool_safety_report.json`](tool_safety_report.json)。
168+
169+
### 审计日志(issue 验收标准 7)
170+
171+
每条决策写一行 JSONL 到 `tool_safety_audit.jsonl`,字段含:
172+
`tool_name`、`decision`、`risk_level`、`rule_ids`、`scan_duration_ms`、
173+
`sanitized`、`intercepted`、`blocked`、`timestamp`、`script_path`。
174+
175+
示例见 [`tool_safety_audit.jsonl`](tool_safety_audit.jsonl)。
176+
177+
### OpenTelemetry 埋点
178+
179+
当宿主进程启用了 OpenTelemetry,扫描会在当前 span 上设置:
180+
181+
- `tool.safety.decision`
182+
- `tool.safety.risk_level`
183+
- `tool.safety.rule_id`(逗号分隔)
184+
- `tool.safety.scan_duration_ms`
185+
- `tool.safety.sanitized`
186+
- `tool.safety.blocked`
187+
- `tool.safety.tool_name`
188+
189+
未启用 OTel 时为静默 no-op,不影响安全路径。
190+
191+
---
192+
193+
## 测试与验收
194+
195+
### 运行测试
196+
197+
```bash
198+
cd d:\Tencent\trpc-agent-python
199+
pytest tests/tool_safety/ -v
200+
```
201+
202+
### 验收标准对照
203+
204+
| issue 验收标准 | 验收方式 | 结果 |
205+
|---|---|---|
206+
| 1. 12 条样本可扫描并输出结构化报告 | `test_scanner.py` 12 个 case + CLI | ✅ 12/12 |
207+
| 2. 高危检出率 ≥90%,误报率 ≤10% | `test_scanner.py::test_detection_rate` | ✅ 9/9 检出,0/2 误报 |
208+
| 3. 读密钥/危险删除/非白名单外连 100% 检出 | `test_scanner.py::test_required_100_percent_detection` | ✅ 3/3 |
209+
| 4. 500 行脚本 ≤1s | `test_performance.py` | ✅ <1s |
210+
| 5. 报告含 decision/risk_level/rule_id/evidence/recommendation | `test_scanner.py::_assert_report_well_formed` | ✅ |
211+
| 6. 改策略不改代码即生效 | `test_policy.py::test_hot_reload_*` | ✅ |
212+
| 7. Filter 执行前拒绝高危 + 写审计 | `test_tool_filter.py` | ✅ 4 case |
213+
| 8. 文档说明与沙箱等关系 | 本 README 末节 | ✅ |
214+
215+
### 12 条样本
216+
217+
| # | 样本 | 期望决策 |
218+
|---|---|---|
219+
| 01 | 安全 Python | allow |
220+
| 02 | 危险删除 `rm -rf /` | deny |
221+
| 03 | 读取 `~/.ssh/id_rsa` / `.env` / `.aws/credentials` | deny |
222+
| 04 | 网络外连 evil.example.com | deny |
223+
| 05 | 白名单网络(api.github.com) | allow |
224+
| 06 | subprocess + `shell=True` | deny |
225+
| 07 | shell 注入(eval/sudo/嵌套$()) | deny |
226+
| 08 | 依赖安装(pip/npm/apt) | deny |
227+
| 09 | 无限循环 + 长 sleep | deny |
228+
| 10 | 密钥泄漏到日志/网络/文件 | deny |
229+
| 11 | bash 管道 + fork + dd | deny |
230+
| 12 | 动态网络目标(无法静态判定) | needs_human_review |
231+
232+
---
233+
234+
## 已知限制与绕过风险
235+
236+
本工具是**静态分析 + 策略判定**,存在以下固有局限:
237+
238+
### 误报(False Positives)
239+
240+
- 字符串中包含 `rm -rf` 文本(如文档/注释)可能被标记。
241+
- 变量名含 `token`/`key` 但实际非密钥(如 `token_count`)可能被标记。
242+
- 白名单域名的子域匹配可能对形如 `evil-api.github.com.attacker.com` 的伪造成因后缀匹配逻辑而漏报(已用 `endswith("." + d)` 缓解)。
243+
244+
### 漏报(False Negatives)
245+
246+
- **动态构造**: `getattr(os, "sys" + "tem")("rm -rf /")` 无法被静态解析捕获。
247+
- **编码混淆**: base64/hex 编码后的命令、`exec(__import__('base64').b64decode(...))` 可绕过。
248+
- **间接调用**: 通过 `importlib.import_module` 动态加载模块再调用。
249+
- **环境变量拼接**: `os.system(env_var + " evil")` 的 `env_var` 值在运行时才确定。
250+
- **Bash 复杂语法**: heredoc、`eval` 嵌套、别名展开等超出正则覆盖范围。
251+
252+
### 绕过风险
253+
254+
- 攻击者可利用 Python 元编程(`__import__`、`globals()`、`getattr` 链)绕过 AST 名字解析。
255+
- Bash 中可通过变量间接展开 `${!var}` 或 `eval` 二次展开绕过静态扫描。
256+
- 这是**所有静态分析工具**的固有限制,正是为什么本工具**不能替代沙箱**。
257+
258+
---
259+
260+
## 扩展新规则
261+
262+
1. 在 [`safety/rules/`](safety/rules/) 下新建文件,实现 `SafetyRule` 子类:
263+
264+
```python
265+
from .base import SafetyRule
266+
from ..types import RiskLevel, SafetyFinding, ScanInput
267+
from ..policy import PolicyConfig
268+
269+
class MyRule(SafetyRule):
270+
rule_id = "R007_my_rule"
271+
rule_name = "My Custom Rule"
272+
risk_type = "custom"
273+
default_level = RiskLevel.MEDIUM
274+
languages = ("python", "bash")
275+
276+
def check(self, scan_input: ScanInput, policy: PolicyConfig) -> list[SafetyFinding]:
277+
# 你的检测逻辑
278+
return []
279+
```
280+
281+
2. 在 [`safety/rules/__init__.py`](safety/rules/__init__.py) 的 `default_rules()` 中注册。
282+
283+
3. 可在 `tool_safety_policy.yaml` 的 `disabled_rules` 中按 `rule_id` 禁用,无需改代码。
284+
285+
4. 在 `tests/tool_safety/test_rules.py` 添加单测。
286+
287+
---
288+
289+
## Relationship with Sandbox / Filter / Telemetry / CodeExecutor
290+
291+
本机制在 tRPC-Agent 的安全体系中处于**执行前静态检查**位置,与其它组件分工如下:
292+
293+
```
294+
Tool 调用请求
295+
296+
297+
┌─────────────────────────────┐
298+
│ ToolSafetyFilter (本工具) │ ← 静态扫描 + 策略判定,执行前拦截
299+
│ - AST/正则分析脚本 │
300+
│ - allow/deny/review 决策 │
301+
│ - 写审计日志 + OTel span │
302+
└────────────┬────────────────┘
303+
│ allow / review
304+
305+
┌─────────────────────────────┐
306+
│ CodeExecutor (执行层) │ ← 实际运行代码
307+
│ - UnsafeLocalCodeExecutor │
308+
│ - ContainerCodeExecutor │ ← 沙箱隔离(容器/进程级)
309+
└────────────┬────────────────┘
310+
311+
312+
┌─────────────────────────────┐
313+
│ Telemetry (可观测) │ ← 运行时监控
314+
│ - span / metrics / logs │
315+
└─────────────────────────────┘
316+
```
317+
318+
### 为什么不能替代沙箱隔离
319+
320+
| 维度 | 本 Safety Guard | 沙箱(CodeExecutor) |
321+
|---|---|---|
322+
| 作用时机 | 执行**前** | 执行**中** |
323+
| 能力 | 静态模式匹配、策略判定 | 资源限制(CPU/内存/磁盘)、文件系统隔离、网络隔离、系统调用过滤 |
324+
| 局限 | 无法检测动态构造/编码混淆(见上文) | 无法识别脚本意图,但有硬隔离边界 |
325+
| 失败模式 | 漏报 → 危险代码进入执行 | 即便执行也是受限环境 |
326+
327+
**结论**:本工具是**第一道防线**,负责在执行前过滤明显的高危模式;沙箱是**最后一道防线**,负责限制已执行代码的副作用。两者**必须配合使用**:
328+
329+
- 只用沙箱不用本工具:无法提前预警,审计缺失意图信息。
330+
- 只用本工具不用沙箱:一旦漏报,攻击者直接获得宿主权限。
331+
332+
Filter 链路(`ToolSafetyFilter`)负责把守入口,CodeExecutor 负责隔离执行,Telemetry 贯穿全程提供可观测性——三者共同构成 defense-in-depth。

examples/tool_safety/__init__.py

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
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 the Apache License Version 2.0
6+
"""Tool Script Safety Guard example package."""

0 commit comments

Comments
 (0)