# Hard Contracts (引擎层契约) **目标**: 给装 `platform/industry/` 的团队列引擎层的 fail-closed 契约清单, 不必挖源码. 引擎层"hard contract" = 默认开 + 消费者通常不需要配置 + 触发即 fail-closed (拒绝写产物 / 退轮 / 注入纠偏 user message). 业界对照: Claude Code FileEditTool.ts / pathValidation.ts / readFileState ref. > ## What this means for you > > - 默认安装的 `engine.New()` + builtin tools 已经带这些守卫 — **不要为了过测试关掉**, 关掉等于把生产救命线拆了. > - 部分守卫**必须传依赖才能构造**(e.g. `NewFileEditTool` 必传 `stateCache`); 旧构造器 `NewFileEditTool()` 已删除, 升级路径见各条. > - 触发 fail-closed 的 LLM 看到的是 `Result{IsError:true}` + 提示, 自纠重试; 引擎不感知, 不需要 platform 层处理. --- ## 总览 | # | 守卫 | 层 | 默认 | 可关? | 触发 | |---|---|---|---|---|---| | 1 | Final text duplication guard | `engine.runLoop` 6.4 | ON | 不可 | LLM 一次 final response 内输出多个内容相同 ContentText 块 | | 1.1 | Thinking 频道死循环 guard | `engine.runLoop` 6.4.1 | ON | 不可 | thinking 块尾部 1KB unique rune 比例 < 2% (e.g. r15 minimax `首 重 1 公斤 |` 死循环) | | 2 | ResponseValidator | `engine.Config.ResponseValidator` | OFF (opt-in) | 必填 nil = 不开 | LLM 出 final text 时跑 `Validate(ctx, DiffInput)` | | 3 | ResponseValidatorMaxBlocks | `engine.Config.ResponseValidatorMaxBlocks` | 0 (无限) | 0 = 不限 | ResponseValidator 累计 block 触底 | | 4 | Read-before-Edit | `FileEditTool.stateCache` | ON | **不可** (构造必传) | Edit 路径无 Read 记录 / partial view / mtime 漂 + content 不同 | | 5 | FileRead absolute path | `FileReadTool` | ON | 不可 | 相对路径 reject | | 6 | FileRead symlink loop | `FileReadTool` | ON | 不可 | symlink 死循环 reject | | 7 | Bash dangerous removal | `BashTool` | ON | 不可 | rm/rmdir 目标在系统关键路径黑名单 | | 8 | WebFetch SSRF + TOCTOU | `WebFetchTool` | ON | 不可 | DNS 解析的 IP 命中 RFC1918 / loopback / link-local / IPv6 内网 | | 9 | WebFetch redirect cap | `WebFetchTool` | ≤ 5 | 不可 | HTTP redirect 链超 5 跳 | | 10 | WebFetch response size | `WebFetchTool` | 1MB | 不可 | response body 超 1MB | | 11 | SQL readonly gate | `SQLValidatorTool` | ON (装上即开) | opt-in 装 | 非 SELECT/WITH/EXPLAIN 单条语句 reject | | 12 | SQL CAS optimistic lock | `SQLCASExecutor` | ON | 不可 | version 列冲突 RowsAffected=0 reject | | 13 | MaxTurns budget cap | `engine.Config.MaxTurns` | 100 | 改 Config | turn loop 触底 fatal `max_turns_reached` | | 14 | Input truncation | `engine.Config.AuditInputMaxBytes` | 100KB | 改 Config | 单条 input 超限自动截断 + WarningEvent | | 15 | MaxTokens escalation | `engine.runLoop` | 8192 → up | 不可 | 单轮 max_tokens hit 时自动升级一次 | --- ## 各守卫详解 ### 1. Final text duplication guard (引擎层 schema-agnostic) **问题**: 弱模型 (probe r12 Gemma 4 26B / r13 同模型 + T=0.2 / r14 gpt-5.1 main verdict) 一次 final response 内重复输出同一内容的 ContentText 块, 拼接后下游 schema 验证只看 corrupted 单串看不出. **实现**: `core/pkg/engine/engine.go` - `detectDuplicateTextBlocks(content)`: TrimSpace 后内容相同的 ContentText 块视为重复 (thinking / tool_use 块跳过) - `runLoop` 6.4 段排在 ResponseValidator (6.5) 之前, 块级检测先于 schema-aware 验证 - 命中 → 推 `WarningEvent{Code:"final_text_duplicate_blocks"}` + 注入纠偏 user message + `continue` turn loop - 不加独立 cap: MaxTurns 已限总 retry, 模型死循环走 `max_turns_reached` 退 **消费者行动**: 无. 默认开. WarningEvent 上观测面就能看到漂移频次. --- ### 1.1. Thinking 频道死循环 guard (schema-agnostic, 块内级) **问题**: 弱模型 (probe r11 Mistral Nemo / r15 minimax-M2.7-highspeed main agent) 在 thinking 段陷入死循环 -- 跑了几 KB 合法推理后开始重复同一短 token 序列 (e.g. `首 重 1 公斤 |`) 直到 API 端 `engine stream error: 操作已取消` timeout. 1.4 final text duplication guard 抓不到 -- thinking 累加成单个 ContentThinking 块, 不是多个相同 ContentText 块. **实现**: `core/pkg/engine/engine.go detectThinkingLoop` - 尾部窗口扫描 (1024 bytes): 覆盖混合 case (合法推理前缀 + 尾部死循环, r15 实际形态) - 唯一 rune 比例: ratio = unique runes / total runes; 阈值 0.02 (2%) 低于即判循环 - 校准: r15 minimax 循环 ~6 unique runes / 1024 = 0.6% (远低于阈值); 健康 chain-of-thought >= 5% (50+ unique runes / 1024); 阈值离健康下限 4x safety, 离循环上限 3x margin - 短 thinking 块 (< 1024 bytes) 跳过检测避免噪声 - runLoop 6.4.1 段, 排在 6.4 final text dup 之后, 6.5 ResponseValidator 之前. 命中 → `WarningEvent{Code:"thinking_loop_detected"}` + 注 user message 纠偏 + `continue` **消费者行动**: 无. 默认开. r11 + r15 两证, 业界 OSS 框架未见同档实装, Flyto 首个引擎层默认 thinking 守卫. --- ### 2/3. ResponseValidator + ResponseValidatorMaxBlocks (schema-aware 响应闸) **问题**: LLM 出的 final text 不符合业务 schema (e.g. 物流报价 JSON / 主 agent verdict JSON / 自由文本格式约束). **实现**: `core/pkg/engine/engine.go runLoop 6.5` - LLM 出 final text 没调工具 (stop_reason=end_turn) → 跑 `cfg.ResponseValidator.Validate(ctx, DiffInput{SourceTool:"LLMResponse", Raw: finalText})` - `Verdict.Approved=false` 或非 nil error → 注 Reason 为 user message, turn loop 继续, 被拒文本不流给消费层 - `MaxBlocks > 0` 时累计 block 触底 → graceful break + `WarningEvent{Code:"response_validator_max_blocks"}`, 把 last text 流给消费层 (orchestrator / 主 agent 接管决策, 不让一个 Run 烧完整 MaxTurns 失去重写 prompt 机会) **消费者行动 (装的话)**: ```go // 1. 业务反射器作为 sub agent 端 ResponseValidator import "flyto-platform-common/internal/billrecon/llm" subEng := engine.New(&engine.Config{ ResponseValidator: &llm.QuoteResponseValidator{Tool: reflectTool}, ResponseValidatorMaxBlocks: 3, // 弱模型生产环境必设 ... }) // 2. JSON verdict gate 作为 main agent 端 import "flyto-platform-common/responseguard" mainEng := engine.New(&engine.Config{ ResponseValidator: responseguard.NewJSONVerdictValidator(), ResponseValidatorMaxBlocks: 3, ... }) ``` **升级路径**: 已默认 nil (零开销零侵入). 装 validator 即开. --- ### 4. Read-before-Edit (FileEditTool 必填 stateCache) **问题**: 多轮编辑时 LLM 基于过时记忆 Edit, 文件早被改 → 覆盖修改 / silent corruption. **实现**: `core/pkg/tools/builtin/fileedit.go`, 对位 Claude Code FileEditTool.ts:275-310 - `FileStateCacheReader` 接口 (`GetState(path) (Entry, bool)`) - `DefaultFileStateCache` 实现 both Recorder + Reader, RWMutex 线程安全 - `FileReadTool.Execute` 末尾 RecordState (timestamp + ContentHash + IsPartialView) - `FileEditTool.validate` 在 ReadFile 后 / CRLF 转换前三重 check: 1. 路径无 Read 记录 → reject "has not been read yet" 2. partial view (offset/limit) → reject "read fully first" 3. mtime 漂 + ContentHash 不等 → reject "modified since last read"; mtime 漂但 ContentHash 等 → 放行 (cloud sync / antivirus 改 mtime 不改字节假阳性 fallback) **消费者行动**: **构造时必传**, nil → panic. ```go cache := builtin.NewDefaultFileStateCache() readTool := builtin.NewFileReadToolFull(fileCache, cache) editTool := builtin.NewFileEditTool(cache, builtin.WithFileEditCache(fileCache), builtin.WithFileEditHistory(history), builtin.WithFileEditCwd(cwd), builtin.WithFileEditGuard(guard), ) ``` **破坏性 API**: v0.4 周期 (2026-04 → 2026-05) 删除了旧 `NewFileEditTool()` / `NewFileEditToolWithCwd` / `WithCache` / `Full` / `Complete` / `WithGuard` 6 个构造器. 升级仅一行 (上面示例). 唯一 production caller `core/pkg/engine/engine.go` 已升级. --- ### 5/6. FileRead absolute path + symlink loop **已默认实装**: `FileReadTool` 强制 `filepath.IsAbs()`; 检测 symlink 解析死循环. 消费者无需配置. --- ### 7. Bash dangerous removal (rm/rmdir 系统关键路径) **问题**: LLM 误执行 `rm -rf /` / `rm -rf /etc` 等灾难命令. **实现**: `core/pkg/tools/builtin/bash_dangerous_path.go`, 对位 Claude Code utils/permissions/pathValidation.ts:isDangerousRemovalPath - 黑名单: `/` 根 / `/` 直接子目录 (/etc /usr /tmp /var /bin /sbin /lib /opt /home /root /boot /sys /proc /dev) / Windows 盘根 (`C:`/`C:\Windows`) / `$HOME` 本身 / `*` 与 `/*` 通配 - 子目录不算危险: `rm -rf /tmp/build`、`rm -rf ~/code/myproject`、`rm -rf dist/` 都允许 - `extractRemovalTargets`: regex + Fields 锚定语句边界 (`^ ; && || | \n`) 防 `echo 'rm -rf /'` 误抓; v1 不做 POSIX shell AST (覆盖 90% 真实 LLM 输出) - `BashTool.Execute` 早期插入拦截; 命中 → `Result{IsError:true, Output: }` fail-loud, LLM 看错误提示自纠 **与 cc 设计差异**: cc 走 user-in-the-loop UI 审批 (一次授权可允); Flyto v1 hard reject. user-audit 通道 (`ToolResult.RequireApproval`) 是已识别的 follow-up, 待 PM 决策驱动. **消费者行动**: 无. 默认开. --- ### 8/9/10. WebFetch SSRF + redirect cap + size cap **已默认实装**: `core/pkg/tools/builtin/webfetch.go` - 10 条 CIDR 黑名单 (RFC1918 / loopback / link-local 含 169.254.169.254 AWS 元数据 / IPv6 唯一本地 / RFC6598 Tailscale 共享段) - `safeDialContext` 在 TCP 建立前 DNS 解析检查所有 IP, 防 DNS rebinding 字符串绕过 - TOCTOU 加固 (commit `e522a3e`): dial 用第一次 verified 的 `addrs[0].IP` 直接构造 host:port, 不让 Dialer 内部二次 resolve. HTTPS SNI 由 net/http.Transport 用原 hostname 设 ServerName 与 dial address 解耦, hijack 不影响 TLS 证书校验 - redirect ≤ 5 跳 / response body ≤ 1MB **消费者行动**: 无. 默认开. --- ### 11. SQL readonly gate (装上即开) **实现**: `core/pkg/tools/builtin/sql_validator.go SQLValidatorTool` - 纯字符串解析, 0 DB 依赖 - 强制单条语句 + 首关键字 SELECT / WITH / EXPLAIN - 可选 LIMIT 强制 + 表白名单 (config 调) - 与 `sql_dryrun.go` (DML 预览) / `sql_cas.go` (staging 乐观锁 UPDATE) 配合: SQL 工具链做 readonly 验证, dryrun 走 ROLLBACK 演练 DML, cas 写 staging 表 **消费者行动**: opt-in 装到 platform 层 SQL pipeline. 不装 = 没这层 gate. --- ### 12. SQL CAS optimistic lock (StagingDB 专用) **已实装**: `core/pkg/tools/builtin/sql_cas.go` - `WHERE id = ? AND version = ?` + RowsAffected=0 → reject - `maxRetries=0` fail-fast 默认; version 类型非 int 运行时 reject - StagingDB newtype DI, 不指向生产 OLTP **消费者行动**: 装 staging pipeline 时 wire 上. --- ### 13/14/15. 引擎运行时 budget **MaxTurns** (`Config.MaxTurns`, 默认 100): turn loop 触底 fatal `max_turns_reached`. 消费者按场景调整 (probe 跑 1000 单/s 估上限). **Input truncation** (`Config.AuditInputMaxBytes`, 默认 100KB): 单条 input 超限截断 + WarningEvent. 消费者大文件场景调高. **MaxTokens escalation**: 单轮 max_tokens hit 自动升级一次 (8192 → up). 不可关. 消费者无配置. --- ## 给消费者的快速 checklist 部署 platform/industry 时跑一遍: - [ ] `engine.New()` 装的是 builtin tools (含 FileEdit + FileRead + Bash + WebFetch + ...)? 默认 OK - [ ] `engine.Config.ResponseValidator` 装了业务反射器? (sub agent 必装, main agent 装 `responseguard.NewJSONVerdictValidator`) - [ ] `engine.Config.ResponseValidatorMaxBlocks` 设了非 0 值? (推荐 3, 防弱模型死循环烧 MaxTurns) - [ ] `engine.Config.MaxTurns` 跟场景匹配? (10 round probe / 100 default / 短回答场景调低) - [ ] platform 层 SQL pipeline 装了 `SQLValidatorTool` + `SQLDryRunTool` + `SQLCASExecutor` (staging) 三件套? - [ ] FileEdit / FileRead 共享同一 `DefaultFileStateCache` 实例? (engine.registerBuiltinTools 已自动) --- ## 升级路径 (v0.4 → v0.5) | 改动 | 你做什么 | |---|---| | `NewFileEditTool()` 删除, 必传 stateCache | 用 `NewFileEditTool(stateCache, opts...)` 一行替换. 唯一 production caller engine 已升级. | | `Config.Temperature/TopP` 暴露 | 自由 opt-in: `Temperature: flyto.Float(0.2)` 给弱模型降温 (但 r13 实测 Gemma 4 26B 不温度敏感, 用前先 PoC) | | `final_text_duplicate_blocks` WarningEvent code 新增 | 观测面接通 (admin/safetychain/verdicts 已自动收) | | `responseguard` 新包 | 装到 main agent: `import "flyto-platform-common/responseguard"; cfg.ResponseValidator = responseguard.NewJSONVerdictValidator()` | --- ## 已识别的 follow-up | 项 | 优先级 | 说明 | |---|---|---| | user-audit approval 通道 (`ToolResult.RequireApproval`) | P3 | cc 走 UI 审批, Flyto v1 hard reject. PM 决策驱动 | | Bash 完整 POSIX shell AST (tree-sitter 风格) | P3 | v1 regex + Fields 覆盖 90% 场景, eval / sh -c / 变量展开漏抓 | | Tool result size cap (单条 ≤ 16KB) | B 级 | LLM context overflow 防护, 实证频率低暂不做 | | 子进程 spawn 深度预算 | B 级 | Skill 嵌套场景, 等真消费者 | | 循环重试检测 (LLM 死循环) | C 级 | r11 mistral nemo 单例, 可由 hook 层自实装 (safetychain 参考) | --- **版本**: v0.5-dev (Hard Contract 系列 5 commit `e6e715a..e522a3e`) **起点**: 2026-04-30 **对位**: Claude Code FileEditTool.ts / pathValidation.ts / utils/webContentFetcher SSRF