08 技能系统的 manifest 契约机制
结论先说:manifest 的价值不在“配置了什么”,而在“框架可以据此拒绝什么” #
很多项目里的 manifest.yaml 最后都会退化成一张说明书:
- 叫什么
- 版本号是多少
- 作者是谁
这类 manifest 对运行时几乎没有约束力。SecurityClaw 这套技能系统里,manifest 更像可执行契约:它不只是描述技能,而是给框架提供拒绝、跳过、调度和隔离的依据。
一、manifest 在系统里的职责 #
你可以先把 manifest 抽象成四类信息:
class SkillManifest(BaseModel):
name: str
version: str
# 1. 调度信息
schedule_interval_seconds: Optional[int]
schedule_cron_expr: Optional[str]
run_on_first_startup: bool = False
# 2. 依赖信息
required_env_vars: list[str] = []
depends_on: list[str] = []
# 3. 能力约束
allowed_actions: list[str] = []
allowed_field_patterns: list[str] = []
risk_level: Literal["low", "medium", "high"] = "low"
# 4. 运行属性
idempotent: bool = False
这四类信息分别解决不同问题:
- 调度:什么时候运行
- 依赖:缺了什么不能运行
- 约束:允许做什么、不允许做什么
- 运行时属性:重试、风险、幂等性如何处理
二、为什么 manifest 是“契约”,不是“配置表” #
配置表的思路是:
- 写一点元数据
- 系统尽量照着跑
契约的思路是:
- 你声明一组条件
- 系统在条件不满足时明确拒绝或跳过
也就是说,manifest 的价值不在“帮助系统更方便运行”,而在“帮助系统知道什么时候不该运行”。
例子 1:环境变量缺失 #
if env_var not in os.environ:
return InvalidManifest("ENV_MISSING")
这一步不是为了友好提示,而是为了避免技能在真正执行时才因为缺凭证崩掉。
例子 2:依赖环 #
# A depends_on B, B depends_on A
raise InvalidDependencyChain("cycle detected")
如果系统在启动期就不能看出依赖环,那运行期迟早会在调度里死锁或反复跳转。
例子 3:CRON / interval 冲突 #
如果一个技能同时声明:
schedule_interval_secondsschedule_cron_expr
框架必须有明确规则:
- 选一个
- 或直接拒绝
不能含糊执行。
这就是契约思维:不确定性必须在系统边界被收敛。
三、manifest 验证流程其实就是一层静态防火墙 #
sequenceDiagram
participant Loader as SkillLoader
participant FS as FileSystem
participant Manifest as Validator
participant Registry as Runner
Loader->>FS: 读取 skills/<name>/manifest.yaml
FS-->>Loader: 原始 YAML
Loader->>Manifest: validate_manifest(raw)
Manifest->>Manifest: 校验字段类型 / 依赖 / env / 调度冲突
alt 校验通过
Manifest-->>Loader: SkillManifest
Loader->>Registry: 注册技能
else 校验失败
Manifest-->>Loader: InvalidManifest
Loader->>Loader: 记录 warning 并跳过
end
这层验证很像编译期静态检查:
- 技能还没运行
- 业务逻辑还没执行
- 但系统已经能拒绝明显不合法的能力包
如果没有这层,所有错误都会拖到运行期才暴露,代价会高很多。
四、最关键的三类失败路径 #
4.1 依赖失败 #
场景 #
技能声明:
required_env_vars:
- DB_PASSWORD
但进程环境里没有 DB_PASSWORD。
正确行为 #
- 技能不注册
- 系统继续启动
- 输出结构化 warning
为什么不能直接炸启动 #
因为一个技能坏了,不应该拖垮整套系统。
4.2 调度失败 #
场景 #
cron 表达式非法,或者 interval 类型错误。
正确行为 #
- 该技能拒绝注册自动调度
- 但如果核心逻辑合法,是否还能保留手动调用能力,要有明确策略
这里就有 tradeoff:
方案 A:调度字段出错就整个技能跳过 #
优点:简单 缺点:一个小调度错误就让技能彻底不可用
方案 B:自动调度禁用,但允许手动调用 #
优点:更宽容 缺点:运行语义更复杂
如果是我,我更倾向 B。因为“不能自动跑”和“不能被调用”不是一回事。
4.3 约束失败 #
场景 #
manifest 声明:
allowed_actions: [read]
但 LLM 规划里出现了 delete。
正确行为 #
运行时直接拒绝,不进入执行器。
这说明 manifest 的价值不仅在启动期,也在运行期持续生效。它是控制面的输入之一。
五、为什么文件系统扫描是合理但不完美的选择 #
这套系统允许把技能目录直接放进 skills/,Loader 扫描后自动注册。
这个设计的优点 #
- 零配置扩展:复制一个目录就能加技能
- 适合快速迭代:安全分析师不需要理解中央注册表
- 天然支持热加载:目录变化即可触发重扫
它的问题也很明确 #
- 安全边界依赖文件系统权限
- 技能来源可信度难保证
- 缺少签名 / 完整性验证
所以文件系统扫描不是“好设计”或“坏设计”,而是一个很典型的工程取舍:
用部署面简单,换取运行时信任面更脆弱。
如果以后这套系统要走更高安全级别,我会优先加:
- 技能签名
- 白名单来源
- manifest 哈希校验
六、测试 manifest,实际上是在测试框架的拒绝能力 #
manifest 相关测试最重要的不是 happy path,而是 failure path。
def test_skip_on_missing_env_var():
...
def test_invalid_cron_format():
...
def test_dependency_cycle_rejected():
...
这些测试证明的不是“manifest 能被解析”,而是:
- 系统会不会在坏输入下误注册技能
- 框架能不能优雅降级
- 拒绝逻辑是否稳定可复现
这也是为什么我一直强调:测试 manifest,本质上是在测试框架的防线。
七、这一篇真正要记住的框架 #
manifest 的知识框架可以压成一句话:
manifest 不是为了描述技能,而是为了让系统在技能不满足条件时,有充分理由拒绝它。
你可以把它记成四层:
声明能力
-> 声明依赖
-> 声明调度
-> 声明约束
然后由框架把这些声明转成:
允许注册 / 拒绝注册 / 允许执行 / 拒绝执行
这才叫契约。
八、验证命令 #
python main.py list-skills
pytest tests/test_skill_loader.py -v
如果你要专门验证 manifest 失败路径:
pytest tests/ -k "manifest or env_var or cron or dependency" -v