Files
Agent-Native/runtime/policy/policy.go
T
weichenandCursor 0a52d9f976
ci / vet-and-test (pull_request) Successful in 46s
ci / docker-checkout-hostgateway (pull_request) Successful in 1s
Add a hook bus and permission decisions for tool calls
Why: Squad members never saw the direct-run stop, reminder, and before-tool gates, and there was no shared place to decide deny, ask, or allow.
Evidence: GOWORK=off go test ./runtime/hooks/ ./runtime/policy/ ./runtime/ ./adapters/native/ passed, including an A/B run where hook adapters match the old callbacks.
Impact: An empty bus leaves stop reason, reminder count, and deny count unchanged. Callers opt in by setting GateOptions.
Refs: feature/permission-hooks
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-10-03 17:02:28 +00:00

222 lines
5.0 KiB
Go

// Package policy 决定一次工具调用在当前权限模式下是拒绝、询问还是放行。
// 默认模式不额外表态,调用方继续用原有画像策略。
package policy
import "strings"
type Mode string
const (
ModeDefault Mode = "default"
ModePlan Mode = "plan"
ModeAutoEdit Mode = "auto_edit"
)
type Effect string
const (
EffectNone Effect = ""
EffectDeny Effect = "deny"
EffectAsk Effect = "ask"
EffectAllow Effect = "allow"
)
type Traits struct {
ReadOnly bool
Edits bool
Risk string
}
type Rule struct {
Effect Effect
Tool string
ArgGlob string
}
type Request struct {
Mode Mode
Name string
Args string
Traits Traits
SessionAllow bool
}
func Normalize(mode string) Mode {
switch Mode(mode) {
case ModePlan, ModeAutoEdit, ModeDefault:
return Mode(mode)
default:
return ModeDefault
}
}
// Decide 的优先级是拒绝规则、计划模式禁写、本会话放行、询问规则、自动改文件、高风险命令。
// 都没有命中时返回空,表示沿用画像的拒绝、确认和放行。
func Decide(req Request, rules []Rule) Effect {
var sawAsk, sawAllow bool
for _, rule := range rules {
if !matchTool(rule.Tool, req.Name) || !glob(rule.ArgGlob, req.Args) {
continue
}
switch rule.Effect {
case EffectDeny:
return EffectDeny
case EffectAsk:
sawAsk = true
case EffectAllow:
sawAllow = true
}
}
traits := mergeTraits(req.Name, req.Traits)
if commandRisk(req.Name, req.Args) == "high" {
traits.Risk = "high"
}
if Normalize(string(req.Mode)) == ModePlan && !planVisible(req.Name, traits.ReadOnly) {
return EffectDeny
}
if req.SessionAllow {
return EffectAllow
}
if sawAsk {
return EffectAsk
}
if Normalize(string(req.Mode)) == ModeAutoEdit && traits.Edits && traits.Risk != "high" {
return EffectAllow
}
if sawAllow {
return EffectAllow
}
if traits.Risk == "high" && (req.Name == "run_shell" || req.Name == "run_command") {
return EffectAsk
}
return EffectNone
}
// Visible 报告计划模式下这个工具能否出现在清单里。其他模式一律可见。
func Visible(mode string, name string, readOnly bool) bool {
if Normalize(mode) != ModePlan {
return true
}
return planVisible(name, readOnly)
}
func planVisible(name string, readOnly bool) bool {
switch name {
case "update_plan", "update_todos", "ask_user", "exit_plan_mode":
return true
default:
return readOnly
}
}
// TraitsFor 是内置工具的只读、改文件和风险声明。未知工具为零值。
func TraitsFor(name string) Traits {
switch name {
case "read_file", "list_dir", "grep", "glob", "load_skill",
"fs_read_file", "fs_list_dir", "time_now", "knowledge_search",
"list_conversations", "read_conversation", "quality_review",
"web_search", "tavily_search", "fetch_url", "ask_user", "exit_plan_mode",
"update_plan", "update_todos":
return Traits{ReadOnly: true, Risk: "low"}
case "write_file", "edit_file", "multi_edit", "fs_write_file", "write_artifact", "register_artifact":
return Traits{Edits: true, Risk: "medium"}
case "run_shell", "run_command", "run_python":
return Traits{Risk: "medium"}
default:
return Traits{}
}
}
func mergeTraits(name string, given Traits) Traits {
base := TraitsFor(name)
if given.ReadOnly {
base.ReadOnly = true
}
if given.Edits {
base.Edits = true
}
if given.Risk != "" {
base.Risk = given.Risk
}
return base
}
// ClassifyCommand 按命令文本分级。高风险会在默认模式下改为询问。
func ClassifyCommand(cmd string) string {
c := strings.ToLower(cmd)
for _, n := range []string{"rm -rf", "rm -fr", "sudo ", "mkfs", "shutdown", "reboot", "> /dev/", "| sh", "|sh", "chmod -r 777"} {
if strings.Contains(c, n) {
return "high"
}
}
for _, n := range []string{"rm ", "mv ", "chmod ", "chown ", "kill "} {
if strings.Contains(c, n) {
return "medium"
}
}
return "low"
}
func commandRisk(name, args string) string {
if name != "run_shell" && name != "run_command" {
return ""
}
return ClassifyCommand(extractCommand(args))
}
func extractCommand(args string) string {
for _, key := range []string{`"command"`, `"cmd"`} {
i := strings.Index(args, key)
if i < 0 {
continue
}
rest := args[i+len(key):]
rest = strings.TrimLeft(rest, " \t\n:")
if !strings.HasPrefix(rest, `"`) {
continue
}
rest = rest[1:]
if j := strings.Index(rest, `"`); j >= 0 {
return rest[:j]
}
}
return args
}
func matchTool(pattern, name string) bool {
if pattern == "" || pattern == "*" {
return true
}
return glob(pattern, name)
}
func glob(pattern, s string) bool {
if pattern == "" || pattern == "*" {
return true
}
parts := strings.Split(pattern, "*")
if !strings.HasPrefix(pattern, "*") {
if !strings.HasPrefix(s, parts[0]) {
return false
}
}
if !strings.HasSuffix(pattern, "*") {
last := parts[len(parts)-1]
if !strings.HasSuffix(s, last) {
return false
}
}
idx := 0
for _, part := range parts {
if part == "" {
continue
}
j := strings.Index(s[idx:], part)
if j < 0 {
return false
}
idx += j + len(part)
}
return true
}