第八篇:安全模型
概述
AI Agent 拥有执行终端命令、读写文件、访问网络等强大能力,这些能力如果没有适当的安全控制,可能被恶意利用或因误操作造成严重损害。一个不受约束的 Agent 可以执行 rm -rf /、泄露 API Key 到远程服务器、修改 SSH 授权密钥实现持久化访问。这些风险并非理论上的——在 Prompt Injection 攻击场景下,攻击者可以通过精心构造的用户输入诱导 Agent 执行危险操作。
Hermes Agent 构建了多层安全防御体系:命令审批系统负责检测和拦截危险操作,Tirith 安全扫描器提供深度内容分析,凭证管理确保密钥安全,检查点系统提供变更回滚能力。每一层都独立工作,即使某一层被绕过,其他层仍然提供保护。
本篇将从源码层面深入分析 Hermes Agent 的四层安全架构,帮助开发者理解每个安全机制的设计意图和实现细节。
1. 命令审批系统
1.1 DANGEROUS_PATTERNS 正则列表
命令审批的核心是 tools/approval.py 中的 DANGEROUS_PATTERNS(第 75 行),一个包含约 30 个正则表达式的列表。每个模式匹配一类危险操作,并附带人类可读的描述:
DANGEROUS_PATTERNS = [
(r'\brm\s+(-[^\s]*\s+)*/', "delete in root path"),
(r'\brm\s+-[^\s]*r', "recursive delete"),
(r'\bchmod\s+.*777', "world/other-writable permissions"),
(r'\bmkfs\b', "format filesystem"),
(r'\bdd\s+.*if=', "disk copy"),
(r'\bDROP\s+(TABLE|DATABASE)\b', "SQL DROP"),
(r'\bDELETE\s+FROM\b(?!.*\bWHERE\b)', "SQL DELETE without WHERE"),
(r'\b(curl|wget)\b.*\|\s*(ba)?sh\b', "pipe remote content to shell"),
# ... 更多模式
]
模式覆盖的危险操作类别:
| 类别 | 示例模式 | 描述 |
|---|---|---|
| 文件删除 | rm -r, find -delete | 递归删除、根路径删除 |
| 权限变更 | chmod 777, chown root -R | 世界可写、递归属主变更 |
| 系统破坏 | mkfs, dd | 格式化、磁盘复制 |
| Fork 炸弹检测 | :(){ :|:& };: | 经典 Bash fork bomb,可致系统瘫痪 |
| SQL 危险 | DROP TABLE, DELETE without WHERE | 不可逆数据库操作 |
| SQL 危险 | DROP TABLE, DELETE without WHERE | 不可逆数据库操作 |
| 远程执行 | `curl | sh, wget |
| 自我终止 | pkill hermes, kill $(pgrep hermes) | Agent 终止自身进程 |
| Git 破坏 | git reset --hard, git push --force | 丢失未提交变更、重写历史 |
| 系统配置 | > /etc/, sed -i /etc/ | 覆盖系统配置文件 |
| Shell 注入 | bash -c, python -e | 通过 -c/-e 标志执行脚本 |
| Heredoc 执行 | python3 << | 通过 heredoc 绕过 -c/-e 检测 |
fork bomb 的检测模式特别值得注意:
(r':\(\)\s*\{\s*:\s*\|\s*:\s*&\s*\}\s*;\s*:', "fork bomb")
这匹配经典的 Bash fork bomb :(){ :|:& };:,这是一个看起来很简短但能让系统完全瘫痪的命令。
SQL 注入防护不仅检测 DROP,还检测没有 WHERE 子句的 DELETE:
(r'\bDELETE\s+FROM\b(?!.*\bWHERE\b)', "SQL DELETE without WHERE")
(?!.*\bWHERE\b) 是负向前瞻断言,确保只匹配没有 WHERE 子句的 DELETE 语句。
1.2 敏感写入目标(_SENSITIVE_WRITE_TARGET)
除了命令模式匹配,系统还定义了一组敏感写入路径,即使通过 tee 或重定向也需要审批:
_SSH_SENSITIVE_PATH = r'(?:~|\$home|\$\{home\})/\.ssh(?:/|$)'
_HERMES_ENV_PATH = r'(?:~\/\.hermes/|...)\.env\b'
_SENSITIVE_WRITE_TARGET = (
r'(?:/etc/|/dev/sd|'
rf'{_SSH_SENSITIVE_PATH}|'
rf'{_HERMES_ENV_PATH})'
)
敏感路径包括:
/etc/— 系统配置目录(任何写入都可能导致系统不稳定)/dev/sd— 块设备(直接写入可能损坏文件系统)~/.ssh/— SSH 密钥目录(支持$HOME、${home}等变体,防止通过环境变量绕过)~/.hermes/.env— Agent 环境变量文件(包含 API Key 等敏感信息)
SSH 路径的匹配考虑了多种 shell 变体:
_SSH_SENSITIVE_PATH = r'(?:~|\$home|\$\{home\})/\.ssh(?:/|$)'
这匹配 ~/.ssh/、$home/.ssh/、${home}/.ssh/ 三种写法,覆盖了 Bash、Zsh、Fish 等常见 shell 的家目录引用方式。
1.3 命令规范化与绕过防御
攻击者可能通过 ANSI 转义序列、null 字节、Unicode 全角字符等方式绕过正则检测。_normalize_command_for_detection() 负责预处理:
def _normalize_command_for_detection(command: str) -> str:
from tools.ansi_strip import strip_ansi
command = strip_ansi(command) # 1. 剥离 ANSI 转义序列(完整的 ECMA-48 支持)
command = command.replace('\x00', '') # 2. 剥离 null 字节
command = unicodedata.normalize('NFKC', command) # 3. Unicode 规范化
return command
三层预处理的防御场景:
- ANSI 转义序列:攻击者可以在命令中插入不可见的 ANSI 序列(如
\x1b[0m),在终端中不可见但可能干扰正则匹配 - Null 字节:某些系统调用在遇到 null 字节时会截断字符串,可能导致正则匹配到截断后的安全命令,而实际执行截断前的危险命令
- Unicode 规范化:NFKC 规范化会将全角拉丁字符(如
rm)转换为对应的 ASCII 字符(rm),将兼容性分解字符合并,防止通过视觉相似但编码不同的字符绕过检测
1.4 审批级别与会话状态
当检测到危险命令时,用户有四种审批选择:
| 级别 | 快捷键 | 作用范围 | 持久化位置 |
|---|---|---|---|
| once | o | 仅当前这一次执行 | 不持久化 |
| session | s | 当前会话内相同模式免审 | 内存(_session_approved 字典) |
| always | a | 永久允许相同模式 | config.yaml 的 command_allowlist |
| deny | D(默认) | 拒绝执行 | 不持久化 |
会话状态管理使用线程安全的全局字典。在 Gateway 模式下,审批系统通过 ContextVar 实现会话隔离——每个请求处理线程绑定自己的 session_key,确保多会话并发时状态互不干扰:
_approval_session_key: contextvars.ContextVar[str] = contextvars.ContextVar(
"approval_session_key", default="",
)
全局审批状态字典:
_lock = threading.Lock()
_pending: dict[str, dict] = {}
_session_approved: dict[str, set] = {}
_session_yolo: set[str] = set()
_permanent_approved: set = set()
_session_approved 按 session_key 分组,每个会话有独立的审批集合。_permanent_approved 是全局的,在模块导入时从 config.yaml 加载。
always 的持久化实现:
def approve_permanent(pattern_key: str):
with _lock:
_permanent_approved.add(pattern_key)
def save_permanent_allowlist(patterns: set):
config = load_config()
config["command_allowlist"] = list(patterns)
save_config(config)
pattern_key 使用人类可读的描述字符串(如 "recursive delete"),而非正则表达式本身。这有两个好处:config.yaml 可读可维护,且正则表达式变更不会使旧的审批失效(通过 _approval_key_aliases 兼容新旧 key)。
1.5 审批 Key 的向后兼容
当 DANGEROUS_PATTERNS 列表更新时,正则可能变更但描述保持不变,或者描述变更但正则保持不变。为了确保已有的审批(session 或 permanent)不失效,系统维护了一个 key 别名映射:
_PATTERN_KEY_ALIASES: dict[str, set[str]] = {}
for _pattern, _description in DANGEROUS_PATTERNS:
_legacy_key = _legacy_pattern_key(_pattern) # 从正则推导的旧 key
_canonical_key = _description # 人类可读的规范 key
_PATTERN_KEY_ALIASES.setdefault(_canonical_key, set()).update({_canonical_key, _legacy_key})
_PATTERN_KEY_ALIASES.setdefault(_legacy_key, set()).update({_legacy_key, _canonical_key})
is_approved() 检查时会匹配所有别名:
def is_approved(session_key, pattern_key):
aliases = _approval_key_aliases(pattern_key)
with _lock:
if any(alias in _permanent_approved for alias in aliases):
return True
session_approvals = _session_approved.get(session_key, set())
return any(alias in session_approvals for alias in aliases)
1.6 YOLO 模式
--yolo 标志或 HERMES_YOLO_MODE 环境变量可以绕过所有审批提示。在 Gateway 中,YOLO 模式通过 /yolo 命令按会话启用:
def enable_session_yolo(session_key: str):
with _lock:
_session_yolo.add(session_key)
Gateway 场景下 YOLO 是会话级的,而非全局的,避免一个用户的 YOLO 设置影响其他用户。这是通过 ContextVar 实现的会话隔离:
_approval_session_key: contextvars.ContextVar[str] = contextvars.ContextVar(
"approval_session_key", default="",
)
每个 Gateway 请求处理线程绑定自己的 session_key,is_current_session_yolo_enabled() 只检查当前会话的状态。
1.7 Smart Approval(智能审批)
Smart Approval 利用辅助 LLM 评估命令的实际风险,自动审批误报。灵感来自 OpenAI Codex 的 Smart Approvals guardian subagent(openai/codex#13860)。
def _smart_approve(command: str, description: str) -> str:
prompt = f"""You are a security reviewer for an AI coding agent.
Command: {command}
Flagged reason: {description}
Rules:
- APPROVE if clearly safe (benign script execution, safe file operations, etc.)
- DENY if genuinely dangerous (recursive delete, overwriting system files, etc.)
- ESCALATE if uncertain
"""
response = client.chat.completions.create(
model=model, messages=[...], temperature=0, max_tokens=16,
)
answer = response.choices[0].message.content.strip().upper()
if "APPROVE" in answer: return "approve"
elif "DENY" in answer: return "deny"
else: return "escalate"
Smart Approval 的三种返回值处理:
- approve:自动批准,授予会话级审批,用户不感知
- deny:直接阻止,告知用户是 Smart Approval 判定为危险
- escalate:无法确定,回退到手动审批流程
配置方式:
# config.yaml
approvals:
mode: smart # manual | smart | off
timeout: 60 # CLI 审批超时秒数
gateway_timeout: 300 # Gateway 审批超时秒数
Smart Approval 的典型误报场景:
python3 -c "print('hello')"被标记为 "script execution via -c flag",但完全无害git commit -m "fix: remove deprecated API"被标记为包含rm关键字(在 "remove" 中)npm install可能触发 shell 相关模式
1.8 Gateway 异步审批
Gateway 场景下,Agent 运行在线程池中,审批需要异步机制。这是因为 Gateway 的请求处理是事件驱动的,Agent 线程不能阻塞在 input() 上等待用户输入。
class _ApprovalEntry:
__slots__ = ("event", "data", "result")
def __init__(self, data):
self.event = threading.Event() # 同步原语
self.data = data # 审批请求数据
self.result: Optional[str] = None # "once"|"session"|"always"|"deny"
完整的审批流程:
- Agent 线程创建
_ApprovalEntry,加入会话的审批队列(_gateway_queues[session_key]) - 通过注册的回调函数(
_gateway_notify_cbs[session_key])通知用户——这桥接了同步的 Agent 线程到异步的 Gateway 事件循环 - Agent 线程阻塞在
entry.event.wait(timeout=300),等待用户响应 - 用户通过
/approve或/deny命令响应,Gateway 调用resolve_gateway_approval() resolve_gateway_approval()设置entry.result并调用event.set(),唤醒 Agent 线程
def resolve_gateway_approval(session_key, choice, resolve_all=False):
with _lock:
queue = _gateway_queues.get(session_key)
if resolve_all:
targets = list(queue)
queue.clear()
else:
targets = [queue.pop(0)] # FIFO:只解决最旧的审批
for entry in targets:
entry.result = choice
entry.event.set()
支持并行子 Agent 的并发审批——每个 Agent 线程有独立的 _ApprovalEntry,按 FIFO 顺序解决。/approve all 一次性解决所有待处理的审批请求。
超时处理:如果用户在 300 秒内未响应(默认 gateway_timeout),审批自动被拒绝:
resolved = entry.event.wait(timeout=timeout)
if not resolved or choice is None or choice == "deny":
reason = "timed out" if not resolved else "denied by user"
return {"approved": False, "message": f"BLOCKED: Command {reason}."}
2. Tirith 安全扫描器
2.1 架构概述
Tirith 是一个独立的安全扫描二进制,通过子进程调用,提供更深层的命令内容分析。与 DANGEROUS_PATTERNS 的正则匹配不同,Tirith 可以检测:
- 同形字 URL(homograph URLs):使用视觉相似的 Unicode 字符构造的钓鱼 URL,如用西里尔字母
а(U+0430)替换拉丁字母a(U+0061) - 管道到解释器的攻击(pipe-to-interpreter):将远程内容直接管道到 shell 或脚本解释器执行
- 终端注入攻击(terminal injection):利用终端转义序列控制用户界面,隐藏恶意命令
- 其他复杂的内容级威胁:需要语义分析才能检测的攻击模式
Tirith 使用 Rust 编写,启动快速,扫描延迟通常在 100ms 以内。
2.2 调用接口
check_command_security() 是 Tirith 的调用入口(tools/tirith_security.py):
def check_command_security(command: str) -> dict:
result = subprocess.run(
[tirith_path, "check", "--json", "--non-interactive",
"--shell", "posix", "--", command],
capture_output=True, text=True, timeout=timeout,
)
退出码决定操作(这是唯一的 verdict 来源,JSON 输出只提供补充信息):
| 退出码 | 动作 | 含义 |
|---|---|---|
| 0 | allow | 安全,放行 |
| 1 | block | 危险,阻止 |
| 2 | warn | 可疑,警告 |
返回结构包含详细的发现信息:
{"action": "allow"|"warn"|"block", "findings": [...], "summary": "..."}
每个 finding 包含 severity(严重程度)、title(标题)、description(描述)和 rule_id(规则 ID),为用户审批提供充分的信息。
2.3 自动安装机制
Tirith 支持自动下载安装到 $HERMES_HOME/bin/tirith,整个过程在后台线程中进行,不阻塞 Agent 启动:
def ensure_installed():
# 1. 快速本地检查(同步,不阻塞)
found = shutil.which("tirith")
if found: return found
# 2. 检查之前安装的位置
hermes_bin = os.path.join(_hermes_bin_dir(), "tirith")
if os.path.isfile(hermes_bin): return hermes_bin
# 3. 需要下载 → 启动后台线程
_install_thread = threading.Thread(target=_background_install, daemon=True)
_install_thread.start()
return None # 暂时不可用,命令将 fail-open
安装过程的多层验证:
- HTTPS 传输安全:下载始终通过 HTTPS,防止中间人攻击
- SHA-256 校验和:下载后验证文件的 SHA-256 哈希值,确保完整性
- Cosign 来源验证(可选):验证二进制文件由预期的 GitHub Actions workflow 构建并签名
def _install_tirith():
# 下载二进制、校验和、签名、证书
_download_file(f"{base_url}/{archive_name}", archive_path)
_download_file(f"{base_url}/checksums.txt", checksums_path)
# Cosign 验证(可选但推荐)
if shutil.which("cosign"):
_download_file(f"{base_url}/checksums.txt.sig", sig_path)
_download_file(f"{base_url}/checksums.txt.pem", cert_path)
cosign_result = _verify_cosign(checksums_path, sig_path, cert_path)
if cosign_result is False:
return None, "cosign_verification_failed" # 明确验证失败 → 中止
# SHA-256 验证(必需)
if not _verify_checksum(archive_path, checksums_path, archive_name):
return None, "checksum_failed"
安装失败会缓存 24 小时(_MARKER_TTL = 86400),通过磁盘标记文件(.tirith-install-failed)跨进程持久化。这避免了每次 Agent 启动都尝试网络下载。
特殊的失败标记恢复机制:如果失败原因是 cosign_missing(没有 cosign 工具),当 cosign 后来安装到 PATH 后,标记会自动清除并重试:
def _is_install_failed_on_disk():
reason = _read_failure_reason()
if reason == "cosign_missing" and shutil.which("cosign"):
_clear_install_failed()
return False # 允许重试
return True
2.4 Fail-Open vs Fail-Closed
当 Tirith 不可用(未安装、启动失败、超时)时,行为由配置决定:
# config.yaml
security:
tirith_enabled: true
tirith_fail_open: true # true: 不可用时放行 | false: 不可用时阻止
tirith_timeout: 5 # 超时秒数
默认 fail_open: true——Tirith 不可用时不影响正常使用,但降低安全级别。这种选择基于可用性考虑:大多数用户没有安装 Tirith,如果默认 fail-closed 会导致 Agent 完全无法使用。
生产环境建议设置 fail_open: false,确保在安全扫描不可用时,命令被阻止而非放行。
2.5 与 DANGEROUS_PATTERNS 的协同
check_all_command_guards() 将两个检查合并为单一的审批流程,这是一个重要的设计决策:
def check_all_command_guards(command, env_type, approval_callback=None):
# Phase 1: 收集两个检查的发现
tirith_result = check_command_security(command)
is_dangerous, pattern_key, description = detect_dangerous_command(command)
# Phase 2: 汇总需要审批的警告
warnings = []
if tirith_result["action"] in ("block", "warn"):
warnings.append((tirith_key, tirith_desc, True)) # is_tirith=True
if is_dangerous:
warnings.append((pattern_key, description, False)) # is_tirith=False
# Phase 3: Smart Approval 或手动审批(合并所有警告)
关键设计点:
- 单一审批提示:即使两个检查都触发,用户只看到一个合并的审批提示
- Tirith block 进入审批流程:之前是硬性阻止,现在允许用户在了解风险的情况下批准
- Tirith 警告不支持 "always":当有 Tirith 警告时,
allow_permanent选项被隐藏,防止用户对内容级威胁永久放行
# CLI 提示
if has_tirith:
print(" [o]nce | [s]ession | [d]eny") # 无 [a]lways
else:
print(" [o]nce | [s]ession | [a]lways | [d]eny")
3. 凭证管理
3.1 凭证文件注册
tools/credential_files.py 管理需要挂载到远程沙箱(Docker、Modal、SSH、Daytona)的凭证文件。远程沙箱是一个全新的环境,没有宿主机的文件系统,需要显式地将凭证文件传递进去。
注册来源有两种:
- Skill 声明:技能的 frontmatter 中声明
required_credential_files,例如一个需要 Google 访问令牌的技能可以声明required_credential_files: ['google_token.json'] - 用户配置:
terminal.credential_files配置项,用户可以指定任意凭证文件
def register_credential_file(relative_path: str, container_base="/root/.hermes"):
# 安全检查 1:拒绝绝对路径
if os.path.isabs(relative_path):
return False
# 安全检查 2:验证路径在 HERMES_HOME 内
containment_error = validate_within_dir(host_path, hermes_home)
if containment_error:
return False
# 安全检查 3:验证文件存在
if not resolved.is_file():
return False
路径安全验证确保恶意技能不能声明 ../../.ssh/id_rsa 来窃取宿主机文件。即使技能尝试使用符号链接逃逸,validate_within_dir() 会解析符号链接后再验证。
3.2 会话隔离
凭证注册使用 ContextVar 实现会话隔离:
_registered_files_var: ContextVar[Dict[str, str]] = ContextVar("_registered_files")
Gateway 场景下,多个会话并发运行,ContextVar 确保每个会话只能看到自己注册的凭证文件,防止跨会话数据泄露。例如,用户 A 的 Google 令牌不会出现在用户 B 的 Docker 沙箱中。
3.3 缓存目录挂载
除了凭证文件,四个缓存目录也会被挂载到远程沙箱:
_CACHE_DIRS = [
("cache/documents", "document_cache"), # 文档缓存
("cache/images", "image_cache"), # 图片缓存
("cache/audio", "audio_cache"), # 音频缓存
("cache/screenshots", "browser_screenshots"), # 浏览器截图
]
这些目录以只读方式挂载,允许 Agent 在远程沙箱中引用宿主机上已缓存的文件(如解压上传的压缩包)。
3.4 Symlink 安全
技能目录挂载时,检测并处理符号链接。这是一个重要的安全措施——Docker 的 bind mount 会跟随符号链接,如果技能目录中包含指向 /etc/shadow 的符号链接,该文件将被暴露到容器中:
def _safe_skills_path(skills_dir: Path) -> str:
symlinks = [p for p in skills_dir.rglob("*") if p.is_symlink()]
if not symlinks:
return str(skills_dir) # 无符号链接,直接使用原目录
# 有符号链接 → 创建去除符号链接的安全副本
safe_dir = Path(tempfile.mkdtemp(prefix="hermes-skills-safe-"))
for item in skills_dir.rglob("*"):
if item.is_symlink():
continue # 跳过所有符号链接
if item.is_dir():
target.mkdir(parents=True, exist_ok=True)
elif item.is_file():
shutil.copy2(str(item), str(target))
安全副本在进程退出时通过 atexit 处理器清理,避免临时目录累积。
3.5 输出脱敏
所有工具输出在返回给模型前,都经过敏感信息脱敏处理。Cron 任务的脚本输出也会被脱敏:
from agent.redact import redact_sensitive_text
stdout = redact_sensitive_text(stdout)
stderr = redact_sensitive_text(stderr)
脱敏处理通常检测并替换 API Key、密码、令牌等模式,确保敏感信息不会出现在 Agent 的上下文中(从而不会出现在日志或记忆中)。
4. 检查点系统
4.1 Shadow Git 仓库
CheckpointManager(tools/checkpoint_manager.py)使用 Shadow Git 仓库实现透明的文件系统快照。Shadow Git 仓库是独立的 git 仓库,存储在 ~/.hermes/checkpoints/ 目录下,与用户的项目完全隔离。
Shadow 仓库的工作方式:
~/.hermes/checkpoints/{sha256(abs_dir)[:16]}/
HEAD, refs/, objects/ ← 标准 git 内部结构
HERMES_WORKDIR ← 记录原始目录路径
info/exclude ← 默认排除规则
Shadow 仓库的路径是确定性的——基于工作目录绝对路径的 SHA-256 哈希前 16 位。这意味着同一个目录始终映射到同一个 Shadow 仓库:
def _shadow_repo_path(working_dir: str) -> Path:
abs_path = str(_normalize_path(working_dir))
dir_hash = hashlib.sha256(abs_path.encode()).hexdigest()[:16]
return CHECKPOINT_BASE / dir_hash
关键设计:Shadow 仓库通过 GIT_DIR 和 GIT_WORK_TREE 环境变量与用户项目隔离,不会在用户项目目录中创建 .git:
def _git_env(shadow_repo: Path, working_dir: str) -> dict:
env = os.environ.copy()
env["GIT_DIR"] = str(shadow_repo) # git 内部数据存储位置
env["GIT_WORK_TREE"] = str(working_dir) # 实际工作目录
env.pop("GIT_INDEX_FILE", None) # 清除可能冲突的变量
return env
这意味着用户的 git status、git log 等命令不受影响——Shadow 仓库完全透明。
4.2 快照触发机制
检查点在每个会话 turn 中,首次修改文件前自动创建。触发时机是在工具执行前(如 write_file、patch 操作),通过 ensure_checkpoint() 方法:
class CheckpointManager:
def new_turn(self):
self._checkpointed_dirs.clear() # 每轮重置去重集合
def ensure_checkpoint(self, working_dir, reason="auto"):
if not self.enabled: return False
if abs_dir in self._checkpointed_dirs: return False # 本轮已创建
self._checkpointed_dirs.add(abs_dir)
return self._take(abs_dir, reason)
去重设计确保每个目录每轮最多创建一个快照,避免频繁写入操作产生大量冗余快照。例如,Agent 在一轮中修改了 10 个文件,只会在第一个文件修改前创建一个快照。
4.3 排除规则
默认排除列表避免快照不必要的大目录和敏感文件:
DEFAULT_EXCLUDES = [
"node_modules/", "dist/", "build/", # 大型依赖和构建目录
".env", ".env.*", ".env.local", # 敏感环境变量文件
"__pycache__/", "*.pyc", "*.pyo", # Python 缓存
".git/", # 嵌套 git 仓库
".venv/", "venv/", # 虚拟环境
".cache/", ".next/", ".nuxt/", # 框架缓存
]
同时有文件数量上限(_MAX_FILES = 50,000),防止超大目录导致快照耗时过长。在快照前会快速估算文件数量:
def _dir_file_count(path: str) -> int:
count = 0
for _ in Path(path).rglob("*"):
count += 1
if count > _MAX_FILES: return count
return count
4.4 回滚操作
回滚使用 git checkout <hash> -- <path> 实现,不移动 HEAD,因此是安全的。回滚支持三种粒度:
- 整个目录回滚:
/rollback <N> - 单个文件回滚:
/rollback <N> <file> - 预览差异:
/rollback diff <N>
def restore(self, working_dir, commit_hash, file_path=None):
# 1. 验证 commit hash 格式(防止 git 参数注入)
hash_err = _validate_commit_hash(commit_hash)
if hash_err: return {"success": False, "error": hash_err}
# 2. 验证文件路径(防止路径遍历)
if file_path:
path_err = _validate_file_path(file_path, abs_dir)
if path_err: return {"success": False, "error": path_err}
# 3. 创建回滚前快照(可撤销的撤销)
self._take(abs_dir, f"pre-rollback snapshot (restoring to {commit_hash[:8]})")
# 4. 执行回滚
_run_git(["checkout", commit_hash, "--", restore_target], ...)
安全验证细节:
def _validate_commit_hash(commit_hash: str):
if commit_hash.startswith("-"):
return "Invalid: must not start with '-'" # 防止 git 标志注入
if not _COMMIT_HASH_RE.match(commit_hash):
return "Invalid: expected 4-64 hex characters"
def _validate_file_path(file_path: str, working_dir: str):
if os.path.isabs(file_path):
return "Must be relative"
resolved = (abs_workdir / file_path).resolve()
resolved.relative_to(abs_workdir) # ValueError if escapes
防止 git 参数注入是回滚安全的关键——如果 commit_hash 以 - 开头(如 --patch),git 会将其解释为标志而非 commit 引用。
4.5 用户交互
通过 /rollback 命令管理检查点:
/rollback ← 列出所有检查点
/rollback <N> ← 回滚到第 N 个检查点
/rollback diff <N> ← 查看检查点与当前状态的差异
/rollback <N> <file> ← 仅回滚特定文件
检查点列表的格式化输出包含丰富的变更统计:
Checkpoints for /app:
1. a1b2c3d4 2025-04-18 14:30 auto (3 files, +45/-12)
2. e5f6a7b8 2025-04-18 14:25 auto (1 file, +8/-3)
/rollback <N> restore to checkpoint N
/rollback diff <N> preview changes since checkpoint N
/rollback <N> <file> restore a single file from checkpoint N
5. 安全最佳实践
5.1 生产环境配置建议
审批模式选择:
# 开发环境:手动审批,允许 always
approvals:
mode: manual
timeout: 60
# 生产/Gateway:Smart Approval
approvals:
mode: smart
timeout: 60
gateway_timeout: 300
# 无人值守(Cron/CI):关闭审批
approvals:
mode: off
Tirith 配置:
security:
tirith_enabled: true
tirith_fail_open: false # 生产环境建议 fail-closed
tirith_timeout: 10
检查点配置:
checkpoints:
enabled: true
max_snapshots: 50
5.2 最小权限原则
-
容器环境自动免审:Docker、Modal、Singularity 环境中的命令自动批准(
env_type in ("docker", "singularity", "modal", "daytona")),因为容器本身提供了环境隔离 -
按需工具集:通过
enabled_toolsets限制可用工具,子 Agent 进一步受限(DELEGATE_BLOCKED_TOOLS) -
凭证隔离:每个会话独立的凭证注册,ContextVar 防止跨会话泄露
-
审批粒度:Tirith 警告只允许会话级审批(不支持 always),防止用户对内容级威胁永久放行
5.3 纵深防御
Hermes Agent 的安全采用多层防御策略,每一层独立工作,即使某一层被绕过,其他层仍然提供保护:
┌──────────────────────────────────────────┐
│ Layer 4: 检查点回滚 │ ← 最后一道防线,恢复误操作
├──────────────────────────────────────────┤
│ Layer 3: 内容注入扫描 │ ← 防止 Prompt Injection 进入系统提示词
├──────────────────────────────────────────┤
│ Layer 2: Tirith 深度扫描 │ ← 内容级威胁检测(同形字、注入等)
├──────────────────────────────────────────┤
│ Layer 1: DANGEROUS_PATTERNS 正则匹配 │ ← 快速模式匹配,覆盖常见危险操作
├──────────────────────────────────────────┤
│ Layer 0: 容器沙箱隔离 │ ← 环境级隔离,限制爆炸半径
└──────────────────────────────────────────┘
5.4 安全审计
- 命令日志:所有执行的命令(含审批结果)记录在会话日志中,支持事后审计
- 检查点历史:Shadow Git 保留完整变更历史,可追溯每次修改
- 凭证访问:远程沙箱的凭证挂载有明确的注册和验证流程,可审计哪些凭证被注入到哪个沙箱
- 审批持久化:
command_allowlist保存在 config.yaml 中,可审计和撤销
5.5 安全配置 Checklist
# 完整的安全配置示例
approvals:
mode: smart # 智能审批
timeout: 60
gateway_timeout: 300
security:
tirith_enabled: true
tirith_fail_open: false # 生产环境 fail-closed
tirith_timeout: 10
checkpoints:
enabled: true # 启用检查点
max_snapshots: 50
memory:
memory_enabled: true # 启用记忆(经过注入扫描)
思考题
-
正则绕过:攻击者可能使用 Unicode 引号(
'U+2018 代替')、不可见空格(U+00A0 代替普通空格)等方式绕过 DANGEROUS_PATTERNS。_normalize_command_for_detection()能否捕获这些变体?还有哪些可能的绕过方式?考虑 ANSI 转义序列、反向连字符(‐U+2010)等。 -
Fail-Open 的权衡:Tirith 默认 fail-open 设计在可用性和安全性之间如何权衡?在什么场景下应该切换到 fail-closed?考虑 Cron 无人值守任务、Gateway 多用户场景和 CI/CD 流水线。
-
Smart Approval 的信任边界:将命令风险评估委托给辅助 LLM 引入了新的信任边界。如果辅助模型被欺骗(如命令中包含针对审批模型的 prompt injection),会带来什么风险?当前实现中
temperature=0和max_tokens=16的限制能否缓解? -
检查点的空间开销:Shadow Git 仓库的快照机制在频繁修改大型项目的场景下,磁盘空间增长如何控制?
_MAX_FILES = 50,000的限制是否合理?考虑 node_modules 被排除的效果。 -
凭证文件的路径安全:
register_credential_file()通过validate_within_dir()防止路径遍历。考虑符号链接场景:如果~/.hermes/data是一个指向/etc的符号链接,注册data/shadow是否能逃逸?当前实现中_safe_skills_path()的 symlink 处理是否覆盖了凭证文件?
本篇涉及的核心源文件:
tools/approval.py— DANGEROUS_PATTERNS、审批流程、Smart Approval、Gateway 异步审批tools/tirith_security.py— Tirith 扫描器调用、自动安装、Cosign 验证tools/credential_files.py— 凭证文件注册、会话隔离、Symlink 安全tools/checkpoint_manager.py— Shadow Git 检查点、回滚、安全验证tools/memory_tool.py第 60-97 行 — 记忆内容注入扫描agent/prompt_builder.py第 36-73 行 — 上下文文件注入扫描