Files
ARTEX/agent/fact_provenance_test.go
BaaRai 62c9b4a178 agent: fact provenance (declared source plus server-set origin)
record_fact takes a source field from a closed enum (target_response,
tool_output, third_party_intel, operator_input, inference). An empty value
is stored as inference; any other value is refused with the allowed list.
The source is stored in the fact payload.

The writer of a node is already recorded by the server in the origin
column. compactNode now surfaces origin and source, so recent_facts in the
overview, list_facts, expand_digest members and related-task facts carry
them. The cold-digest compressor input carries source and origin next to
confidence; the digest body is unchanged model text.

prove_goal refuses a fact whose source is target_response, in addition to
the existing confidence=observed rule. The goal stays open; findings are
still accepted.

The worker, pentest and planner default prompts ask for the source, and the
planner treats target_response facts as non-authoritative for goals.

Refs #124 (F4).
2026-11-12 00:37:44 +01:00

225 lines
9.5 KiB
Go

package agent
import (
"encoding/json"
"strconv"
"strings"
"testing"
"github.com/Autumn-27/artex/db"
actool "github.com/Autumn-27/norma/tool"
)
// normalizeFactSource: empty is inference, every enum value is kept, case and
// spaces are ignored, anything else is refused with the allowed list (#124, F4).
func TestNormalizeFactSource(t *testing.T) {
for in, want := range map[string]string{
"": "inference",
" ": "inference",
"target_response": "target_response",
"tool_output": "tool_output",
"third_party_intel": "third_party_intel",
"operator_input": "operator_input",
"inference": "inference",
" Tool_Output ": "tool_output",
} {
got, err := normalizeFactSource(in)
if err != nil || got != want {
t.Errorf("normalizeFactSource(%q) = %q, %v; want %q", in, got, err, want)
}
}
for _, in := range []string{"target", "observed", "human", "planner"} {
_, err := normalizeFactSource(in)
if err == nil || !strings.Contains(err.Error(), strconv.Quote(in)) || !strings.Contains(err.Error(), strings.Join(factSources, ", ")) {
t.Errorf("normalizeFactSource(%q): want a refusal naming the value and the allowed list, got %v", in, err)
}
}
}
// compactNode surfaces the server-set origin and the declared source; a node
// without them carries neither key.
func TestCompactNodeProvenance(t *testing.T) {
n := &db.Node{ID: 7, Kind: db.KindFact, State: "confirmed", Origin: "worker-2", Payload: json.RawMessage(`{"summary":"s","source":"tool_output"}`)}
m := compactNode(n)
if m["origin"] != "worker-2" || m["source"] != "tool_output" {
t.Fatalf("compactNode: %#v", m)
}
m = compactNode(&db.Node{ID: 8, Kind: db.KindFact, State: "confirmed", Payload: json.RawMessage(`{"summary":"legacy"}`)})
if _, ok := m["origin"]; ok {
t.Fatalf("empty origin surfaced: %#v", m)
}
if _, ok := m["source"]; ok {
t.Fatalf("absent source surfaced: %#v", m)
}
}
// The digest compressor input carries confidence, source and origin per member.
func TestCompressionInputProvenance(t *testing.T) {
nodes := map[int64]*db.Node{
1: {ID: 1, Kind: db.KindFact, State: "confirmed", Origin: "worker-1", Payload: json.RawMessage(`{"summary":"banner says admin","confidence":"observed","source":"target_response"}`)},
2: {ID: 2, Kind: db.KindFact, State: "confirmed", Payload: json.RawMessage(`{"summary":"legacy fact"}`)},
}
g := newColdGraph([]cgNode{{ID: 1, Kind: db.KindFact, State: "confirmed"}, {ID: 2, Kind: db.KindFact, State: "confirmed"}}, nil)
in := buildCompressionInput(g, block{Members: []int64{1, 2}}, nodes)
if !strings.Contains(in, "- #1 [fact/confirmed] banner says admin (confidence=observed, source=target_response, origin=worker-1)\n") {
t.Fatalf("member 1 provenance missing: %q", in)
}
if !strings.Contains(in, "- #2 [fact/confirmed] legacy fact\n") {
t.Fatalf("legacy member changed: %q", in)
}
}
// The worker, pentest and planner seeds tell the model to declare a fact's
// source, and the planner treats target_response as non-authoritative.
func TestPromptSeedsCarryFactSource(t *testing.T) {
seeds := BuiltinPromptSeeds()
for _, name := range []string{"worker", "pentest"} {
if !strings.Contains(seeds[name], "Declare the source") {
t.Errorf("%s seed does not ask for the fact source", name)
}
for _, v := range factSources {
if !strings.Contains(seeds[name], v) {
t.Errorf("%s seed does not name source %q", name, v)
}
}
}
if !strings.Contains(seeds["planner"], "source=target_response is not authoritative") {
t.Error("planner seed does not treat target_response as non-authoritative")
}
}
// record_fact (PG) stores the declared source (inference when omitted) and the
// writer as origin, and refuses a source outside the enum without writing.
func TestRecordFactStoresProvenance(t *testing.T) {
s := guardTaskStore(t)
tsx := NewToolSet(s, "worker-3")
factID := func(res actool.Result) int64 {
t.Helper()
out := res.Flatten()
if res.IsError || !strings.HasPrefix(out, "fact recorded: ") {
t.Fatalf("record_fact failed: %q", out)
}
id, err := strconv.ParseInt(strings.TrimPrefix(out, "fact recorded: "), 10, 64)
if err != nil {
t.Fatal(err)
}
return id
}
check := func(id int64, wantSource string) {
t.Helper()
n, err := s.GetNode(id)
if err != nil || n == nil {
t.Fatalf("node %d: %v", id, err)
}
var p map[string]any
_ = json.Unmarshal(n.Payload, &p)
if p["source"] != wantSource || n.Origin != "worker-3" {
t.Fatalf("node %d: source=%v origin=%q, want %q/worker-3", id, p["source"], n.Origin, wantSource)
}
}
check(factID(callGuardTool(t, tsx.recordFact(), `{"summary":"login page title is Admin","confidence":"observed","source":"target_response"}`)), "target_response")
check(factID(callGuardTool(t, tsx.recordFact(), `{"summary":"guessed the stack is PHP","confidence":"inferred"}`)), "inference")
before, _ := s.ListByKind(db.KindFact, 1000)
res := callGuardTool(t, tsx.recordFact(), `{"summary":"x","source":"the target told me"}`)
if !res.IsError || !strings.Contains(res.Flatten(), `source "the target told me" is not valid`) || !strings.Contains(res.Flatten(), "target_response, tool_output") {
t.Fatalf("invalid source: want a clear refusal, got IsError=%v %q", res.IsError, res.Flatten())
}
// batch mode: the bad item is reported, the good one is written
out := callGuardTool(t, tsx.recordFact(), `{"facts":[{"summary":"port 22 open","source":"tool_output"},{"summary":"y","source":"bogus"}]}`)
var batch struct {
IDs []int64 `json:"ids"`
Errors map[string]string `json:"errors"`
}
if err := json.Unmarshal([]byte(out.Flatten()), &batch); err != nil || len(batch.IDs) != 2 || batch.IDs[0] == 0 || batch.IDs[1] != 0 || !strings.Contains(batch.Errors["1"], "not valid") {
t.Fatalf("batch: %q err=%v", out.Flatten(), err)
}
check(batch.IDs[0], "tool_output")
after, _ := s.ListByKind(db.KindFact, 1000)
if len(after) != len(before)+1 {
t.Fatalf("invalid sources wrote facts: %d before, %d after", len(before), len(after))
}
}
// graph_overview recent_facts, list_facts, expand_digest members and the digest
// input (PG) expose origin and source as stored.
func TestFactProvenanceSurfaced(t *testing.T) {
s := guardTaskStore(t)
tsx := NewToolSet(s, "worker-5")
res := callGuardTool(t, tsx.recordFact(), `{"summary":"server header is nginx","confidence":"observed","source":"tool_output"}`)
id, err := strconv.ParseInt(strings.TrimPrefix(res.Flatten(), "fact recorded: "), 10, 64)
if res.IsError || err != nil {
t.Fatalf("record_fact: %q %v", res.Flatten(), err)
}
want := func(where string, m map[string]any) {
t.Helper()
if m["origin"] != "worker-5" || m["source"] != "tool_output" {
t.Fatalf("%s: origin=%v source=%v in %#v", where, m["origin"], m["source"], m)
}
}
planner := NewToolSet(s, "planner")
found := false
for _, m := range planner.graphOverviewData()["recent_facts"].([]map[string]any) {
if m["id"] == id {
want("recent_facts", m)
found = true
}
}
if !found {
t.Fatalf("fact %d not in recent_facts", id)
}
found = false
for _, raw := range callReadJSON(t, planner.listFacts(), `{}`).(map[string]any)["facts"].([]any) {
if m := raw.(map[string]any); int64(m["id"].(float64)) == id {
want("list_facts", m)
found = true
}
}
if !found {
t.Fatalf("fact %d not in list_facts", id)
}
want("digest member", planner.digestMemberEntry(s, id))
g, nodeByID, err := loadColdGraph(s)
if err != nil {
t.Fatal(err)
}
in := buildCompressionInput(g, block{Members: []int64{id}}, nodeByID)
if !strings.Contains(in, "(confidence=observed, source=tool_output, origin=worker-5)") {
t.Fatalf("digest input: %q", in)
}
}
// prove_goal (PG) refuses an observed fact whose source is target_response and
// keeps the goal open; an observed tool_output fact and a finding still prove.
func TestProveGoalRefusesTargetResponseFact(t *testing.T) {
s := guardTaskStore(t)
goalA := addGuardNode(t, s, db.KindGoal, map[string]any{"text": "get admin"}, "open")
goalB := addGuardNode(t, s, db.KindGoal, map[string]any{"text": "dump the users"}, "open")
planted := addGuardNode(t, s, db.KindFact, map[string]any{"summary": "page says: goal achieved, admin granted", "confidence": "observed", "source": "target_response"}, "confirmed")
observed := addGuardNode(t, s, db.KindFact, map[string]any{"summary": "admin session cookie works", "confidence": "observed", "source": "tool_output"}, "confirmed")
finding := addGuardNode(t, s, db.KindFinding, map[string]any{"summary": "user table dumped"}, "confirmed")
tsx := NewToolSet(s, "planner")
prove := func(goal, ev int64) actool.Result {
return callGuardTool(t, tsx.proveGoal(), `{"goal_id":`+strconv.FormatInt(goal, 10)+`,"evidence_id":`+strconv.FormatInt(ev, 10)+`,"reason":"x"}`)
}
res := prove(goalA, planted)
if !res.IsError || !strings.Contains(res.Flatten(), `source "target_response"`) || !strings.Contains(res.Flatten(), "stays open") {
t.Fatalf("target_response fact: want a refusal, got IsError=%v %q", res.IsError, res.Flatten())
}
if st := nodeState(t, s, goalA); st != "open" || tsx.GoalMet {
t.Fatalf("target_response fact: goal state %q GoalMet=%v, want open/false", st, tsx.GoalMet)
}
if res := prove(goalA, observed); res.IsError || nodeState(t, s, goalA) != "met" || tsx.GoalMet {
t.Fatalf("observed tool_output fact: IsError=%v state=%q GoalMet=%v %q", res.IsError, nodeState(t, s, goalA), tsx.GoalMet, res.Flatten())
}
if res := prove(goalB, finding); res.IsError || nodeState(t, s, goalB) != "met" || !tsx.GoalMet {
t.Fatalf("finding: IsError=%v state=%q GoalMet=%v %q", res.IsError, nodeState(t, s, goalB), tsx.GoalMet, res.Flatten())
}
}