Merge pull request 'Add a hook bus and permission decisions for tool calls' (#13) from feature/permission-hooks into main
ci / vet-and-test (push) Successful in 46s
ci / docker-checkout-hostgateway (push) Successful in 1s

This commit was merged in pull request #13.
This commit is contained in:
2026-10-03 17:06:47 +00:00
8 changed files with 680 additions and 2 deletions
+49
View File
@@ -15,6 +15,7 @@ import (
"agent-native/exec"
"agent-native/runtime"
"agent-native/runtime/filestate"
"agent-native/runtime/hooks"
"agent-native/runtime/llm"
"agent-native/runtime/mcp"
"agent-native/runtime/promptx"
@@ -59,6 +60,16 @@ type ObserveOptions struct {
ExecutionID string
}
// GateOptions 把直连已经使用的停止、提醒和工具钩子交给小队成员。零值不改变行为。
type GateOptions struct {
BeforeTool func(call llm.ToolCall) error
AfterTool func(call llm.ToolCall, res llm.Message)
Stop func(last llm.Message, info runtime.StopInfo) runtime.StopDecision
Reminders func(turn int) []string
RepeatGuard bool
Hooks *hooks.Bus
}
// Options 构造后端所需的模型接入与执行参数(由组合根注入,无产品依赖)。
// 字段经嵌入提升,读取仍写 opts.Model;复合字面量要写子结构名。
type Options struct {
@@ -66,6 +77,7 @@ type Options struct {
ToolOptions
SessionOptions
ObserveOptions
GateOptions
}
// CompactConfig 声明长会话压缩策略(优化性质:压缩失败不阻断执行,但留日志)。
@@ -333,6 +345,9 @@ func (b *Backend) run(ctx context.Context, model string, budget *runtime.Budget,
Log: b.logger(),
ExecutionID: execID,
Budget: budget,
Reminders: b.opts.Reminders,
RepeatGuard: b.opts.RepeatGuard,
Hooks: gateBus(b.opts.GateOptions),
OnEvent: func(ev runtime.Event) {
logs.WriteString(ev.Type)
if ev.Tool != "" {
@@ -423,6 +438,40 @@ func defaultSummarizer(stream llm.StreamFunc, model string) func(ctx context.Con
}
}
func gateBus(g GateOptions) *hooks.Bus {
bus := g.Hooks
if g.BeforeTool == nil && g.AfterTool == nil && g.Stop == nil {
return bus
}
if bus == nil {
bus = hooks.New()
}
if g.BeforeTool != nil {
fn := g.BeforeTool
bus.On(hooks.AdaptBefore(func(name, args string) error { return fn(llm.ToolCall{Name: name, Arguments: args}) }))
}
if g.AfterTool != nil {
fn := g.AfterTool
bus.On(hooks.AdaptAfter(func(name, args, result string, isError bool) {
fn(llm.ToolCall{Name: name, Arguments: args}, llm.Message{Content: result, IsError: isError})
}))
}
if g.Stop != nil {
fn := g.Stop
bus.On(func(c hooks.Call) hooks.Outcome {
if c.Event != hooks.Stop {
return hooks.Outcome{}
}
d := fn(llm.Message{Role: llm.RoleAssistant, Content: c.LastText}, runtime.StopInfo{Continuations: c.Continuations, ToolCalls: c.ToolCalls})
if d.Continue {
return hooks.Outcome{Decision: hooks.Block, Reason: d.Reason, Replacement: d.Replacement, Notice: d.Notice}
}
return hooks.Outcome{Decision: hooks.Allow, Notice: d.Notice}
})
}
return bus
}
type errString string
func (e errString) Error() string { return string(e) }
+34
View File
@@ -2,6 +2,7 @@ package native
import (
"context"
"errors"
"os"
"path/filepath"
"strings"
@@ -139,3 +140,36 @@ func TestBackendFailClosedWithoutConfig(t *testing.T) {
t.Fatalf("want profile error, got %+v", res3)
}
}
func TestBeforeToolReachesSquadMembers(t *testing.T) {
dir := t.TempDir()
var seen string
b := New(Options{
ModelOptions: ModelOptions{
Model: "fake",
Stream: script(
llm.Message{Role: llm.RoleAssistant, StopReason: llm.StopToolCalls, ToolCalls: []llm.ToolCall{{
ID: "c1", Name: "write_file", Arguments: `{"path":"output.md","content":"nope"}`,
}}},
llm.Message{Role: llm.RoleAssistant, Content: "blocked", StopReason: llm.StopEnd},
),
},
GateOptions: GateOptions{BeforeTool: func(call llm.ToolCall) error {
seen = call.Name
return errors.New("计划模式拒绝了写工具")
}},
})
res, err := b.Execute(context.Background(), exec.Request{WorkDir: dir, Prompt: "写", Targets: []string{"output.md"}})
if err != nil {
t.Fatal(err)
}
if seen != "write_file" {
t.Fatalf("before tool saw %q", seen)
}
if res.Success {
t.Fatal("denied write still succeeded")
}
if _, err := os.Stat(filepath.Join(dir, "output.md")); err == nil {
t.Fatal("write_file ran after deny")
}
}
+95
View File
@@ -0,0 +1,95 @@
package runtime
import (
"context"
"errors"
"testing"
"agent-native/runtime/hooks"
"agent-native/runtime/llm"
"agent-native/runtime/tools"
)
func TestHookAdaptersMatchDirectCallbacks(t *testing.T) {
direct := runGate(t, false)
adapted := runGate(t, true)
if direct.stop != adapted.stop || direct.text != adapted.text {
t.Fatalf("stop/text direct=%s %q adapted=%s %q", direct.stop, direct.text, adapted.stop, adapted.text)
}
if direct.denies != adapted.denies || direct.reminders != adapted.reminders || direct.errors != adapted.errors {
t.Fatalf("counts direct=%+v adapted=%+v", direct, adapted)
}
if direct.denies != 1 || direct.reminders == 0 {
t.Fatalf("unexpected counts %+v", direct)
}
}
type gateStats struct {
stop, text string
denies, reminders, errors int
}
func runGate(t *testing.T, viaHooks bool) gateStats {
t.Helper()
var stats gateStats
before := func(call llm.ToolCall) error {
if call.Name == "read_file" {
stats.denies++
return errors.New("denied")
}
return nil
}
reminders := func(int) []string {
stats.reminders++
return []string{"记一笔"}
}
stop := func(last llm.Message, info StopInfo) StopDecision {
if info.Continuations < 1 {
return StopDecision{Continue: true, Reason: "再看一眼"}
}
return StopDecision{}
}
cfg := Config{
Model: "fake",
Tools: tools.WorkspaceTools(t.TempDir()),
Stream: script(
llm.Message{Role: llm.RoleAssistant, StopReason: llm.StopToolCalls, ToolCalls: []llm.ToolCall{{
ID: "c1", Name: "read_file", Arguments: `{"path":"a.md"}`,
}}},
llm.Message{Role: llm.RoleAssistant, Content: "done", StopReason: llm.StopEnd},
),
Reminders: reminders,
}
if viaHooks {
bus := hooks.New()
bus.On(hooks.AdaptBefore(func(name, args string) error {
return before(llm.ToolCall{Name: name, Arguments: args})
}))
bus.On(func(c hooks.Call) hooks.Outcome {
if c.Event != hooks.Stop {
return hooks.Outcome{}
}
d := stop(llm.Message{Content: c.LastText}, StopInfo{Continuations: c.Continuations, ToolCalls: c.ToolCalls})
if d.Continue {
return hooks.Outcome{Decision: hooks.Block, Reason: d.Reason, Replacement: d.Replacement, Notice: d.Notice}
}
return hooks.Outcome{Decision: hooks.Allow, Notice: d.Notice}
})
cfg.Hooks = bus
} else {
cfg.BeforeTool = before
cfg.Stop = stop
}
res, err := Run(context.Background(), cfg, llm.Message{Role: llm.RoleUser, Content: "读文件"})
if err != nil {
t.Fatal(err)
}
stats.stop = string(res.StopReason)
stats.text = res.Text
for _, m := range res.Messages {
if m.Role == llm.RoleTool && m.IsError {
stats.errors++
}
}
return stats
}
+134
View File
@@ -0,0 +1,134 @@
// Package hooks 是进程内钩子总线。决策按 block/deny、ask、modify、allow 取更强的一条。
// 没有表态的处理函数不改变结果。
package hooks
import "sync"
type Event string
const (
SessionStart Event = "SessionStart"
UserPromptSubmit Event = "UserPromptSubmit"
PreToolUse Event = "PreToolUse"
PermissionRequest Event = "PermissionRequest"
PostToolUse Event = "PostToolUse"
PostToolUseFailure Event = "PostToolUseFailure"
Stop Event = "Stop"
SubagentStart Event = "SubagentStart"
SubagentStop Event = "SubagentStop"
PreCompact Event = "PreCompact"
)
type Decision string
const (
Allow Decision = "allow"
Deny Decision = "deny"
Ask Decision = "ask"
Modify Decision = "modify"
Block Decision = "block"
)
// Call 是一次事件。工具字段只在工具事件上有意义。
type Call struct {
Event Event
Tool string
Args string
Result string
IsError bool
Turn int
LastText string
Continuations int
ToolCalls int
}
// Outcome 是一个处理函数的表态。空 Decision 表示不插手。
type Outcome struct {
Decision Decision
Reason string
Args string
Notice string
Replacement string
}
type Handler func(Call) Outcome
type Bus struct {
mu sync.Mutex
hs []Handler
}
func New() *Bus { return &Bus{} }
func (b *Bus) On(h Handler) {
if b == nil || h == nil {
return
}
b.mu.Lock()
b.hs = append(b.hs, h)
b.mu.Unlock()
}
// Emit 按优先级合并表态。deny 与 block 同级,先登记的保留。
func (b *Bus) Emit(c Call) Outcome {
if b == nil {
return Outcome{}
}
b.mu.Lock()
hs := append([]Handler(nil), b.hs...)
b.mu.Unlock()
var merged Outcome
for _, h := range hs {
merged = prefer(merged, h(c))
}
return merged
}
func prefer(cur, next Outcome) Outcome {
if rank(next.Decision) > rank(cur.Decision) {
return next
}
return cur
}
func rank(d Decision) int {
switch d {
case Block, Deny:
return 4
case Ask:
return 3
case Modify:
return 2
case Allow:
return 1
default:
return 0
}
}
// AdaptBefore 把「返回错误即拒绝」收成 PreToolUse 处理函数。
func AdaptBefore(fn func(name, args string) error) Handler {
return func(c Call) Outcome {
if c.Event != PreToolUse || fn == nil {
return Outcome{}
}
if err := fn(c.Tool, c.Args); err != nil {
return Outcome{Decision: Deny, Reason: err.Error()}
}
return Outcome{Decision: Allow}
}
}
// AdaptAfter 把工具结果观察收成 PostToolUse 或失败事件。它不改写结果。
func AdaptAfter(fn func(name, args, result string, isError bool)) Handler {
return func(c Call) Outcome {
if fn == nil {
return Outcome{}
}
switch c.Event {
case PostToolUse, PostToolUseFailure:
fn(c.Tool, c.Args, c.Result, c.IsError)
}
return Outcome{}
}
}
+40
View File
@@ -0,0 +1,40 @@
package hooks
import "testing"
func TestDenyBeatsAllow(t *testing.T) {
b := New()
b.On(func(Call) Outcome { return Outcome{Decision: Allow} })
b.On(func(c Call) Outcome {
if c.Event == PreToolUse {
return Outcome{Decision: Deny, Reason: "no"}
}
return Outcome{}
})
out := b.Emit(Call{Event: PreToolUse, Tool: "write_file"})
if out.Decision != Deny || out.Reason != "no" {
t.Fatalf("%+v", out)
}
}
func TestAdaptBefore(t *testing.T) {
h := AdaptBefore(func(name, args string) error {
if name == "write_file" {
return errString("blocked")
}
return nil
})
if h(Call{Event: PreToolUse, Tool: "read_file"}).Decision != Allow {
t.Fatal("read")
}
if h(Call{Event: PreToolUse, Tool: "write_file"}).Decision != Deny {
t.Fatal("write")
}
if h(Call{Event: Stop}).Decision != "" {
t.Fatal("stop should be ignored")
}
}
type errString string
func (e errString) Error() string { return string(e) }
+66 -2
View File
@@ -8,6 +8,7 @@ import (
"sync"
"time"
"agent-native/runtime/hooks"
"agent-native/runtime/llm"
"agent-native/runtime/reminders"
"agent-native/runtime/session"
@@ -104,6 +105,8 @@ type Config struct {
ContextBuffer int
// KeepToolResults 是清理时保留的最近工具结果条数。0 表示 5。
KeepToolResults int
// Hooks 是进程内钩子。空总线不改变停止、提醒和拒绝次数。
Hooks *hooks.Bus
}
// StopInfo 是 Stop 钩子看到的本 run 进度。
@@ -275,8 +278,7 @@ func Run(ctx context.Context, cfg Config, user llm.Message) (Result, error) {
}
continue
}
if cfg.Stop != nil {
decision := cfg.Stop(last, StopInfo{Continuations: stopContinues, ToolCalls: toolCalls})
if decision, hooked := stopDecision(cfg, last, stopContinues, toolCalls); hooked {
if decision.Continue && stopContinues < maxStopContinuations {
stopContinues++
if decision.Replacement != "" && len(messages) > 0 {
@@ -488,6 +490,56 @@ func rejectIncompleteToolCall(call llm.ToolCall) string {
return ""
}
func stopDecision(cfg Config, last llm.Message, continuations, toolCalls int) (StopDecision, bool) {
if cfg.Hooks != nil {
out := cfg.Hooks.Emit(hooks.Call{
Event: hooks.Stop, LastText: last.Content, Continuations: continuations, ToolCalls: toolCalls,
})
if out.Decision != "" {
return StopDecision{
Continue: out.Decision == hooks.Block, Reason: out.Reason,
Replacement: out.Replacement, Notice: out.Notice,
}, true
}
}
if cfg.Stop != nil {
return cfg.Stop(last, StopInfo{Continuations: continuations, ToolCalls: toolCalls}), true
}
return StopDecision{}, false
}
func gateTool(cfg Config, call llm.ToolCall) (llm.ToolCall, llm.Message, bool) {
if cfg.Hooks == nil {
return call, llm.Message{}, false
}
out := cfg.Hooks.Emit(hooks.Call{Event: hooks.PreToolUse, Tool: call.Name, Args: call.Arguments})
switch out.Decision {
case hooks.Deny, hooks.Block, hooks.Ask:
why := out.Reason
if why == "" {
why = "工具调用被拒绝"
}
msg := llm.Message{Role: llm.RoleTool, Name: call.Name, ToolCallID: call.ID, Content: why, IsError: true}
return call, msg, true
case hooks.Modify:
if out.Args != "" {
call.Arguments = out.Args
}
}
return call, llm.Message{}, false
}
func noteToolHook(cfg Config, call llm.ToolCall, msg llm.Message) {
if cfg.Hooks == nil {
return
}
ev := hooks.PostToolUse
if msg.IsError {
ev = hooks.PostToolUseFailure
}
cfg.Hooks.Emit(hooks.Call{Event: ev, Tool: call.Name, Args: call.Arguments, Result: msg.Content, IsError: msg.IsError})
}
func runOne(ctx context.Context, cfg Config, call llm.ToolCall) llm.Message {
if err := ctx.Err(); err != nil {
return cancelledToolMessage(call, err)
@@ -502,10 +554,21 @@ func runOne(ctx context.Context, cfg Config, call llm.ToolCall) llm.Message {
return msg
}
emit(cfg, Event{Type: "tool_execution_start", Tool: call.Name, Detail: call.Arguments})
if gated, msg, blocked := gateTool(cfg, call); blocked {
emit(cfg, Event{Type: "tool_execution_end", Tool: call.Name, Text: msg.Content})
noteToolHook(cfg, call, msg)
if cfg.AfterTool != nil {
cfg.AfterTool(call, msg)
}
return msg
} else {
call = gated
}
if cfg.BeforeTool != nil {
if err := cfg.BeforeTool(call); err != nil {
msg := llm.Message{Role: llm.RoleTool, Name: call.Name, ToolCallID: call.ID, Content: err.Error(), IsError: true}
emit(cfg, Event{Type: "tool_execution_end", Tool: call.Name, Text: msg.Content})
noteToolHook(cfg, call, msg)
if cfg.AfterTool != nil {
cfg.AfterTool(call, msg)
}
@@ -531,6 +594,7 @@ func runOne(ctx context.Context, cfg Config, call llm.ToolCall) llm.Message {
IsError: res.IsError,
}
emit(cfg, Event{Type: "tool_execution_end", Tool: call.Name, Text: res.Content})
noteToolHook(cfg, call, msg)
if cfg.AfterTool != nil {
cfg.AfterTool(call, msg)
}
+221
View File
@@ -0,0 +1,221 @@
// 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
}
+41
View File
@@ -0,0 +1,41 @@
package policy
import "testing"
func TestPlanModeDeniesWrites(t *testing.T) {
effect := Decide(Request{Mode: ModePlan, Name: "write_file", Args: `{"path":"a.md"}`}, nil)
if effect != EffectDeny {
t.Fatalf("effect %q", effect)
}
if !Visible(string(ModePlan), "update_plan", false) {
t.Fatal("plan tool hidden")
}
if Visible(string(ModePlan), "write_file", false) {
t.Fatal("write visible in plan mode")
}
}
func TestDefaultModeStaysQuiet(t *testing.T) {
if got := Decide(Request{Mode: ModeDefault, Name: "write_file"}, nil); got != EffectNone {
t.Fatalf("default write %q", got)
}
if got := Decide(Request{Mode: ModeDefault, Name: "run_shell", Args: `{"command":"rm -rf /"}`}, nil); got != EffectAsk {
t.Fatalf("high shell %q", got)
}
}
func TestRulePrecedenceAndSessionAllow(t *testing.T) {
rules := []Rule{{Effect: EffectAllow, Tool: "write_file"}, {Effect: EffectDeny, Tool: "write_*", ArgGlob: "*secret*"}}
if got := Decide(Request{Name: "write_file", Args: `{"path":"secret.txt"}`}, rules); got != EffectDeny {
t.Fatalf("deny lost: %q", got)
}
if got := Decide(Request{Name: "http.fetch", SessionAllow: true}, []Rule{{Effect: EffectAsk, Tool: "http.*"}}); got != EffectAllow {
t.Fatalf("session allow %q", got)
}
}
func TestAutoEditAllowsWorkspaceWrites(t *testing.T) {
if got := Decide(Request{Mode: ModeAutoEdit, Name: "fs_write_file"}, nil); got != EffectAllow {
t.Fatalf("auto edit %q", got)
}
}