0.11.4: 旗舰版第五轮完善(智能体节点任务下发/个人智能体安全策略)

- 节点任务:管理端向节点池下发(Prompt/HTTP/MCP/Skill/数字员工/自定义),
  指定节点或池路由,认领 SKIP LOCKED + 15 分钟租约,认领令牌防重放上报,
  失败 30s×次数退避重入队,达上限 failed,支持取消/重试,完成与失败站内信。
- 个人智能体安全策略:auto_approve_tools 跳过个人调用审批门;
  rate_limit_multiplier 按 (tool,user) 独立窗口放宽个人限流(全局额度不受影响)。
- 修复存量缺陷:/v1/agent/nodes/ 未挂 publicMux,节点心跳/认领端点在部署
  拓扑下不可达。
- 迁移 000046;任务全链路集成测试连真实库通过,HTTP 端到端验证
  (下发→认领→伪造令牌拒绝→上报→succeeded,列表不泄露认领令牌);
  25 包测试通过,前后端构建通过。
This commit is contained in:
LLMGuardX Dev
2026-08-13 14:30:37 +08:00
parent 87c2b04174
commit d534865b33
16 changed files with 1082 additions and 15 deletions
+122
View File
@@ -0,0 +1,122 @@
package workbench
import (
"context"
"encoding/json"
"errors"
"net/http"
"strings"
"aigateway.local/core/internal/identity"
"aigateway.local/core/internal/platform/apiresponse"
"github.com/jackc/pgx/v5"
"github.com/jackc/pgx/v5/pgxpool"
)
// AgentPolicy 是个人智能体安全策略。
type AgentPolicy struct {
AutoApproveTools bool `json:"auto_approve_tools"`
RateLimitMultiplier int `json:"rate_limit_multiplier"`
}
// AgentPolicyService 管理个人智能体安全策略。
type AgentPolicyService struct {
pool *pgxpool.Pool
}
func NewAgentPolicyService(pool *pgxpool.Pool) *AgentPolicyService {
return &AgentPolicyService{pool: pool}
}
// Get 返回用户的策略(未配置时返回默认值)。
func (s *AgentPolicyService) Get(ctx context.Context, portalUserID string) (AgentPolicy, error) {
var policy AgentPolicy
if s == nil || s.pool == nil {
return policy, errors.New("安全策略服务不可用")
}
err := s.pool.QueryRow(ctx, `SELECT auto_approve_tools,rate_limit_multiplier FROM gateway.portal_agent_policies WHERE portal_user_id=$1`, portalUserID).Scan(&policy.AutoApproveTools, &policy.RateLimitMultiplier)
if errors.Is(err, pgx.ErrNoRows) {
policy.RateLimitMultiplier = 1
return policy, nil
}
if err != nil {
return policy, err
}
return policy, nil
}
// Set 更新用户的策略。
func (s *AgentPolicyService) Set(ctx context.Context, portalUserID string, policy AgentPolicy) error {
if s == nil || s.pool == nil {
return errors.New("安全策略服务不可用")
}
if policy.RateLimitMultiplier < 1 || policy.RateLimitMultiplier > 100 {
return errors.New("限流倍数必须在 1-100 之间")
}
_, err := s.pool.Exec(ctx, `INSERT INTO gateway.portal_agent_policies(portal_user_id,auto_approve_tools,rate_limit_multiplier) VALUES($1,$2,$3)
ON CONFLICT(portal_user_id) DO UPDATE SET auto_approve_tools=$2,rate_limit_multiplier=$3,updated_at=clock_timestamp()`,
portalUserID, policy.AutoApproveTools, policy.RateLimitMultiplier)
return err
}
// AgentPolicyHTTPHandler 门户个人智能体安全策略。
type AgentPolicyHTTPHandler struct {
service *AgentPolicyService
identity *identity.Service
mux *http.ServeMux
}
func NewAgentPolicyHTTPHandler(service *AgentPolicyService, identityService *identity.Service) *AgentPolicyHTTPHandler {
h := &AgentPolicyHTTPHandler{service: service, identity: identityService, mux: http.NewServeMux()}
h.mux.HandleFunc("GET /api/v1/portal/agent-policy", h.get)
h.mux.HandleFunc("PUT /api/v1/portal/agent-policy", h.put)
return h
}
func (h *AgentPolicyHTTPHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
h.mux.ServeHTTP(w, r)
}
func (h *AgentPolicyHTTPHandler) account(w http.ResponseWriter, r *http.Request) (identity.Account, bool) {
account, err := h.identity.Authenticate(r.Context(), identity.KindPortal, r.Header.Get("Authorization"))
if err != nil {
apiresponse.Error(w, http.StatusUnauthorized, "登录状态无效或已过期")
return identity.Account{}, false
}
return account, true
}
func (h *AgentPolicyHTTPHandler) get(w http.ResponseWriter, r *http.Request) {
a, ok := h.account(w, r)
if !ok {
return
}
policy, err := h.service.Get(r.Context(), a.ID)
if err != nil {
apiresponse.Error(w, http.StatusServiceUnavailable, "安全策略查询失败")
return
}
apiresponse.OK(w, policy)
}
func (h *AgentPolicyHTTPHandler) put(w http.ResponseWriter, r *http.Request) {
a, ok := h.account(w, r)
if !ok {
return
}
var input struct {
AutoApproveTools bool `json:"auto_approve_tools"`
RateLimitMultiplier int `json:"rate_limit_multiplier"`
}
decoder := json.NewDecoder(http.MaxBytesReader(w, r.Body, 1<<20))
decoder.DisallowUnknownFields()
if decoder.Decode(&input) != nil {
apiresponse.Error(w, http.StatusBadRequest, "请求格式无效")
return
}
if err := h.service.Set(r.Context(), a.ID, AgentPolicy{AutoApproveTools: input.AutoApproveTools, RateLimitMultiplier: input.RateLimitMultiplier}); err != nil {
apiresponse.Error(w, http.StatusBadRequest, strings.TrimSpace(err.Error()))
return
}
apiresponse.OK(w, map[string]bool{"saved": true})
}
+4
View File
@@ -132,6 +132,10 @@ func inboxPlan(eventType string, payload json.RawMessage) []inboxDraft {
return []inboxDraft{{RecipientKind: "portal", Category: "approval", Title: "资源申请已处理", Body: "您的资源权限申请(" + payloadValue(payload, "resource_type") + " " + payloadValue(payload, "resource_code") + ")已被" + text, Link: "/portal/requests", UserID: payloadValue(payload, "portal_user_id")}}
case "tool_approval.requested":
return []inboxDraft{{RecipientKind: "admin", Category: "approval", Title: "工具使用待审批", Body: "工具 " + payloadValue(payload, "tool_code") + " 首次被调用,需审批后才能使用", Link: "/system/approvals", AllAdmins: true}}
case "agent_task.completed":
return []inboxDraft{{RecipientKind: "admin", Category: "task_result", Title: "节点任务已完成", Body: "节点任务(" + payloadValue(payload, "task_id") + ")执行成功", Link: "/security/agent-tasks", AllAdmins: true}}
case "agent_task.failed":
return []inboxDraft{{RecipientKind: "admin", Category: "task_result", Title: "节点任务执行失败", Body: "节点任务(" + payloadValue(payload, "task_id") + ")执行失败: " + payloadValue(payload, "error"), Link: "/security/agent-tasks", AllAdmins: true}}
}
return nil
}
+53 -11
View File
@@ -217,12 +217,19 @@ var ErrToolRateLimited = errors.New("工具调用频率超限,请稍后重试"
// enforceGovernance 在工具执行前做治理校验:审批标记 + 调用频率上限。
// 审批缺失时自动发起一次申请(每工具至多一个待审项);限流用固定窗口原子
// upsert,多实例共享同一额度。返回 (allowed, err)。
func (s *ToolService) enforceGovernance(ctx context.Context, tool Tool) (bool, error) {
// upsert,多实例共享同一额度。apiKeyID 对应的门户用户若配置了个人智能体
// 安全策略:auto_approve_tools 跳过审批门,rate_limit_multiplier 按倍数
// 放宽个人限流(个人窗口独立计数)。返回 (allowed, err)。
func (s *ToolService) enforceGovernance(ctx context.Context, tool Tool, apiKeyID string) (bool, error) {
if s == nil || s.assets == nil || s.assets.pool == nil {
return false, errors.New("工具服务不可用")
}
if tool.ApprovalRequired {
// 解析调用者的个人策略(仅门户运行时凭据可能命中)。
portalUserID, policy, err := s.personalPolicy(ctx, apiKeyID)
if err != nil {
return false, err
}
if tool.ApprovalRequired && !policy.AutoApproveTools {
var approved bool
if err := s.assets.pool.QueryRow(ctx, `SELECT EXISTS(SELECT 1 FROM gateway.tool_approval_requests WHERE tool_id=$1 AND status='approved')`, tool.ID).Scan(&approved); err != nil {
return false, err
@@ -258,22 +265,56 @@ func (s *ToolService) enforceGovernance(ctx context.Context, tool Tool) (bool, e
return false, fmt.Errorf("%w: %s(已自动发起审批)", ErrToolApprovalRequired, tool.Name)
}
}
if tool.RateLimitRPM > 0 {
limit := tool.RateLimitRPM
if portalUserID != "" && policy.RateLimitMultiplier > 1 {
limit = tool.RateLimitRPM * policy.RateLimitMultiplier
}
if limit > 0 {
userKey := portalUserID
if userKey == "" {
userKey = personalPolicySentinel
}
var count int64
err := s.assets.pool.QueryRow(ctx, `INSERT INTO gateway.tool_rate_usage(tool_id,window_start,call_count)
VALUES($1,date_trunc('minute',clock_timestamp()),1)
ON CONFLICT (tool_id,window_start) DO UPDATE SET call_count=gateway.tool_rate_usage.call_count+1
RETURNING call_count`, tool.ID).Scan(&count)
err := s.assets.pool.QueryRow(ctx, `INSERT INTO gateway.tool_rate_usage(tool_id,portal_user_id,window_start,call_count)
VALUES($1,$2,date_trunc('minute',clock_timestamp()),1)
ON CONFLICT (tool_id,portal_user_id,window_start) DO UPDATE SET call_count=gateway.tool_rate_usage.call_count+1
RETURNING call_count`, tool.ID, userKey).Scan(&count)
if err != nil {
return false, err
}
if count > int64(tool.RateLimitRPM) {
if count > int64(limit) {
return false, ErrToolRateLimited
}
}
return true, nil
}
// personalPolicySentinel 是非个人调用的限流窗口占位键(全零 UUID)。
const personalPolicySentinel = "00000000-0000-0000-0000-000000000000"
// personalPolicy 解析 API Key 对应的门户用户及其个人智能体安全策略。
// 非门户 Key 返回空 userID 与默认策略(不跳过审批、倍数 1)。
func (s *ToolService) personalPolicy(ctx context.Context, apiKeyID string) (string, AgentPolicy, error) {
if strings.TrimSpace(apiKeyID) == "" {
return "", AgentPolicy{RateLimitMultiplier: 1}, nil
}
var portalUserID *string
err := s.assets.pool.QueryRow(ctx, `SELECT portal_user_id::text FROM gateway.api_keys WHERE id=$1`, apiKeyID).Scan(&portalUserID)
if err != nil || portalUserID == nil || *portalUserID == "" {
return "", AgentPolicy{RateLimitMultiplier: 1}, nil
}
var policy AgentPolicy
err = s.assets.pool.QueryRow(ctx, `SELECT auto_approve_tools,rate_limit_multiplier FROM gateway.portal_agent_policies WHERE portal_user_id=$1`, *portalUserID).Scan(&policy.AutoApproveTools, &policy.RateLimitMultiplier)
if errors.Is(err, pgx.ErrNoRows) {
policy.RateLimitMultiplier = 1
return *portalUserID, policy, nil
}
if err != nil {
return "", AgentPolicy{}, err
}
return *portalUserID, policy, nil
}
// ListApprovalRequests 返回工具审批申请(含工具信息)。
func (s *ToolService) ListApprovalRequests(ctx context.Context, status string) ([]map[string]any, error) {
if s == nil || s.assets == nil || s.assets.pool == nil {
@@ -351,8 +392,9 @@ func (s *ToolService) Execute(ctx context.Context, tool Tool, input map[string]a
if err = validateToolInput(tool.InputSchema, input); err != nil {
return nil, err
}
// 治理校验:审批标记 + 频率上限。被拒时不算一次成功调用(但会记 tool_runs 失败)。
if allowed, governanceErr := s.enforceGovernance(ctx, tool); !allowed {
// 治理校验:审批标记 + 频率上限(个人策略可跳过审批/放宽限流)。被拒时
// 不算一次成功调用(但会记 tool_runs 失败)。
if allowed, governanceErr := s.enforceGovernance(ctx, tool, apiKeyID); !allowed {
return nil, governanceErr
}
headers, err := s.headers(tool)