# 数据安全设计文档 > 本文档面向接手该模块的团队成员,记录 AI Agent 操作数据时的安全架构决策,接口设计和实现指导. > > 关联文档: > - [architecture.md](architecture.md) - 全局架构,ToolCapability/OperationLog/安全审计体系 > - [defensive-programming.md](defensive-programming.md) - 三层防御通用模式 > - [research-agent-database-safety.md](research-agent-database-safety.md) - 数据库隔离技术调研 > - [research-operation-history.md](research-operation-history.md) - 操作历史/回滚方案调研 --- ## 目录 - [核心原则](#核心原则) - [三个操作维度](#三个操作维度) - [AI 与 DB 关系的架构决策](#ai-与-db-关系的架构决策) - [读操作防护](#读操作防护) - [写操作分级](#写操作分级) - [凭据安全:SetSecret 接口](#凭据安全setsecret-接口) - [Dry-run 模式设计](#dry-run-模式设计) - [Staging 写入模式](#staging-写入模式) - [审计日志设计](#审计日志设计) - [四层安全防御](#四层安全防御) - [WMS 波次建立参考案例](#wms-波次建立参考案例) - [LLM 多轮推理分支场景](#llm-多轮推理分支场景) - [已推翻的旧结论](#已推翻的旧结论) - [引擎接口清单](#引擎接口清单) --- ## 核心原则 **AI 负责决策,业务系统 API 负责执行.AI 不直接写业务数据库.** 这不是一条"最佳实践",而是边界划分.理由: 1. AI 生成的 SQL 是自然语言到结构化语言的翻译,语义歧义不可消除. 2. 业务系统的不变量(库存不能为负,金额精度,外键约束)最好由业务系统自己守护. 3. 审计责任链要清晰:谁批准了,谁执行了,系统验证了什么. **引擎层的职责**:提供 Dry-run 接口,写操作分级声明,审计钩子. **消费层的职责**:实现具体的 DB 驱动,SQL 验证器,人工审批流程. --- ## 三个操作维度 Agent 操作数据的场景可以归纳为三类,每类有不同的安全机制: | 维度 | 典型操作 | 引擎现有支持 | 本文补充 | |------|---------|------------|---------| | **文件操作** | FileEdit,FileWrite | 原子写入 + 备份(FileHistory),OperationLog Saga 补偿 | - | | **数据库操作** | SQL 查询,记录更新 | AuditSink 接口,ToolCapability.DryRunnable | 本文重点 | | **API 调用** | 第三方 REST,webhook | Reversible 接口,Saga 补偿 | Dry-run 验证流程 | 文件操作的安全机制已在 [architecture.md#FileHistory 文件历史系统](architecture.md#filehistory-文件历史系统) 和 [architecture.md#OperationLog 统一操作日志](architecture.md#operationlog-统一操作日志) 中详细记录,本文不再重复. --- ## AI 与 DB 关系的架构决策 ### 决策图 ``` 外部世界 │ │ Agent 永远通过这一层 ▼ 业务系统 API(WMS、OMS、ERP...) │ ↑ │ │ Dry-run 时:事务内 diff 回传 │ │ 正式写时:短事务 + CAS 乐观锁 ▼ │ 生产数据库(只读账号 + staging 隔离) │ ▼ 审计日志(异步,不阻塞主事务) ``` ### 为什么 AI 不直接写 | 方案 | 问题 | |------|------| | AI 直接执行 INSERT/UPDATE | 无业务不变量校验,无人工审批窗口,难以追责 | | AI 写 staging 表 | 可接受,但需要业务系统配合 | | AI 调用业务 API(dry-run → 正式) | **推荐**:API 层自带不变量校验,天然有两阶段窗口 | --- ## 读操作防护 即使是读,也需要防护.AI 可能因提示注入而执行恶意查询(全表扫描,高频轮询). ### 防护层次 ``` 基础设施层(硬约束,代码绕不过) └── 只读数据库账号:GRANT SELECT ON schema.* TO ai_readonly 工具层(第二道防线) └── SQL 解析器:非 SELECT 语句直接拒绝返回错误 规则:不允许 INSERT / UPDATE / DELETE / DROP / TRUNCATE / ALTER / CALL 查询层(第三道防线) └── 行数限制:LIMIT 注入(若 AI 未写 LIMIT,自动追加 LIMIT 1000) └── 查询超时:context.WithTimeout,超时后取消连接 └── 表名白名单:可配置,仅允许查询指定表 ``` ### 消费层实现要点 引擎提供 `ToolCapability` 接口声明,具体校验由消费层在工具实现中完成: ```go // 消费层示例:SQL 读工具(伪代码,消费层实现,不在引擎层) type SQLReadTool struct { db *sql.DB // 只读账号连接 maxRows int // 默认 1000 timeout time.Duration tables []string // 白名单,空=允许所有 } func (t *SQLReadTool) Execute(ctx context.Context, params map[string]any) (tools.Result, error) { query := params["query"].(string) // 1. SQL 解析:拒绝非 SELECT if !isSelectOnly(query) { return tools.ErrorResult("只允许 SELECT 查询"), nil } // 2. 行数限制注入 query = injectLimit(query, t.maxRows) // 3. 超时控制 ctx, cancel := context.WithTimeout(ctx, t.timeout) defer cancel() // 4. 执行 return executeQuery(ctx, t.db, query) } ``` --- ## 写操作分级 不是所有写操作风险相同.分级的目的是**让 AI 权限与操作风险严格匹配**. ### 风险分级表 | 级别 | 操作类型 | 典型场景 | 处理方式 | 人工介入 | |------|---------|---------|---------|---------| | L1 | 追加写 | 审计日志,状态推进(pending→processing),任务记录新增 | 直接提交,异步审计 | 不需要 | | L2 | 修改已有记录 | 更新订单状态,修改商品描述,数量调整 | 业务不变量检查后提交 | 按配置决定 | | L3 | 高风险写 | 金额变更,库存核减,关键配置修改,跨系统同步 | 必须人工审批窗口 | 必须 | | L4 | 不可逆删除 | 物理删除记录,清空分区,DDL 变更 | 默认禁止,需明确 override | 必须 | ### 分级声明方式 消费层工具通过实现 `ToolCapability` 接口声明自己的安全等级(引擎层已有此接口,详见 [architecture.md#ToolCapability 安全协议](architecture.md#toolcapability-安全协议)): ```go // 引擎层已有接口(pkg/tools/tool.go),消费层工具实现 type CapabilityProvider interface { Capabilities() ToolCapability } type ToolCapability struct { Level int // 0=无保护 1=可回滚 2=DryRun+可回滚 RiskLevel string // "l1_append" / "l2_modify" / "l3_critical" / "l4_irreversible" // ... } ``` --- ## 凭据安全:SetSecret 接口 ### 核心问题 凭据(密码,Token,私钥)**不能通过 prompt 传递**.原因: 1. Prompt 内容进入 LLM 上下文窗口,context 会被日志记录,快照序列化,压缩保留. 2. 命令行参数会出现在 `ps aux`,`/proc//cmdline`,shell 历史中,进程级可见. 3. 一旦凭据进入任何一层日志,脱敏极难做到完全彻底--漏一处就是全部泄漏. ### 设计决策 引擎提供 `engine.SetSecret(name, plaintextValue)` 接口,消费层在启动阶段注入凭据.Agent 在 prompt 中只使用凭据名称占位符引用,引擎在执行时将其替换为环境变量注入,**凭据值永不出现在命令行参数中**. ``` 消费层启动阶段 Agent Prompt 引擎执行阶段 ───────────────── ──────────────────── ──────────────────────────── engine.SetSecret( "用 ssh_pass 登录 export SSH_PASS=<实际值> "ssh_pass", 目标主机..." ssh ... (环境变量注入) "s3cr3t", ) prompt 中引用: 审计日志记录: {{ssh_pass}} [credential:ssh_pass] (不记录实际值) ``` ### 接口设计 ```go // SDK 公开接口(pkg/engine/engine.go 或 pkg/engine/secrets.go) // SetSecret 注册一个命名凭据。plaintextValue 仅存于内存,不序列化、不落盘。 // name 不区分大小写,推荐下划线格式(ssh_pass、db_password、api_token)。 func (e *Engine) SetSecret(name, plaintextValue string) // 消费层示例:Agent 启动前注入 eng := engine.New(cfg) eng.SetSecret("ssh_pass", os.Getenv("TARGET_SSH_PASSWORD")) eng.SetSecret("db_password", os.Getenv("DB_PASSWORD")) eng.SetSecret("api_token", vault.Get("my-service/api-token")) ``` ### Prompt 中的引用方式 Agent 在 prompt(system prompt 或 user message)中通过 `{{name}}` 占位符引用凭据名称: ``` 你可以使用 ssh_pass 登录目标主机。 登录命令:ssh -p 22 user@host,密码从环境变量 SSH_PASS 获取。 ``` 引擎在工具执行前将占位符替换为对应的环境变量名(不是值),工具调用时以环境变量方式注入: ```go // 引擎执行 BashTool 时: // cmd.Env = append(os.Environ(), "SSH_PASS=s3cr3t") // 注入凭据 // 命令本身:sshpass -e ssh user@host // -e 表示从环境变量读 // 命令行参数中不含密码明文 ``` ### 审计日志脱敏规则 凭据值出现在任何可观测路径(OperationLog,AuditEntry,Observer 事件,StderrObserver 输出)时,必须替换为 `[credential:name]` 标记: ```go // OperationLog 中的命令日志(BashTool 执行记录) // 原始命令:sshpass -p s3cr3t ssh user@host // 脱敏后: sshpass -p [credential:ssh_pass] ssh user@host // AuditEntry.Extra 中 extra["command"] = "[credential:ssh_pass] 凭据已注入,命令已脱敏" // Observer 事件中(StderrObserver / BufferedObserver) // 任何包含凭据值的字符串,在写出前经过 engine.Redact() 替换 ``` 脱敏由引擎内部的 `SecretStore` 统一维护,所有写出路径在输出前调用 `Redact()`,消费层无需自行处理. ### 实现优先级与顺序 **顺序不能颠倒**: 1. **先实现 SetSecret + 日志脱敏**:凭据注入 + `engine.Redact()` 覆盖所有日志写出路径.没有脱敏之前,审计日志不能上传. 2. **再实现审计日志上传**:脱敏确认完整后,才能将审计日志上传到外部 SIEM / 存储系统.若先上传后脱敏,泄漏窗口不可逆. ### 设计边界 | 项目 | 引擎层(本模块实现) | 消费层职责 | |------|-------------------|-----------| | SetSecret 接口 | 提供,内存存储,不落盘 | 从 vault/env/配置文件读取实际凭据值后调用 | | Redact() 脱敏 | 提供,覆盖所有引擎内部日志路径 | 消费层自定义日志组件调用 `engine.Redact()` | | 环境变量注入 | BashTool 执行时自动注入 | 无需额外操作 | | 凭据轮转 | 支持重复调用 SetSecret 覆盖旧值 | 消费层负责定期更新凭据 | | 进程隔离 | 子进程环境变量隔离(不继承父进程全量 env) | - | ### 反向论证 **反对意见**:环境变量注入仍然可以通过 `/proc//environ` 读取,并非完全安全. **回应**:正确,但对比 prompt 传递,环境变量注入的攻击面更小: - 需要相同 UID 或 root 权限才能读取 `/proc//environ` - 日志,上下文,快照这些更容易泄漏的路径被完全隔断 - 这是工程上的防御纵深,不是银弹--凭据安全的最终保障是权限收紧(最小权限原则),引擎只负责不主动扩大攻击面 --- ## Dry-run 模式设计 Dry-run 的核心思想:**在不持久化副作用的情况下,看清楚"如果执行会发生什么"**. ### 数据库场景的 Dry-run(事务回滚模式) ``` 业务 API 收到 dry_run=true 请求 │ ▼ BEGIN TRANSACTION │ ▼ 执行 AI 生成的 SQL(INSERT/UPDATE/DELETE) │ ▼ 捕获 diff(before/after 快照,通过 RETURNING 子句或二次查询获取) │ ▼ ROLLBACK(不持久化) │ ▼ 将 diff 序列化,返回给调用方 │ ▼ ML 验证层(事务外,无锁期间执行,时间窗口不阻塞数据库) │ ├── 通过 ──► 正式执行请求(dry_run=false) │ │ │ ▼ │ 短事务 + CAS 乐观锁(基于 version 字段) │ │ │ ├── 乐观锁通过 ──► COMMIT │ └── 乐观锁失败 ──► 重试(最多 3 次)或升级人工 │ └── 失败 ──► 返回拒绝原因,由 Agent 重新规划 ``` ### 关键设计决策 **ML 验证在事务外执行**(不是在事务内). 原因:ML 推理时间通常在 100ms ~ 2000ms,在事务内持有锁会导致: - 锁持有时间过长,影响并发写 - 数据库连接池耗尽 - 超时与锁冲突叠加 正确做法:事务仅用于读取 diff(毫秒级),拿到 diff 后立即 ROLLBACK,在事务外做 ML 验证. **乐观锁 CAS 保证执行时数据未被篡改**. Dry-run 到正式执行之间存在时间窗口,期间其他操作可能修改了目标记录.CAS 用 `version` 字段确保: ```sql UPDATE orders SET status = 'processing', version = version + 1 WHERE id = 123 AND version = 5 -- 执行时检查版本未变 ``` 若版本不匹配,说明数据在验证窗口内被修改,需要重新 Dry-run. ### 接口定义(消费层参考) ```go // 消费层工具实现 DryRunnable 接口(引擎层接口,消费层工具实现) type DryRunResult struct { SQL string // 实际执行的 SQL AffectedRows int // 影响行数 Diff []RowDiff // 每行 before/after Warnings []string // 非阻断性警告(如:影响 1000 行以上) } type RowDiff struct { Table string PK map[string]any Before map[string]any After map[string]any } ``` ### 边界条件 | 情况 | 处理方式 | |------|---------| | SQL 语法错误 | Dry-run 直接返回解析错误,不开启事务 | | 影响行数超过阈值(如 10000 行) | Dry-run 返回 Warning,强制要求人工确认 | | 外键约束违反 | Dry-run 捕获数据库错误,返回给 Agent 重新规划 | | Dry-run 成功但正式执行时乐观锁失败 | 最多重试 3 次,超过后升级 L3 需人工介入 | | 数据库连接超时 | 统一返回 `ErrDBTimeout`,Agent 按超时处理,不重试写操作 | --- ## Staging 写入模式 适用于"AI 要写,但生产系统没有 dry-run API"的场景(比如遗留系统,第三方服务). ### 流程 ``` AI 生成写操作 │ ▼ 写入 staging 表(不触碰生产表) e.g.: wave_candidates(待确认的波次方案),而非 wave(正式波次) │ ▼ 自动规则检查(脚本/触发器) ├── 库存充足? ├── 业务约束满足? └── 数量在合理范围内? │ ├── 自动通过(低风险追加写)──► 触发迁移任务 → 写入生产表 │ └── 需要人工确认(中高风险)──► 通过 permission.Handler 发起审批请求 审批通过 → 触发迁移任务 → 写入生产表 审批拒绝 → 清理 staging 记录 + 通知 Agent ``` ### 与引擎的集成点 - `permission.Handler`(`pkg/permission/permission.go`):审批请求推送给人工操作员 - `security.AuditSink`(`pkg/security/audit.go`):staging 写入和迁移执行都记录审计日志 ### Staging 表设计要点 ```sql -- staging 表比生产表多三列: CREATE TABLE wave_candidates ( -- 业务字段与生产表相同... -- staging 专有字段 agent_session_id VARCHAR(64) NOT NULL, -- 来自哪个 Agent 会话 created_at TIMESTAMP NOT NULL DEFAULT NOW(), status VARCHAR(16) NOT NULL DEFAULT 'pending', -- pending/approved/rejected/migrated reviewer VARCHAR(64), -- 审批人(自动通过时为 'auto') reviewed_at TIMESTAMP ); ``` --- ## 审计日志设计 引擎已有 `AuditSink` 接口和 `LocalAuditSink` 实现(JSONL 追加写入). 本节补充数据库写操作场景的字段规范和使用要点. ### 所有 AI 写操作必须记录的字段 ```go // 消费层填充 AuditEntry.Extra 字段(引擎已有 AuditEntry 结构) extra := map[string]string{ // 必填 "session_id": session.ID, // 来自哪个 Agent 会话 "operation_id": uuid.New(), // 本次操作唯一 ID // DB 写操作必填 "db_table": "orders", "db_pk": "id=123", "before_state": marshalJSON(before), "after_state": marshalJSON(after), // Dry-run 流程必填 "dry_run_passed": "true", "ml_validator": "wave_validator_v2", "ml_score": "0.97", // 可选 "risk_level": "l2_modify", "approver": "auto", // 或操作员 ID } ``` ### 审计日志写入时机 | 时机 | 必须记录 | |------|---------| | Dry-run 执行(无论通过与否) | ✅ | | ML 验证结果(通过/拒绝) | ✅ | | 正式写入执行前 | ✅ | | 正式写入执行后(含失败) | ✅ | | 人工审批决定 | ✅ | | 乐观锁冲突 + 重试 | ✅ | ### 异步写审计(不阻塞主事务) 审计日志通过 `AuditObserver`(EventObserver 实现)异步写入,主事务提交后发出事件,AuditObserver 在 goroutine 中处理,主路径零延迟. **权衡**:异步意味着在极端情况(进程崩溃)下可能丢失最后几条日志.对于 L3/L4 高风险操作,消费层可选择同步写审计(在主事务 COMMIT 前 flush). ### 批量回滚(按 session_id) 当一个 Agent 会话的操作需要整体撤销时(如发现决策错误),通过 `session_id` 查询该会话所有写操作的 `before_state`,按时间倒序执行补偿: ```sql SELECT * FROM audit_log WHERE extra->>'session_id' = 'sess_abc123' AND tool_name IN ('DBWrite', 'DBUpdate') ORDER BY timestamp DESC; -- 然后消费层逐条读取 before_state,执行补偿 SQL ``` --- ## 四层安全防御 对于生产数据库写操作,四层防御全部在位才允许执行: ``` ┌─────────────────────────────────────────────────────┐ │ 层 1:输出约束(SQL 白名单 + 业务规则预校验) │ │ AI 生成 SQL → 工具层解析 → 仅允许合法 SQL 语法 │ │ 业务规则:金额字段 > 0、数量字段为整数、时间格式合法等 │ └─────────────────────┬───────────────────────────────┘ │ 通过 ┌─────────────────────▼───────────────────────────────┐ │ 层 2:Dry-run + ML 验证 │ │ 事务内执行 → 捕获 diff → ROLLBACK │ │ 事务外 ML 验证 → 语义合理性校验(异常 diff 拒绝) │ └─────────────────────┬───────────────────────────────┘ │ 通过 ┌─────────────────────▼───────────────────────────────┐ │ 层 3:熔断器(连续失败自动暂停 AI 写权限) │ │ 5 次连续 ML 拒绝 → 熔断 15 分钟 │ │ 触发时通知操作员,人工 reset 才能恢复 │ └─────────────────────┬───────────────────────────────┘ │ 通过 ┌─────────────────────▼───────────────────────────────┐ │ 层 4:人工 kill switch │ │ 操作员随时可通过 permission.Handler 暂停 AI 写权限 │ │ L3/L4 操作无论其他层是否通过,均需人工最终确认 │ └─────────────────────────────────────────────────────┘ ``` ### 熔断器与引擎的集成 熔断器逻辑属于消费层(不同场景的阈值不同),通过引擎的 `permission.Handler` 实现: ```go // 消费层:DB 写工具内部的熔断检查(伪代码) func (t *DBWriteTool) Execute(ctx context.Context, params map[string]any) (tools.Result, error) { if t.circuitBreaker.IsOpen() { // 触发 permission.Handler,通知人工,返回等待 return t.permHandler.RequestPermission(ctx, "db_write_circuit_open", ...) } // ... 正常流程 } ``` --- ## WMS 波次建立参考案例 波次建立是理解数据安全设计的标准参考场景:AI 需要根据订单情况建立捡货波次(分配库位,创建捡货任务),这是典型的 L2 ~ L3 级别写操作. ### 为什么选这个场景 - 数据量大(十万级订单,百万级库存记录) - 操作有业务不变量(库存不能超分配,波次内订单不能重叠) - 操作不可即时撤销(任务一旦分配,工人可能已在路上) - 典型的"AI 决策 + 人工确认"边界 ### 状态机改造(对 WMS 改动量极小) 现有 WMS 状态机通常是: ``` 波次创建 → 库位分配 → 任务创建 → 可捡货 → 捡货中 → 完成 ``` 只需增加一个"任务已建待确认"状态: ``` 波次创建 → 库位分配 → 任务创建 → 【任务已建待确认】→ 可捡货 → 捡货中 → 完成 ▲ │ AI 验证窗口(此时 ML 可读,工人不能捡货) DB 可 ROLLBACK 到"波次创建"状态 ``` "任务已建待确认"这个状态是 AI 介入点: - 它让 WMS 不需要新增 staging 表 - 工人端看不到该状态的任务(UI 过滤) - ML 验证器可以查询所有处于该状态的任务 ### 完整执行流程 ``` 1. Agent 接收波次建立请求(订单列表 + 约束条件) 2. Agent 调用 WMS API(dry_run=true) └── WMS:BEGIN TX → 创建波次+任务 → 状态置为"任务已建待确认" → 捕获 diff → ROLLBACK 3. WMS 返回 diff(影响行数、库存分配方案、任务数量) 4. ML 验证器(事务外) ├── 检查库存分配是否合理 ├── 检查波次效率指标(行走距离、时间窗口) └── 输出通过/拒绝 + 置信度 5a. ML 通过(置信度 > 0.95) └── Agent 调用 WMS API(dry_run=false) └── WMS:短事务 + CAS → 真正创建任务 → 状态置为"任务已建待确认" └── 同步审计日志 5b. ML 通过(0.7 < 置信度 < 0.95) └── 通过 permission.Handler 发起人工确认请求 └── 操作员确认 → 同 5a └── 操作员拒绝 → Agent 重新规划 5c. ML 拒绝(置信度 < 0.7) └── 返回拒绝原因给 Agent,Agent 调整方案后重试(最多 3 次) └── 3 次失败 → 熔断 + 通知操作员 6. 操作员最终确认(任意方式,如 WMS UI / API) └── 状态变更为"可捡货",工人侧可见 ``` ### 四层防御映射 | 防御层 | WMS 场景实现 | |-------|------------| | 层 1 输出约束 | WMS API 本身的参数校验(库存不能超,时间格式等) | | 层 2 Dry-run + ML | WMS dry_run 模式 + 波次效率 ML 模型 | | 层 3 熔断器 | 连续 3 次 ML 拒绝 → 暂停该 Agent 的波次建立权限 | | 层 4 Kill switch | 操作员可随时通过 WMS 管理界面暂停 AI 波次建立 | --- ## LLM 多轮推理分支场景 ### 什么时候需要分支/影子表 这是一个容易误判的设计点,直接说结论: | 场景 | 是否需要分支表 | 原因 | |------|-------------|------| | 纯预测/分类(读数据 → 输出决策) | **不需要** | AI 只读数据,不写中间状态 | | 单轮决策 + 写入 | **不需要** | Dry-run + 一次性提交即可 | | **多轮推理:AI 需要查看自己决策应用后的中间状态** | **需要** | 见下方说明 | ### 多轮推理需要分支的场景 典型例子:AI 在建立波次时,需要多轮推理: ``` 轮 1:建立波次 A(假设执行)→ 查询剩余库存 → 发现某品类库存紧张 轮 2:基于轮 1 的结果,调整波次 B → 查询调整后的库存 → 决定是否建立波次 C 轮 3:... ``` 如果每一轮都基于真实生产数据库,轮 1 和轮 2 之间没有状态隔离: - 另一个进程的写入会干扰 AI 的推理链 - AI 无法"假设轮 1 执行后,轮 2 的数据状态是什么" **解决方案:影子表(Shadow Table)/ 会话级临时状态** 本方案 2026-04-25 由 `core/pkg/shadowdb/` 实现 (L437). 方案 C 列标记隔离 — 物理 shadow 表加 `session_id VARCHAR(64) NOT NULL` 列, 按 session_id filter 做跨 session 隔离. 原始方案 (`CREATE TEMP TABLE shadow_inventory_`) 因 pooled `*sql.DB` 会让连接级 TEMP 表在下一条 query 上蒸发, 且 driver 语义不一致 (SQLite TEMP connection-local / MySQL TEMP 连接池不保证回收) 未采纳, 改走列标记路径获得跨 driver 一致性 + 零 DDL: ```sql -- 1) 调用方预建物理 shadow 表 (一次性, schema 与生产对齐 + session_id 列) CREATE TABLE shadow_inventory ( session_id VARCHAR(64) NOT NULL, sku TEXT, qty INTEGER ); -- 2) shadowdb.Opener.Open 自动执行种子 SQL INSERT INTO shadow_inventory SELECT ?, * FROM inventory; -- ? = sessionID -- 3) AI 每轮决策带 session_id filter (由 EnforceSessionFilter 校验) UPDATE shadow_inventory SET qty = qty - 20 WHERE sku = 'SKU-001' AND session_id = ?; -- 4) 查询同样带 filter, 两个并发 session 看不到对方数据 SELECT * FROM shadow_inventory WHERE qty < 10 AND session_id = ?; -- 5) 多轮推理完成, 最终方案走 staging 审批 (pkg/staging/) → 生产 -- shadowdb.Session.Close 删除 session_id=? 的所有行 DELETE FROM shadow_inventory WHERE session_id = ?; -- 6) 崩溃错过 Close 的孤儿由 Opener.Reap(ctx, olderThan) 清理 -- (platform 层从自己的 cron 触发, core 不起 goroutine) ``` 三层防御: 1. 工具说明层: 工具 description 要求 LLM 每个 WHERE 带 `AND session_id=?` 2. 参数层: `shadowdb.EnforceSessionFilter(sql)` quote-aware 校验 filter 存在 3. 兜底层: DB 列 `session_id NOT NULL` + 审计扫描串台行 ### 何时不用分支 Neon 数据库分支方案(在 [research-agent-database-safety.md](research-agent-database-safety.md) 中被评为五星推荐)**不适合生产并发写场景**,理由: - 分支之间没有实时同步:分支建立后,生产库的新写入不会自动同步到分支 - 多 Agent 并行时,每个分支的快照时间点不同,合并时有冲突 - 适合场景:开发/测试隔离,单一任务的完全独立执行(非并发生产写) 结论:**Neon 分支只在 LLM 需要多轮推理查询中间状态,且任务完全隔离时有意义**.生产并发写场景用影子表 + 乐观锁更合适. --- ## 已推翻的旧结论 > 记录这些是为了防止后来的人走同样的弯路. ### 旧结论 1:Neon 分支对生产并发写场景有效 **旧方案**:所有 AI 写操作都在 Neon 分支上执行,验证通过后合并到主库. **推翻原因**: 1. 分支建立后,生产库的新写入不会实时同步到分支,导致 AI 看到的是过期快照. 2. 多个 Agent 并行时,各分支的起始时间点不同,合并时有 Write-Write 冲突,需要人工解决,成本不可控. 3. "合并"本身不是原子操作,合并期间的并发写仍然有风险. **正确场景**:Neon 分支适合开发/测试环境隔离,或单一任务完全独立运行(无并发生产写)的场景. ### 旧结论 2:分支在所有多轮推理场景下都有必要 **旧方案**:只要 LLM 需要多轮推理,就创建数据库分支. **推翻原因**:大多数多轮推理场景(如 ML 分类,参数调优)并不需要"写中间状态",只需要读.只有当 LLM 明确需要"写入决策 → 基于写入结果再次查询"这种模式时,才需要分支或影子表. **正确判断标准**:LLM 是否需要查询"自己假设执行后的数据库状态".只读的多轮推理不需要任何分支机制. --- ## 引擎接口清单 本模块涉及的引擎接口(均已实现,消费层可直接使用): | 接口 | 位置 | 用途 | |------|------|------| | `engine.SetSecret()` | `pkg/engine/engine.go` | 注入命名凭据,内存存储,不落盘 | | `engine.Redact(s string) string` | `pkg/engine/engine.go` | 将字符串中的凭据值替换为 `[credential:name]`(通过 Engine 公开方法访问,SecretStore 本身未导出) | | `tools.DryRunnable` | `pkg/tools/tool.go` | 工具声明支持 Dry-run | | `tools.Reversible` | `pkg/tools/tool.go` | 工具声明支持撤销 | | `tools.CapabilityProvider` | `pkg/tools/tool.go` | 工具声明风险等级 | | `security.AuditSink` | `pkg/security/audit.go` | 审计日志落地(写 DB 或 SIEM) | | `security.AuditEntry` | `pkg/security/audit.go` | 审计记录结构(Extra map 扩展) | | `permission.Handler` | `pkg/permission/permission.go` | 人工审批请求推送 | | `engine.Config.AuditSink` | `pkg/engine/config.go` | 注入自定义 AuditSink | 消费层需要自行实现的部分: - 凭据值来源(vault 读取 / 环境变量 / 配置文件),调用 `SetSecret` 注入 - 消费层自定义日志组件中调用 `engine.Redact(s)` 做脱敏(SecretStore 未导出,通过 Engine 方法访问) - SQL 解析器(工具层 isSelectOnly 校验) - ML 验证器(评估 Dry-run diff 的语义合理性) - 熔断器(连续失败计数 + 权限暂停逻辑) - 影子表管理(会话级临时表创建/清理) - staging 表迁移任务(审批通过后的生产写入) --- *本文档最后更新:2026-04-05* *作者:架构讨论整理(引擎设计会议)* *下次更新时机:消费层 DB 写工具实现后,补充真实接口签名和测试用例路径.* --- ## SDK 编排能力设计原则 **核心理念:正确的做法是最容易的做法** 不只是提供能力,而是让安全的编排模式成为最自然的路径.消费者(如 FlySafe)不应该需要自己推导出安全编排模式--SDK 的接口,事件,文档要主动引导他们. ### 三个层次 **1. CheckpointHandler 接口** 针对不可逆操作,SDK 提供显式检查点机制: ```go engine.Run(ctx, prompt, engine.WithCheckpointHandler(func(cp CheckpointEvent) bool { // cp.Hint: 人类可读的风险提示 // cp.Context: 操作上下文(server、operation 等) // cp.CanSkip: 是否允许跳过此检查点 // 返回 true = 允许继续执行;false = 拒绝(操作不会执行) return mySystem.VerifyExternally(cp.Context) }), ) ``` **2. 运行时事件提示** 即使消费者没有注册 CheckpointHandler,引擎识别出高风险操作时主动发出 Event: - Type: "checkpoint_suggested" - Hint: 人类可读的风险说明 - Context: 操作上下文 - CanSkip: 是否强制 消费者至少知道发生了什么,不是黑盒. **3. 场景化文档** 文档不只写 API reference,还提供已知高风险场景的完整编排示例: - SSH 部署公钥 + 禁用密码登录(两阶段,需外部验证) - 数据库写入 + 不可逆状态变更 - 系统级配置修改 ### 设计边界 | 引擎负责 | 消费层负责 | |---------|----------| | 识别高风险操作,发出 checkpoint 事件 | 注册 CheckpointHandler | | 提供暂停/恢复执行的接口 | 实现外部验证逻辑 | | 记录检查点结果到审计日志 | 决定是否继续 | | 文档中提供场景示例 | 根据业务场景选择适合的编排模式 | ### 为什么这是竞争壁垒 其他 SDK 提供能力,我们提供**带护栏的能力**.开发者接入后自然走向安全路径,而不是需要自己踩坑后才发现安全问题.这是产品差异,不只是技术差异.