Blog Pipeline HITL 门控失效事故复盘:进程隔离漏洞、架构设计缺陷与完整修复方案
一次 exec 绕过导致的状态丢失,暴露了 observability 进程隔离与 maxSpawnDepth 架构债务的三重漏洞

一句话总结:一次跳过的用户审核,暴露了observability工具的进程隔离漏洞、架构设计的层次限制、以及历史遗留的技术债务。

事故回顾
2026年8月5日,kb-writer(详见 [[kb-writer配置与迭代]])在执行博客发布流程时,发生了一次严重的流程违规:
Phase 4 图片生成完成后,直接执行了 Phase 5(发布到 Hugo),跳过了 HITL 2(终稿审核)。
这意味着:
- 用户没有机会审核最终文章和图片质量
- 文章已发布到 https://oklife.me
- 用户审核权被完全绕过
根因分析
1. 执行方式错误:exec 绕过进程隔离(详见 [[blog-pipeline-observability-system]])
事故的直接原因是 observability 工具的调用方式错误。
错误代码:
# ❌ 错误:每次 exec 都是新进程,_hitl_gate 状态丢失
exec("python3 -c \"
import importlib.util
spec = importlib.util.spec_from_file_location('observability', '...')
obs = importlib.util.module_from_spec(spec)
spec.loader.exec_module(obs)
# 此时 _hitl_gate 是空字典!
obs.log_phase_end('HITL 2', success=True)
\"")问题本质:
- observability.py 的
_hitl_gate是进程内内存变量 - 每次
exec("python3 ...")启动新进程 - 新进程的
_hitl_gate是空字典 - 门控检查
_check_hitl_gate()永远返回 False - 但实际上,因为是新进程,
_hitl_gate根本不存在,检查逻辑失效
2. 架构设计漏洞:Phase 5 无前置检查
observability.py 的门控机制存在设计缺陷:
现有门控(已存在):
def log_phase_end(phase_name, success=True, tokens=None):
# 只检查 log_phase_end("HITL x") 是否被跳过
if "HITL" in phase_name and not _check_hitl_gate(phase_name):
return # 阻断漏洞:
- 只检查
log_phase_end("HITL x")是否被正确调用 - 不检查 Phase 5 是否在 HITL 通过后才启动
- 如果直接调用
log_phase_start("Phase 5"),门控完全无效
攻击向量:
# 可以完全绕过 HITL 2
log_phase_start("Phase 4: 生图")
log_phase_end("Phase 4: 生图", success=True)
# 不调用 mark_hitl_yielded("HITL 2")
# 不调用 log_phase_end("HITL 2")
log_phase_start("Phase 5: 发布") # 直接启动!
log_phase_end("Phase 5: 发布", success=True)3. 历史遗留问题:maxSpawnDepth 限制

blog-pipeline v2.9 的架构设计受到 OpenClaw maxSpawnDepth: 2 限制的影响。
历史架构(已废弃):
主session → 云天枢(orchestrator) → Phase agents
深度: 0 → 1 → 2问题:
- 云天枢(详见 [[博客流水线HITL事故复盘]])被 spawn 后是深度 1
- 云天枢再 spawn Phase agents 是深度 2
- 如果云天枢的 subagent 再 spawn,就是深度 3
- 超过
maxSpawnDepth: 2,卡死
当前架构(v2.9):
主session 直接编排 Phase agents(详见 [[OpenClaw多智能体博客流水线v3架构演进]])
深度: 0 → 1优点:
- 所有 agent 在深度 1,不触碰限制
- 避免了嵌套层数问题
缺点:
- 主session直接控制所有 Phase
- 缺乏物理隔离保护
- HITL 依赖"软约束"(代码检查),而非"硬约束"(session隔离)
修复方案
修复 1:新增 check_all_hitl_gates() 函数
在 observability.py 中新增前置检查函数:
def check_all_hitl_gates():
"""检查所有 HITL 门控是否已通过(Phase 5 启动前调用)
返回 (True, "") 表示通过,(False, "error_msg") 表示阻断
"""
required_gates = ["HITL 1: 用户审核草稿", "HITL 2: 用户审核终稿"]
missing = [g for g in required_gates if not _hitl_gate.get(g)]
if missing:
msg = f"🚫 Phase 5 阻断:未完成的 HITL 门控:{missing}..."
logger.error(msg)
return False, msg
return True, ""使用方式:
# Phase 5 启动前强制检查
_hitl_ok, _hitl_msg = check_all_hitl_gates()
if not _hitl_ok:
raise RuntimeError(_hitl_msg)修复 2:强制使用 importlib 加载 observability
在 AGENTS.md 和 SKILL.md 中明确禁止 exec 方式:
⚠️ 绝对禁止用 `exec("python3 -c ...")` 调用 observability 函数。
每次 `exec` 都是新进程,`_hitl_gate` 状态丢失,门控检查失效。
必须用 `importlib.util.spec_from_file_location` 在**当前进程**内加载。正确代码:
import importlib.util
spec = importlib.util.spec_from_file_location(
"observability",
"/home/oklife/.openclaw/skills/blog-pipeline/observability.py"
)
obs = importlib.util.module_from_spec(spec)
spec.loader.exec_module(obs)
# 此时 _hitl_gate 在当前进程中持久化
mark_hitl_yielded = obs.mark_hitl_yielded
check_all_hitl_gates = obs.check_all_hitl_gates修复 3:更新 SKILL.md 文档
同步 observability.py 的最新代码(含门控逻辑):
# 现有代码(旧版,无门控)
def log_phase_end(phase_name, success=True, tokens=None):
elapsed = time.time() - run_state["phases"][phase_name]["start"]
# ... 记录结束时间
# 更新后(含门控)
def log_phase_end(phase_name, success=True, tokens=None):
# HITL 门控:phase_name 包含 HITL 时,必须先通过 yield
if "HITL" in phase_name and not _check_hitl_gate(phase_name):
logger.error(f"[BLOCKED] {phase_name} 被 HITL 门控阻断")
return # 直接返回,不记录为 success
# ... 原有逻辑验证结果
测试 1:未通过 HITL,Phase 5 应被阻断
import importlib.util
spec = importlib.util.spec_from_file_location('observability', '...')
obs = importlib.util.module_from_spec(spec)
spec.loader.exec_module(obs)
# 不调用 mark_hitl_yielded,直接检查
ok, msg = obs.check_all_hitl_gates()
print(f'阻断结果:ok={ok}') # 输出:ok=False ✅结果: 门控正确阻断,Phase 5 无法启动。
测试 2:通过 HITL 后,Phase 5 应放行
# 先调用 mark_hitl_yielded
obs.mark_hitl_yielded('HITL 1: 用户审核草稿')
obs.mark_hitl_yielded('HITL 2: 用户审核终稿')
# 再检查
ok, msg = obs.check_all_hitl_gates()
print(f'放行结果:ok={ok}') # 输出:ok=True ✅结果: 两个 HITL 都通过后,Phase 5 可以启动。
架构讨论
方案对比
| 方案 | 架构 | 嵌套深度 | 安全性 | 复杂度 |
|---|---|---|---|---|
| 当前方案(C) | 主session直接编排 | 深度 1 | 中(依赖代码检查) | 低 |
| 方案 A | 主session → publish-agent | 深度 1 | 高(session隔离) | 中 |
| 方案 B | 主session → orchestrator → agents | 深度 2 | 高(物理隔离) | 高 |
方案 A:拆 Phase 5 为独立 agent
主session (kb-writer)
├── Phase 0-4: 直接执行
├── HITL 2 通过后: sessions_yield
└── 用户回复"通过"后: spawn pub-yuntianxing (深度1)
└── pub-yuntianxing 执行 Phase 5
├── 调用 check_all_hitl_gates()
├── 通过 → 执行发布
└── 不通过 → RuntimeError 终止优势:
- Phase 5 在独立 session 执行
- 主 session 无法跳过门控检查
- 即使主 session 代码有 bug,独立 agent 有第二重检查
劣势:
- 需要定义
pub-yuntianxingagent - 增加架构复杂度
- 当前只有 1 个发布平台,价值不明显
方案 B:重构为 orchestrator 架构
主session → 云天枢(orchestrator, 深度1) → Phase agents (深度2)问题:
- 云天枢设计用于小说创作,不是博客流水线
- 需要重写 YAML 工作流
- 可能重新触发
maxSpawnDepth限制
结论: 不值得现在重构。
最终决策

保持方案 C(当前架构),不修改 maxSpawnDepth。
理由:
- 现有门控已足够:
check_all_hitl_gates()+sessions_yield+ 规则约束 - 架构简单:所有 agent 在深度 1,不触碰限制
- 维护成本低:不需要引入新的 agent 或重构架构
- 需求不明确:多平台发布需求尚未明确,过早抽象是浪费
未来演进路径:
- 阶段 1(现在):保持现状,依赖代码检查
- 阶段 2(有明确多平台需求时):Phase 5 拆为独立脚本
- 阶段 3(平台变多、逻辑复杂时):构建
pub-yuntianxingagent
经验教训
1. 写规则不等于修问题
本次事故前,AGENTS.md 已经写了"禁止跳过 HITL"的规则。但规则只是文字,没有物理强制力。
正确做法: 用代码机制让违规在技术上不可行。
2. 进程隔离是安全的第一道防线(详见 [[blog-pipeline-observability-system]])
exec("python3 ...") 启动新进程,所有内存状态丢失。这是 Python 的基本特性,不是 bug。
教训: 涉及状态持久化的工具,必须用 importlib 在当前进程加载,不能用 exec。
3. 架构设计要考虑历史债务
blog-pipeline v2.9 的"主session直接编排"架构,是为了绕过 maxSpawnDepth: 2 限制而做的妥协。这个历史决策影响了当前的架构设计。
反思: 如果当初 maxSpawnDepth 是 3,是否会有不同的架构选择?
4. 过度设计 vs 设计不足
在只有 1 个发布平台时,构建独立 pub-yuntianxing agent 是过度设计。但在有明确多平台需求时,不构建独立 agent 是设计不足。
平衡点: 根据实际需求演进,不超前设计,也不滞后修复。
附录:修改文件清单
| 文件 | 修改内容 |
|---|---|
observability.py | 新增 check_all_hitl_gates() 函数 |
SKILL.md | 更新 observability.py 代码示例(含门控逻辑) |
AGENTS.md | 新增"observability 门控绕过铁律" |
MEMORY.md | 记录事故根因和修复方案 |
参考链接
本文更新于 2026-08-05
梦行志