|
| 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。 |
0 commit comments