完成 preflight EPIC(#29)的最后一子项 #41: - POST /api/heicode/preflight/confirm:重算摘要 → 仅 ready 时可确认 → 派生防篡改 版本哈希 → 写审计事件 preflight.confirmed(谁/何时/哪个 version)→ 返回 version。 - 防篡改版本 computePreflightVersion:在**稳定安全面**(template + 资源 binding_id/type/provider/name/status/has_secret + 高危 enum + 必需缺失项)上做 sha256,刻意排除易变预算数字/agent_slot,避免版本无意义抖动。 - 部署校验:POST /api/heicode/agents 新增可选 preflight_version。默认仅在带了它时 校验(向后兼容);HEICODE_PREFLIGHT_REQUIRED=true 时强制。确认后资源/模板/高危面 漂移或被篡改 → 版本不匹配 → 部署拒绝。 - 重构:抽出 buildPreflightSummary(GET/confirm/deploy 共用);GET 现也回 version。 测试:版本确定性/稳定性(不随预算变)、资源篡改翻转版本、normalizeBindingIDs。 go build/vet 干净,controller preflight 测试全过。文档 §4.1/§4.1.1 更新。 Affects: Manager only(新增 confirm 端点 + 部署可选校验,默认向后兼容)。 无计费/审计 schema 改动(复用既有 agent_audit_events)。 Closes #41 Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
409 lines
15 KiB
Go
409 lines
15 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);由稳定子集派生
|
|
}
|
|
|
|
// 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)+1)
|
|
parts = append(parts, "tpl="+s.TemplateID)
|
|
|
|
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, ""
|
|
}
|
|
summary, ok := buildPreflightSummary(userID, templateID, normalizeBindingIDs(bindingIDs))
|
|
if !ok {
|
|
return false, "unknown template_id"
|
|
}
|
|
if computePreflightVersion(summary) != providedVersion {
|
|
return false, "preflight changed since confirmation (resources/template drifted or tampered); re-run preflight confirm and retry"
|
|
}
|
|
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,
|
|
}
|
|
}
|
|
|
|
// 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(¤tAgents).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)
|
|
|
|
// 审计:谁、何时、确认了哪个版本(防篡改版本号 + 模板 + 绑定)。
|
|
bindingsJSON, _ := common.Marshal(summary.confirmBindingIDs())
|
|
detail, _ := common.Marshal(gin.H{
|
|
"version": version,
|
|
"template_id": req.TemplateID,
|
|
"binding_ids": string(bindingsJSON),
|
|
})
|
|
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,
|
|
})
|
|
}
|