diff --git a/docs/integration/heicode-desktop-client-api.md b/docs/integration/heicode-desktop-client-api.md index d1d2ac55..4a183601 100644 --- a/docs/integration/heicode-desktop-client-api.md +++ b/docs/integration/heicode-desktop-client-api.md @@ -175,7 +175,8 @@ signature = base64( ed25519_sign( device_priv, sha256(canonical) ) ) | 方法 | 路径 | 鉴权 | 说明 | |---|---|---|---| -| GET | `/api/heicode/preflight?template_id=&binding_ids=1,2,3` | `UserOrV2DeviceAuth` | 返回缺失项 + 可读执行摘要 | +| GET | `/api/heicode/preflight?template_id=&binding_ids=1,2,3` | `UserOrV2DeviceAuth` | 返回缺失项 + 可读执行摘要 + `version` | +| POST | `/api/heicode/preflight/confirm` | `UserOrV2DeviceAuth` | 确认摘要 → 记审计 + 返回防篡改 `version`(#41) | - `binding_ids` 同部署入参(逗号分隔或重复 key,可空)。 @@ -197,7 +198,8 @@ signature = base64( ed25519_sign( device_priv, sha256(canonical) ) ) ], "budget": { "remaining_quota":1234567, "quota_per_unit":500000, "tier_max_agents":5, "current_agents":1 }, "approval_policy": { "mode":"per_high_risk_op" }, - "ready": false + "ready": false, + "version": "pfv1_3a9c…" }} ``` @@ -205,8 +207,25 @@ signature = base64( ed25519_sign( device_priv, sha256(canonical) ) ) - **`high_risk_ops`**:固定 enum —— `production_deploy` / `db_write` / `cloud_resource_delete` / `production_secret` / `large_budget`;由已绑资源类型推导,均 `requires_approval`。 - **红线**:`resources` 只暴露 `type/provider/name/status/has_secret`(布尔),**绝不返回 `secret_ref`/`channelId`/`base_url`/价格**。 - **`invalid_bindings`**:请求里无效 / 非本人 / 非 active 的绑定 id(不阻断,供前端提示)。 +- **`version`**:防篡改摘要版本(#41),由**稳定安全面**派生(template + 资源 + 高危 + 必需缺失项);**不含**易变的预算数字,故余额波动不会改版本。 -> confirm + 审计 + 防篡改版本校验(#41)将作为 `POST /api/heicode/preflight/confirm` 后续补充;当前 preflight 为只读。 +### 4.1.1 确认 + 防篡改版本(#41) + +`POST /api/heicode/preflight/confirm` body:`{ "template_id":"architect", "binding_ids":[17] }` + +```json +{ "success": true, "data": { + "version": "pfv1_3a9c…", // 把它带到部署请求 + "template_id": "architect", + "summary": { …同上执行摘要… }, + "confirmed": true +}} +``` + +- 仅当 `ready=true` 才能确认;否则返回 `POLICY_REJECTED`(先补齐缺失项)。 +- 确认会**持久化一条强一致的确认记录**(user+template+version,默认 TTL `HEICODE_PREFLIGHT_CONFIRMATION_TTL_SECONDS`=3600s),并附带写一条审计事件 `preflight.confirmed`。`version` 的派生**已纳入模板安全面**(definition/model/name)——管理员改了同一模板的 definition/model,旧确认即失效。 +- **部署校验**:`POST /api/heicode/agents` 可带 `preflight_version`。HM 校验三件事:① 用**当前**资源/模板状态重算版本 == 传入(资源增删/改类型/改凭证、模板变更 → 不匹配即拒);② 当前摘要仍 `ready`(预算/agent_slot 等易变项重查,防 confirm 后余额耗尽仍启动);③ **存在该版本的未过期确认记录**(杜绝直接拿 GET 的 `version` 绕过 confirm)。 + - 默认**仅在带了 `preflight_version` 时校验**(向后兼容,不带照常部署);设 `HEICODE_PREFLIGHT_REQUIRED=true` 则**强制**要求先 confirm。 --- diff --git a/heicode/controller/agent_preflight.go b/heicode/controller/agent_preflight.go index c3080f7e..aadecc3e 100644 --- a/heicode/controller/agent_preflight.go +++ b/heicode/controller/agent_preflight.go @@ -1,6 +1,9 @@ package controller import ( + "crypto/sha256" + "encoding/hex" + "sort" "strconv" "strings" @@ -93,7 +96,124 @@ type preflightSummary struct { HighRiskOps []preflightHighRisk `json:"high_risk_ops"` Budget preflightBudget `json:"budget"` ApprovalPolicy gin.H `json:"approval_policy"` - Ready bool `json:"ready"` // 缺失项为空 + agent 配额未满 + 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),便于单测。给定模板、已解析的脱敏 @@ -159,9 +279,16 @@ func computePreflight(tpl model.AgentTemplate, resources []preflightResource, in }, 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") @@ -179,30 +306,15 @@ func parsePreflightBindingIDs(c *gin.Context) []int { return ids } -// 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 - } +// 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 { - agentError(c, "POLICY_REJECTED", "unknown template_id") - return + return preflightSummary{}, false } - - // 解析请求的绑定 → 脱敏视图;无效/非本人/非 active 的归入 invalidBindings。 - bindingIDs := parsePreflightBindingIDs(c) resources := make([]preflightResource, 0, len(bindingIDs)) invalid := make([]int, 0) for _, id := range bindingIDs { @@ -220,8 +332,6 @@ func HeicodePreflight(c *gin.Context) { HasSecret: strings.TrimSpace(b.SecretRef) != "", }) } - - // 用户剩余额度 + tier 上限 + 当前在跑 agent 数(与部署门禁同口径)。 var remainingQuota int64 if u, err := model.GetUserById(userID, false); err == nil && u != nil { remainingQuota = int64(u.Quota) @@ -231,7 +341,115 @@ func HeicodePreflight(c *gin.Context) { _ = model.DB.Model(&model.AgentDeployment{}). Where("user_id = ? AND template_id <> '' AND LOWER(status) <> ?", strconv.Itoa(userID), "stopped"). Count(¤tAgents).Error + return computePreflight(tpl, resources, invalid, remainingQuota, common.QuotaPerUnit, maxAgents, int(currentAgents)), true +} - summary := computePreflight(tpl, resources, invalid, remainingQuota, common.QuotaPerUnit, maxAgents, int(currentAgents)) - common.ApiSuccess(c, summary) +// 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, + }) } diff --git a/heicode/controller/agent_preflight_test.go b/heicode/controller/agent_preflight_test.go index f701864d..55b5357b 100644 --- a/heicode/controller/agent_preflight_test.go +++ b/heicode/controller/agent_preflight_test.go @@ -79,6 +79,63 @@ func TestComputePreflight_HighRiskEnum(t *testing.T) { require.True(t, ops[highRiskLargeBudget]) // 标准确认项 } +// #41: 版本哈希对稳定安全面确定且稳定;不随易变预算/在跑数变化。 +func TestComputePreflightVersion_StableAndDeterministic(t *testing.T) { + res := []preflightResource{ + {BindingID: 1, Type: "git", Provider: "github", Name: "repo", Status: "active", HasSecret: true}, + {BindingID: 2, Type: "sk", Provider: "custom", Name: "sk", Status: "active", HasSecret: true}, + } + s1 := computePreflight(tpl(), res, nil, 1_000_000, 500000, 5, 1) + s2 := computePreflight(tpl(), res, nil, 7_777_777, 500000, 5, 3) // 预算/在跑数不同 + v1 := computePreflightVersion(s1) + require.Equal(t, v1, computePreflightVersion(s2), "版本不应随易变的预算/在跑数变化") + require.True(t, len(v1) > 5 && v1[:5] == "pfv1_") + + // 资源顺序不影响版本(内部排序) + resReordered := []preflightResource{res[1], res[0]} + require.Equal(t, v1, computePreflightVersion(computePreflight(tpl(), resReordered, nil, 1, 500000, 5, 0))) +} + +// #41: 资源篡改(改 has_secret / 增删资源)→ 版本翻转。 +func TestComputePreflightVersion_ChangesOnTamper(t *testing.T) { + base := computePreflight(tpl(), []preflightResource{ + {BindingID: 1, Type: "git", Provider: "github", Name: "repo", Status: "active", HasSecret: true}, + }, nil, 1_000_000, 500000, 5, 0) + v0 := computePreflightVersion(base) + + tampered := computePreflight(tpl(), []preflightResource{ + {BindingID: 1, Type: "git", Provider: "github", Name: "repo", Status: "active", HasSecret: false}, + }, nil, 1_000_000, 500000, 5, 0) + require.NotEqual(t, v0, computePreflightVersion(tampered), "改 has_secret 应翻转版本") + + added := computePreflight(tpl(), []preflightResource{ + {BindingID: 1, Type: "git", Provider: "github", Name: "repo", Status: "active", HasSecret: true}, + {BindingID: 2, Type: "database", Provider: "postgres", Name: "db", Status: "active", HasSecret: true}, + }, nil, 1_000_000, 500000, 5, 0) + require.NotEqual(t, v0, computePreflightVersion(added), "新增资源(引入 db_write 高危)应翻转版本") +} + +// #41 复审 #3:模板安全面(definition/model/name)变化 → 版本翻转(同一 template_key 被改也失效)。 +func TestComputePreflightVersion_ChangesOnTemplateEdit(t *testing.T) { + res := []preflightResource{ + {BindingID: 1, Type: "git", Provider: "github", Name: "repo", Status: "active", HasSecret: true}, + } + base := computePreflight(model.AgentTemplate{TemplateKey: "architect", NameZh: "架构顾问", Model: "opus", Definition: "v1 body"}, res, nil, 1_000_000, 500000, 5, 0) + v0 := computePreflightVersion(base) + + editedDef := computePreflight(model.AgentTemplate{TemplateKey: "architect", NameZh: "架构顾问", Model: "opus", Definition: "v2 body changed"}, res, nil, 1_000_000, 500000, 5, 0) + require.NotEqual(t, v0, computePreflightVersion(editedDef), "改 definition 应翻转版本") + + editedModel := computePreflight(model.AgentTemplate{TemplateKey: "architect", NameZh: "架构顾问", Model: "sonnet", Definition: "v1 body"}, res, nil, 1_000_000, 500000, 5, 0) + require.NotEqual(t, v0, computePreflightVersion(editedModel), "改 model 应翻转版本") +} + +// #41: normalizeBindingIDs 去重 + 去非正数 + 保序。 +func TestNormalizeBindingIDs(t *testing.T) { + require.Equal(t, []int{3, 1, 2}, normalizeBindingIDs([]int{3, 1, 3, 0, 2, -5, 1})) + require.Empty(t, normalizeBindingIDs(nil)) +} + // #40 红线:resource 视图序列化后绝不含 secret_ref/channel_id/base_url/price。 func TestComputePreflight_NoSensitiveFieldsLeaked(t *testing.T) { res := []preflightResource{ diff --git a/heicode/controller/agent_template_handlers.go b/heicode/controller/agent_template_handlers.go index ec2f4390..a851c912 100644 --- a/heicode/controller/agent_template_handlers.go +++ b/heicode/controller/agent_template_handlers.go @@ -150,8 +150,9 @@ func HeicodeDeployAgent(c *gin.Context) { } var req struct { - TemplateID string `json:"template_id"` - BindingIDs []int `json:"binding_ids"` + TemplateID string `json:"template_id"` + BindingIDs []int `json:"binding_ids"` + PreflightVersion string `json:"preflight_version"` } if err := common.UnmarshalBodyReusable(c, &req); err != nil { agentError(c, "POLICY_REJECTED", "invalid request body") @@ -170,6 +171,13 @@ func HeicodeDeployAgent(c *gin.Context) { return } + // #41: preflight 防篡改确认校验。默认仅在客户端带了 preflight_version 时校验(向后兼容); + // HEICODE_PREFLIGHT_REQUIRED=true 时强制要求。确认后资源/模板/高危面漂移或被篡改 → 版本不匹配 → 拒绝。 + if ok, msg := verifyDeployPreflight(userID, req.TemplateID, req.BindingIDs, req.PreflightVersion); !ok { + agentError(c, "POLICY_REJECTED", msg) + return + } + // Enforce the per-user deployed-agent cap (产品文档「个人5/团队8」). The cap is // tier-aware (#8): it is the highest MaxAgents among the user's active // subscription plans, falling back to env HEICODE_MAX_AGENTS_PER_USER diff --git a/heicode/model/main.go b/heicode/model/main.go index 961b61a1..0a24e1f4 100644 --- a/heicode/model/main.go +++ b/heicode/model/main.go @@ -334,6 +334,7 @@ func migrateDB() error { // restart. See model/agent_audit.go for the rationale. &AgentAuditEvent{}, &TelemetryEvent{}, + &PreflightConfirmation{}, ) if err != nil { return err diff --git a/heicode/model/preflight_confirmation.go b/heicode/model/preflight_confirmation.go new file mode 100644 index 00000000..6d36fce1 --- /dev/null +++ b/heicode/model/preflight_confirmation.go @@ -0,0 +1,70 @@ +package model + +import ( + "sort" + "strconv" + "strings" +) + +// PreflightConfirmation 持久化一次 preflight「已确认版本」记录(#41 复审 #1/#4)。 +// +// 为什么需要它:仅靠「部署时重算 hash == 传入 version」无法证明这个 version 曾被 confirm —— +// GET /api/heicode/preflight 也会返回同一个 version,客户端可绕过 confirm 与审计直接拿 GET +// version 去部署。把 confirm 落成一条**强一致、可查询**的记录,部署时校验该记录确实存在且未过期, +// 才真正满足「启动接口校验已确认版本」。审计事件(agent_audit_events)作为附加审计流。 +type PreflightConfirmation struct { + Id int `gorm:"primaryKey" json:"id"` + UserID int `gorm:"index" json:"user_id"` + TemplateID string `gorm:"type:varchar(64);index" json:"template_id"` + BindingKey string `gorm:"type:varchar(512);index" json:"binding_key"` // 归一化排序后的 binding ids,便于审计/排查 + Version string `gorm:"type:varchar(64);index" json:"version"` // 防篡改摘要版本 + CreatedAtMs int64 `gorm:"bigint;index" json:"created_at_ms"` + ExpiresAtMs int64 `gorm:"bigint;index" json:"expires_at_ms"` // 0 表示不过期 +} + +func (PreflightConfirmation) TableName() string { return "preflight_confirmations" } + +// PreflightBindingKey 把绑定 id 归一化(去重/去非正/升序)后拼成稳定 key,confirm 与 deploy +// 用同一算法,保证同一组绑定得到同一 key。 +func PreflightBindingKey(bindingIDs []int) string { + seen := map[int]bool{} + ids := make([]int, 0, len(bindingIDs)) + for _, n := range bindingIDs { + if n > 0 && !seen[n] { + seen[n] = true + ids = append(ids, n) + } + } + sort.Ints(ids) + parts := make([]string, 0, len(ids)) + for _, n := range ids { + parts = append(parts, strconv.Itoa(n)) + } + return strings.Join(parts, ",") +} + +// InsertPreflightConfirmation 持久化一条确认记录。 +func InsertPreflightConfirmation(rec *PreflightConfirmation) error { + if DB == nil || rec == nil { + return nil + } + return DB.Create(rec).Error +} + +// PreflightConfirmationExists 校验存在一条匹配的、未过期的确认记录(#41 部署侧强校验)。 +// 匹配 user + template + version(version 已编码资源+模板安全面);nowMs 用于过期判断,便于单测。 +func PreflightConfirmationExists(userID int, templateID, version string, nowMs int64) (bool, error) { + if DB == nil { + return false, nil + } + version = strings.TrimSpace(version) + if userID <= 0 || strings.TrimSpace(templateID) == "" || version == "" { + return false, nil + } + var count int64 + err := DB.Model(&PreflightConfirmation{}). + Where("user_id = ? AND template_id = ? AND version = ?", userID, templateID, version). + Where("expires_at_ms = 0 OR expires_at_ms > ?", nowMs). + Count(&count).Error + return count > 0, err +} diff --git a/heicode/model/preflight_confirmation_test.go b/heicode/model/preflight_confirmation_test.go new file mode 100644 index 00000000..55f5015f --- /dev/null +++ b/heicode/model/preflight_confirmation_test.go @@ -0,0 +1,57 @@ +package model + +import ( + "testing" + + "github.com/stretchr/testify/require" +) + +// #41 复审 #1/#4:确认记录强校验 —— 存在/版本不符/过期/跨用户。 +func TestPreflightConfirmationExists(t *testing.T) { + require.NoError(t, DB.Where("1 = 1").Delete(&PreflightConfirmation{}).Error) + const now = int64(1_700_000_000_000) + + require.NoError(t, InsertPreflightConfirmation(&PreflightConfirmation{ + UserID: 7, TemplateID: "architect", BindingKey: "1,2", Version: "pfv1_abc", + CreatedAtMs: now, ExpiresAtMs: now + 3600_000, + })) + + // 命中 + ok, err := PreflightConfirmationExists(7, "architect", "pfv1_abc", now+1000) + require.NoError(t, err) + require.True(t, ok) + + // 版本不符 → 不命中(防止拿别的 version 部署) + ok, _ = PreflightConfirmationExists(7, "architect", "pfv1_OTHER", now+1000) + require.False(t, ok) + + // 跨用户 → 不命中 + ok, _ = PreflightConfirmationExists(9, "architect", "pfv1_abc", now+1000) + require.False(t, ok) + + // 过期 → 不命中 + ok, _ = PreflightConfirmationExists(7, "architect", "pfv1_abc", now+7200_000) + require.False(t, ok) + + // 空参数 → 不命中 + ok, _ = PreflightConfirmationExists(0, "architect", "pfv1_abc", now) + require.False(t, ok) + ok, _ = PreflightConfirmationExists(7, "architect", "", now) + require.False(t, ok) +} + +// 不过期记录(ExpiresAtMs=0)恒命中。 +func TestPreflightConfirmationExists_NoExpiry(t *testing.T) { + require.NoError(t, DB.Where("1 = 1").Delete(&PreflightConfirmation{}).Error) + require.NoError(t, InsertPreflightConfirmation(&PreflightConfirmation{ + UserID: 5, TemplateID: "t", Version: "v", CreatedAtMs: 1, ExpiresAtMs: 0, + })) + ok, err := PreflightConfirmationExists(5, "t", "v", 9_999_999_999_999) + require.NoError(t, err) + require.True(t, ok) +} + +func TestPreflightBindingKey_Normalizes(t *testing.T) { + require.Equal(t, "1,2,3", PreflightBindingKey([]int{3, 1, 2, 3, 0, -1, 1})) + require.Equal(t, "", PreflightBindingKey(nil)) +} diff --git a/heicode/model/task_cas_test.go b/heicode/model/task_cas_test.go index 40103c45..8e405da6 100644 --- a/heicode/model/task_cas_test.go +++ b/heicode/model/task_cas_test.go @@ -44,6 +44,7 @@ func TestMain(m *testing.M) { &SubscriptionOrder{}, &UserSubscription{}, &TelemetryEvent{}, + &PreflightConfirmation{}, &AgentDeployment{}, &AgentCallbackEvent{}, ); err != nil { diff --git a/heicode/router/api-router.go b/heicode/router/api-router.go index fe6c9e6f..ac728179 100644 --- a/heicode/router/api-router.go +++ b/heicode/router/api-router.go @@ -538,8 +538,9 @@ func SetApiRouter(router *gin.Engine) { { heicodeAgentRoute.GET("/agent-templates", controller.HeicodeListAgentTemplates) heicodeAgentRoute.GET("/available-models", controller.HeicodeAvailableModels) - // Preflight / execution-summary (#39 缺失项检测 + #40 可读摘要). + // Preflight / execution-summary (#39 缺失项检测 + #40 可读摘要 + #41 confirm/版本). heicodeAgentRoute.GET("/preflight", controller.HeicodePreflight) + heicodeAgentRoute.POST("/preflight/confirm", controller.HeicodePreflightConfirm) heicodeAgentRoute.POST("/agents", controller.HeicodeDeployAgent) heicodeAgentRoute.GET("/agents", controller.HeicodeListAgents) heicodeAgentRoute.GET("/agents/:deployment_id", controller.HeicodeGetAgent)