最好的守门人不是拒绝一切的人,而是能分辨谁该进门的人。
智能审批:让 LLM 判断 LLM 的行为是否安全
问题:正则模式太粗
第 10 讲说过,DANGEROUS_PATTERNS 有约 40 条正则。正则的问题是误报:
python -c "print('hello world')"
这条命令匹配了 (python[23]?|perl|ruby|node)\s+-[ec]\s+------"脚本执行 via -c flag"。但它显然是安全的。如果每次 Agent 跑 python -c 都要弹确认框,用户会在第三次之后永久关掉审批。
_smart_approve():辅助 LLM 做裁判
tools/approval.py:535-579 实现了 smart 模式的核心:
def _smart_approve(command: str, description: str) -> str:
prompt = f"""You are a security reviewer for an AI coding agent. A terminal command was flagged by pattern matching as potentially dangerous.
Command: {command}
Flagged reason: {description}
Assess the ACTUAL risk of this command. Many flagged commands are false positives --- for example, `python -c "print('hello')"` is flagged as "script execution via -c flag" but is completely harmless.
Rules:
- APPROVE if the command is clearly safe (benign script execution, safe file operations, development tools, package installs, git operations, etc.)
- DENY if the command could genuinely damage the system (recursive delete of important paths, overwriting system files, fork bombs, wiping disks, dropping databases, etc.)
- ESCALATE if you're uncertain
Respond with exactly one word: APPROVE, DENY, or ESCALATE"""
response = call_llm(
task="approval",
messages=[{"role": "user", "content": prompt}],
temperature=0,
max_tokens=16,
)
answer = (response.choices[0].message.content or "").strip().upper()
if "APPROVE" in answer:
return "approve"
elif "DENY" in answer:
return "deny"
else:
return "escalate"
几个设计点:
1. temperature=0------安全决策必须确定性。不同温度下 LLM 可能给出不同的安全评估。
2. max_tokens=16------只要一个词的回答。不让模型长篇大论分析风险,直接 APPROVE / DENY / ESCALATE。
3. prompt 里主动提 false positive ------"Many flagged commands are false positives --- for example, python -c "print('hello')" is flagged as..." 这不是废话,这是校准提示。如果不提,LLM 会倾向于"既然被标记了,那大概确实危险"------confirmation bias。
4. ESCALATE 是兜底 ------LLM 不确定时不做决定,而是把球踢给人类。异常(LLM 调用失败、解析错误)也走 ESCALATE。安全系统的默认行为应该是"升级给人"而不是"自动放行"。
三级决策链
Smart 模式不是单独工作的------它嵌入在 check_all_command_guards() 的完整决策链中(approval.py:689-948):
Phase 1: 收集安全发现
├─ Tirith 外部扫描 → block / warn / allow
└─ 正则危险模式匹配 → 匹配的模式列表
Phase 2: 整合警告
├─ Tirith block/warn → 可审批的警告
└─ 正则匹配 → 检查是否已预审批
Phase 2.5: Smart 审批(仅 mode=smart 时)
├─ APPROVE → 批准 + 自动授予 session 级预审批
├─ DENY → 拒绝,返回 "BLOCKED by smart approval"
└─ ESCALATE → 继续到 Phase 3
Phase 3: 人工审批
├─ CLI → [o/s/a/D] 交互提示
└─ Gateway → 平台按钮
Smart 模式不替代人类------它过滤掉大量误报,只把真正需要判断的命令交给人。
多平台审批体验:同一个安全决策,不同的 UX
审批决策是统一的(approval.py),但呈现给用户的方式因平台而异。
CLI:经典的交互式提示
⚠️ DANGEROUS COMMAND: recursive delete
rm -rf /tmp/old-project
[o]nce | [s]ession | [a]lways | [d]eny
Choice [o/s/a/D]:
四个选项,默认是 deny(大写 D 表示默认)。60 秒超时后自动拒绝。
一个细节 :当 Tirith 报告了安全警告时,[a]lways 选项会被隐藏------防止用户把一个有安全隐患的命令模式永久加入白名单。只保留 [o/s/D]。
Telegram:内联键盘按钮
Telegram 适配器(gateway/platforms/telegram.py)用 InlineKeyboardMarkup 发送 2×2 的按钮矩阵:
keyboard = InlineKeyboardMarkup([
[
InlineKeyboardButton("✅ Allow Once", callback_data=f"ea:once:{approval_id}"),
InlineKeyboardButton("✅ Session", callback_data=f"ea:session:{approval_id}"),
],
[
InlineKeyboardButton("✅ Always", callback_data=f"ea:always:{approval_id}"),
InlineKeyboardButton("❌ Deny", callback_data=f"ea:deny:{approval_id}"),
],
])
用户点击按钮后,_handle_callback_query() 解析 ea:choice:approval_id,通过 resolve_gateway_approval() 解除等待中的 Agent 线程阻塞。
Slack:Block Kit 按钮
Slack 适配器(gateway/platforms/slack.py)用 Block Kit 的 actions 块发送四个按钮:
elements = [
{"action_id": "hermes_approve_once", "value": session_key, ...},
{"action_id": "hermes_approve_session", "value": session_key, ...},
{"action_id": "hermes_approve_always", "value": session_key, ...},
{"action_id": "hermes_deny", "value": session_key, ...},
]
Slack 的交互回调通过 _handle_approval_action() 处理。有一个防重复点击 机制:_approval_resolved 字典跟踪已处理的 message_ts,防止用户多次点击导致重复审批。
飞书 / Lark:交互卡片
飞书适配器(gateway/platforms/feishu.py)用交互卡片(Interactive Card):
card = {
"header": {"title": {"content": "⚠️ Command Approval Required"}, "template": "orange"},
"elements": [
{"tag": "markdown", "content": f"```\n{cmd_preview}\n```\n**Reason:** {description}"},
{"tag": "action", "actions": [
_btn("✅ Allow Once", "approve_once", "primary"),
_btn("✅ Session", "approve_session"),
_btn("✅ Always", "approve_always"),
_btn("❌ Deny", "deny", "danger"),
]},
],
}
其他平台:显式文本命令
对于不支持按钮交互的平台(SMS、Email、部分 Matrix 客户端),Gateway 退回到显式文本命令 模式:用户需要回复 /approve 或 /deny,而不是随手回一个 yes。这是一个刻意的设计:gateway/run.py 明确禁止 bare-text matching,避免普通对话里的"yes"误触发危险命令放行。
如果要做更细粒度的授权,Gateway 这条链路还支持 /approve session、/approve always 这样的 scope 变体。
统一的超时机制 :审批统一是 fail-closed(超时即拒绝),但默认值分两档。CLI 走 approval.timeout,默认 60 秒;Gateway 走 approvals.gateway_timeout,默认 300 秒,并且在等待期间会持续刷新活跃心跳,避免长时间等用户点击按钮时被会话超时 watchdog 误杀。
MCP 集成的安全考量
MCP(Model Context Protocol)让 Agent 能连接外部工具服务器------GitHub、数据库、文件系统等等。这极大扩展了能力,但也引入了两类安全风险:认证安全 和供应链安全。
OAuth 2.1 PKCE:认证安全
tools/mcp_oauth.py 实现了完整的 OAuth 2.1 Authorization Code + PKCE 流程。
为什么是 PKCE? 传统的 OAuth Authorization Code 流程依赖 client_secret 来换 token。但 Hermes Agent 是一个本地 CLI 工具------client_secret 不可能安全地嵌入到用户的机器上。PKCE(Proof Key for Code Exchange)用一次性的 code_verifier / code_challenge 对替代 client_secret,让公共客户端也能安全地做 OAuth。
关键实现(mcp_oauth.py:420-435):
metadata_kwargs = {
"client_name": "Hermes Agent",
"redirect_uris": [f"http://127.0.0.1:{port}/callback"],
"grant_types": ["authorization_code", "refresh_token"],
"response_types": ["code"],
"token_endpoint_auth_method": "none", # PKCE 不需要 client_secret
}
if cfg.get("client_secret"):
metadata_kwargs["token_endpoint_auth_method"] = "client_secret_post"
本地回调服务器 :OAuth 的 redirect_uri 是 http://127.0.0.1:{port}/callback------一个临时启动的本地 HTTP 服务器(mcp_oauth.py:242-278)。端口通过 socket.bind(0) 自动选取空闲端口。回调处理完成后服务器立即关闭。
硬编码 localhost------redirect_uri 不允许指向外部地址。这防止了恶意 MCP 服务器通过篡改 redirect_uri 把 OAuth code 外泄到攻击者的服务器。
Token 持久化 :Token 存储在 $HERMES_HOME/mcp-tokens/<server_name>.json,权限 0600。MCP SDK 的 OAuthClientProvider 自动处理 token 刷新------过期时用 refresh_token 静默续期,不需要用户重新授权。
OSV 恶意软件扫描:供应链安全
MCP 服务器通常通过 npx 或 uvx 安装运行。tools/osv_check.py 在启动 MCP 服务器之前,检查包是否有已知的恶意软件通报。
def check_package_for_malware(command: str, args: list) -> Optional[str]:
ecosystem = _infer_ecosystem(command) # npx → "npm", uvx → "PyPI"
if not ecosystem:
return None
package, version = _parse_package_from_args(args, ecosystem)
if not package:
return None
malware = _query_osv(package, ecosystem, version)
if malware:
ids = ", ".join(m["id"] for m in malware[:3])
return f"BLOCKED: Package '{package}' ({ecosystem}) has known malware advisories: {ids}"
return None
关键设计:
1. 只拦截 MAL- 通报(osv_check.py:155)------不拦截普通的 CVE。为什么?因为 CVE 是"已知漏洞",可能只影响特定使用场景;MAL-* 是"确认的恶意软件"------包本身就是恶意的,无论怎么用都不安全。
2. API 是免费的 ------Google 维护的 OSV API(https://api.osv.dev/v1/query\),无需认证,延迟约 300ms。
3. Fail-open------网络错误、超时、解析失败都允许包继续安装。安全检查不应该因为网络问题而阻止正常工作。
散布在代码库各处的安全防御
除了集中的安全模块,Hermes Agent 在代码库的各个角落还有四类防御。
SSRF 防护
tools/url_safety.py 防止 Agent 被诱导访问内部网络地址(云元数据端点、localhost 服务等)。
_BLOCKED_HOSTNAMES = frozenset({
"metadata.google.internal",
"metadata.goog",
})
_CGNAT_NETWORK = ipaddress.ip_network("100.64.0.0/10")
def _is_blocked_ip(ip) -> bool:
if ip.is_private or ip.is_loopback or ip.is_link_local or ip.is_reserved:
return True
if ip.is_multicast or ip.is_unspecified:
return True
if ip in _CGNAT_NETWORK: # Tailscale/WireGuard/云内部
return True
return False
检查流程:解析 URL → DNS 解析得到 IP → 检查 IP 是否在私有/保留/CGNAT 范围内。
已知局限 (url_safety.py:8-16 的文档注释明确说明):
-
DNS rebinding(TOCTOU) :攻击者控制的 DNS 在安全检查时返回公网 IP,实际连接时返回私有 IP。这个问题在 pre-flight 级别无法彻底修复;源码注释给出的方向是连接级校验库,或独立的 egress proxy。
-
重定向绕过 :这部分有现实缓解。
httpxevent hook 会在重定向时重新检查目标地址,因此可以拦住"先跳公网、再跳内网"的 redirect-based bypass。
时序攻击防护
多个文件使用 hmac.compare_digest() 做常数时间比较------防止攻击者通过响应时间差推断签名/Token 的正确前缀:
-
hermes_cli/web_server.py(Web 面板 auth token) -
gateway/platforms/webhook.py(GitHub/GitLab webhook 签名) -
gateway/platforms/feishu.py(飞书 webhook HMAC-SHA256 签名) -
gateway/platforms/api_server.py(API Key 验证)
Tar 遍历防护
tools/tirith_security.py:348-356 在解压 Tirith 二进制时做路径安全检查:
with tarfile.open(archive_path, "r:gz") as tar:
for member in tar.getmembers():
if member.name == "tirith" or member.name.endswith("/tirith"):
if ".." in member.name: # 路径穿越检查
continue
member.name = "tirith" # 归一化为安全名字
tar.extract(member, tmpdir)
break
三层防护:白名单文件名 → 拒绝 .. 组件 → 归一化文件名 → 解压到隔离临时目录。
凭证泄露防护
agent/redact.py 实现了 57+ 条凭证脱敏正则,覆盖:
| 类别 | 示例模式 |
|---|---|
| API Key 前缀 | sk- 、ghp_、AIza、AKIA、sk-ant- |
| 环境变量赋值 | OPENAI_API_KEY=sk-abc... |
| JSON 字段 | "apiKey": "value" |
| Authorization 头 | Bearer sk-... |
| Telegram Bot Token | <digits>:<token> |
| 私钥块 | -----BEGIN RSA PRIVATE KEY----- |
| 数据库连接串 | postgres://user:PASSWORD@host |
| JWT | eyJ... |
| Discord 提及 | <@snowflake_id> |
| 电话号码 | E.164 格式 +1234567890 |
脱敏策略 (redact.py:117-121):
def _mask_token(token: str) -> str:
if len(token) < 18:
return "***"
return f"{token[:6]}...{token[-4:]}" # 保留前 6 + 后 4,可调试
脱敏应用在三个出口:终端工具输出、日志格式化器、平台适配器的外发日志。
YOLO 模式的精确边界
最后给 YOLO 模式画一条清晰的线。
三种激活方式
| 方式 | 作用域 | 存储 |
|---|---|---|
hermes --yolo |
进程级(CLI 进程结束即失效) | os.environ["HERMES_YOLO_MODE"] |
/yolo 命令 |
Session 级(toggle 开关) | _session_yolo 集合 |
HERMES_YOLO_MODE=1 |
环境变量级 | 进程环境 |
/yolo 是 toggle ------第一次开,第二次关。空字符串 HERMES_YOLO_MODE="" 不会激活(空字符串是 falsy)。
绕过了什么
# approval.py:602-603
if os.getenv("HERMES_YOLO_MODE") or is_current_session_yolo_enabled():
return {"approved": True, "message": None}
这段代码在 check_all_command_guards() 的最前面------意味着 YOLO 直接跳过整个函数,包括:
-
危险命令正则匹配
-
Tirith 外部扫描
-
Smart LLM 审批
-
所有审批提示
没绕过什么
| 安全机制 | YOLO 下是否仍生效 | 原因 |
|---|---|---|
| 容器隔离(Docker/Singularity/Modal) | 是 | 独立的执行环境层 |
| MCP 凭证过滤 | 是 | 环境变量白名单独立于审批 |
| MCP OAuth 认证 | 是 | 认证流程不经过审批 |
| OSV 恶意软件扫描 | 是 | 独立的供应链检查 |
| URL 安全(SSRF) | 是 | 独立的网络层检查 |
| 上下文文件扫描 | 是 | 独立的 prompt 注入检测 |
| 输入净化(workdir) | 是 | 独立的参数校验 |
| 凭证脱敏 | 是 | 独立的输出过滤 |
| IterationBudget | 是 | 独立的资源限制 |
| 技能安全扫描 | 是 | 独立的文件安全检查 |
YOLO 只关闭第 2 层这整条 pre-exec 命令审批链。 具体说,被跳过的是 check_all_command_guards() 里的 Tirith 外部扫描 + 危险命令正则 + Smart LLM 审批 + 人工审批。其余 6 层,以及不依赖这条审批链的独立补充机制,仍然生效。
这是一个正确的设计:YOLO 的语义是"我信任 Agent 执行的命令",不是"我信任一切"。 你信任命令不代表你信任 MCP 服务器的供应链、不代表你信任项目文件没有注入、不代表你信任 Agent 不会被诱导访问内部网络。