Why: N1 increment three (WP1/WP4) - agents need a canonical spec format with plugin restrictions, hosts need typed input/output/tool guardrails, delegation needs lifecycle events, and squad adapters need to pass the permission ask port through. Evidence: go vet clean; go test ./... 26 packages ok, including new agentspec (12 tests), guardrails (5), loop_guardrails (5), and subagent hook tests (3). Impact: new runtime/agentspec and runtime/guardrails packages; loop consumes UserPromptSubmit outcomes (deny skips the loop, modify appends the rewrite); subagent gains RegisterWithHooks emitting SubagentStart/Stop around delegation; GateOptions gains PermissionAsk forwarded into the kernel config. All additions are opt-in: nil ports preserve prior behavior exactly. Refs: Documents proposal 2026-10-07-agent-stack-n1q1i1-plans.md WP1/WP4
90 lines
3.0 KiB
Go
90 lines
3.0 KiB
Go
package guardrails
|
||
|
||
import (
|
||
"context"
|
||
"testing"
|
||
|
||
"agent-native/runtime/hooks"
|
||
)
|
||
|
||
func TestToolGuardrailMergesOnPreToolUse(t *testing.T) {
|
||
bus := hooks.New()
|
||
Register(bus, Set{
|
||
Tool: func(ctx context.Context, name, args string) Verdict {
|
||
if name == "run_shell" {
|
||
return Verdict{Decision: hooks.Deny, Reason: "shell 被护栏禁用"}
|
||
}
|
||
return Verdict{}
|
||
},
|
||
}, Scope{})
|
||
out := bus.Emit(hooks.Call{Event: hooks.PreToolUse, Tool: "run_shell"})
|
||
if out.Decision != hooks.Deny || out.Reason == "" {
|
||
t.Fatalf("out = %+v", out)
|
||
}
|
||
out = bus.Emit(hooks.Call{Event: hooks.PreToolUse, Tool: "read_file"})
|
||
if out.Decision != "" {
|
||
t.Fatalf("read_file out = %+v", out)
|
||
}
|
||
}
|
||
|
||
func TestToolGuardrailModifyRewritesArgs(t *testing.T) {
|
||
bus := hooks.New()
|
||
Register(bus, Set{
|
||
Tool: func(ctx context.Context, name, args string) Verdict {
|
||
return Verdict{Decision: hooks.Modify, Replacement: `{"path":"safe.txt"}`}
|
||
},
|
||
}, Scope{})
|
||
out := bus.Emit(hooks.Call{Event: hooks.PreToolUse, Tool: "write_file"})
|
||
if out.Decision != hooks.Modify || out.Args != `{"path":"safe.txt"}` {
|
||
t.Fatalf("out = %+v", out)
|
||
}
|
||
}
|
||
|
||
func TestOutputGuardrailDenyBecomesContinuation(t *testing.T) {
|
||
bus := hooks.New()
|
||
Register(bus, Set{
|
||
Output: func(ctx context.Context, text string) Verdict {
|
||
return Verdict{Decision: hooks.Deny, Reason: "缺少来源标注"}
|
||
},
|
||
}, Scope{Final: true})
|
||
out := bus.Emit(hooks.Call{Event: hooks.Stop, LastText: "结论"})
|
||
// Output deny 必须表达为续跑(Block),而不是把 run 掐死。
|
||
if out.Decision != hooks.Block || out.Reason != "缺少来源标注" {
|
||
t.Fatalf("out = %+v", out)
|
||
}
|
||
}
|
||
|
||
func TestScopeGatesInputAndOutput(t *testing.T) {
|
||
fired := map[string]bool{}
|
||
mk := func() Set {
|
||
return Set{
|
||
Input: func(ctx context.Context, text string) Verdict { fired["input"] = true; return Verdict{} },
|
||
Output: func(ctx context.Context, text string) Verdict { fired["output"] = true; return Verdict{} },
|
||
}
|
||
}
|
||
// 子代理 run(Entry/Final 都为 false):Input/Output 不注册。
|
||
bus := hooks.New()
|
||
Register(bus, mk(), Scope{})
|
||
bus.Emit(hooks.Call{Event: hooks.UserPromptSubmit, LastText: "x"})
|
||
bus.Emit(hooks.Call{Event: hooks.Stop, LastText: "y"})
|
||
if fired["input"] || fired["output"] {
|
||
t.Fatalf("sub-run scope fired: %v", fired)
|
||
}
|
||
// 入口且最终:两层都触发。
|
||
bus2 := hooks.New()
|
||
Register(bus2, mk(), Scope{Entry: true, Final: true})
|
||
bus2.Emit(hooks.Call{Event: hooks.UserPromptSubmit, LastText: "x"})
|
||
bus2.Emit(hooks.Call{Event: hooks.Stop, LastText: "y"})
|
||
if !fired["input"] || !fired["output"] {
|
||
t.Fatalf("entry/final scope did not fire: %v", fired)
|
||
}
|
||
}
|
||
|
||
func TestNilBusIsNoop(t *testing.T) {
|
||
Register(nil, Set{
|
||
Input: func(ctx context.Context, text string) Verdict { return Verdict{Decision: hooks.Deny} },
|
||
Output: func(ctx context.Context, text string) Verdict { return Verdict{Decision: hooks.Deny} },
|
||
Tool: func(ctx context.Context, name, args string) Verdict { return Verdict{Decision: hooks.Deny} },
|
||
}, Scope{Entry: true, Final: true})
|
||
}
|