AgentHarness 课程
Hermes 专题/8

第八篇:安全模型

命令审批、Tirith扫描器、凭证管理、Shadow Git

概述

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不可逆数据库操作
远程执行`curlsh, 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

三层预处理的防御场景:

  1. ANSI 转义序列:攻击者可以在命令中插入不可见的 ANSI 序列(如 \x1b[0m),在终端中不可见但可能干扰正则匹配
  2. Null 字节:某些系统调用在遇到 null 字节时会截断字符串,可能导致正则匹配到截断后的安全命令,而实际执行截断前的危险命令
  3. Unicode 规范化:NFKC 规范化会将全角拉丁字符(如 rm)转换为对应的 ASCII 字符(rm),将兼容性分解字符合并,防止通过视觉相似但编码不同的字符绕过检测

1.4 审批级别与会话状态

当检测到危险命令时,用户有四种审批选择:

级别快捷键作用范围持久化位置
onceo仅当前这一次执行不持久化
sessions当前会话内相同模式免审内存(_session_approved 字典)
alwaysa永久允许相同模式config.yaml 的 command_allowlist
denyD(默认)拒绝执行不持久化

会话状态管理使用线程安全的全局字典。在 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"

完整的审批流程:

  1. Agent 线程创建 _ApprovalEntry,加入会话的审批队列(_gateway_queues[session_key]
  2. 通过注册的回调函数(_gateway_notify_cbs[session_key])通知用户——这桥接了同步的 Agent 线程到异步的 Gateway 事件循环
  3. Agent 线程阻塞在 entry.event.wait(timeout=300),等待用户响应
  4. 用户通过 /approve/deny 命令响应,Gateway 调用 resolve_gateway_approval()
  5. 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 输出只提供补充信息):

退出码动作含义
0allow安全,放行
1block危险,阻止
2warn可疑,警告

返回结构包含详细的发现信息:

{"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

安装过程的多层验证:

  1. HTTPS 传输安全:下载始终通过 HTTPS,防止中间人攻击
  2. SHA-256 校验和:下载后验证文件的 SHA-256 哈希值,确保完整性
  3. 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 或手动审批(合并所有警告)

关键设计点:

  1. 单一审批提示:即使两个检查都触发,用户只看到一个合并的审批提示
  2. Tirith block 进入审批流程:之前是硬性阻止,现在允许用户在了解风险的情况下批准
  3. 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)的凭证文件。远程沙箱是一个全新的环境,没有宿主机的文件系统,需要显式地将凭证文件传递进去。

注册来源有两种:

  1. Skill 声明:技能的 frontmatter 中声明 required_credential_files,例如一个需要 Google 访问令牌的技能可以声明 required_credential_files: ['google_token.json']
  2. 用户配置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 仓库

CheckpointManagertools/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_DIRGIT_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 statusgit log 等命令不受影响——Shadow 仓库完全透明。

4.2 快照触发机制

检查点在每个会话 turn 中,首次修改文件前自动创建。触发时机是在工具执行前(如 write_filepatch 操作),通过 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,因此是安全的。回滚支持三种粒度:

  1. 整个目录回滚/rollback <N>
  2. 单个文件回滚/rollback <N> <file>
  3. 预览差异/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 最小权限原则

  1. 容器环境自动免审:Docker、Modal、Singularity 环境中的命令自动批准(env_type in ("docker", "singularity", "modal", "daytona")),因为容器本身提供了环境隔离

  2. 按需工具集:通过 enabled_toolsets 限制可用工具,子 Agent 进一步受限(DELEGATE_BLOCKED_TOOLS

  3. 凭证隔离:每个会话独立的凭证注册,ContextVar 防止跨会话泄露

  4. 审批粒度: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           # 启用记忆(经过注入扫描)

思考题

  1. 正则绕过:攻击者可能使用 Unicode 引号(' U+2018 代替 ')、不可见空格(U+00A0 代替普通空格)等方式绕过 DANGEROUS_PATTERNS。_normalize_command_for_detection() 能否捕获这些变体?还有哪些可能的绕过方式?考虑 ANSI 转义序列、反向连字符( U+2010)等。

  2. Fail-Open 的权衡:Tirith 默认 fail-open 设计在可用性和安全性之间如何权衡?在什么场景下应该切换到 fail-closed?考虑 Cron 无人值守任务、Gateway 多用户场景和 CI/CD 流水线。

  3. Smart Approval 的信任边界:将命令风险评估委托给辅助 LLM 引入了新的信任边界。如果辅助模型被欺骗(如命令中包含针对审批模型的 prompt injection),会带来什么风险?当前实现中 temperature=0max_tokens=16 的限制能否缓解?

  4. 检查点的空间开销:Shadow Git 仓库的快照机制在频繁修改大型项目的场景下,磁盘空间增长如何控制?_MAX_FILES = 50,000 的限制是否合理?考虑 node_modules 被排除的效果。

  5. 凭证文件的路径安全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 行 — 上下文文件注入扫描