// permission_service.go 实装 ACP 工具调用权限审批框架。 // // 代理通过 session/request_permission 发起工具调用授权请求,relay 为每个请求起 // 独立 goroutine 调用 PermissionHandler.Handle → PermissionService.Decide。Decide // 分两条路径: // - 命中 agent kind 的 tool_allowlist:自动放行(status=auto),立即返回。 // - 未命中:落库为 pending、通过 WS 推送 permission_request 事件、阻塞等待人工 // 决策(Resolve)/ 超时(默认拒绝)/ 会话终止(CancelSession)。 // // 阻塞安全:relay.handleAgentRequest 对每个 agent 请求起独立 goroutine,因此在 // Decide 内阻塞不会卡住 reader 主循环;ctx 来自该请求的 relay.Run,会话关闭时取消。 package acp import ( "context" "encoding/json" "log/slog" "strings" "sync" "time" "github.com/google/uuid" "github.com/yan1h/agent-coding-workflow/internal/acp/handlers" "github.com/yan1h/agent-coding-workflow/internal/audit" "github.com/yan1h/agent-coding-workflow/internal/infra/errs" ) // DefaultPermissionTimeout 是未配置时的人工决策超时;到期默认拒绝。 const DefaultPermissionTimeout = 5 * time.Minute // RelayLocator 让 PermissionService 按 session id 取到 *Relay 以推送 WS 事件。 // Supervisor 满足该接口(GetRelay)。用窄接口避免 permSvc 依赖整个 Supervisor。 type RelayLocator interface { GetRelay(sid uuid.UUID) *Relay } // decision 是投递给阻塞中 Decide 的人工/系统决策结果。 type decision struct { optionID string } // waiter 关联一个挂起的权限请求与其所属会话,便于会话终止时定向清理。 type waiter struct { ch chan decision sessionID uuid.UUID } // PermissionService 是进程级单例,注入到所有 relay 的 PermissionHandler。 type PermissionService struct { repo Repository audit audit.Recorder log *slog.Logger timeout time.Duration mu sync.Mutex waiters map[uuid.UUID]*waiter // requestID -> waiter locMu sync.RWMutex loc RelayLocator } // NewPermissionService 构造 PermissionService。timeout<=0 时用 DefaultPermissionTimeout。 func NewPermissionService(repo Repository, rec audit.Recorder, timeout time.Duration, log *slog.Logger) *PermissionService { if log == nil { log = slog.Default() } if timeout <= 0 { timeout = DefaultPermissionTimeout } return &PermissionService{ repo: repo, audit: rec, log: log, timeout: timeout, waiters: map[uuid.UUID]*waiter{}, } } // SetRelayLocator 注入 relay 定位器。因 Supervisor 与 PermissionService 互相依赖, // 装配时先构造二者再回填(与 workspace CloneRunner.SetScheduler 同模式)。 func (s *PermissionService) SetRelayLocator(loc RelayLocator) { s.locMu.Lock() s.loc = loc s.locMu.Unlock() } // Decide 实现 handlers.PermissionDecider。返回选中的 option id;空串表示拒绝。 func (s *PermissionService) Decide(ctx context.Context, sess handlers.SessionContext, agentReqID string, toolCall json.RawMessage, options []handlers.PermOption) string { toolName := extractToolName(toolCall) optionsJSON, _ := json.Marshal(options) // 路径一:白名单命中 → 自动放行。 if allowlistHit(sess.ToolAllowlist, toolName) { chosen := chooseAllowOption(options) _, err := s.repo.InsertPermissionRequest(ctx, &PermissionRequest{ ID: uuid.New(), SessionID: sess.SessionID, AgentRequestID: agentReqID, ToolName: toolName, ToolCall: jsonOrNull(toolCall), Options: optionsJSON, Status: PermissionAuto, ChosenOptionID: strPtrOrNil(chosen), }) if err != nil { s.log.Error("acp.permission.insert_auto", "session_id", sess.SessionID, "err", err.Error()) } s.recordDecision(ctx, sess.UserID, sess.SessionID, toolName, "auto", nil) return chosen } // 路径二:落库 pending 并阻塞等待决策。 reqID := uuid.New() row, err := s.repo.InsertPermissionRequest(ctx, &PermissionRequest{ ID: reqID, SessionID: sess.SessionID, AgentRequestID: agentReqID, ToolName: toolName, ToolCall: jsonOrNull(toolCall), Options: optionsJSON, Status: PermissionPending, }) if err != nil { // 落库失败时无法人工审批,保守默认拒绝。 s.log.Error("acp.permission.insert_pending", "session_id", sess.SessionID, "err", err.Error()) return "" } w := &waiter{ch: make(chan decision, 1), sessionID: sess.SessionID} s.mu.Lock() s.waiters[reqID] = w s.mu.Unlock() defer s.removeWaiter(reqID) s.fanout(sess.SessionID, &EventEnvelope{ Kind: "permission_request", Payload: mustJSON(permissionRequestPayload(row)), }) select { case d := <-w.ch: return d.optionID case <-time.After(s.timeout): // 超时 → CAS 标记 expired(系统决策,decided_by=NULL)。 if _, ok, _ := s.repo.DecidePermissionRequest(context.WithoutCancel(ctx), reqID, PermissionExpired, nil, nil); ok { s.fanout(sess.SessionID, resolvedEnvelope(reqID, PermissionExpired, "")) } return "" case <-ctx.Done(): // relay/进程关闭:CancelSession 已处理 DB;这里仅退出,agent 收默认拒绝。 return "" } } // Resolve 落地一次人工决策(approve/deny)。鉴权:session owner 或 admin。 func (s *PermissionService) Resolve(ctx context.Context, c Caller, reqID uuid.UUID, approve bool, optionID string) error { req, err := s.repo.GetPermissionRequestByID(ctx, reqID) if err != nil { return err } sessRow, err := s.repo.GetSessionByID(ctx, req.SessionID) if err != nil { return err } if !c.IsAdmin && sessRow.UserID != c.UserID { return errs.New(errs.CodeAcpSessionNotOwned, "无权处理该会话的权限请求") } var options []handlers.PermOption _ = json.Unmarshal(req.Options, &options) var chosen string var status PermissionStatus if approve { chosen = optionID if chosen == "" { chosen = chooseAllowOption(options) } else if !optionExists(options, chosen) { return errs.New(errs.CodeAcpPermissionInvalidOption, "无效的选项 id") } status = PermissionApproved } else { chosen = chooseRejectOption(options) status = PermissionDenied } decidedBy := c.UserID _, ok, err := s.repo.DecidePermissionRequest(ctx, reqID, status, strPtrOrNil(chosen), &decidedBy) if err != nil { return err } if !ok { // 已被超时/会话终止/重复决策抢先落地。 return errs.New(errs.CodeAcpPermissionAlreadyDecided, "该权限请求已被处理") } s.wake(reqID, decision{optionID: chosen}) s.fanout(req.SessionID, resolvedEnvelope(reqID, status, chosen)) s.recordDecision(ctx, c.UserID, req.SessionID, req.ToolName, string(status), &c.UserID) return nil } // ListPendingBySession 返回某会话的待审请求。鉴权同 Resolve。 func (s *PermissionService) ListPendingBySession(ctx context.Context, c Caller, sessionID uuid.UUID) ([]*PermissionRequest, error) { sessRow, err := s.repo.GetSessionByID(ctx, sessionID) if err != nil { return nil, err } if !c.IsAdmin && sessRow.UserID != c.UserID { return nil, errs.New(errs.CodeAcpSessionNotOwned, "无权查看该会话") } return s.repo.ListPendingPermissionRequestsBySession(ctx, sessionID) } // CancelSession 在会话终止时把其全部 pending 请求标 expired,并唤醒阻塞的 Decide // goroutine 使其立即返回(agent 收默认拒绝)。supervisor.onExit 调用。 func (s *PermissionService) CancelSession(ctx context.Context, sessionID uuid.UUID) { if _, err := s.repo.ExpirePendingPermissionRequestsBySession(ctx, sessionID); err != nil { s.log.Error("acp.permission.cancel_session", "session_id", sessionID, "err", err.Error()) } s.mu.Lock() for reqID, w := range s.waiters { if w.sessionID == sessionID { select { case w.ch <- decision{optionID: ""}: default: } delete(s.waiters, reqID) } } s.mu.Unlock() } // ExpireStale 把超过 before 仍 pending 的请求标 expired(启动 reaper / 兜底)。 func (s *PermissionService) ExpireStale(ctx context.Context, before time.Time) (int64, error) { return s.repo.ExpireStalePermissionRequests(ctx, before) } // ===== 内部辅助 ===== func (s *PermissionService) removeWaiter(reqID uuid.UUID) { s.mu.Lock() delete(s.waiters, reqID) s.mu.Unlock() } func (s *PermissionService) wake(reqID uuid.UUID, d decision) { s.mu.Lock() w, ok := s.waiters[reqID] if ok { delete(s.waiters, reqID) } s.mu.Unlock() if ok { select { case w.ch <- d: default: } } } func (s *PermissionService) fanout(sessionID uuid.UUID, env *EventEnvelope) { s.locMu.RLock() loc := s.loc s.locMu.RUnlock() if loc == nil { return } if relay := loc.GetRelay(sessionID); relay != nil { relay.FanoutControl(env) } } func (s *PermissionService) recordDecision(ctx context.Context, actor, sessionID uuid.UUID, toolName, outcome string, decidedBy *uuid.UUID) { if s.audit == nil { return } a := actor _ = s.audit.Record(context.WithoutCancel(ctx), audit.Entry{ UserID: &a, Action: "acp.permission." + outcome, TargetType: "acp_session", TargetID: sessionID.String(), Metadata: map[string]any{ "tool_name": toolName, "decided_by": decidedBy, }, }) } func permissionRequestPayload(p *PermissionRequest) map[string]any { return map[string]any{ "request_id": p.ID.String(), "session_id": p.SessionID.String(), "tool_name": p.ToolName, "tool_call": json.RawMessage(p.ToolCall), "options": json.RawMessage(p.Options), "status": string(p.Status), "created_at": p.CreatedAt, } } func resolvedEnvelope(reqID uuid.UUID, status PermissionStatus, chosen string) *EventEnvelope { return &EventEnvelope{ Kind: "permission_resolved", Payload: mustJSON(map[string]any{ "request_id": reqID.String(), "status": string(status), "chosen_option_id": chosen, }), } } // extractToolName 尝试从 toolCall JSON 中提取真实工具标识。 // // 安全约束(自动放行依据):只接受真实工具名字段(name / toolName)。绝不回退到 // ACP toolCall 的 'kind'(粗粒度枚举 read/edit/execute…)或 'title'(自由文本)—— // 二者都由不可信 agent 自行填写,agent 可把一个 execute/shell 调用标成 kind='read' // 或冒用白名单里的 title 来绕过人工审批闸门。取不到真实名时返回空串,使 allowlistHit // 一律不放行(保守走人工审批),同时保留空名默认拒绝的语义。 func extractToolName(toolCall json.RawMessage) string { if len(toolCall) == 0 { return "" } var m map[string]any if err := json.Unmarshal(toolCall, &m); err != nil { return "" } for _, k := range []string{"name", "toolName"} { if v, ok := m[k].(string); ok && v != "" { return v } } return "" } // allowlistHit 判断工具是否在白名单内。含 "*" 表示放行全部可识别的工具; // toolName 为空(无法识别的 tool call)时一律不放行,保守走人工审批。 func allowlistHit(allowlist []string, toolName string) bool { if toolName == "" { return false } for _, a := range allowlist { if a == "*" || a == toolName { return true } } return false } // chooseAllowOption 选一个“批准”类选项 id:优先包含 allow/yes 的,否则取首个。 func chooseAllowOption(options []handlers.PermOption) string { if id := matchOption(options, []string{"allow", "yes", "approve", "accept"}); id != "" { return id } if len(options) > 0 { return options[0].ID } return "" } // chooseRejectOption 选一个“拒绝”类选项 id:优先包含 reject/deny/no 的,否则空串。 func chooseRejectOption(options []handlers.PermOption) string { return matchOption(options, []string{"reject", "deny", "no"}) } func matchOption(options []handlers.PermOption, keywords []string) string { for _, opt := range options { lc := strings.ToLower(opt.ID) for _, kw := range keywords { if strings.Contains(lc, kw) { return opt.ID } } } return "" } func optionExists(options []handlers.PermOption, id string) bool { for _, opt := range options { if opt.ID == id { return true } } return false } func strPtrOrNil(s string) *string { if s == "" { return nil } return &s } func jsonOrNull(b json.RawMessage) []byte { if len(b) == 0 { return []byte("null") } return b }