Files
heicode-mananger/heicode/controller/agent_preflight.go
T
chenchenandClaude Opus 4.8 19640b44f5 fix(preflight): address #41 review — persisted confirmation, deploy ready re-check, template-aware version
回应 Fasthei 复审(PR #51 CHANGES_REQUESTED):
1. 持久化确认记录(强一致):新增 model.PreflightConfirmation 表 + InsertPreflightConfirmation +
   PreflightConfirmationExists。confirm 时落库(默认 TTL=HEICODE_PREFLIGHT_CONFIRMATION_TTL_SECONDS
   =3600s,可设 0 不过期),写失败直接报错(非 best-effort)。部署侧要求该版本存在未过期确认记录
   → 杜绝直接拿 GET version 绕过 confirm/审计。
2. 部署重新校验 Ready:verifyDeployPreflight 增加 summary.Ready 检查 —— 预算/agent_slot 等
   易变项不进版本哈希,故部署时重查,防 confirm 后余额耗尽/槽位占满仍启动。
3. 版本哈希纳入模板安全面:computePreflightVersion 加 tplDigest(definition+model+name 摘要),
   管理员改同一 template_key 的 definition/model 后旧确认失效。补 TestComputePreflightVersion_ChangesOnTemplateEdit。
4. 审计降为附加流:强一致确认记录作为部署门禁;审计 preflight.confirmed 互补。

测试:PreflightConfirmationExists(命中/版本不符/跨用户/过期/不过期/空参)、PreflightBindingKey、
模板变更翻转版本。TestMain + 生产迁移注册 PreflightConfirmation。controller+model 全回归通过。
文档 §4.1.1 更新。

Affects: Manager only(新增 preflight_confirmations 表 + 部署门禁强化)。无计费改动。

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-10 15:28:07 +08:00

456 lines
17 KiB
Go

package controller
import (
"crypto/sha256"
"encoding/hex"
"sort"
"strconv"
"strings"
"github.com/gin-gonic/gin"
"github.com/heicode/manager/common"
"github.com/heicode/manager/model"
)
// Preflight / execution-summary (#29 EPIC, sub-issues #39 缺失项检测 + #40 可读执行摘要).
//
// Manager 是辅助控制台,不是编码入口:用户在启动 agent 前应看到一份「执行摘要」——
// 这个 agent 会用哪些资源、还缺什么、有哪些高危操作、预算上限是多少 —— 只确认摘要而非
// 面对完整参数(产品文档「准备清单 / 推荐摘要」第四、五步)。
//
// 本文件实现只读的 preflight:GET /api/heicode/preflight?template_id=&binding_ids=1,2,3
// 返回缺失项 + 可读摘要。#41(confirm + 审计 + 防篡改版本校验)在此之上单独实现。
//
// 红线(#40):resource 视图绝不暴露 secret_ref / channelId / base_url / price 等敏感字段;
// 高危操作用固定 enum,不自由文本。
// 高危操作固定 enum(#40 红线):只能取以下值。
const (
highRiskProductionDeploy = "production_deploy" // 生产部署 / 代码改动推送
highRiskDBWrite = "db_write" // 数据库写入
highRiskCloudDelete = "cloud_resource_delete" // 云资源删除
highRiskProductionSecret = "production_secret" // 生产密钥访问
highRiskLargeBudget = "large_budget" // 大额预算消耗
)
var highRiskOpLabels = map[string]string{
highRiskProductionDeploy: "生产部署 / 代码改动",
highRiskDBWrite: "数据库写入",
highRiskCloudDelete: "云资源删除",
highRiskProductionSecret: "生产密钥访问",
highRiskLargeBudget: "大额预算消耗",
}
// 准备清单要求用户连接的资源类别(#39 缺失项检测)。budget 单独判定。
var preflightRequiredKinds = []struct {
kind string // 与 ResourceBinding.ResourceType 对齐
label string
}{
{"git", "代码仓库(Git)"},
{"sk", "SK 资源包"},
{"project_document", "项目文档"},
{"cloud_account", "云账号"},
}
type preflightRole struct {
TemplateID string `json:"template_id"`
Name string `json:"name"`
Model string `json:"model"`
}
// preflightResource 是资源的**脱敏**视图:绝不含 secret_ref/channelId/base_url/price。
type preflightResource struct {
BindingID int `json:"binding_id"`
Type string `json:"type"`
Provider string `json:"provider"`
Name string `json:"name"`
Status string `json:"status"`
HasSecret bool `json:"has_secret"` // 是否已绑定凭证(布尔,不含凭证本身)
}
type preflightMissing struct {
Kind string `json:"kind"`
Reason string `json:"reason"`
}
type preflightHighRisk struct {
Op string `json:"op"` // 固定 enum
Label string `json:"label"` // 中文展示
RequiresApproval bool `json:"requires_approval"`
}
type preflightBudget struct {
RemainingQuota int64 `json:"remaining_quota"`
QuotaPerUnit float64 `json:"quota_per_unit"`
TierMaxAgents int `json:"tier_max_agents"`
CurrentAgents int `json:"current_agents"`
}
type preflightSummary struct {
TemplateID string `json:"template_id"`
AgentRole preflightRole `json:"agent_role"`
Resources []preflightResource `json:"resources"`
InvalidBindings []int `json:"invalid_bindings"` // 请求里无效/非本人/非 active 的绑定 id
Missing []preflightMissing `json:"missing"`
HighRiskOps []preflightHighRisk `json:"high_risk_ops"`
Budget preflightBudget `json:"budget"`
ApprovalPolicy gin.H `json:"approval_policy"`
Ready bool `json:"ready"` // 缺失项为空 + agent 配额未满
Version string `json:"version,omitempty"` // 防篡改摘要版本(#41);由稳定子集派生
// tplDigest 是模板**安全面**(definition + model + name)的摘要,纳入版本哈希,使管理员
// 修改同一 template_key 的 definition/model 后旧确认失效(#41 复审 #3)。不序列化。
tplDigest string
}
// confirmBindingIDs 返回摘要里有效(已解析)资源的绑定 id,用于审计记录。
func (s preflightSummary) confirmBindingIDs() []int {
ids := make([]int, 0, len(s.Resources))
for _, r := range s.Resources {
ids = append(ids, r.BindingID)
}
return ids
}
// computePreflightVersion 在摘要的**安全相关且稳定**子集上派生版本哈希(#41):
// template_id + 资源(binding_id/type/provider/name/status/has_secret)+ 高危操作 +
// 必需资源类缺失项。**刻意排除**易变的预算数字(remaining_quota 随每次调用变化)与
// budget/agent_slot 缺失项,否则版本会无意义地频繁变化导致部署总被拒。资源(增删/改类型/
// 改 secret)或高危面变化 → 哈希变化 → 部署校验拒绝(防篡改 / 防漂移)。
func computePreflightVersion(s preflightSummary) string {
parts := make([]string, 0, len(s.Resources)+len(s.HighRiskOps)+len(s.Missing)+2)
parts = append(parts, "tpl="+s.TemplateID)
parts = append(parts, "tpld="+s.tplDigest) // 模板安全面摘要(#41 复审 #3:模板变更翻转版本)
res := make([]string, 0, len(s.Resources))
for _, r := range s.Resources {
res = append(res, strconv.Itoa(r.BindingID)+":"+r.Type+":"+r.Provider+":"+r.Name+":"+r.Status+":"+boolStr(r.HasSecret))
}
sort.Strings(res)
parts = append(parts, "res=["+strings.Join(res, ",")+"]")
ops := make([]string, 0, len(s.HighRiskOps))
for _, h := range s.HighRiskOps {
ops = append(ops, h.Op)
}
sort.Strings(ops)
parts = append(parts, "risk=["+strings.Join(ops, ",")+"]")
// 仅纳入「必需资源类」缺失(git/sk/project_document/cloud_account),排除 budget/agent_slot。
miss := make([]string, 0)
for _, m := range s.Missing {
if m.Kind != "budget" && m.Kind != "agent_slot" {
miss = append(miss, m.Kind)
}
}
sort.Strings(miss)
parts = append(parts, "missing=["+strings.Join(miss, ",")+"]")
sum := sha256.Sum256([]byte(strings.Join(parts, "|")))
return "pfv1_" + hex.EncodeToString(sum[:])[:32]
}
func boolStr(b bool) string {
if b {
return "1"
}
return "0"
}
// withPreflightVersion 在摘要上填入版本哈希后返回(GET / confirm 都用)。
func withPreflightVersion(s preflightSummary) preflightSummary {
s.Version = computePreflightVersion(s)
return s
}
// verifyDeployPreflight 在部署时执行 #41 的防篡改确认校验。返回 (allowed, message):
// - HEICODE_PREFLIGHT_REQUIRED=true:必须带匹配的 preflight_version;
// - 否则:若客户端带了 preflight_version 则必须与实时状态匹配(漂移/篡改防护);未带则放行(向后兼容)。
//
// "匹配" = 用**当前**(模板、绑定、安全面)重算的版本等于传入版本。确认后任何对已绑资源/模板/
// 高危面的改动都会翻转哈希并拒绝部署。
func verifyDeployPreflight(userID int, templateID string, bindingIDs []int, providedVersion string) (bool, string) {
required := common.GetEnvOrDefaultBool("HEICODE_PREFLIGHT_REQUIRED", false)
providedVersion = strings.TrimSpace(providedVersion)
if providedVersion == "" {
if required {
return false, "preflight confirmation required: call POST /api/heicode/preflight/confirm and pass its version as preflight_version"
}
return true, "" // 向后兼容:未带 version 且非强制 → 放行
}
summary, ok := buildPreflightSummary(userID, templateID, normalizeBindingIDs(bindingIDs))
if !ok {
return false, "unknown template_id"
}
// (a) 漂移/篡改防护:当前重算版本必须与传入一致(资源/模板安全面变化即翻转,#41 复审 #3)。
if computePreflightVersion(summary) != providedVersion {
return false, "preflight changed since confirmation (resources/template drifted or tampered); re-run preflight confirm and retry"
}
// (b) 当前仍须 Ready(#41 复审 #2):预算/agent_slot 等易变项不进版本哈希,故部署时重新校验,
// 防止用 confirm 时的 ready 版本在余额耗尽/槽位占满后仍能启动。
if !summary.Ready {
return false, "preflight no longer ready (e.g. budget/agent slot); re-run preflight and resolve missing items"
}
// (c) 必须存在一条该版本的已确认记录(#41 复审 #1/#4):杜绝直接拿 GET version 绕过 confirm。
exists, err := model.PreflightConfirmationExists(userID, templateID, providedVersion, common.GetTimestamp()*1000)
if err != nil {
return false, "failed to verify preflight confirmation"
}
if !exists {
return false, "this preflight version was never confirmed (or has expired); call POST /api/heicode/preflight/confirm first"
}
return true, ""
}
// normalizeBindingIDs 去重 + 去非正数,保持顺序(用于 confirm/deploy 的 []int 入参)。
func normalizeBindingIDs(in []int) []int {
out := make([]int, 0, len(in))
seen := map[int]bool{}
for _, n := range in {
if n > 0 && !seen[n] {
seen[n] = true
out = append(out, n)
}
}
return out
}
// computePreflight 是纯函数(无 DB / 无 gin.Context),便于单测。给定模板、已解析的脱敏
// 资源视图、用户剩余额度、tier 上限与当前在跑 agent 数,产出执行摘要。
func computePreflight(tpl model.AgentTemplate, resources []preflightResource, invalidBindings []int,
remainingQuota int64, quotaPerUnit float64, maxAgents, currentAgents int) preflightSummary {
present := map[string]bool{}
for _, r := range resources {
present[r.Type] = true
}
// #39 缺失项:必需资源类别未绑定 + 预算不足。
missing := make([]preflightMissing, 0)
for _, req := range preflightRequiredKinds {
if !present[req.kind] {
missing = append(missing, preflightMissing{Kind: req.kind, Reason: "未绑定" + req.label})
}
}
budgetInsufficient := remainingQuota <= 0
if budgetInsufficient {
missing = append(missing, preflightMissing{Kind: "budget", Reason: "账户可用额度不足,请充值或开通订阅"})
}
agentSlotFull := maxAgents > 0 && currentAgents >= maxAgents
if agentSlotFull {
missing = append(missing, preflightMissing{Kind: "agent_slot", Reason: "在跑 Agent 数已达上限(" + strconv.Itoa(maxAgents) + "),请先停止/删除一个"})
}
// #40 高危操作(固定 enum):由已绑资源类型推导,均需审批。
highRisk := make([]preflightHighRisk, 0)
addRisk := func(op string) {
highRisk = append(highRisk, preflightHighRisk{Op: op, Label: highRiskOpLabels[op], RequiresApproval: true})
}
if present["git"] {
addRisk(highRiskProductionDeploy)
}
if present["database"] {
addRisk(highRiskDBWrite)
}
if present["cloud_account"] || present["cloud_resource"] {
addRisk(highRiskCloudDelete)
addRisk(highRiskProductionSecret)
}
// 预算是标准确认项:启动前用户须确认本任务的预算口径。
addRisk(highRiskLargeBudget)
return preflightSummary{
TemplateID: tpl.TemplateKey,
AgentRole: preflightRole{
TemplateID: tpl.TemplateKey,
Name: tpl.NameZh,
Model: tpl.Model,
},
Resources: resources,
InvalidBindings: invalidBindings,
Missing: missing,
HighRiskOps: highRisk,
Budget: preflightBudget{
RemainingQuota: remainingQuota,
QuotaPerUnit: quotaPerUnit,
TierMaxAgents: maxAgents,
CurrentAgents: currentAgents,
},
ApprovalPolicy: gin.H{"mode": "per_high_risk_op"},
Ready: len(missing) == 0,
tplDigest: templateSecurityDigest(tpl),
}
}
// templateSecurityDigest 取模板安全面(definition + model + name)的短摘要,纳入版本哈希。
func templateSecurityDigest(tpl model.AgentTemplate) string {
sum := sha256.Sum256([]byte(tpl.Definition + "|" + tpl.Model + "|" + tpl.NameZh))
return hex.EncodeToString(sum[:])[:16]
}
// parsePreflightBindingIDs 解析 binding_ids 查询参数(支持逗号分隔 "1,2,3" 或重复 key)。
func parsePreflightBindingIDs(c *gin.Context) []int {
raw := c.QueryArray("binding_ids")
if len(raw) == 1 && strings.Contains(raw[0], ",") {
raw = strings.Split(raw[0], ",")
}
ids := make([]int, 0, len(raw))
seen := map[int]bool{}
for _, s := range raw {
if n, err := strconv.Atoi(strings.TrimSpace(s)); err == nil && n > 0 && !seen[n] {
seen[n] = true
ids = append(ids, n)
}
}
return ids
}
// buildPreflightSummary loads the template + resolved (redacted) resource views +
// quota/tier/current-agent state for (userID, templateID, bindingIDs) and computes
// the execution summary. Returns (summary, ok); ok=false means unknown template.
// Shared by the GET preflight, POST confirm, and the deploy-time version check.
func buildPreflightSummary(userID int, templateID string, bindingIDs []int) (preflightSummary, bool) {
tpl, ok := loadAgentTemplate(templateID)
if !ok {
return preflightSummary{}, false
}
resources := make([]preflightResource, 0, len(bindingIDs))
invalid := make([]int, 0)
for _, id := range bindingIDs {
var b model.ResourceBinding
if err := model.DB.Where("id = ? AND user_id = ? AND status = ?", id, userID, "active").First(&b).Error; err != nil {
invalid = append(invalid, id)
continue
}
resources = append(resources, preflightResource{
BindingID: b.Id,
Type: b.ResourceType,
Provider: b.Provider,
Name: b.Name,
Status: b.Status,
HasSecret: strings.TrimSpace(b.SecretRef) != "",
})
}
var remainingQuota int64
if u, err := model.GetUserById(userID, false); err == nil && u != nil {
remainingQuota = int64(u.Quota)
}
maxAgents := model.GetUserMaxAgents(userID, common.GetEnvOrDefault("HEICODE_MAX_AGENTS_PER_USER", 5))
var currentAgents int64
_ = model.DB.Model(&model.AgentDeployment{}).
Where("user_id = ? AND template_id <> '' AND LOWER(status) <> ?", strconv.Itoa(userID), "stopped").
Count(&currentAgents).Error
return computePreflight(tpl, resources, invalid, remainingQuota, common.QuotaPerUnit, maxAgents, int(currentAgents)), true
}
// HeicodePreflight: GET /api/heicode/preflight?template_id=&binding_ids=1,2,3 (#39 + #40).
func HeicodePreflight(c *gin.Context) {
userID := c.GetInt("id")
if userID <= 0 {
agentError(c, "POLICY_REJECTED", "authentication required")
return
}
if model.DB == nil {
agentError(c, "DEPLOYMENT_PERSIST_FAILED", "database not initialised")
return
}
templateID := strings.TrimSpace(c.Query("template_id"))
if templateID == "" {
agentError(c, "POLICY_REJECTED", "template_id is required")
return
}
summary, ok := buildPreflightSummary(userID, templateID, parsePreflightBindingIDs(c))
if !ok {
agentError(c, "POLICY_REJECTED", "unknown template_id")
return
}
common.ApiSuccess(c, withPreflightVersion(summary))
}
// HeicodePreflightConfirm: POST /api/heicode/preflight/confirm (#41).
// Body: {template_id, binding_ids:[...]}. Recomputes the summary, derives a
// tamper-proof version hash over the security-relevant (non-volatile) content,
// and records an audit event (who / when / which version). The client passes the
// returned `version` to POST /api/heicode/agents; deploy re-derives the version
// from the live state and rejects if it changed (resource/template tampered or
// drifted since confirmation).
func HeicodePreflightConfirm(c *gin.Context) {
userID := c.GetInt("id")
if userID <= 0 {
agentError(c, "POLICY_REJECTED", "authentication required")
return
}
if model.DB == nil {
agentError(c, "DEPLOYMENT_PERSIST_FAILED", "database not initialised")
return
}
var req struct {
TemplateID string `json:"template_id"`
BindingIDs []int `json:"binding_ids"`
}
if err := common.UnmarshalBodyReusable(c, &req); err != nil {
agentError(c, "POLICY_REJECTED", "invalid request body")
return
}
req.TemplateID = strings.TrimSpace(req.TemplateID)
if req.TemplateID == "" {
agentError(c, "POLICY_REJECTED", "template_id is required")
return
}
summary, ok := buildPreflightSummary(userID, req.TemplateID, normalizeBindingIDs(req.BindingIDs))
if !ok {
agentError(c, "POLICY_REJECTED", "unknown template_id")
return
}
if !summary.Ready {
agentError(c, "POLICY_REJECTED", "preflight not ready: resolve missing items before confirming")
return
}
version := computePreflightVersion(summary)
bindingKey := model.PreflightBindingKey(req.BindingIDs)
// 强一致的确认记录(#41 复审 #1/#4):部署侧据此校验「该版本曾被 confirm」。**先持久化成功**
// 才算确认;写失败直接报错(不像审计那样 best-effort),否则部署侧会因查不到记录而拒绝。
nowMs := common.GetTimestamp() * 1000
ttlSec := common.GetEnvOrDefault("HEICODE_PREFLIGHT_CONFIRMATION_TTL_SECONDS", 3600)
expiresAtMs := int64(0)
if ttlSec > 0 {
expiresAtMs = nowMs + int64(ttlSec)*1000
}
if err := model.InsertPreflightConfirmation(&model.PreflightConfirmation{
UserID: userID,
TemplateID: req.TemplateID,
BindingKey: bindingKey,
Version: version,
CreatedAtMs: nowMs,
ExpiresAtMs: expiresAtMs,
}); err != nil {
common.SysLog("preflight confirm persist failed: " + err.Error())
agentError(c, "DEPLOYMENT_PERSIST_FAILED", "failed to persist preflight confirmation")
return
}
// 附加审计流:谁、何时、确认了哪个版本(best-effort,与上面的强一致记录互补)。
detail, _ := common.Marshal(gin.H{
"version": version,
"template_id": req.TemplateID,
"binding_key": bindingKey,
})
model.InsertAgentAuditEvent(&model.AgentAuditEvent{
Event: "preflight.confirmed",
Actor: strconv.Itoa(userID),
UserID: strconv.Itoa(userID),
Resource: "template:" + req.TemplateID,
Result: "ok",
DetailsJSON: string(detail),
})
common.ApiSuccess(c, gin.H{
"version": version,
"template_id": req.TemplateID,
"summary": withPreflightVersion(summary),
"confirmed": true,
})
}