Files
Agent-Intent/intent/u5_isolation_generated_test.go
T
weichen 765a7e7177 Isolate snapshots and validate generated commands
Why: F07/F08 - registry and catalog accessors shared caller-mutable
nested state, and the generation fallback skipped full slot validation,
risk metadata and mis-keyed handler slot tables.
Evidence: intent package tests pass incl. new isolation/generated cases
(verify-u5.json).
Impact: no exported signature change; rule and generated paths now share
authoritative validation.
Refs: feature/agent-stack-audit-remediation-b1 U5
2026-10-21 15:41:31 +00:00

382 lines
13 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
package intent
import (
"context"
"math"
"testing"
)
// TestU5RegisterMutationDoesNotAffectSnapshot:登记后改原 spec 不影响 SpecByID/Catalog。
func TestU5RegisterMutationDoesNotAffectSnapshot(t *testing.T) {
spec := IntentSpec{
ID: "demo.iso.reg", Domain: "demo", Version: 1,
Matcher: MatchSpec{IncludesAny: []string{"隔离登记"}},
Risk: RiskHigh,
RequiresConfirm: true,
Handler: "iso.handler",
Examples: []string{"示例甲"},
Action: &ActionRef{Kind: ActionWorkflow, ID: "wf-iso"},
Slots: []SlotSpec{{
Key: "env", Pattern: `env[: ]*(\w+)`, Group: 1,
Enum: []string{"prod", "dev"}, Normalize: []string{"trim"},
}},
Metadata: map[string]string{"guidance": "keep", "token": "secret"},
}
b := NewRegistryBuilder(Options{ProtectedDomains: []string{"demo"}, GuardDomains: []string{"guard"}})
if err := b.Register(spec); err != nil {
t.Fatal(err)
}
// 登记后篡改原对象与嵌套可变字段。
spec.Risk = RiskLow
spec.RequiresConfirm = false
spec.Handler = "tampered"
spec.Examples[0] = "被改"
spec.Action.ID = "wf-tampered"
spec.Slots[0].Enum[0] = "evil"
spec.Metadata["guidance"] = "mutated"
spec.Matcher.IncludesAny[0] = "被改词"
reg, err := b.Build()
if err != nil {
t.Fatal(err)
}
snap := reg.Snapshot()
got, ok := snap.SpecByID("demo.iso.reg")
if !ok {
t.Fatal("missing spec")
}
if got.Risk != RiskHigh || !got.RequiresConfirm || got.Handler != "iso.handler" {
t.Fatalf("register isolation broken: %+v", got)
}
if got.Examples[0] != "示例甲" || got.Action == nil || got.Action.ID != "wf-iso" {
t.Fatalf("nested isolation broken: examples=%v action=%+v", got.Examples, got.Action)
}
if got.Slots[0].Enum[0] != "prod" || got.Metadata["guidance"] != "keep" {
t.Fatalf("slot/meta isolation broken: %+v", got)
}
cat := snap.Catalog()
if len(cat) != 1 || cat[0].Handler != "iso.handler" || cat[0].Slots[0].Enum[0] != "prod" {
t.Fatalf("catalog isolation after register mutate: %+v", cat)
}
opts := reg.Options()
opts.ProtectedDomains[0] = "hacked"
opts2 := reg.Options()
if opts2.ProtectedDomains[0] != "demo" {
t.Fatalf("Options isolation broken: %v", opts2.ProtectedDomains)
}
}
// TestU5ReturnedSpecMutationDoesNotAffectRegistry:改返回值不影响快照。
func TestU5ReturnedSpecMutationDoesNotAffectRegistry(t *testing.T) {
b := NewRegistryBuilder(Options{})
if err := b.Register(IntentSpec{
ID: "demo.iso.ret", Domain: "demo", Version: 1,
Matcher: MatchSpec{IncludesAny: []string{"隔离返回"}, Patterns: []string{`隔离返回`}},
Examples: []string{"例"},
Action: &ActionRef{Kind: ActionCommand, ID: "cmd.iso"},
Slots: []SlotSpec{{Key: "x", Pattern: `(x)`, Enum: []string{"a", "b"}}},
Metadata: map[string]string{"guidance": "g"},
}); err != nil {
t.Fatal(err)
}
reg, err := b.Build()
if err != nil {
t.Fatal(err)
}
snap := reg.Snapshot()
got, _ := snap.SpecByID("demo.iso.ret")
got.Examples[0] = "mut"
got.Action.ID = "cmd.mut"
got.Slots[0].Enum[0] = "z"
got.Metadata["guidance"] = "mut"
got.Matcher.Patterns[0] = "mut"
again, _ := snap.SpecByID("demo.iso.ret")
if again.Examples[0] != "例" || again.Action.ID != "cmd.iso" || again.Slots[0].Enum[0] != "a" {
t.Fatalf("SpecByID leak: %+v", again)
}
if again.Metadata["guidance"] != "g" || again.Matcher.Patterns[0] != `隔离返回` {
t.Fatalf("SpecByID matcher/meta leak: %+v", again)
}
cat := snap.Catalog()
cat[0].Slots[0].Enum[0] = "z"
cat[0].Metadata["guidance"] = "mut"
cat2 := snap.Catalog()
if cat2[0].Slots[0].Enum[0] != "a" || cat2[0].Metadata["guidance"] != "g" {
t.Fatalf("Catalog leak: %+v", cat2)
}
}
// TestU5GeneratedMatchesRuleValidationAndMetadata:同 action+slots 时生成路径与规则路径校验/元数据一致。
func TestU5GeneratedMatchesRuleValidationAndMetadata(t *testing.T) {
spec := IntentSpec{
ID: "report.generate", Domain: "report", Version: 1,
Matcher: MatchSpec{IncludesAny: []string{"生成报告"}},
Slots: []SlotSpec{{
Key: "topic", Required: true, Pattern: `主题是(.+)`, Group: 1,
Enum: []string{"电机", "泵"}, Normalize: []string{"trim"},
}},
Risk: RiskMedium,
RequiresConfirm: true,
Handler: "reportHandler",
Action: &ActionRef{Kind: ActionWorkflow, ID: "wf-report"},
Metadata: map[string]string{"guidance": "ask topic", "blueprint": "bp"},
}
b := NewRegistryBuilder(Options{})
if err := b.Register(spec); err != nil {
t.Fatal(err)
}
reg, err := b.Build()
if err != nil {
t.Fatal(err)
}
snap := reg.Snapshot()
rec := NewRecognizer(Options{}, nil, nil)
rule, err := rec.Recognize(context.Background(), "生成报告 主题是电机", snap)
if err != nil {
t.Fatal(err)
}
if rule.Branch != BranchMatched || rule.Top == nil {
t.Fatalf("rule: %+v", rule)
}
gen := stubGenerator{cmds: []Command{
{Kind: CommandStart, Action: ActionRef{Kind: ActionWorkflow, ID: "wf-report"},
Slots: map[string]string{"topic": "电机"}},
}}
genRec := NewRecognizer(Options{Generator: gen}, nil, nil)
out, err := genRec.Recognize(context.Background(), "完全不相关的一句话", snap)
if err != nil {
t.Fatal(err)
}
if out.Branch != BranchMatched || out.Top == nil {
t.Fatalf("generated: %+v", out)
}
if out.Top.ID != rule.Top.ID || out.Top.Risk != rule.Top.Risk || out.Top.RequiresConfirm != rule.Top.RequiresConfirm {
t.Fatalf("metadata mismatch rule=%+v gen=%+v", rule.Top, out.Top)
}
if out.Top.Handler != rule.Top.Handler {
t.Fatalf("handler mismatch rule=%q gen=%q", rule.Top.Handler, out.Top.Handler)
}
if out.Top.Action == nil || rule.Top.Action == nil || out.Top.Action.Kind != rule.Top.Action.Kind || out.Top.Action.ID != rule.Top.Action.ID {
t.Fatalf("action mismatch rule=%+v gen=%+v", rule.Top.Action, out.Top.Action)
}
if out.Top.Slots["topic"] != rule.Top.Slots["topic"] {
t.Fatalf("slots mismatch rule=%v gen=%v", rule.Top.Slots, out.Top.Slots)
}
if len(out.Top.MissingRequiredSlots) != 0 || len(out.Top.InvalidSlots) != 0 {
t.Fatalf("unexpected missing/invalid: %+v", out.Top)
}
}
// TestU5GeneratedSlotValidationStable:缺必填/非法枚举/未知键/重复 handler 在生成路径稳定处理。
func TestU5GeneratedSlotValidationStable(t *testing.T) {
mk := func(t *testing.T, specs ...IntentSpec) *Snapshot {
t.Helper()
b := NewRegistryBuilder(Options{})
if err := b.Register(specs...); err != nil {
t.Fatal(err)
}
reg, err := b.Build()
if err != nil {
t.Fatal(err)
}
return reg.Snapshot()
}
base := IntentSpec{
ID: "report.generate", Domain: "report", Version: 1,
Matcher: MatchSpec{IncludesAny: []string{"生成报告"}},
Slots: []SlotSpec{{
Key: "topic", Required: true, Pattern: `主题是(.+)`,
Enum: []string{"电机", "泵"},
}},
Risk: RiskHigh, RequiresConfirm: true, Handler: "reportHandler",
Action: &ActionRef{Kind: ActionCommand, ID: "cmd.report"},
}
t.Run("missing_required", func(t *testing.T) {
snap := mk(t, base)
rec := NewRecognizer(Options{Generator: stubGenerator{cmds: []Command{
{Kind: CommandStart, Action: ActionRef{Kind: ActionCommand, ID: "cmd.report"}, Slots: map[string]string{}},
}}}, nil, nil)
res, err := rec.Recognize(context.Background(), "无关输入", snap)
if err != nil {
t.Fatal(err)
}
if res.Branch != BranchMatched || res.Top == nil {
t.Fatalf("want matched with missing: %+v", res)
}
if len(res.Top.MissingRequiredSlots) != 1 || res.Top.MissingRequiredSlots[0] != "topic" {
t.Fatalf("missing: %+v", res.Top.MissingRequiredSlots)
}
if res.Top.Risk != RiskHigh || !res.Top.RequiresConfirm {
t.Fatalf("metadata lost: %+v", res.Top)
}
})
t.Run("invalid_enum", func(t *testing.T) {
snap := mk(t, base)
rec := NewRecognizer(Options{Generator: stubGenerator{cmds: []Command{
{Kind: CommandStart, Action: ActionRef{Kind: ActionHandler, ID: "reportHandler"},
Slots: map[string]string{"topic": "涡轮"}},
}}}, nil, nil)
res, err := rec.Recognize(context.Background(), "无关输入", snap)
if err != nil {
t.Fatal(err)
}
if res.Branch != BranchMatched || res.Top == nil {
t.Fatalf("want matched with invalid: %+v", res)
}
if res.Top.InvalidSlots["topic"] == "" {
t.Fatalf("invalid slots: %+v", res.Top.InvalidSlots)
}
if res.Top.Slots["topic"] != "" {
t.Fatalf("invalid value must not fill slots: %+v", res.Top.Slots)
}
})
t.Run("unknown_slot", func(t *testing.T) {
snap := mk(t, base)
rec := NewRecognizer(Options{Generator: stubGenerator{cmds: []Command{
{Kind: CommandStart, Action: ActionRef{Kind: ActionWorkflow, ID: "report.generate"},
Slots: map[string]string{"bogus": "v"}},
}}}, nil, nil)
res, err := rec.Recognize(context.Background(), "无关输入", snap)
if err != nil {
t.Fatal(err)
}
if res.Branch != BranchFallback || res.Top != nil {
t.Fatalf("unknown slot must fallback: %+v", res)
}
})
t.Run("duplicate_handler", func(t *testing.T) {
a := base
a.ID = "report.a"
a.Handler = "shared"
bSpec := base
bSpec.ID = "report.b"
bSpec.Handler = "shared"
bSpec.Action = &ActionRef{Kind: ActionCommand, ID: "cmd.b"}
snap := mk(t, a, bSpec)
rec := NewRecognizer(Options{Generator: stubGenerator{cmds: []Command{
{Kind: CommandStart, Action: ActionRef{Kind: ActionHandler, ID: "shared"},
Slots: map[string]string{"topic": "电机"}},
}}}, nil, nil)
res, err := rec.Recognize(context.Background(), "无关输入", snap)
if err != nil {
t.Fatal(err)
}
if res.Branch != BranchFallback || res.Top != nil {
t.Fatalf("ambiguous handler must fallback: %+v", res)
}
})
}
// TestU5GeneratedActionAliasRoutesToSpec:Action 指向不同 command ID 时仍解析到权威 spec。
func TestU5GeneratedActionAliasRoutesToSpec(t *testing.T) {
b := NewRegistryBuilder(Options{})
if err := b.Register(IntentSpec{
ID: "report.generate", Domain: "report", Version: 1,
Matcher: MatchSpec{IncludesAny: []string{"生成报告"}},
Slots: []SlotSpec{{Key: "topic", Pattern: `(.+)`}},
Action: &ActionRef{Kind: ActionCommand, ID: "cmd.other-id"},
Handler: "h",
Risk: RiskLow,
}); err != nil {
t.Fatal(err)
}
reg, err := b.Build()
if err != nil {
t.Fatal(err)
}
rec := NewRecognizer(Options{Generator: stubGenerator{cmds: []Command{
{Kind: CommandStart, Action: ActionRef{Kind: ActionCommand, ID: "cmd.other-id"},
Slots: map[string]string{"topic": "x"}},
}}}, nil, nil)
res, err := rec.Recognize(context.Background(), "无关输入", reg.Snapshot())
if err != nil {
t.Fatal(err)
}
if res.Branch != BranchMatched || res.Top == nil || res.Top.ID != "report.generate" {
t.Fatalf("alias resolve: %+v", res)
}
if res.Top.Action == nil || res.Top.Action.ID != "cmd.other-id" {
t.Fatalf("action metadata: %+v", res.Top.Action)
}
}
// TestU5ClassifierAndSemanticRejectBadScores:非有限/越界分数不得变成 matched。
func TestU5ClassifierAndSemanticRejectBadScores(t *testing.T) {
snap := ambiguousFixture(t, []string{"查运行进度"}, []string{"闲聊问候"})
t.Run("classifier_inf", func(t *testing.T) {
rec := NewRecognizer(Options{}, nil, rewriteClassifier{fn: func(cands []Candidate) []Candidate {
out := append([]Candidate(nil), cands...)
out[0].Confidence = math.Inf(1)
out[1].Confidence = 0.1
return out
}})
res, err := rec.Recognize(context.Background(), "模糊词", snap)
if err != nil {
t.Fatal(err)
}
if res.Branch != BranchAmbiguous || res.Top != nil {
t.Fatalf("inf must stay ambiguous: %+v", res)
}
})
t.Run("classifier_in_place_mutation", func(t *testing.T) {
rec := NewRecognizer(Options{}, nil, rewriteClassifier{fn: func(cands []Candidate) []Candidate {
cands[0].Handler = "tampered"
cands[0].Confidence = 0.99
cands[1].Confidence = 0.1
return cands
}})
res, err := rec.Recognize(context.Background(), "模糊词", snap)
if err != nil {
t.Fatal(err)
}
if res.Branch != BranchMatched || res.Top == nil {
t.Fatalf("expected matched: %+v", res)
}
if res.Top.Handler == "tampered" {
t.Fatalf("classifier in-place mutation leaked into top: %+v", res.Top)
}
})
t.Run("semantic_nan", func(t *testing.T) {
rec := NewRecognizer(Options{Semantic: fakeScorer{scores: map[string]float64{"查运行进度": math.NaN()}}}, nil, nil)
res, err := rec.Recognize(context.Background(), "模糊词", snap)
if err != nil {
t.Fatal(err)
}
if res.Branch != BranchAmbiguous || res.Top != nil {
t.Fatalf("nan semantic must stay ambiguous: %+v", res)
}
})
t.Run("semantic_above_one", func(t *testing.T) {
rec := NewRecognizer(Options{Semantic: fakeScorer{scores: map[string]float64{"查运行进度": 1.5}}}, nil, nil)
res, err := rec.Recognize(context.Background(), "模糊词", snap)
if err != nil {
t.Fatal(err)
}
if res.Branch != BranchAmbiguous || res.Top != nil {
t.Fatalf("out-of-range semantic must stay ambiguous: %+v", res)
}
})
t.Run("semantic_inf_not_matched", func(t *testing.T) {
rec := NewRecognizer(Options{Semantic: fakeScorer{scores: map[string]float64{"查运行进度": math.Inf(1)}}}, nil, nil)
res, err := rec.Recognize(context.Background(), "模糊词", snap)
if err != nil {
t.Fatal(err)
}
if res.Branch != BranchAmbiguous || res.Top != nil {
t.Fatalf("inf semantic must stay ambiguous: %+v", res)
}
})
}