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
91 lines
3.4 KiB
Go
91 lines
3.4 KiB
Go
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)
|
||
}
|
||
}
|
||
|
||
// TestPolicyDecideMatchesFunctionForm:Policy 结构体形态与函数形态判定一致。
|
||
func TestPolicyDecideMatchesFunctionForm(t *testing.T) {
|
||
rules := []Rule{{Effect: EffectAllow, Tool: "write_file"}, {Effect: EffectDeny, Tool: "write_*", ArgGlob: "*secret*"}}
|
||
cases := []struct {
|
||
p Policy
|
||
name string
|
||
args string
|
||
want Effect
|
||
}{
|
||
{Policy{Mode: ModePlan}, "write_file", `{"path":"a.md"}`, EffectDeny},
|
||
{Policy{Mode: ModeDefault, Rules: rules}, "write_file", `{"path":"a.md"}`, EffectAllow},
|
||
{Policy{Mode: ModeDefault, Rules: rules}, "write_file", `{"path":"secret.txt"}`, EffectDeny},
|
||
{Policy{Mode: ModeDefault}, "run_shell", `{"command":"rm -rf /"}`, EffectAsk},
|
||
}
|
||
for i, c := range cases {
|
||
if got := c.p.Decide(c.name, c.args, Traits{}); got != c.want {
|
||
t.Fatalf("case %d: effect %q, want %q", i, got, c.want)
|
||
}
|
||
}
|
||
}
|
||
|
||
// TestPolicySessionAllowsStore:会话放行经存储短路 ask,且跨调用存活。
|
||
func TestPolicySessionAllowsStore(t *testing.T) {
|
||
store := NewMemoryAllows()
|
||
p := Policy{Mode: ModeDefault, Rules: []Rule{{Effect: EffectAsk, Tool: "run_shell"}}, SessionAllows: store}
|
||
if got := p.Decide("run_shell", `{"command":"ls"}`, Traits{}); got != EffectAsk {
|
||
t.Fatalf("before remember: %q, want ask", got)
|
||
}
|
||
store.Remember("run_shell", `{"command":"ls"}`)
|
||
if got := p.Decide("run_shell", `{"command":"ls"}`, Traits{}); got != EffectAllow {
|
||
t.Fatalf("after remember: %q, want allow", got)
|
||
}
|
||
if got := p.Decide("run_shell", `{"command":"rm -rf /"}`, Traits{}); got != EffectAsk {
|
||
t.Fatalf("other args: %q, want ask(精确匹配不放行)", got)
|
||
}
|
||
}
|
||
|
||
// TestPolicyNilStoreAndVisible:nil 存储等价于 SessionAllow 恒 false;Visible 方法可用。
|
||
func TestPolicyNilStoreAndVisible(t *testing.T) {
|
||
p := Policy{Mode: ModePlan}
|
||
if got := p.Decide("write_file", `{}`, Traits{}); got != EffectDeny {
|
||
t.Fatalf("plan deny: %q", got)
|
||
}
|
||
if !p.Visible("update_plan") || p.Visible("write_file") {
|
||
t.Fatal("policy visible mismatch")
|
||
}
|
||
}
|
||
|