You've already forked agentic-coding-workflow
修改
This commit is contained in:
@@ -0,0 +1,397 @@
|
||||
// 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 中提取工具标识。多个 agent 实现的字段名不同,
|
||||
// 依次尝试 name/toolName/kind/title;都取不到则返回空(保守走人工审批)。
|
||||
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", "kind", "title"} {
|
||||
if v, ok := m[k].(string); ok && v != "" {
|
||||
return v
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// allowlistHit 判断工具是否在白名单内。含 "*" 表示全部放行;toolName 为空时不放行。
|
||||
func allowlistHit(allowlist []string, toolName string) bool {
|
||||
for _, a := range allowlist {
|
||||
if a == "*" {
|
||||
return true
|
||||
}
|
||||
if toolName != "" && 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
|
||||
}
|
||||
Reference in New Issue
Block a user