mirror of
https://github.com/Ed1s0nZ/CyberStrikeAI.git
synced 2026-10-19 22:17:42 +02:00
A node claiming something ran must cite a tool_call_id the host issued in the trajectory. A citation absent from the trajectory is invented, so the node is dropped along with its edges; a missing citation counts as invented only when every real ID survived budget fitting, so a trimmed payload cannot silently empty a graph. Judging is skipped where the evidence cannot be enumerated: the process-detail supplement dumps raw event JSON and the messages-table fallback carries no call IDs. Verified citations land in node metadata rather than tool_execution_id, which is foreign-keyed to tool_executions. Also close two smaller holes: fact text can no longer carry the host index delimiters, which would close the block early and spill the remaining index into the system prompt; and the console fact endpoints reject the reserved note/context-* prefix, matching the MCP tool layer.
111 lines
4.1 KiB
Go
111 lines
4.1 KiB
Go
package attackchain
|
|
|
|
import (
|
|
"strings"
|
|
"testing"
|
|
|
|
"cyberstrike-ai/internal/agent"
|
|
|
|
"go.uber.org/zap"
|
|
)
|
|
|
|
func traceWithToolCalls(ids ...string) []agent.ChatMessage {
|
|
var msgs []agent.ChatMessage
|
|
for _, id := range ids {
|
|
msgs = append(msgs,
|
|
agent.ChatMessage{Role: "assistant", ToolCalls: []agent.ToolCall{{ID: id, Function: agent.FunctionCall{Name: "scan"}}}},
|
|
agent.ChatMessage{Role: "tool", ToolCallID: id, Content: "result " + id},
|
|
)
|
|
}
|
|
return msgs
|
|
}
|
|
|
|
func TestNewToolEvidenceSkipsSourcesItCannotEnumerate(t *testing.T) {
|
|
b := &Builder{logger: zap.NewNop()}
|
|
msgs := traceWithToolCalls("call-1")
|
|
prompt := b.formatAgentTraceFromChatMessages(msgs)
|
|
if ev := newToolEvidence(msgs, prompt, true); ev != nil {
|
|
t.Fatalf("process-detail supplement must disable judging: %#v", ev)
|
|
}
|
|
if ev := newToolEvidence(nil, prompt, false); ev != nil {
|
|
t.Fatalf("messages-table fallback must disable judging: %#v", ev)
|
|
}
|
|
if ev := newToolEvidence([]agent.ChatMessage{{Role: "user", Content: "hi"}}, prompt, false); ev != nil {
|
|
t.Fatalf("a trace without tool calls must disable judging: %#v", ev)
|
|
}
|
|
ev := newToolEvidence(msgs, prompt, false)
|
|
if ev == nil || len(ev.real) != 1 || len(ev.shown) != 1 {
|
|
t.Fatalf("host-issued IDs not enumerated: %#v", ev)
|
|
}
|
|
}
|
|
|
|
func TestToolEvidenceKeepRules(t *testing.T) {
|
|
b := &Builder{logger: zap.NewNop()}
|
|
msgs := traceWithToolCalls("call-1", "call-2")
|
|
full := newToolEvidence(msgs, b.formatAgentTraceFromChatMessages(msgs), false)
|
|
// The token budget cut one call away, so a missing citation is ambiguous.
|
|
partial := newToolEvidence(msgs, "only call-1 survived", false)
|
|
var none *toolEvidence
|
|
for _, tc := range []struct {
|
|
name string
|
|
ev *toolEvidence
|
|
cited string
|
|
wantKeep bool
|
|
}{
|
|
{"no evidence never judges an uncited node", none, "", true},
|
|
{"no evidence never judges a citation", none, "call-9", true},
|
|
{"citation present in the trajectory", full, "call-1", true},
|
|
{"citation absent from the trajectory", full, "call-9", false},
|
|
{"uncited while every ID was still visible", full, "", false},
|
|
{"uncited while an ID was cut by the budget", partial, "", true},
|
|
{"fabricated citation is dropped even when cut", partial, "call-9", false},
|
|
} {
|
|
if got := tc.ev.keep(tc.cited); got != tc.wantKeep {
|
|
t.Errorf("%s: keep=%v want %v", tc.name, got, tc.wantKeep)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestParseChainJSONDropsNodesWithoutRealToolEvidence(t *testing.T) {
|
|
b := &Builder{logger: zap.NewNop()}
|
|
msgs := traceWithToolCalls("call-1")
|
|
ev := newToolEvidence(msgs, b.formatAgentTraceFromChatMessages(msgs), false)
|
|
raw := `{"nodes":[
|
|
{"id":"t","type":"target","label":"scope.example"},
|
|
{"id":"ok","type":"action","label":"Real scan","tool_call_id":"call-1"},
|
|
{"id":"nested","type":"action","label":"Nested citation","metadata":{"tool_call_id":"call-1"}},
|
|
{"id":"forged","type":"action","label":"Invented scan","tool_call_id":"call-9"},
|
|
{"id":"nestedforged","type":"action","label":"Nested invented","metadata":{"tool_call_id":"call-9"}},
|
|
{"id":"uncited","type":"vulnerability","label":"Invented SQLi"},
|
|
{"id":"again","type":"action","label":"Second real scan","tool_call_id":"call-1"}
|
|
],"edges":[
|
|
{"source":"t","target":"ok","type":"leads_to"},
|
|
{"source":"ok","target":"forged","type":"leads_to"},
|
|
{"source":"ok","target":"uncited","type":"discovers"}
|
|
]}`
|
|
chain, err := b.parseChainJSON(raw, ev)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
labels := make([]string, 0, len(chain.Nodes))
|
|
for _, n := range chain.Nodes {
|
|
labels = append(labels, n.Label)
|
|
switch n.Label {
|
|
case "Real scan", "Nested citation", "Second real scan":
|
|
if n.Metadata["tool_call_id"] != "call-1" {
|
|
t.Fatalf("verified citation not recorded on %q: %#v", n.Label, n.Metadata)
|
|
}
|
|
case "scope.example":
|
|
if _, ok := n.Metadata["tool_call_id"]; ok {
|
|
t.Fatalf("target node gained a citation: %#v", n.Metadata)
|
|
}
|
|
}
|
|
}
|
|
if strings.Join(labels, "|") != "scope.example|Real scan|Nested citation|Second real scan" {
|
|
t.Fatalf("evidence gate kept the wrong nodes: %v", labels)
|
|
}
|
|
if len(chain.Edges) != 1 {
|
|
t.Fatalf("edges pointing at dropped nodes survived: %#v", chain.Edges)
|
|
}
|
|
}
|