fix(swarm): address #45 review — payload redaction, user-scoped events, no fake stop
回应 Fasthei 复审(PR #53 CHANGES_REQUESTED): 1. 事件 payload 脱敏:swarmEventView 经 sanitizeSwarmPayload —— 递归剔除 secret_ref/credentials/token/api_key/private_key/access_key/password 及 plan/payload/ permission_manifest/env 大字段,再跑 RedactText 兜底。绝不下发 azkv:// secret_ref 或 sk-/Bearer(approval.requested 等 envelope 携带的凭据引用)。加 TestSanitizeSwarmPayload_*。 2. user 作用域:model.ListSwarmCallbackEventsAfter 增加 userID 参数 + WHERE user_id, controller 传入当前用户;防 runtime_swarm_id/deployment_id 碰撞或误写导致跨用户事件泄漏。 测试补 user 隔离用例。 3. stop 不伪造成功:移除「开关打开返回 accepted:true」路径;未启用→POLICY_REJECTED, 启用也→NOT_IMPLEMENTED(未转发运行时),直到 agent_swarm#2 冻结接上真实 stop。 文档 §5.2 同步(脱敏 / user 作用域 / stop 语义)。go build/vet 干净,controller+model 全回归通过。 Affects: Manager only(只读查询脱敏 + 写端点安全语义)。 Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -315,7 +315,8 @@ signature = base64( ed25519_sign( device_priv, sha256(canonical) ) )
|
||||
```
|
||||
|
||||
- 状态机(契约 §4):`waiting_approval → running →(blocked ⇄ running)→ completed/failed/stopped`。
|
||||
- `events` 用 `id` 游标(`next_after`)增量轮询;`stop` 在契约冻结前返回明确未启用提示(不臆造未冻结写接口)。
|
||||
- `events` 用 `id` 游标(`next_after`)增量轮询;事件 `payload` **已脱敏**(递归剔除 `secret_ref`/credentials/大字段 + `RedactText` 兜底,绝不下发 `azkv://` secret_ref 或 sk-/Bearer)。事件查询按**当前用户**作用域(防跨用户泄漏)。
|
||||
- `stop` 在契约冻结前**一律不伪造成功**:未启用 → `POLICY_REJECTED`;即使 `SWARM_RUNTIME_ENABLED=true` 也返回 `NOT_IMPLEMENTED`(未真正转发运行时),直到 `agent_swarm#2` 冻结后接上真实 stop。
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -70,7 +70,58 @@ func swarmDeploymentView(dep model.AgentDeployment) gin.H {
|
||||
}
|
||||
}
|
||||
|
||||
// swarmEventView maps a persisted callback event to the client view.
|
||||
// swarmSensitivePayloadKeys 是事件 payload 中**绝不下发**给客户端的键(凭据/大字段)。
|
||||
// 递归剔除(#45 复审 #1:回调 envelope 可能含 secret_ref,如 approval.requested)。
|
||||
var swarmSensitivePayloadKeys = map[string]bool{
|
||||
"secret_ref": true, "secretref": true, "credentials": true, "credential": true,
|
||||
"secret": true, "token": true, "access_token": true, "refresh_token": true,
|
||||
"api_key": true, "apikey": true, "private_key": true, "access_key": true,
|
||||
"password": true, "passwd": true,
|
||||
// 大字段/内部结构,避免顺带泄漏
|
||||
"plan": true, "payload": true, "permission_manifest": true, "env": true,
|
||||
}
|
||||
|
||||
// stripSensitiveKeys 递归删除敏感键(键名小写匹配 swarmSensitivePayloadKeys)。
|
||||
func stripSensitiveKeys(v any) any {
|
||||
switch t := v.(type) {
|
||||
case map[string]any:
|
||||
out := make(map[string]any, len(t))
|
||||
for k, val := range t {
|
||||
if swarmSensitivePayloadKeys[strings.ToLower(strings.TrimSpace(k))] {
|
||||
continue
|
||||
}
|
||||
out[k] = stripSensitiveKeys(val)
|
||||
}
|
||||
return out
|
||||
case []any:
|
||||
out := make([]any, 0, len(t))
|
||||
for _, item := range t {
|
||||
out = append(out, stripSensitiveKeys(item))
|
||||
}
|
||||
return out
|
||||
default:
|
||||
return v
|
||||
}
|
||||
}
|
||||
|
||||
// sanitizeSwarmPayload 递归剔除敏感/大字段键,再对序列化结果跑一次 RedactText 兜底
|
||||
// (剥离 sk-/Bearer/URL token/JSON 密钥字段)。
|
||||
func sanitizeSwarmPayload(raw string) map[string]any {
|
||||
m := unmarshalResourceJSON(raw)
|
||||
if m == nil {
|
||||
return nil
|
||||
}
|
||||
cleaned, _ := stripSensitiveKeys(m).(map[string]any)
|
||||
if b, err := common.Marshal(cleaned); err == nil {
|
||||
var out map[string]any
|
||||
if err := common.UnmarshalJsonStr(model.RedactText(string(b)), &out); err == nil {
|
||||
return out
|
||||
}
|
||||
}
|
||||
return cleaned
|
||||
}
|
||||
|
||||
// swarmEventView maps a persisted callback event to the client view (payload 脱敏)。
|
||||
func swarmEventView(e model.AgentCallbackEvent) gin.H {
|
||||
return gin.H{
|
||||
"id": e.Id, // 作为下一次 ?after= 的游标
|
||||
@@ -81,7 +132,7 @@ func swarmEventView(e model.AgentCallbackEvent) gin.H {
|
||||
"result": e.Result,
|
||||
"occurred_at": e.OccurredAt,
|
||||
"created_at_ms": e.CreatedAtMs,
|
||||
"payload": unmarshalResourceJSON(e.PayloadJSON),
|
||||
"payload": sanitizeSwarmPayload(e.PayloadJSON),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -129,7 +180,7 @@ func HeicodeListSwarmEvents(c *gin.Context) {
|
||||
}
|
||||
after, _ := strconv.Atoi(strings.TrimSpace(c.Query("after")))
|
||||
limit, _ := strconv.Atoi(strings.TrimSpace(c.Query("limit")))
|
||||
events, err := model.ListSwarmCallbackEventsAfter(dep.DeploymentID, dep.RuntimeSwarmID, after, limit)
|
||||
events, err := model.ListSwarmCallbackEventsAfter(strconv.Itoa(c.GetInt("id")), dep.DeploymentID, dep.RuntimeSwarmID, after, limit)
|
||||
if err != nil {
|
||||
agentError(c, "DEPLOYMENT_CONFLICT", "failed to list swarm events")
|
||||
return
|
||||
@@ -152,7 +203,7 @@ func HeicodeListSwarmArtifacts(c *gin.Context) {
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
events, err := model.ListSwarmCallbackEventsAfter(dep.DeploymentID, dep.RuntimeSwarmID, 0, 1000)
|
||||
events, err := model.ListSwarmCallbackEventsAfter(strconv.Itoa(c.GetInt("id")), dep.DeploymentID, dep.RuntimeSwarmID, 0, 1000)
|
||||
if err != nil {
|
||||
agentError(c, "DEPLOYMENT_CONFLICT", "failed to list swarm artifacts")
|
||||
return
|
||||
@@ -176,16 +227,15 @@ func HeicodeStopSwarm(c *gin.Context) {
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
// 运行时 stop 的真实接入随 agent_swarm#2 契约冻结落地。**在此之前一律不伪造 accepted**
|
||||
// (#45 复审 #2:开关打开也不能返回 accepted:true 误导客户端/审计)。无论开关如何,均返回
|
||||
// 明确的「未实现/待契约」语义,直到真正接上运行时 stop。
|
||||
_ = dep
|
||||
if !common.GetEnvOrDefaultBool("SWARM_RUNTIME_ENABLED", false) {
|
||||
agentError(c, "POLICY_REJECTED",
|
||||
"swarm stop is not enabled yet: pending agent_swarm#2 runtime-contract freeze and SWARM_RUNTIME_ENABLED=true")
|
||||
"swarm stop not enabled: set SWARM_RUNTIME_ENABLED=true after agent_swarm#2 runtime-contract freeze")
|
||||
return
|
||||
}
|
||||
// 契约冻结后在此调用 Swarm 运行时 stop;当前仅在本地记录意图,避免对未冻结写接口下注。
|
||||
common.ApiSuccess(c, gin.H{
|
||||
"deployment_id": dep.DeploymentID,
|
||||
"swarm_id": dep.RuntimeSwarmID,
|
||||
"accepted": true,
|
||||
"note": "runtime stop wiring lands with agent_swarm#2 contract freeze",
|
||||
})
|
||||
agentError(c, "NOT_IMPLEMENTED",
|
||||
"swarm stop runtime wiring is pending agent_swarm#2 contract freeze; not forwarded to runtime")
|
||||
}
|
||||
|
||||
@@ -0,0 +1,46 @@
|
||||
package controller
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
// #45 复审 #1:事件 payload 必须脱敏 —— 递归剔除 secret_ref / credentials / 大字段,
|
||||
// 并对结果再跑 RedactText 兜底,绝不把 azkv:// secret_ref 或 sk-/Bearer 下发给客户端。
|
||||
func TestSanitizeSwarmPayload_StripsSecrets(t *testing.T) {
|
||||
raw := `{
|
||||
"task_id":"t1",
|
||||
"approval":{"secret_ref":"azkv://heicode-kv.vault.azure.net/secrets/git-pat","note":"deploy"},
|
||||
"credentials":{"access_key":"AKIA123","secret_access_key":"xxx"},
|
||||
"headers":{"authorization":"Bearer aZ09tokenVALUE"},
|
||||
"api_key":"sk-abcDEF1234567890",
|
||||
"stack":["plain frame","key=sk-leak0987654321ABCD"],
|
||||
"ok":true
|
||||
}`
|
||||
out := sanitizeSwarmPayload(raw)
|
||||
b, err := json.Marshal(out)
|
||||
require.NoError(t, err)
|
||||
s := string(b)
|
||||
|
||||
// 敏感键被递归剔除
|
||||
require.NotContains(t, s, "secret_ref")
|
||||
require.NotContains(t, s, "azkv://")
|
||||
require.NotContains(t, s, "git-pat")
|
||||
require.NotContains(t, s, "credentials")
|
||||
require.NotContains(t, s, "AKIA123")
|
||||
require.NotContains(t, s, "api_key")
|
||||
// RedactText 兜底:残留在普通字段里的 sk-/Bearer 也被打码
|
||||
require.NotContains(t, s, "sk-leak0987654321ABCD")
|
||||
require.NotContains(t, s, "aZ09tokenVALUE")
|
||||
// 非敏感内容保留
|
||||
require.Contains(t, s, "t1")
|
||||
require.Contains(t, s, "ok")
|
||||
}
|
||||
|
||||
func TestSanitizeSwarmPayload_EmptyAndPlain(t *testing.T) {
|
||||
require.Empty(t, sanitizeSwarmPayload(""))
|
||||
out := sanitizeSwarmPayload(`{"route":"chat","n":3}`)
|
||||
require.Equal(t, "chat", out["route"])
|
||||
}
|
||||
@@ -1,6 +1,9 @@
|
||||
package model
|
||||
|
||||
import "errors"
|
||||
import (
|
||||
"errors"
|
||||
"strings"
|
||||
)
|
||||
|
||||
type AgentCallbackEvent struct {
|
||||
Id int `gorm:"primaryKey" json:"id"`
|
||||
@@ -60,7 +63,7 @@ func InsertAgentCallbackEvent(row *AgentCallbackEvent) (bool, error) {
|
||||
// incremental polling (#45 events?after). Ordered oldest-first so the client can
|
||||
// append; the caller uses the last returned Id as the next `after`. Reading from
|
||||
// HM-persisted callback rows means this needs no live Swarm call.
|
||||
func ListSwarmCallbackEventsAfter(deploymentID, swarmID string, afterID, limit int) ([]AgentCallbackEvent, error) {
|
||||
func ListSwarmCallbackEventsAfter(userID, deploymentID, swarmID string, afterID, limit int) ([]AgentCallbackEvent, error) {
|
||||
if DB == nil {
|
||||
return nil, nil
|
||||
}
|
||||
@@ -75,6 +78,10 @@ func ListSwarmCallbackEventsAfter(deploymentID, swarmID string, afterID, limit i
|
||||
default:
|
||||
return nil, nil
|
||||
}
|
||||
// 防跨用户泄漏:即使 runtime_swarm_id/deployment_id 碰撞或误写,也按 user_id 收口(#45 复审 #3)。
|
||||
if strings.TrimSpace(userID) != "" {
|
||||
q = q.Where("user_id = ?", userID)
|
||||
}
|
||||
if afterID > 0 {
|
||||
q = q.Where("id > ?", afterID)
|
||||
}
|
||||
|
||||
@@ -6,40 +6,45 @@ import (
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
// #45: events?after 游标 —— 按 deployment_id/swarm_id 过滤,id>after 增量返回,oldest-first。
|
||||
// #45: events?after 游标 + user 作用域 —— 按 deployment_id/swarm_id 过滤,id>after 增量,oldest-first;
|
||||
// 传入 userID 时按 user_id 收口(防跨用户泄漏,复审 #3)。
|
||||
func TestListSwarmCallbackEventsAfter(t *testing.T) {
|
||||
require.NoError(t, LOG_DB.Where("1 = 1").Delete(&AgentCallbackEvent{}).Error)
|
||||
mk := func(eventID, dep, swarm, etype string) {
|
||||
mk := func(eventID, uid, dep, swarm, etype string) {
|
||||
_, err := InsertAgentCallbackEvent(&AgentCallbackEvent{
|
||||
EventID: eventID, DeploymentID: dep, SwarmID: swarm, EventType: etype,
|
||||
EventID: eventID, UserID: uid, DeploymentID: dep, SwarmID: swarm, EventType: etype,
|
||||
})
|
||||
require.NoError(t, err)
|
||||
}
|
||||
mk("e1", "dep_A", "swarm_A", "deployment.status_changed")
|
||||
mk("e2", "dep_A", "swarm_A", "task.created")
|
||||
mk("e3", "dep_A", "swarm_A", "artifact.produced")
|
||||
mk("e4", "dep_B", "swarm_B", "task.created") // 另一个 run,应被排除
|
||||
mk("e1", "7", "dep_A", "swarm_A", "deployment.status_changed")
|
||||
mk("e2", "7", "dep_A", "swarm_A", "task.created")
|
||||
mk("e3", "7", "dep_A", "swarm_A", "artifact.produced")
|
||||
mk("e4", "7", "dep_B", "swarm_B", "task.created") // 另一个 run
|
||||
mk("e5", "9", "dep_A", "swarm_A", "task.created") // 同 dep/swarm 但别的用户 → 不应泄漏给 user 7
|
||||
|
||||
// 全量(after=0):dep_A 的 3 条,oldest-first
|
||||
all, err := ListSwarmCallbackEventsAfter("dep_A", "swarm_A", 0, 100)
|
||||
// user 7 + dep_A:3 条(e5 属 user 9,被排除),oldest-first
|
||||
all, err := ListSwarmCallbackEventsAfter("7", "dep_A", "swarm_A", 0, 100)
|
||||
require.NoError(t, err)
|
||||
require.Len(t, all, 3)
|
||||
require.Equal(t, "e1", all[0].EventID)
|
||||
for _, e := range all {
|
||||
require.NotEqual(t, "e5", e.EventID, "不得返回别的用户的事件")
|
||||
}
|
||||
|
||||
// 游标:after = 第一条 id → 只返回其后的 2 条
|
||||
after := all[0].Id
|
||||
rest, err := ListSwarmCallbackEventsAfter("dep_A", "swarm_A", after, 100)
|
||||
// 游标:after = 第一条 id → 其后 2 条
|
||||
rest, err := ListSwarmCallbackEventsAfter("7", "dep_A", "swarm_A", all[0].Id, 100)
|
||||
require.NoError(t, err)
|
||||
require.Len(t, rest, 2)
|
||||
require.Equal(t, "e2", rest[0].EventID)
|
||||
|
||||
// 仅按 swarm_id 也能查到
|
||||
bySwarm, err := ListSwarmCallbackEventsAfter("", "swarm_A", 0, 100)
|
||||
// user 9 只看到自己的 e5
|
||||
u9, err := ListSwarmCallbackEventsAfter("9", "dep_A", "swarm_A", 0, 100)
|
||||
require.NoError(t, err)
|
||||
require.Len(t, bySwarm, 3)
|
||||
require.Len(t, u9, 1)
|
||||
require.Equal(t, "e5", u9[0].EventID)
|
||||
|
||||
// 空标识 → 空
|
||||
none, err := ListSwarmCallbackEventsAfter("", "", 0, 100)
|
||||
none, err := ListSwarmCallbackEventsAfter("7", "", "", 0, 100)
|
||||
require.NoError(t, err)
|
||||
require.Empty(t, none)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user