全文目录Contents
- 0 · The Project at a Glance, and a Map of the Code
- 1 · The Gateway Layer
- 2 · Identity and Session Routing
- 3 · The Main Loop ★
- 3.1 The Loop Entry: A Triple Budget Gate
- 3.2 Per-Turn Preparation: build_turn_context
- 3.3 Mid-Turn Interjection: /steer
- 3.4 The Wall-Clock Budget Wrap-Up Reminder
- 3.5 The Interrupt Check
- 3.6 The Step Callback: An Observation Point for the Gateway
- 3.7 Other State Inside the Loop
- 3.8 Why the Loop Exited
- 3.9 An Optional Bypass: The Codex App Server Runtime
- 4 · The Tool System
- 4.1 The Most Important Design Decision: Implementation Separated from Exposure
- 4.2 Composing and Resolving Toolsets
- 4.3 Central Tool Dispatch
- 4.4 The Argument Coercion Layer
- 4.5 Sanitizing Tool Error Messages
- 4.6 Observing Tool Results, and Hooks
- 4.7 Recognizing a Delegation Context
- 4.8 Caching Tool Definitions
- 5 · Approval and Safety Red Lines ★
- 5.1 The Overall Structure
- 5.2 The 12 Hard Red Lines
- 5.3 The Real Difficulty: Telling “Command” from “Data”
- 5.4 Quote Masking: But Leave a Way In for “the Part That Really Executes”
- 5.5 Quoting Is Not a Bypass
- 5.6 Sensitive Paths and Write Targets
- 5.7 The sudo Stdin Guard
- 5.8 Performance: Why Precompile
- 5.9 Retaining Blocked Commands
- 5.10 Smart Approval: An Optional Model Judgment
- 5.11 Context Awareness: Different Situations, Different Policies
- 5.12 Where This Layer Sits: Outermost, and Thinnest
- 6 · Execution Environments
- 7 · The Context Engine ★
- 7.1 What It Defines
- 7.2 Lifecycle
- 7.3 The Three Methods You Must Implement
- 7.4 The Sharpest Design Decision: select and compress Are Two Orthogonal Verbs
- 7.5 The Post-Turn Observation Hook
- 7.6 Other Optional Hooks
- 7.7 Default Parameter Values
- 7.8 Control Over User-Visible Status
- 7.9 Size Comparison of the Built-in Implementation
- 8 · The Memory System ★
- 8.1 The Memory Provider Interface
- 8.2 Lifecycle and Hooks
- 8.3 The Interface's Versioned Contract
- 8.4 The Trivial-Prompt Filter
- 8.5 The Memory-Usage Indicator
- 8.6 The Built-in Holographic Memory
- 8.7 The Storage Layer and Trust Scores
- 8.8 The SQLite State Layer
- 8.9 Summing Up the Division of Labor Among Three Kinds of Memory
- 9 · The Plugin System
- 9.1 Three Discovery Sources
- 9.2 What a Plugin Can Provide
- 9.3 The Most Important Design Decision: “Stackable Capabilities” vs. “Mutually Exclusive Strategies”
- 9.4 Plugin Storage
- 9.5 How Plugins Tie into Toolsets
- 9.6 Plugin Hooks
- 9.7 MCP: The Other Extension Path
- 9.8 The Overall Shape of This Extension System
- 10 · Delegation and Multi-Agent
- 11 · Model Providers and the Credential Pool
- 12 · Scheduled Tasks (Cron)
- 12.1 What “cron” Is
- 12.2 Scenarios for Scheduled Agents
- 12.3 The Single Most Important Class: CronPromptInjectionBlocked
- 12.4 Narrowing the Toolset for Scheduled Tasks
- 12.5 Failure Handling
- 12.6 Preventing Duplicate Runs
- 12.7 Why scheduler.py Is 367 KB
- 12.8 Where Scheduled Tasks Sit in the Overall Architecture
- 13 · The Skill System
- 13.1 What a Skill Is
- 13.2 The Front Matter, Field by Field
- 13.3 Progressive Disclosure: The Core Mechanism of the Skill System
- 13.4 The Infrastructure Around Skills
- 13.5 Skills vs. Tools vs. Plugins
- 13.6 An Implicit Design in the Skill System: Composability
- 13.7 The 15 Skill Categories
- 13.8 Looking Back Across the Book: The Overall Shape of Hermes
5 · 审批与安全红线 ★
tools/approval.py,5,802 行。这是整个项目里对抗性思维最密集的一个文件。
5.1 整体结构
5.2 12 条硬红线
HARDLINE_PATTERNS = [
# rm 递归删除根文件系统或受保护的根目录
(_RM_FLAG_PREFIX + _hardline_rm_path(r'/(?:(?:\.\.?)?/)*(?:\.\.?)?\**|/ \*'),
"recursive delete of root filesystem"),
(_RM_FLAG_PREFIX + _hardline_rm_path(_HARDLINE_SYSTEM_DIRS),
"recursive delete of system directory"),
(_RM_FLAG_PREFIX + _hardline_rm_path(r'(?:~|\$\{?HOME\}?)(?:/?|/\*)?'),
"recursive delete of home directory"),
# 格式化文件系统
(_CMDPOS + r'mkfs(\.[a-z0-9]+)?\b', "format filesystem (mkfs)"),
# 直接往裸磁盘设备写数据
(_CMDPOS + r'dd\b[^\n]*\bof=/dev/(sd|nvme|hd|mmcblk|vd|xvd)[a-z0-9]*',
"dd to raw block device"),
(r'>\s*/dev/(sd|nvme|hd|mmcblk|vd|xvd)[a-z0-9]*\b',
"redirect to raw block device"),
# 分叉炸弹:无限自我复制进程,直接卡死机器
(r':\(\)\s*\{\s*:\s*\|\s*:\s*&\s*\}\s*;\s*:', "fork bomb"),
# 杀死系统所有进程
(_CMDPOS + r'kill\s+(-[^\s]+\s+)*-1\b', "kill all processes"),
# 关机 / 重启(四种写法)
(_CMDPOS + r'(shutdown|reboot|halt|poweroff)\b', "system shutdown/reboot"),
(_CMDPOS + r'init\s+[06]\b', "init 0/6"),
(_CMDPOS + r'systemctl\s+(poweroff|reboot|halt|kexec)\b', "systemctl poweroff/reboot"),
(_CMDPOS + r'telinit\s+[06]\b', "telinit 0/6"),
]
_HARDLINE_SYSTEM_DIRS = (
r'/home|/home/\*|/root|/root/\*|/etc|/etc/\*|/usr|/usr/\*|'
r'/var|/var/\*|/bin|/bin/\*|/sbin|/sbin/\*|/boot|/boot/\*|/lib|/lib/\*'
)
hermes-agent/tools/approval.py
「无条件」的意思是:用户配了什么都没用,这些命令永远不会被执行。
5.3 真正的难点:区分「命令」和「数据」
一个真实的事故
最朴素的实现是 if "rm -rf /" in command: 拦截。源码注释记录了它造成的问题:
「…so the rule fires only when rm is an actual command word — not when the literal string "rm -rf /" appears as DATA inside another command's argument, e.g. gh pr create --title "block rm -rf / spellings" or git commit -m "…rm -rf /…". Those tripped the unconditional floor and could not run at all before the anchor.」
译:……所以这条规则只在 rm 确实处于「命令词」位置时才触发 —— 而不是当字符串 "rm -rf /" 作为数据出现在另一个命令的参数里时也触发,比如 gh pr create --title "block rm -rf / spellings"(创建一个标题里含这段文字的合并请求)或 git commit -m "…rm -rf /…"。在加上位置锚点之前,这些命令都会撞上无条件红线,完全无法执行。
也就是说:你没法提交一条说明文字里含 "rm -rf /" 的代码提交。
解法:命令位置锚点
_CMDPOS = (
# 行首 / 命令分隔符之后 / 子 shell 开启符之后 / sudo|env|exec 包装之后
...
)
# rm 加上它的标志组,被三条 rm 规则共享。保持成普通的字符串拼接
# (而不是 f-string),这样正则里的反斜杠永远不会出现在 f-string 的
# 替换字段里 —— 那在 Python 3.11 下不支持。
_RM_FLAG_PREFIX = _CMDPOS + r'rm\s+(-[^\s]*\s+)*'
_CMDPOS 只在这几个位置匹配:
| 位置 | 例子 |
|---|---|
| 行首 | rm -rf / |
| 命令分隔符之后 | cd /tmp; rm -rf / · make && rm -rf / · a || rm -rf / · x | rm -rf / |
| 子 shell 开启符之后 | $(rm -rf /) · 反引号包裹 |
| 包装命令之后 | sudo rm -rf / · env X=1 rm -rf / · exec rm -rf / |
而在 --title "block rm -rf / spellings" 里,rm 前面是空格和引号,不属于上述任何一种位置 —— 所以不匹配,命令正常执行。
路径归一化:哪些写法其实等于根目录
那条根目录规则的正则是 /(?:(?:\.\.?)?/)*(?:\.\.?)?\**|/ \*,注释解释了它的设计:
「The path token matches any root-anchored path whose components collapse back to "/" in the shell: a bare "/", repeated slashes ("//"), and "."/".." current/parent segments ("/.", "/./", "/..", "/../..") all resolve to root, optionally followed by a trailing glob ("/*", "//*"). Each inter-slash segment must be exactly "." or "..", so a longer dot run or any real name is a literal directory, NOT root — "/tmp", "/home", "/.ssh", "/.config" and even "/..." (a dir literally named "...") fall through…」
译:这个路径词元匹配任何「在 shell 里会塌缩回根目录」的根锚定路径:裸的 "/"、重复斜杠 "//"、以及 "." / ".." 这样的当前/父目录段("/."、"/./"、"/.."、"/../..")全部解析为根目录,后面可以可选地跟一个通配符("/*"、"//*")。每两个斜杠之间的段必须恰好是 "." 或 "..",所以更长的点串或任何真实名字都是字面目录、不是根目录 —— "/tmp"、"/home"、"/.ssh"、"/.config"、甚至 "/..."(一个真的叫「...」的目录)都会落到更宽松的规则去处理……
| 判为根目录(拦) | 不判为根目录(放行到软规则) |
|---|---|
/ · // · /. · /./ · /.. · /../../* · //* · / *(shell 看成两个参数)带引号的 "/"、"$HOME"、${HOME} |
/tmp · /home · /.ssh · /.config/...(真的叫「...」的目录) |
注释里还提到:「显式的 "/ \*" 分支保留了「斜杠-空格-通配符」这种写法(rm -rf / *,shell 看到的是两个参数:/ 和通配符 *)」 —— 这是一个经典的手滑事故写法(本来想删 /tmp/*,多打了个空格)。
5.4 引号遮蔽:但要给「真会执行的部分」留后门
有两条红线规则没有命令名可以锚定 —— 重定向符号 > /dev/sda 和分叉炸弹的函数定义,它们在命令行任意位置都有效。
_QUOTE_MASKED_HARDLINE_DESCRIPTIONS = frozenset({
"redirect to raw block device",
"fork bomb",
})
HARDLINE_PATTERNS_COMPILED = [
(re.compile(pattern, _RE_FLAGS),
description,
description in _QUOTE_MASKED_HARDLINE_DESCRIPTIONS) # ★ 第三个字段:要不要遮蔽
for pattern, description in HARDLINE_PATTERNS
]
遮蔽函数的精确语义
def _mask_quoted_prose(command: str) -> str:
"""Blank out quoted string CONTENT for positionless hardline matching.
Detection-only rewrite used by the quote-masked hardline rules
(redirect-to-block-device, fork bomb): text inside single or double
quotes is data the shell passes as an argument, so `echo "cat f >
/dev/sda"` must not trip the unconditional floor. Structure is
preserved: the quote characters themselves stay, and inside double
quotes `$(...)` command substitutions and backtick spans are kept RAW
because the shell really executes them (`echo "$(cat f > /dev/sda)"`
remains a true positive). Unquoted text is untouched.
"""
译:为那些无位置的红线规则,把引号里的内容清空。这是一次仅用于检测的重写……引号里的文字是 shell 作为参数传递的数据,所以 echo "cat f > /dev/sda" 不该撞上无条件红线。结构会被保留:引号字符本身留着,而且双引号里的 $(...) 命令替换和反引号片段保持原样不遮蔽,因为 shell 真的会执行它们。未加引号的文字不动。
| 命令 | 该拦吗 | 为什么 |
|---|---|---|
cat f > /dev/sda | 拦 | 真的在往磁盘设备写数据 |
echo "cat f > /dev/sda" | 不拦 | 引号里是数据,只是打印一段文字 |
echo "$(cat f > /dev/sda)" | 拦 | 虽然在引号里,但 $(...) 会被 shell 真正执行 |
这一段体现的是对 shell 语义的精确建模,不是简单的字符串处理。
写这段代码的人必须准确知道:单引号和双引号的区别、双引号里哪些结构会被展开、命令替换的两种写法、以及它们嵌套时的行为。
这类知识没法从文档里查到「该怎么写安全检查」—— 只能从「shell 到底怎么解析」反推。
5.5 引号不能成为绕过手段
_SHELL_CARRIER_NAMES = frozenset({
"eval", "sh", "bash", "zsh", "ksh", "dash", "source", ".",
})
def _contains_shell_carrier(command: str) -> bool:
"""Return whether any command-position word is a shell-carrying command."""
for _, _, word in _iter_shell_command_word_spans(command):
name = os.path.basename(
_deobfuscate_shell_word_for_detection(word) # ★ 反混淆
).lower()
if name in _SHELL_CARRIER_NAMES:
return True
return False
逻辑:如果命令里出现了 sh -c "..."、bash -c "..."、eval "..." 这类「把引号内容交给另一个 shell 执行」的命令,那么引号里的东西就是代码而不是散文 —— 遮蔽规则整个失效,必须扫描原始字符串。
注释一句话总结:「quoting is not a bypass」(加引号不是绕过手段)。
三层防御的叠加
还有反混淆
_deobfuscate_shell_word_for_detection(为检测目的对 shell 词做反混淆)处理的是这类写法:
| 攻击者写的 | shell 实际解析成 |
|---|---|
b''ash -c '...' | bash -c '...'(空字符串拼接被消除) |
ba\sh -c '...' | bash -c '...'(反斜杠转义了一个普通字符) |
"bash" -c '...' | bash -c '...' |
/bin/bash -c '...' | 用 os.path.basename 取出 bash |
5.6 敏感路径与写入目标
除了危险命令,还有一组针对敏感文件写入的模式:
_SSH_SENSITIVE_PATH = r'(?:~|\$home|\$\{home\})/\.ssh(?:/|$)'
_HERMES_ENV_PATH = ... # ~/.hermes/.env(智能体自己的凭据)
_HERMES_CONFIG_PATH = ... # ~/.hermes/config.yaml(智能体自己的配置)
_PROJECT_ENV_PATH = r'(?:(?:/|\.{1,2}/)?(?:[^\s/"\'`]+/)*\.env(?:\.[^/\s"\'`]+)*)'
_PROJECT_CONFIG_PATH = r'(?:(?:/|\.{1,2}/)?(?:[^\s/"\'`]+/)*config\.yaml)'
_SHELL_RC_FILES = (...) # .bashrc / .zshrc 等 shell 启动脚本
_CREDENTIAL_FILES = (...)
_MACOS_PRIVATE_SYSTEM_PATH = r'/private/(?:etc|var|tmp|home)/'
_SYSTEM_CONFIG_PATH = (...)
_SENSITIVE_WRITE_TARGET = (...)
_USER_SENSITIVE_WRITE_TARGET = (...)
_PROJECT_SENSITIVE_WRITE_TARGET = rf'(?:{_PROJECT_ENV_PATH}|{_PROJECT_CONFIG_PATH})'
_COMMAND_TAIL = r'(?:\s*(?:&&|\|\||;).*)?$'
_WRITE_TARGET_BOUNDARY = r'(?=[\s;&|<>"\']|$)'
_HERMES_ENV_PATH 和 _HERMES_CONFIG_PATH这两条保护的是智能体自己的配置和凭据文件。
为什么必须保护?因为如果智能体能改自己的配置,它就能改掉自己的安全设置 —— 比如把审批模式改成「全部自动批准」、把红线规则关掉、或者把 API 密钥改成攻击者的。
一个能修改自己权限配置的系统,等于没有权限配置。这条边界必须是硬的。
另外两个正则值得注意:
_COMMAND_TAIL—— 匹配「命令后面还跟着更多命令」的情况(&&、||、;)。防止echo ok && rm -rf /这种把危险命令藏在后面的写法。_WRITE_TARGET_BOUNDARY—— 一个前瞻断言,要求写入目标后面必须是空白、分隔符或行尾。防止.env误匹配.envrc这类不同的文件。
5.7 sudo 标准输入守卫
_SUDO_STDIN_RE = re.compile(...)
def _check_sudo_stdin_guard(command: str) -> tuple: ...
def _sudo_stdin_block_result(description: str) -> dict: ...
这防的是 echo 密码 | sudo -S 危险命令 这种写法 —— sudo -S 表示「从标准输入读密码」,所以可以把密码通过管道喂进去,完全绕过交互式的密码确认。
而交互式密码确认本来是最后一道人工闸门。绕过它意味着智能体可以在用户毫不知情的情况下执行任意特权命令。
5.8 性能:预编译的理由
# 在模块加载时构建这些,可以消除每个进程第一次调用 terminal() 时
# 约 2.6 毫秒的冷缓存 re.compile 扇出开销
# (12 条 HARDLINE + 47 条 DANGEROUS 模式,每一条都可能因为程序其他
# 地方无关的正则操作,而被 Python 那个只有 512 项的 re._cache 挤出去)。
_RE_FLAGS = re.IGNORECASE | re.DOTALL
HARDLINE_PATTERNS_COMPILED = [...]
两层问题:
- 首次编译开销 —— 59 条正则第一次使用时要现场编译,约 2.6 毫秒
- 缓存被挤出 —— Python 内置的正则缓存只有 512 项。程序其他地方一忙(比如日志格式化、文本处理),这些安全正则就会被挤出去,导致反复重新编译
预编译成模块级常量之后,两个问题都消失了。
5.9 被拦截命令的留存
def _save_blocked_payload(command: str) -> Optional[str]: ...
def _hardline_block_result(description: str, command: str = "") -> dict: ...
def _user_deny_block_result(pattern: str) -> dict: ...
_save_blocked_payload(保存被拦截的载荷)把被拦下来的命令存起来。用途有两个:
- 取证 —— 如果真的发生了攻击,可以回溯攻击者试图执行什么
- 误报分析 —— 如果用户抱怨「正常命令被拦了」,可以看到具体是哪条规则命中了什么
5.10 智能审批:可选的模型判断
def _prepare_smart_approval_observer(...)
def _observe_smart_approval_verdict(payload: dict | None, verdict: str) -> None
def _fire_approval_hook(hook_name: str, **kwargs) -> None
除了确定性规则,Hermes 还有一个可选的「智能审批」—— 用模型来判断某个操作安不安全。
注意函数名里的 observer(观察者)和 verdict(裁决):这套机制被设计成可观测的,每次裁决都会被记录。这样才能评估「智能审批的准确率是多少、误判了哪些」。
5.11 上下文感知:不同场景不同策略
def set_hermes_interactive_context(interactive: bool) -> contextvars.Token
def reset_hermes_interactive_context(token: contextvars.Token) -> None
def _is_interactive_cli() -> bool
def _is_cron_approval_context() -> bool
def _is_single_query_approval_context() -> bool
def _is_gateway_approval_context() -> bool
def _get_session_platform() -> str
def _resolve_cli_approval_callback(approval_callback=None)
def _should_fall_through_to_cli_approval(...)
def set_current_session_key(session_key: str) -> contextvars.Token[str]
def get_current_session_key(default: str = "default") -> str
def set_current_observability_context(...)
用的是 Python 的 contextvars(上下文变量)—— 一种「在异步调用链里自动传递、且各协程互不干扰」的变量机制。
为什么必须用 contextvars 而不是全局变量:
网关进程里同时可能有几十个会话在跑。如果用全局变量存「当前是不是交互式」,那么会话 A(终端交互)和会话 B(定时任务)会互相覆盖 —— 结果是定时任务弹出了一个没人会看到的确认框,然后永远卡住。
contextvars 保证每个异步任务看到的是自己的值。这是在并发环境下做「上下文相关决策」的正确工具。
而不同场景的策略差异是:
| 场景 | 能不能弹确认框 | 策略 |
|---|---|---|
| 交互式命令行 | 能 | 危险操作弹框问人 |
| 网关(聊天平台) | 能,但走聊天消息 | 把审批请求发到聊天窗口,等用户回复 |
| 定时任务 | 不能(没人在场) | 要么按预设规则自动决定,要么直接拒绝 |
| 一次性查询 | 取决于调用方 | — |
5.12 这一层的定位:最外面也最薄
5,802 行的对抗性代码,能拦住的是「一眼看去就是灾难」的命令。
它拦不住:
· 一条精心构造的、语法上无害而语义上有害的命令
· 通过合法工具组合达成的破坏(先 read_file 读密钥,再 web_extract 发出去)
· 利用某个具体程序的漏洞
真正的边界是隔离,不是模式匹配。下一章讲执行环境。
5 · Approval and Safety Red Lines ★
tools/approval.py, 5,802 lines. This is the single file in the whole project with the densest adversarial thinking.
5.1 The Overall Structure
5.2 The 12 Hard Red Lines
HARDLINE_PATTERNS = [
# rm recursively deleting the root filesystem or a protected root directory
(_RM_FLAG_PREFIX + _hardline_rm_path(r'/(?:(?:\.\.?)?/)*(?:\.\.?)?\**|/ \*'),
"recursive delete of root filesystem"),
(_RM_FLAG_PREFIX + _hardline_rm_path(_HARDLINE_SYSTEM_DIRS),
"recursive delete of system directory"),
(_RM_FLAG_PREFIX + _hardline_rm_path(r'(?:~|\$\{?HOME\}?)(?:/?|/\*)?'),
"recursive delete of home directory"),
# formatting a filesystem
(_CMDPOS + r'mkfs(\.[a-z0-9]+)?\b', "format filesystem (mkfs)"),
# writing straight to a raw disk device
(_CMDPOS + r'dd\b[^\n]*\bof=/dev/(sd|nvme|hd|mmcblk|vd|xvd)[a-z0-9]*',
"dd to raw block device"),
(r'>\s*/dev/(sd|nvme|hd|mmcblk|vd|xvd)[a-z0-9]*\b',
"redirect to raw block device"),
# fork bomb: a process that replicates itself endlessly and locks up the machine
(r':\(\)\s*\{\s*:\s*\|\s*:\s*&\s*\}\s*;\s*:', "fork bomb"),
# killing every process on the system
(_CMDPOS + r'kill\s+(-[^\s]+\s+)*-1\b', "kill all processes"),
# shutdown / reboot (four spellings)
(_CMDPOS + r'(shutdown|reboot|halt|poweroff)\b', "system shutdown/reboot"),
(_CMDPOS + r'init\s+[06]\b', "init 0/6"),
(_CMDPOS + r'systemctl\s+(poweroff|reboot|halt|kexec)\b', "systemctl poweroff/reboot"),
(_CMDPOS + r'telinit\s+[06]\b', "telinit 0/6"),
]
_HARDLINE_SYSTEM_DIRS = (
r'/home|/home/\*|/root|/root/\*|/etc|/etc/\*|/usr|/usr/\*|'
r'/var|/var/\*|/bin|/bin/\*|/sbin|/sbin/\*|/boot|/boot/\*|/lib|/lib/\*'
)
hermes-agent/tools/approval.py
“Unconditional” means: nothing the user configures matters; these commands will never be executed.
5.3 The Real Difficulty: Telling “Command” from “Data”
A real incident
The most naive implementation is if "rm -rf /" in command: block. A source comment records the problem it caused:
“…so the rule fires only when rm is an actual command word — not when the literal string "rm -rf /" appears as DATA inside another command's argument, e.g. gh pr create --title "block rm -rf / spellings" or git commit -m "…rm -rf /…". Those tripped the unconditional floor and could not run at all before the anchor.”
In plain terms: …so the rule only fires when rm is actually in the “command word” position — not when the string "rm -rf /" shows up as data inside another command's argument, such as gh pr create --title "block rm -rf / spellings" (creating a pull request whose title contains that text) or git commit -m "…rm -rf /…". Before the position anchor was added, those commands hit the unconditional red line and could not run at all.
In other words: you couldn't make a commit whose message contained "rm -rf /".
The solution: command-position anchoring
_CMDPOS = (
# start of line / after a command separator / after a subshell opener / after a sudo|env|exec wrapper
...
)
# rm plus its flag group, shared by the three rm rules. Kept as plain string concatenation
# (rather than an f-string) so the regex's backslashes never appear inside an f-string
# replacement field — which is unsupported on Python 3.11.
_RM_FLAG_PREFIX = _CMDPOS + r'rm\s+(-[^\s]*\s+)*'
_CMDPOS matches only in these positions:
| Position | Example |
|---|---|
| Start of line | rm -rf / |
| After a command separator | cd /tmp; rm -rf / · make && rm -rf / · a || rm -rf / · x | rm -rf / |
| After a subshell opener | $(rm -rf /) · wrapped in backticks |
| After a wrapper command | sudo rm -rf / · env X=1 rm -rf / · exec rm -rf / |
In --title "block rm -rf / spellings", what precedes rm is a space and a quote, which is none of the positions above — so there's no match, and the command runs normally.
Path normalization: which spellings actually mean the root directory
The regex in that root-directory rule is /(?:(?:\.\.?)?/)*(?:\.\.?)?\**|/ \*, and the comment explains its design:
“The path token matches any root-anchored path whose components collapse back to "/" in the shell: a bare "/", repeated slashes ("//"), and "."/".." current/parent segments ("/.", "/./", "/..", "/../..") all resolve to root, optionally followed by a trailing glob ("/*", "//*"). Each inter-slash segment must be exactly "." or "..", so a longer dot run or any real name is a literal directory, NOT root — "/tmp", "/home", "/.ssh", "/.config" and even "/..." (a dir literally named "...") fall through…”
In plain terms: this path token matches any root-anchored path that “collapses back to the root directory in the shell”: a bare "/", doubled slashes "//", and current/parent segments like "." / ".." ("/.", "/./", "/..", "/../..") all resolve to root, optionally followed by a glob ("/*", "//*"). Every segment between two slashes must be exactly "." or "..", so a longer run of dots, or any real name, is a literal directory and not root — "/tmp", "/home", "/.ssh", "/.config", and even "/..." (a directory genuinely named “...”) fall through to the more lenient rules…
| Treated as root (blocked) | Not treated as root (passed on to the soft rules) |
|---|---|
/ · // · /. · /./ · /.. · /../../* · //* · / * (the shell sees two arguments)quoted "/", "$HOME", ${HOME} |
/tmp · /home · /.ssh · /.config/... (a directory genuinely named “...”) |
The comment also notes: “the explicit "/ \*" branch preserves the ‘slash-space-glob’ spelling (rm -rf / *, which the shell sees as two arguments: / and the glob *)” — a classic slip-of-the-finger disaster (you meant to delete /tmp/* and typed one extra space).
5.4 Quote Masking: But Leave a Way In for “the Part That Really Executes”
Two of the red-line rules have no command name to anchor on — the redirect > /dev/sda and the fork bomb's function definition are effective at any position on the command line.
_QUOTE_MASKED_HARDLINE_DESCRIPTIONS = frozenset({
"redirect to raw block device",
"fork bomb",
})
HARDLINE_PATTERNS_COMPILED = [
(re.compile(pattern, _RE_FLAGS),
description,
description in _QUOTE_MASKED_HARDLINE_DESCRIPTIONS) # ★ third field: whether to mask
for pattern, description in HARDLINE_PATTERNS
]
The exact semantics of the masking function
def _mask_quoted_prose(command: str) -> str:
"""Blank out quoted string CONTENT for positionless hardline matching.
Detection-only rewrite used by the quote-masked hardline rules
(redirect-to-block-device, fork bomb): text inside single or double
quotes is data the shell passes as an argument, so `echo "cat f >
/dev/sda"` must not trip the unconditional floor. Structure is
preserved: the quote characters themselves stay, and inside double
quotes `$(...)` command substitutions and backtick spans are kept RAW
because the shell really executes them (`echo "$(cat f > /dev/sda)"`
remains a true positive). Unquoted text is untouched.
"""
In plain terms: for the positionless red-line rules, blank out the content inside quotes. This is a rewrite used only for detection… quoted text is data the shell passes as an argument, so echo "cat f > /dev/sda" must not hit the unconditional red line. Structure is preserved: the quote characters themselves stay, and inside double quotes, $(...) command substitutions and backtick spans are left unmasked, because the shell really does execute them. Unquoted text is untouched.
| Command | Block it? | Why |
|---|---|---|
cat f > /dev/sda | Block | It really is writing to a disk device |
echo "cat f > /dev/sda" | Allow | What's in the quotes is data; it just prints some text |
echo "$(cat f > /dev/sda)" | Block | It's inside quotes, but the shell really executes $(...) |
What this passage shows is precise modeling of shell semantics, not simple string handling.
Whoever wrote this had to know exactly: the difference between single and double quotes, which constructs get expanded inside double quotes, the two spellings of command substitution, and how they behave when nested.
You can't look up “how to write the safety check” for this kind of knowledge in any docs — you can only work backward from “how the shell actually parses.”
5.5 Quoting Is Not a Bypass
_SHELL_CARRIER_NAMES = frozenset({
"eval", "sh", "bash", "zsh", "ksh", "dash", "source", ".",
})
def _contains_shell_carrier(command: str) -> bool:
"""Return whether any command-position word is a shell-carrying command."""
for _, _, word in _iter_shell_command_word_spans(command):
name = os.path.basename(
_deobfuscate_shell_word_for_detection(word) # ★ de-obfuscation
).lower()
if name in _SHELL_CARRIER_NAMES:
return True
return False
The logic: if the command contains something like sh -c "...", bash -c "...", or eval "..." — a command that “hands quoted content to another shell to execute” — then what's inside the quotes is code, not prose. The masking rule is switched off entirely, and the raw string must be scanned.
The comment sums it up in one line: “quoting is not a bypass”.
Three layers of defense, stacked
And de-obfuscation
_deobfuscate_shell_word_for_detection handles spellings like these:
| What the attacker writes | What the shell actually parses |
|---|---|
b''ash -c '...' | bash -c '...' (the empty-string concatenation disappears) |
ba\sh -c '...' | bash -c '...' (a backslash escaping an ordinary character) |
"bash" -c '...' | bash -c '...' |
/bin/bash -c '...' | os.path.basename extracts bash |
5.6 Sensitive Paths and Write Targets
Beyond dangerous commands, there is a set of patterns for writes to sensitive files:
_SSH_SENSITIVE_PATH = r'(?:~|\$home|\$\{home\})/\.ssh(?:/|$)'
_HERMES_ENV_PATH = ... # ~/.hermes/.env (the agent's own credentials)
_HERMES_CONFIG_PATH = ... # ~/.hermes/config.yaml (the agent's own configuration)
_PROJECT_ENV_PATH = r'(?:(?:/|\.{1,2}/)?(?:[^\s/"\'`]+/)*\.env(?:\.[^/\s"\'`]+)*)'
_PROJECT_CONFIG_PATH = r'(?:(?:/|\.{1,2}/)?(?:[^\s/"\'`]+/)*config\.yaml)'
_SHELL_RC_FILES = (...) # .bashrc / .zshrc and other shell startup scripts
_CREDENTIAL_FILES = (...)
_MACOS_PRIVATE_SYSTEM_PATH = r'/private/(?:etc|var|tmp|home)/'
_SYSTEM_CONFIG_PATH = (...)
_SENSITIVE_WRITE_TARGET = (...)
_USER_SENSITIVE_WRITE_TARGET = (...)
_PROJECT_SENSITIVE_WRITE_TARGET = rf'(?:{_PROJECT_ENV_PATH}|{_PROJECT_CONFIG_PATH})'
_COMMAND_TAIL = r'(?:\s*(?:&&|\|\||;).*)?$'
_WRITE_TARGET_BOUNDARY = r'(?=[\s;&|<>"\']|$)'
_HERMES_ENV_PATH and _HERMES_CONFIG_PATHThese two protect the agent's own configuration and credential files.
Why must they be protected? Because if the agent can change its own configuration, it can change its own safety settings — switch the approval mode to “auto-approve everything,” turn off the red-line rules, or swap the API key for the attacker's.
A system that can modify its own permission configuration has no permission configuration. This boundary has to be hard.
Two other regexes deserve attention:
_COMMAND_TAIL— matches the case where “more commands follow this one” (&&,||,;). Prevents spellings likeecho ok && rm -rf /that hide the dangerous command at the end._WRITE_TARGET_BOUNDARY— a lookahead assertion requiring the write target to be followed by whitespace, a separator, or end of line. Prevents.envfrom falsely matching a different file like.envrc.
5.7 The sudo Stdin Guard
_SUDO_STDIN_RE = re.compile(...)
def _check_sudo_stdin_guard(command: str) -> tuple: ...
def _sudo_stdin_block_result(description: str) -> dict: ...
This defends against spellings like echo PASSWORD | sudo -S DANGEROUS_COMMAND — sudo -S means “read the password from standard input,” so the password can be piped in, bypassing the interactive password prompt entirely.
And the interactive password prompt was supposed to be the last human gate. Bypassing it means the agent can run arbitrary privileged commands without the user ever knowing.
5.8 Performance: Why Precompile
# Building these at module load eliminates the ~2.6 ms cold-cache re.compile fan-out
# on each process's first terminal() call
# (12 HARDLINE + 47 DANGEROUS patterns, any of which can be evicted from Python's
# 512-entry re._cache by unrelated regex work elsewhere in the program).
_RE_FLAGS = re.IGNORECASE | re.DOTALL
HARDLINE_PATTERNS_COMPILED = [...]
Two layers of problem:
- First-compile cost — 59 regexes compiled on first use, about 2.6 milliseconds
- Cache eviction — Python's built-in regex cache holds only 512 entries. Whenever the rest of the program gets busy (log formatting, text processing), these safety regexes get evicted, causing repeated recompilation
Precompiled into module-level constants, both problems disappear.
5.9 Retaining Blocked Commands
def _save_blocked_payload(command: str) -> Optional[str]: ...
def _hardline_block_result(description: str, command: str = "") -> dict: ...
def _user_deny_block_result(pattern: str) -> dict: ...
_save_blocked_payload stores the commands that got blocked. Two uses:
- Forensics — if an attack really happened, you can trace what the attacker tried to execute
- False-positive analysis — if a user complains “a normal command got blocked,” you can see exactly which rule matched what
5.10 Smart Approval: An Optional Model Judgment
def _prepare_smart_approval_observer(...)
def _observe_smart_approval_verdict(payload: dict | None, verdict: str) -> None
def _fire_approval_hook(hook_name: str, **kwargs) -> None
Beyond the deterministic rules, Hermes has an optional “smart approval” — using a model to judge whether an operation is safe.
Note the words observer and verdict in the function names: the mechanism is designed to be observable, and every verdict is recorded. That is what makes it possible to evaluate “how accurate is smart approval, and what did it get wrong.”
5.11 Context Awareness: Different Situations, Different Policies
def set_hermes_interactive_context(interactive: bool) -> contextvars.Token
def reset_hermes_interactive_context(token: contextvars.Token) -> None
def _is_interactive_cli() -> bool
def _is_cron_approval_context() -> bool
def _is_single_query_approval_context() -> bool
def _is_gateway_approval_context() -> bool
def _get_session_platform() -> str
def _resolve_cli_approval_callback(approval_callback=None)
def _should_fall_through_to_cli_approval(...)
def set_current_session_key(session_key: str) -> contextvars.Token[str]
def get_current_session_key(default: str = "default") -> str
def set_current_observability_context(...)
This uses Python's contextvars — a variable mechanism that “propagates automatically through an async call chain, with each coroutine isolated from the others.”
Why it has to be contextvars and not a global variable:
A gateway process may have dozens of sessions running at once. If a global variable held “is this interactive right now,” session A (a terminal session) and session B (a scheduled task) would overwrite each other — and the scheduled task would pop up a confirmation prompt nobody will ever see, then hang forever.
contextvars guarantees each async task sees its own value. It is the correct tool for “context-dependent decisions” in a concurrent environment.
And the policy differences across situations are:
| Situation | Can it prompt for confirmation? | Policy |
|---|---|---|
| Interactive CLI | Yes | Dangerous operations prompt a human |
| Gateway (chat platform) | Yes, via chat messages | Send the approval request to the chat window and wait for the user's reply |
| Scheduled task | No (nobody is present) | Either decide automatically by preset rules, or refuse outright |
| One-off query | Depends on the caller | — |
5.12 Where This Layer Sits: Outermost, and Thinnest
5,802 lines of adversarial code can stop commands that are “obviously a disaster at a glance.”
It cannot stop:
· A carefully constructed command that is syntactically harmless and semantically harmful
· Damage achieved by combining legitimate tools (first read_file to read a key, then web_extract to send it out)
· Exploiting a vulnerability in some specific program
The real boundary is isolation, not pattern matching. The next chapter covers execution environments.