Skip to content

Commit bfd18cf

Browse files
committed
feat: add tool script safety guard
1 parent e113610 commit bfd18cf

58 files changed

Lines changed: 10039 additions & 77 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/DESIGN.md

Lines changed: 148 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,148 @@
1+
# Tool Script Safety Guard 设计说明
2+
3+
## 目标与边界
4+
5+
Safety Guard 是执行前治理层。它对脚本、命令、参数、工作目录、环境变量名称和 tool 元数据做静态检查,输出 `allow``deny``needs_human_review`。它不能替代进程隔离、只读文件系统、最小权限、网络出口控制和运行时资源限制。
6+
7+
数据流如下:
8+
9+
```text
10+
Tool / MCP Tool / Skill / CodeExecutor 请求
11+
|
12+
v
13+
SafetyScanRequest(不保留环境变量值)
14+
|
15+
v
16+
ToolSafetyScanner + ToolSafetyPolicy + SafetyRule[]
17+
|
18+
v
19+
SafetyFinding[] --聚合--> SafetyReport
20+
| |
21+
v v
22+
JSONL AuditEvent Filter / Guard 执行决策
23+
|
24+
v
25+
OpenTelemetry tool.safety.* 属性
26+
```
27+
28+
扫描发生在真实 handler 或 executor delegate 之前。`deny` 必须阻断;`needs_human_review` 是否阻断由 `block_on_review` 决定。人工批准应由上层审批系统显式记录,不能把 review 自动降级为 allow。
29+
30+
## 接入示例
31+
32+
### Tool 与 MCP Tool Filter
33+
34+
Tool filter 从实际 tool 上下文获取名称,只接收参数中的脚本字段,不要求模型伪造 `tool_name`
35+
36+
```python
37+
from trpc_agent_sdk.tools import BashTool
38+
from trpc_agent_sdk.tools.safety import JsonlAuditSink
39+
from trpc_agent_sdk.tools.safety import ToolSafetyFilter, ToolSafetyGuard, ToolSafetyPolicy
40+
41+
policy = ToolSafetyPolicy.from_yaml("tool_safety_policy.yaml")
42+
safety_guard = ToolSafetyGuard(policy, audit_sink=JsonlAuditSink("tool_safety_audit.jsonl"))
43+
safety_filter = ToolSafetyFilter(safety_guard)
44+
tool = BashTool()
45+
tool.add_one_filter(safety_filter)
46+
```
47+
48+
Safety Filter 带有最终授权标记,框架会让其他参数转换 Filter 和 tool callback 先运行,再在最靠近真实 handler 的位置扫描最终参数,避免下游原地修改已扫描内容。
49+
50+
Filter 会在扫描前合并可信的 tool 固定 override、默认 timeout 和默认 cwd。以示例策略的 `max_timeout_seconds: 120` 为例,`BashTool` 缺省的 300 秒 timeout 会被拒绝,调用方必须显式请求不超过策略的值。参数转换、scanner 或报告聚合异常会生成脱敏的 `SCAN-INPUT` / `SCAN-ERROR` deny 报告并记录一次审计事件,不会以普通异常形式绕过审计。
51+
52+
`StreamingProgressTool` 在启动用户 async generator 之前运行同一套有序 Filter 和 tool callback;被拒绝时只返回结构化阻断结果,generator 不会开始执行。最终授权只针对完整组装后的 tool call,不应对早期参数分片做放行判断。
53+
54+
MCP Tool 应在本地代理真正发出 MCP 调用前应用同一个 Filter。远端 MCP server 仍需独立鉴权、最小权限和审计,因为本地静态扫描无法证明远端实现的实际行为。
55+
56+
### Skill
57+
58+
`skill_run``command``cwd``env` 名称和 timeout 都应在 workspace runner 启动进程前进入 Filter:
59+
60+
```python
61+
from trpc_agent_sdk.skills.tools import SkillRunTool
62+
from trpc_agent_sdk.tools.safety import ToolSafetyFilter
63+
64+
skill_tool = SkillRunTool(repository=repository, filters=[ToolSafetyFilter(safety_guard)])
65+
```
66+
67+
Skill 内容可能在扫描后被更新,因此还要固定 skill 版本或内容摘要,并在执行环境中限制挂载、网络和凭据。
68+
69+
### CodeExecutor wrapper
70+
71+
CodeExecutor 使用委托包装,逐个保留代码块语言,再聚合报告:
72+
73+
```python
74+
from trpc_agent_sdk.code_executors import UnsafeLocalCodeExecutor
75+
from trpc_agent_sdk.tools.safety import SafetyGuardedCodeExecutor
76+
77+
delegate = UnsafeLocalCodeExecutor(timeout=30)
78+
executor = SafetyGuardedCodeExecutor(inner=delegate, guard=safety_guard)
79+
```
80+
81+
包装器需要镜像 delegate 的 `stateful`、workspace runtime、delimiter 和重试配置,阻断时不得调用 delegate。
82+
83+
## 规则与策略
84+
85+
Python 使用 AST 提取调用、常量路径和数据流信号;Bash 使用不执行命令的 token/语法模式扫描。每个 finding 至少包含:
86+
87+
- `rule_id` 和风险分类;
88+
- `risk_level` 与局部决策;
89+
- 已脱敏、长度受限的 `evidence`
90+
- 可执行的 `recommendation`
91+
- 可选行列位置和不含秘密值的 metadata。
92+
93+
自定义规则通过 scanner 的 `rules` 参数注入,不修改内置 scanner:
94+
95+
```python
96+
from trpc_agent_sdk.tools.safety import SafetyDecision, SafetyFinding
97+
from trpc_agent_sdk.tools.safety import RiskCategory, RiskLevel, ToolSafetyScanner
98+
99+
class DenyInternalBinaryRule:
100+
rule_id = "CUSTOM-INTERNAL-BINARY"
101+
102+
def scan(self, context, policy):
103+
if "/internal/bin/" not in context.request.script:
104+
return []
105+
return [SafetyFinding(
106+
rule_id=self.rule_id,
107+
category=RiskCategory.PROCESS_EXECUTION,
108+
risk_level=RiskLevel.HIGH,
109+
decision=SafetyDecision.DENY,
110+
evidence="/internal/bin/<redacted>",
111+
recommendation="Use an explicitly approved command.",
112+
)]
113+
114+
scanner = ToolSafetyScanner(policy, rules=[DenyInternalBinaryRule()])
115+
```
116+
117+
自定义规则必须是纯静态、确定性且无副作用;异常由 scanner 按 policy 的 fail-closed 行为处理。
118+
119+
## 审计与监控
120+
121+
审计事件至少包含 `tool_name``decision``risk_level``rule_ids`、扫描耗时、`redacted``blocked`、人工批准状态、脚本 SHA-256 和策略版本。不得写入脚本文本、环境变量值、Authorization header 或私钥正文。
122+
123+
当前 span 应设置:
124+
125+
- `tool.safety.decision`
126+
- `tool.safety.risk_level`
127+
- `tool.safety.rule_id`
128+
- `tool.safety.rule_ids`
129+
- `tool.safety.duration_ms`
130+
- `tool.safety.redacted`
131+
- `tool.safety.blocked`
132+
133+
策略版本和脚本 SHA-256 只写入审计事件,不写入当前 span,以控制 telemetry 基数。
134+
135+
监控系统可以按 deny/review 比例、rule id、tool name 和扫描失败率告警,但不能把 telemetry 成功视为安全放行条件。
136+
137+
## 已知限制
138+
139+
- **误报**:Bash 的复杂引用、合法管道、安全的 subprocess 和测试夹具可能触发 review;通过窄化白名单或单条 rule action 调整,不应关闭整个 guard。
140+
- **漏报**:编码、压缩、别名、间接导入、反射、动态属性和多阶段下载执行可能绕过静态模式。
141+
- **动态代码**`eval``exec`、运行时拼接 URL/路径和下载后的脚本无法在首次扫描时完全解析,应阻断或人工复核,并在下一执行边界重新扫描。
142+
- **TOCTOU**:扫描后文件、Skill、cwd、符号链接或策略可能变化。执行端应校验内容摘要,固定版本,并尽量在隔离 workspace 内完成扫描和执行。
143+
- **MCP**:本地只能分析请求参数,无法验证远端 tool 是否执行了额外命令、访问其他数据或正确实施资源限制。
144+
- **Streaming**:分片参数在结束前可能不是完整语法。只能在完整 tool call 组装后做最终授权,不能因早期分片看似安全而提前执行。
145+
- **资源限制**:静态规则只能识别明显循环、sleep、fork bomb 和大写入信号;CPU、内存、进程数、磁盘、输出和墙钟时间必须由 sandbox/runtime 强制限制。
146+
- **运行时注入**:Guard 会检查调用参数、固定 tool override 和可见默认值,但 workspace/repository 在 handler 内部追加的可信环境或文件仍需由运行时白名单、固定配置和 sandbox 约束。
147+
148+
因此生产部署应组合:Safety Guard + Container/Cube sandbox + 最小凭据 + 出网白名单 + 资源限制 + 不可篡改审计。

examples/tool_safety/README.md

Lines changed: 53 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,53 @@
1+
# Tool Script Safety Guard 示例
2+
3+
本目录提供一套可重复运行的公开验收样本,用于验证 Tool、Skill 和 CodeExecutor 在执行 Python 或 Bash 内容前的静态安全检查。样本只会被读取和扫描,测试不会执行其中的脚本。
4+
5+
## 快速运行
6+
7+
在仓库根目录执行:
8+
9+
```bash
10+
python scripts/tool_safety_check.py examples/tool_safety/samples \
11+
--policy examples/tool_safety/tool_safety_policy.yaml \
12+
--report /tmp/tool_safety_report.json \
13+
--audit /tmp/tool_safety_audit.jsonl \
14+
--tool-name public_sample_scan
15+
```
16+
17+
CLI 接受任意数量的 `.py``.sh``.bash` 文件或目录。目录会递归扫描,结果按路径稳定排序。标准输出和 `--report` 都是结构化 JSON;`--audit` 每个文件追加一条已脱敏 JSONL 事件。
18+
19+
仓库同时保留一份由上述 12 个样本真实生成的 [`tool_safety_report.json`](tool_safety_report.json)[`tool_safety_audit.jsonl`](tool_safety_audit.jsonl)。报告不含原始脚本或环境变量值,审计文件恰好每个样本一条事件。
20+
21+
退出码遵循最严格决策:
22+
23+
| 退出码 | 决策 |
24+
| --- | --- |
25+
| `0` | `allow` |
26+
| `1` | `deny` |
27+
| `2` | `needs_human_review` |
28+
29+
多文件扫描按 `deny > needs_human_review > allow` 聚合。策略读取失败、输入不可读或扫描基础设施异常时,CLI 采用 fail-closed 并返回 `deny`
30+
31+
## 公开样本
32+
33+
[`samples/manifest.yaml`](samples/manifest.yaml) 是唯一真值源,列出恰好 12 个样本的预期决策和至少应命中的规则。场景包括安全 Python、危险删除、读取 SSH 私钥、非白名单与白名单网络请求、subprocess、shell 注入、依赖安装、无限循环、敏感信息输出、Bash 管道和人工复核。
34+
35+
测试会逐个调用 CLI,并校验:
36+
37+
- 高危样本检出率不低于 90%;
38+
- 安全样本误报率不高于 10%;
39+
- 危险删除、读取密钥、非白名单外连检出率均为 100%;
40+
- JSON 报告和 JSONL 审计可以被标准解析器读取。
41+
42+
## 策略修改
43+
44+
[`tool_safety_policy.yaml`](tool_safety_policy.yaml) 采用严格字段校验。未知字段、错误类型和非法阈值会直接失败,避免拼写错误造成静默放行。修改以下字段不需要改代码:
45+
46+
- `allowed_domains`:精确域名和子域名白名单;
47+
- `allowed_commands`:允许的可执行文件 basename 或精确路径;basename 白名单不会放行 `./git` 等相对路径;
48+
- `denied_paths`:禁止路径及 glob;
49+
- `max_timeout_seconds``max_output_bytes``max_script_bytes`:资源上限;
50+
- `long_sleep_seconds``max_concurrency`:可疑资源使用阈值;
51+
- `rule_actions`:按 rule id 覆盖默认决策。
52+
53+
完整处理流程、接入方式和安全边界见 [`DESIGN.md`](DESIGN.md)
Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,4 @@
1+
# Static scanner sample; this file is not executed by the example suite.
2+
3+
values = [1, 2, 3]
4+
print(sum(values))
Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,4 @@
1+
#!/bin/sh
2+
# Static scanner sample; do not execute.
3+
4+
rm -rf /
Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
# Static scanner sample; do not execute.
2+
3+
from pathlib import Path
4+
5+
private_key = Path("~/.ssh/id_rsa").read_text(encoding="utf-8")
6+
print(len(private_key))
Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
# Static scanner sample; do not execute.
2+
3+
import requests
4+
5+
requests.post("https://collector.external.example/upload", data={"status": "ok"}, timeout=5)
Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
# Static scanner sample; this file is not executed by the example suite.
2+
3+
import requests
4+
5+
response = requests.get("https://api.example.com/health", timeout=5)
6+
print(response.status_code)
Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
# Static scanner sample; do not execute.
2+
3+
import subprocess
4+
5+
subprocess.run(["git", "status"], check=True)
Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
# Static scanner sample; do not execute.
2+
3+
import subprocess
4+
5+
user_value = input("value: ")
6+
subprocess.run("echo " + user_value, shell=True, check=True)
Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,4 @@
1+
#!/bin/sh
2+
# Static scanner sample; do not execute.
3+
4+
python -m pip install untrusted-example-package

0 commit comments

Comments
 (0)