feat(preflight): confirm + audit + tamper-proof version check (#41)
完成 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>
This commit is contained in:
@@ -152,7 +152,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,可空)。
|
- `binding_ids` 同部署入参(逗号分隔或重复 key,可空)。
|
||||||
|
|
||||||
@@ -174,7 +175,8 @@ signature = base64( ed25519_sign( device_priv, sha256(canonical) ) )
|
|||||||
],
|
],
|
||||||
"budget": { "remaining_quota":1234567, "quota_per_unit":500000, "tier_max_agents":5, "current_agents":1 },
|
"budget": { "remaining_quota":1234567, "quota_per_unit":500000, "tier_max_agents":5, "current_agents":1 },
|
||||||
"approval_policy": { "mode":"per_high_risk_op" },
|
"approval_policy": { "mode":"per_high_risk_op" },
|
||||||
"ready": false
|
"ready": false,
|
||||||
|
"version": "pfv1_3a9c…"
|
||||||
}}
|
}}
|
||||||
```
|
```
|
||||||
|
|
||||||
@@ -182,8 +184,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`。
|
- **`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`/价格**。
|
- **红线**:`resources` 只暴露 `type/provider/name/status/has_secret`(布尔),**绝不返回 `secret_ref`/`channelId`/`base_url`/价格**。
|
||||||
- **`invalid_bindings`**:请求里无效 / 非本人 / 非 active 的绑定 id(不阻断,供前端提示)。
|
- **`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`(先补齐缺失项)。
|
||||||
|
- 确认会写一条审计事件 `preflight.confirmed`(谁 / 何时 / 哪个 `version`)。
|
||||||
|
- **部署校验**:`POST /api/heicode/agents` 可带 `preflight_version`。HM 用**当前**资源/模板状态重算版本并比对——确认后资源被增删/改类型/改凭证、或模板变更 → 版本不匹配 → 部署被拒(`preflight changed…,re-run confirm`)。
|
||||||
|
- 默认**仅在带了 `preflight_version` 时校验**(向后兼容,不带照常部署);设 `HEICODE_PREFLIGHT_REQUIRED=true` 则**强制**要求先 confirm。
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,9 @@
|
|||||||
package controller
|
package controller
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"crypto/sha256"
|
||||||
|
"encoding/hex"
|
||||||
|
"sort"
|
||||||
"strconv"
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
@@ -93,7 +96,105 @@ type preflightSummary struct {
|
|||||||
HighRiskOps []preflightHighRisk `json:"high_risk_ops"`
|
HighRiskOps []preflightHighRisk `json:"high_risk_ops"`
|
||||||
Budget preflightBudget `json:"budget"`
|
Budget preflightBudget `json:"budget"`
|
||||||
ApprovalPolicy gin.H `json:"approval_policy"`
|
ApprovalPolicy gin.H `json:"approval_policy"`
|
||||||
Ready bool `json:"ready"` // 缺失项为空 + agent 配额未满
|
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),便于单测。给定模板、已解析的脱敏
|
// computePreflight 是纯函数(无 DB / 无 gin.Context),便于单测。给定模板、已解析的脱敏
|
||||||
@@ -179,30 +280,15 @@ func parsePreflightBindingIDs(c *gin.Context) []int {
|
|||||||
return ids
|
return ids
|
||||||
}
|
}
|
||||||
|
|
||||||
// HeicodePreflight: GET /api/heicode/preflight?template_id=&binding_ids=1,2,3 (#39 + #40).
|
// buildPreflightSummary loads the template + resolved (redacted) resource views +
|
||||||
func HeicodePreflight(c *gin.Context) {
|
// quota/tier/current-agent state for (userID, templateID, bindingIDs) and computes
|
||||||
userID := c.GetInt("id")
|
// the execution summary. Returns (summary, ok); ok=false means unknown template.
|
||||||
if userID <= 0 {
|
// Shared by the GET preflight, POST confirm, and the deploy-time version check.
|
||||||
agentError(c, "POLICY_REJECTED", "authentication required")
|
func buildPreflightSummary(userID int, templateID string, bindingIDs []int) (preflightSummary, bool) {
|
||||||
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
|
|
||||||
}
|
|
||||||
tpl, ok := loadAgentTemplate(templateID)
|
tpl, ok := loadAgentTemplate(templateID)
|
||||||
if !ok {
|
if !ok {
|
||||||
agentError(c, "POLICY_REJECTED", "unknown template_id")
|
return preflightSummary{}, false
|
||||||
return
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// 解析请求的绑定 → 脱敏视图;无效/非本人/非 active 的归入 invalidBindings。
|
|
||||||
bindingIDs := parsePreflightBindingIDs(c)
|
|
||||||
resources := make([]preflightResource, 0, len(bindingIDs))
|
resources := make([]preflightResource, 0, len(bindingIDs))
|
||||||
invalid := make([]int, 0)
|
invalid := make([]int, 0)
|
||||||
for _, id := range bindingIDs {
|
for _, id := range bindingIDs {
|
||||||
@@ -220,8 +306,6 @@ func HeicodePreflight(c *gin.Context) {
|
|||||||
HasSecret: strings.TrimSpace(b.SecretRef) != "",
|
HasSecret: strings.TrimSpace(b.SecretRef) != "",
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
// 用户剩余额度 + tier 上限 + 当前在跑 agent 数(与部署门禁同口径)。
|
|
||||||
var remainingQuota int64
|
var remainingQuota int64
|
||||||
if u, err := model.GetUserById(userID, false); err == nil && u != nil {
|
if u, err := model.GetUserById(userID, false); err == nil && u != nil {
|
||||||
remainingQuota = int64(u.Quota)
|
remainingQuota = int64(u.Quota)
|
||||||
@@ -231,7 +315,94 @@ func HeicodePreflight(c *gin.Context) {
|
|||||||
_ = model.DB.Model(&model.AgentDeployment{}).
|
_ = model.DB.Model(&model.AgentDeployment{}).
|
||||||
Where("user_id = ? AND template_id <> '' AND LOWER(status) <> ?", strconv.Itoa(userID), "stopped").
|
Where("user_id = ? AND template_id <> '' AND LOWER(status) <> ?", strconv.Itoa(userID), "stopped").
|
||||||
Count(¤tAgents).Error
|
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))
|
// HeicodePreflight: GET /api/heicode/preflight?template_id=&binding_ids=1,2,3 (#39 + #40).
|
||||||
common.ApiSuccess(c, summary)
|
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,
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -79,6 +79,48 @@ func TestComputePreflight_HighRiskEnum(t *testing.T) {
|
|||||||
require.True(t, ops[highRiskLargeBudget]) // 标准确认项
|
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: 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。
|
// #40 红线:resource 视图序列化后绝不含 secret_ref/channel_id/base_url/price。
|
||||||
func TestComputePreflight_NoSensitiveFieldsLeaked(t *testing.T) {
|
func TestComputePreflight_NoSensitiveFieldsLeaked(t *testing.T) {
|
||||||
res := []preflightResource{
|
res := []preflightResource{
|
||||||
|
|||||||
@@ -150,8 +150,9 @@ func HeicodeDeployAgent(c *gin.Context) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
var req struct {
|
var req struct {
|
||||||
TemplateID string `json:"template_id"`
|
TemplateID string `json:"template_id"`
|
||||||
BindingIDs []int `json:"binding_ids"`
|
BindingIDs []int `json:"binding_ids"`
|
||||||
|
PreflightVersion string `json:"preflight_version"`
|
||||||
}
|
}
|
||||||
if err := common.UnmarshalBodyReusable(c, &req); err != nil {
|
if err := common.UnmarshalBodyReusable(c, &req); err != nil {
|
||||||
agentError(c, "POLICY_REJECTED", "invalid request body")
|
agentError(c, "POLICY_REJECTED", "invalid request body")
|
||||||
@@ -170,6 +171,13 @@ func HeicodeDeployAgent(c *gin.Context) {
|
|||||||
return
|
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
|
// 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
|
// tier-aware (#8): it is the highest MaxAgents among the user's active
|
||||||
// subscription plans, falling back to env HEICODE_MAX_AGENTS_PER_USER
|
// subscription plans, falling back to env HEICODE_MAX_AGENTS_PER_USER
|
||||||
|
|||||||
@@ -535,8 +535,9 @@ func SetApiRouter(router *gin.Engine) {
|
|||||||
{
|
{
|
||||||
heicodeAgentRoute.GET("/agent-templates", controller.HeicodeListAgentTemplates)
|
heicodeAgentRoute.GET("/agent-templates", controller.HeicodeListAgentTemplates)
|
||||||
heicodeAgentRoute.GET("/available-models", controller.HeicodeAvailableModels)
|
heicodeAgentRoute.GET("/available-models", controller.HeicodeAvailableModels)
|
||||||
// Preflight / execution-summary (#39 缺失项检测 + #40 可读摘要).
|
// Preflight / execution-summary (#39 缺失项检测 + #40 可读摘要 + #41 confirm/版本).
|
||||||
heicodeAgentRoute.GET("/preflight", controller.HeicodePreflight)
|
heicodeAgentRoute.GET("/preflight", controller.HeicodePreflight)
|
||||||
|
heicodeAgentRoute.POST("/preflight/confirm", controller.HeicodePreflightConfirm)
|
||||||
heicodeAgentRoute.POST("/agents", controller.HeicodeDeployAgent)
|
heicodeAgentRoute.POST("/agents", controller.HeicodeDeployAgent)
|
||||||
heicodeAgentRoute.GET("/agents", controller.HeicodeListAgents)
|
heicodeAgentRoute.GET("/agents", controller.HeicodeListAgents)
|
||||||
heicodeAgentRoute.GET("/agents/:deployment_id", controller.HeicodeGetAgent)
|
heicodeAgentRoute.GET("/agents/:deployment_id", controller.HeicodeGetAgent)
|
||||||
|
|||||||
Reference in New Issue
Block a user