Files
Agent-Native/runtime/policy/policy.go
T
weichen 9758a8d8d0
ci / vet-and-test (pull_request) Successful in 48s
ci / docker-checkout-hostgateway (pull_request) Successful in 1s
Add permission ask loop, lifecycle hook emissions, and Policy type
Why: The N1 harness phase needs the kernel to resolve ask decisions
through the host (paired HITL), emit the full hook lifecycle the plan
promises, and carry mode/rules/session-allow in one named object.

Evidence: go vet clean; go test ./... 24 packages ok, including new
loop_hooks_lifecycle_test.go (lifecycle order, prompt text, ask
allow/deny/legacy paths, stop continuation counts) and three new
policy tests (struct-form parity, session-allow store, nil store).

Impact: hooks adds PostToolBatch and SessionEnd events; loop emits
SessionStart/UserPromptSubmit/PostToolBatch/SessionEnd and PreCompact
lands in AgentSession.Compact; gateTool consults PermissionAsk on ask
and keeps ask=reject when the port is unset; policy gains Policy,
SessionAllows, and MemoryAllows. SubagentStart/Stop emissions are
deferred to the adapters assembly increment (factory site has no bus
access yet).

Refs: Documents proposal 2026-10-07-agent-stack-n1q1i1-plans.md WP2/WP3
2026-10-07 03:01:17 +00:00

276 lines
6.7 KiB
Go

// Package policy 决定一次工具调用在当前权限模式下是拒绝、询问还是放行。
// 默认模式不额外表态,调用方继续用原有画像策略。
package policy
import (
"strings"
"sync"
)
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
}
// SessionAllows 记录并查询「本会话始终允许」的决定。键的口径由实现方定义;
// 宿主可以用自己的模式匹配(如参数通配)实现同一接口。
type SessionAllows interface {
Remember(name, args string)
Allowed(name, args string) bool
}
// MemoryAllows 是进程内的会话放行存储,按「工具名 + 原样参数串」精确匹配。
type MemoryAllows struct {
mu sync.Mutex
allowed map[string]bool
}
func NewMemoryAllows() *MemoryAllows {
return &MemoryAllows{allowed: make(map[string]bool)}
}
func (m *MemoryAllows) Remember(name, args string) {
m.mu.Lock()
m.allowed[name+"\n"+args] = true
m.mu.Unlock()
}
func (m *MemoryAllows) Allowed(name, args string) bool {
m.mu.Lock()
defer m.mu.Unlock()
return m.allowed[name+"\n"+args]
}
// Policy 是一次会话的权限配置:模式、规则与会话放行存在同一个对象上,
// Decide/Visible 语义与同名函数完全一致。
type Policy struct {
Mode Mode
Rules []Rule
SessionAllows SessionAllows // 可为 nil:等价于 SessionAllow 恒为 false
}
// Decide 按 Policy 的模式、规则与会话放行判定一次工具调用。
func (p Policy) Decide(name, args string, traits Traits) Effect {
var allowed bool
if p.SessionAllows != nil {
allowed = p.SessionAllows.Allowed(name, args)
}
return Decide(Request{Mode: p.Mode, Name: name, Args: args, Traits: traits, SessionAllow: allowed}, p.Rules)
}
// Visible 报告该模式下这个工具能否出现在清单里。
func (p Policy) Visible(name string) bool {
return Visible(string(p.Mode), name, mergeTraits(name, Traits{}).ReadOnly)
}
// 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
}