Why: models need a capped way to call several tools from one script, and a slow MCP server must not block the first prompt. Evidence: GOWORK=off go test ./runtime/codeorch/ ./runtime/tools/ ./runtime/mcp/ ./runtime/policy/ ./adapters/native/ Impact: run_tool_script stays unregistered until the caller turns it on. Deferred tools stay out of the model list. An unready MCP server connects in the background. Refs: feature/code-orchestration Co-authored-by: Cursor <cursoragent@cursor.com>
222 lines
5.1 KiB
Go
222 lines
5.1 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", "tool_search":
|
|
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", "run_tool_script", "call_tool":
|
|
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
|
|
}
|