// Package permission 定义权限引擎的接口和实现. // // 权限系统的核心实现. // src/hooks/toolPermission/ 的功能. // // 原项目的问题: // - 权限检查逻辑分散在工具内部(tool.checkPermissions)和外部(hasPermissionsToUseTool)两层 // - 规则匹配嵌套极深,compound 命令分解逻辑和主流程混在一起 // - 权限 UI(React 组件)和权限逻辑强耦合 // // Go 版本的设计: // - 权限引擎是纯逻辑,不依赖任何 UI // - Handler 接口由消费层实现(CLI 弹对话框,SDK 返回 JSON,HTTP 用 WebSocket) // - 规则匹配是独立函数,可单元测试 package permission import ( "context" "encoding/json" "sync/atomic" "time" ) // Mode 是权限模式枚举. // 对应原项目中的 PermissionMode 类型. type Mode string const ( ModeDefault Mode = "default" // 所有操作询问用户 ModeAcceptEdits Mode = "accept_edits" // 自动接受文件编辑 ModeBypass Mode = "bypass" // 绕过所有权限检查 ModePlan Mode = "plan" // 计划模式(暂停执行) ) // Decision 是权限决策结果. type Decision string const ( DecisionAllow Decision = "allow" // 允许执行 DecisionDeny Decision = "deny" // 拒绝执行 DecisionAsk Decision = "ask" // 需要询问用户 ) // Request 是权限检查请求. type Request struct { ToolName string // 工具名称 ToolID string // 工具调用 ID Input map[string]any // 工具输入参数 Message string // 人类可读描述 } // Response 是权限决策响应. type Response struct { Decision Decision // 决策结果 Reason string // 决策原因(用于日志和调试) RiskLevel RiskLevel // 操作风险等级(Ask 决策时填充) SuggestedRules []SuggestedRule // 建议的永久规则(Ask 决策时填充) // UpdatedInput, when non-nil on DecisionAllow, replaces the caller-supplied // tool input. Lets consumer-layer permission handlers sanitize/transform // tool arguments (e.g. rewrite Bash commands, restrict file paths, redact // secrets) before execution. Currently consumed by SubAgent.runLoop in the // Team permission-bubble path; engine main-thread tool exec ignores it (a // separate wiring item). // // UpdatedInput 非 nil 且 Decision=Allow 时, 替换调用方传入的 tool input. // 让消费层 permission handler 在执行前 sanitize/transform 工具参数 (改写 Bash // 命令, 限制文件路径, 脱敏 secret 等). 目前只有 SubAgent.runLoop 在 Team 权限 // 冒泡路径消费; engine 主线程工具执行忽略 (独立 wire 项). UpdatedInput map[string]any } // Handler 是权限处理器接口. // 消费层实现此接口来处理权限请求. // // CLI 消费层:弹出终端对话框,等待用户输入 // SDK 消费层:通过 control_request JSON 协议转发 // HTTP 消费层:通过 WebSocket 推送到前端 // 测试:直接返回 Allow // // Shape: synchronous callback. Engine passes *Request and blocks for // *Response (Decision = Allow / Deny / Ask + optional UpdatedInput rewrite). // // 形态: 同步回调. 引擎传 *Request 阻塞等 *Response (Decision = Allow / Deny // / Ask + 可选 UpdatedInput 改写). type Handler func(ctx context.Context, req *Request) (*Response, error) // Rule 是一条权限规则. // 对应原项目中 PermissionRule 类型. type Rule struct { Source RuleSource // 规则来源 Behavior Decision // allow / deny / ask ToolName string // 工具名称(支持通配符) Content string // 规则内容(路径,前缀,域名等) } // RuleSource 是规则的来源. // 决定优先级:后面的覆盖前面的. type RuleSource string const ( SourceUser RuleSource = "user" // ~/.flyto/settings.json SourceProject RuleSource = "project" // .flyto/settings.json SourceLocal RuleSource = "local" // .flyto/settings.local.json SourceFlag RuleSource = "flag" // --settings CLI 参数 SourcePolicy RuleSource = "policy" // 企业管理设置 SourceCLI RuleSource = "cli" // 命令行参数 SourceSession RuleSource = "session" // 会话临时规则 ) // Checker 是权限引擎接口. // // 升华改进(ELEVATED): 原名 Engine 与 pkg/flyto.Engine(公共顶层接口)同名-- // 在同时导入两个包的文件(如 engine.go)里,permission.Engine vs flyto.Engine 极易混淆. // 改为 Checker 更准确地描述职责(核心方法是 Check),同时消除命名冲突. // 替代方案:<保留 Engine 名> - 否决:同名不同语义是常见 bug 来源, // 代码审查中肉眼难以区分是哪个包的 Engine. type Checker interface { // Check 检查是否有权限执行工具. // 根据规则和模式返回决策,如果是 Ask 则调用 Handler. Check(ctx context.Context, req *Request) (*Response, error) // Mode 返回当前权限模式. Mode() Mode // SetMode 设置权限模式. SetMode(mode Mode) // AddRule 添加权限规则. AddRule(rule Rule) } // DefaultPermissionTimeout 是 Handler 调用的默认超时时间. // // 精妙之处(CLEVER): 5 分钟是"用户能够在终端做出响应"的经验上界-- // 用户在 CLI 看到权限请求后,通常秒级响应;极少数场景(开会,步骤复杂)也不超过 5 分钟. // 超过 5 分钟的 Handler 阻塞几乎必然是 bug(deadlock,网络挂死),而不是正常等待. // 替代方案:不超时(早期设计)--Handler 永久挂死时 runLoop 无法退出,进程只能被 kill. const DefaultPermissionTimeout = 5 * time.Minute // engine 是 Engine 接口的默认实现. type engine struct { // 精妙之处(CLEVER): mode 用 atomic.Value 存储而非裸 Mode 字段-- // plan.go 的 runLoop goroutine 调用 SetMode,SubAgent/Hook executor 并发读 Mode(), // 不加同步保护会触发 go test -race 数据竞争. // atomic.Value 的 Load/Store 是无锁原子操作,比 sync.RWMutex 更轻量. // 替代方案:sync.RWMutex 保护 mode 字段(更通用,但 Mode 是简单字符串,atomic 够用). // 原方案(已替换):mode Mode(裸字段,无同步保护). mode atomic.Value // stores Mode(字符串类型) handler Handler rules []Rule learner *LearningTracker // 权限学习追踪器 denial *DenialTracker // 权限拒绝追踪器 // 升华改进(ELEVATED): 规则应用管道--将重复决策升级为 session 规则, // 而不是缓存单条决策结果.规则是更安全,更高层次的抽象. // 替代方案:决策结果缓存--更简单但有安全风险. ruleApplier *RuleApplier // 精妙之处(CLEVER): tool_use_id 去重--防止 SDK 重传导致重复处理. dedup *PermissionDedup // 决策计数器,每 N 次触发一次建议检查 decisionCount int // handlerTimeout 是 Handler 调用的超时时间. // 零值表示使用 DefaultPermissionTimeout. // // 升华改进(ELEVATED): 超时从引擎外部注入,而非硬编码-- // SaaS 多租户场景下,自动审批模式超时可以设为 1s(快速返回), // 人工审批模式可以设为 30min(等待企业审批流程). // 替代方案:永远使用 DefaultPermissionTimeout(无法按场景定制). handlerTimeout time.Duration } // suggestCheckInterval 每隔多少次用户决策后检查一次建议. // 不需要每次都检查--学习追踪器的阈值一般是 3 次, // 每 5 次决策检查一次足够及时. const suggestCheckInterval = 5 // NewEngine 创建权限引擎(使用默认 Handler 超时 DefaultPermissionTimeout). func NewEngine(mode Mode, handler Handler) Checker { return NewEngineWithTimeout(mode, handler, 0) } // NewEngineWithTimeout 创建权限引擎,支持自定义 Handler 超时. // // timeout 为 0 时使用 DefaultPermissionTimeout. // 消费层可在需要定制超时的场景(自动化审批,企业审批流程)使用此函数. func NewEngineWithTimeout(mode Mode, handler Handler, timeout time.Duration) Checker { if mode == "" { mode = ModeDefault } if timeout <= 0 { timeout = DefaultPermissionTimeout } e := &engine{ handler: handler, handlerTimeout: timeout, rules: make([]Rule, 0), learner: NewLearningTracker(0), // 使用默认阈值 denial: NewDenialTracker(0, 0), // 使用默认阈值 dedup: NewPermissionDedup(0), // 使用默认容量 } e.mode.Store(mode) // ruleApplier 需要引擎自身的引用,在创建后初始化 e.ruleApplier = NewRuleApplier(e, e.learner, 0) return e } // Learner 返回权限学习追踪器. // 消费层可以用它查询建议的永久规则. func (e *engine) Learner() *LearningTracker { return e.learner } // Denial 返回权限拒绝追踪器. // 消费层可以用它查询是否应该向 Agent 发出警告或建议停止. func (e *engine) Denial() *DenialTracker { return e.denial } // RuleApplier 返回规则应用管道. // 消费层可以用它手动应用规则或查询 session 规则状态. func (e *engine) RuleApplier() *RuleApplier { return e.ruleApplier } // Dedup 返回 tool_use_id 去重器. // 消费层可以用它检查和标记已处理的权限请求. func (e *engine) Dedup() *PermissionDedup { return e.dedup } // Check 执行权限检查. // // 整合 CheckToolPermission 的完整规则匹配(前缀,路径 glob,域名, // 复合命令分割,危险检测)和 Handler 交互. // // 当决策为 Ask 时,自动填充: // - 人类可读的权限说明(Message 字段) // - 风险等级评估(RiskLevel 字段) // - 建议的永久规则(SuggestedRules 字段) func (e *engine) Check(ctx context.Context, req *Request) (*Response, error) { // 0. tool_use_id 去重检查 // 精妙之处(CLEVER): 不是决策缓存--不返回之前的决策结果. // 只是防止同一个请求被处理两次(网络重传,SDK 重试场景). if req.ToolID != "" && e.dedup != nil { if e.dedup.IsResolved(req.ToolID) { return &Response{ Decision: DecisionAllow, Reason: "tool_use_id already resolved (dedup)", }, nil } } // 1. 委托给完整的权限检查器 decision := e.matchRules(req) // 2. 如果决策是 Allow 或 Deny,直接返回 if decision != nil && decision.Decision != DecisionAsk { // 记录到拒绝追踪器 if e.denial != nil { if decision.Decision == DecisionDeny { inputSummary := summarizeInput(req.Input) e.denial.RecordDenial(req.ToolName, inputSummary) } else if decision.Decision == DecisionAllow { e.denial.RecordApproval(req.ToolName) } } return decision, nil } // 3. 需要询问用户 -- 先填充人类可读的说明信息 req.Message = ExplainPermissionRequest(req.ToolName, req.Input) // 构建带有风险等级和建议规则的 Ask 响应 riskLevel := AssessRisk(req.ToolName, req.Input) suggestedRules := SuggestRules(req.ToolName, req.Input) // 4. 调用 Handler 让消费层决策(带超时保护) // // 精妙之处(CLEVER): Handler 运行在独立 goroutine + channel 通信而非直接调用-- // 这是唯一能对同步阻塞函数施加超时的方式. // context.WithTimeout 本身不能打断已进入的阻塞调用(如 terminal readline 或 HTTP 等待), // 只有 goroutine + select 才能做到超时返回. // 替代方案:直接调用 e.handler(ctx, req)(早期设计)--Handler 永久阻塞时 runLoop 挂死. if e.handler != nil { // 创建带超时的子 context,保证 Handler 最多运行 handlerTimeout 时间. // 即使上层 ctx 已经有 deadline,WithTimeout 取两者中更早的那个,天然安全. handlerCtx, handlerCancel := context.WithTimeout(ctx, e.handlerTimeout) defer handlerCancel() // 在 goroutine 中执行 Handler,通过 channel 接收结果. // 精妙之处(CLEVER): 用带缓冲的 channel(容量 1)避免 goroutine 泄漏-- // 即使 select 因超时先返回,goroutine 仍可写入 channel 后退出,不会永久阻塞. // 无缓冲 channel 会导致:超时返回后 goroutine 尝试写入永远阻塞 = goroutine 泄漏. type handlerResult struct { resp *Response err error } resultCh := make(chan handlerResult, 1) go func() { resp, err := e.handler(handlerCtx, req) resultCh <- handlerResult{resp: resp, err: err} }() var resp *Response select { case res := <-resultCh: if res.err != nil { return nil, res.err } resp = res.resp case <-handlerCtx.Done(): // 超时或 ctx 取消 if ctx.Err() != nil { // 上层 context 取消(用户中断),透传错误 return nil, ctx.Err() } // Handler 自身超时(Handler 阻塞过久),返回安全拒绝 // 升华改进(ELEVATED): 超时拒绝而非超时允许-- // 权限决策的安全 fallback 是"拒绝",而不是"允许". // 如果超时=允许,攻击者可以构造永远不返回的 Handler 来绕过权限检查. // 替代方案:超时后返回 Allow(减少误报,但有安全风险). if e.denial != nil { inputSummary := summarizeInput(req.Input) e.denial.RecordDenial(req.ToolName, inputSummary) } return &Response{ Decision: DecisionDeny, Reason: "permission handler timeout", RiskLevel: riskLevel, SuggestedRules: suggestedRules, }, nil } if resp == nil { // 精妙之处(CLEVER): Handler 返回 (nil, nil) 时 fail-closed-- // nil 响应是非预期行为(正常 Handler 总应返回决策). // 用 DecisionDeny 而非 DecisionAsk:Ask 会期望下一层处理, // 但此处已是最后一层(Handler 本身),无处可 Ask. // 替代方案:panic(暴露 bug 更彻底,但会中断整个 session). return &Response{ Decision: DecisionDeny, Reason: "permission handler returned nil response", }, nil } // 在 Handler 返回的响应中填充风险和建议信息 resp.RiskLevel = riskLevel resp.SuggestedRules = suggestedRules // 记录决策到学习追踪器 if e.learner != nil { e.learner.TrackDecision(req.ToolName, req.Input, resp.Decision) } // 记录到拒绝追踪器 if e.denial != nil { if resp.Decision == DecisionDeny { inputSummary := summarizeInput(req.Input) e.denial.RecordDenial(req.ToolName, inputSummary) } else if resp.Decision == DecisionAllow { e.denial.RecordApproval(req.ToolName) } } // 标记 tool_use_id 为已处理 if req.ToolID != "" && e.dedup != nil { e.dedup.MarkResolved(req.ToolID) } // 升华改进(ELEVATED): 每 N 次用户决策后,检查是否有建议可以应用为 session 规则. // 不是每次都检查--学习追踪器需要累积足够样本才会产生建议. // 替代方案:每次决策都检查(更及时但浪费 CPU,阈值通常 >= 3). e.decisionCount++ if e.ruleApplier != nil && e.decisionCount%suggestCheckInterval == 0 { e.ruleApplier.ApplySuggestions() } return resp, nil } // 5. 无 Handler,默认拒绝 if e.denial != nil { inputSummary := summarizeInput(req.Input) e.denial.RecordDenial(req.ToolName, inputSummary) } return &Response{ Decision: DecisionDeny, Reason: "no permission handler", RiskLevel: riskLevel, SuggestedRules: suggestedRules, }, nil } // Mode 返回当前权限模式.并发安全(atomic.Value.Load). func (e *engine) Mode() Mode { if v := e.mode.Load(); v != nil { return v.(Mode) } return ModeDefault } // SetMode 设置权限模式.并发安全(atomic.Value.Store). func (e *engine) SetMode(m Mode) { e.mode.Store(m) } func (e *engine) AddRule(r Rule) { e.rules = append(e.rules, r) } // matchRules 按优先级匹配权限规则. // 对应早期实现的同名函数 的规则匹配部分. // // 现在委托给 checker.go 中的 CheckToolPermission 函数, // 它支持完整的规则匹配逻辑:前缀匹配,路径 glob,域名匹配等. func (e *engine) matchRules(req *Request) *Response { return CheckToolPermission(req.ToolName, req.Input, e.rules, e.Mode()) } // isEditTool 判断是否为编辑类工具. func isEditTool(name string) bool { switch name { case "Edit", "Write", "NotebookEdit": return true default: return false } } // summarizeInput 生成输入参数的简短摘要(用于拒绝追踪). func summarizeInput(input map[string]any) string { if input == nil { return "" } // 优先使用 command 字段(Bash 工具) if cmd, ok := input["command"].(string); ok { if len(cmd) > 50 { return cmd[:50] + "..." } return cmd } // 其次使用 file_path 字段(文件工具) if fp, ok := input["file_path"].(string); ok { return fp } // 最后使用 url 字段(Web 工具) if u, ok := input["url"].(string); ok { return u } return "" } // MarshalJSON 支持序列化(用于 SDK 协议). func (r *Request) MarshalJSON() ([]byte, error) { return json.Marshal(struct { ToolName string `json:"tool_name"` ToolID string `json:"tool_use_id"` Input map[string]any `json:"input"` Message string `json:"message"` }{ ToolName: r.ToolName, ToolID: r.ToolID, Input: r.Input, Message: r.Message, }) }