Files
agentic-coding-workflow/internal/acp/permission_service.go
T
2026-06-20 19:16:44 +08:00

404 lines
12 KiB
Go

// 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
}