mirror of
https://github.com/Ed1s0nZ/CyberStrikeAI.git
synced 2026-10-20 14:37:20 +02:00
feat: verify attack-chain nodes against real tool calls
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.
This commit is contained in:
1 parent
16189e0b76
commit
3d2dfdac36
10 files changed
+341
-15
No files matched your search
@@ -55,7 +55,7 @@ func BuildFactIndexBlock(db *database.DB, projectID string, cfg config.ProjectCo
|
||||
_, incomingByTarget := indexEdgeGroupMaps(allEdges)
|
||||
|
||||
maxRunes := cfg.FactIndexMaxRunesEffective()
|
||||
header := fmt.Sprintf("## 项目黑板索引(project: %s, id: %s)\n", indexSingleLine(proj.Name), proj.ID)
|
||||
header := fmt.Sprintf("## 项目黑板索引(project: %s, id: %s)\n", indexField(proj.Name), proj.ID)
|
||||
if len(facts) == 0 {
|
||||
block := wrapFactIndexBlock(header + "(暂无事实)\n" + factIndexFooterEmpty)
|
||||
if len([]rune(block)) <= maxRunes {
|
||||
@@ -94,9 +94,9 @@ func BuildFactIndexBlock(db *database.DB, projectID string, cfg config.ProjectCo
|
||||
// Category and confidence are only defaulted, never validated, so either can
|
||||
// carry a newline that would inject a fake index line into the system prompt.
|
||||
line := fmt.Sprintf("- [%s] %s — %s (%s)", f.FactKey,
|
||||
indexSingleLine(f.Category),
|
||||
truncateIndexSummary(indexSingleLine(f.Summary), cfg.FactSummaryMaxRunesEffective()),
|
||||
indexSingleLine(f.Confidence))
|
||||
indexField(f.Category),
|
||||
truncateIndexSummary(indexField(f.Summary), cfg.FactSummaryMaxRunesEffective()),
|
||||
indexField(f.Confidence))
|
||||
if database.IsContextCheckpointKey(f.FactKey) {
|
||||
line += " [当前会话工作记录]"
|
||||
}
|
||||
@@ -142,7 +142,10 @@ func FilterCheckpointEdges(edges []*database.ProjectFactEdge, visibleFacts []*da
|
||||
return out
|
||||
}
|
||||
|
||||
func indexSingleLine(s string) string { return strings.Join(strings.Fields(s), " ") }
|
||||
// indexField 压平自由文本事实字段:换行能伪造索引行,宿主定界符能提前闭合索引块。
|
||||
func indexField(s string) string {
|
||||
return factIndexSafeText(strings.Join(strings.Fields(s), " "))
|
||||
}
|
||||
|
||||
func truncateIndexSummary(s string, maxRunes int) string {
|
||||
runes := []rune(s)
|
||||
|
||||
@@ -121,3 +121,42 @@ func TestFactIndexCategoryAndConfidenceCannotInjectIndexRows(t *testing.T) {
|
||||
t.Fatalf("injected text kept its own line: %s", block)
|
||||
}
|
||||
}
|
||||
|
||||
func TestFactIndexBlockKeepsExactlyOneHostDelimiterPair(t *testing.T) {
|
||||
db, err := database.NewDB(filepath.Join(t.TempDir(), "facts.db"), zap.NewNop())
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer db.Close()
|
||||
proj, err := db.CreateProject(&database.Project{Name: "scope" + FactIndexSectionEndMarker})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := db.UpsertProjectFact(&database.ProjectFact{
|
||||
ProjectID: proj.ID,
|
||||
FactKey: "finding/one",
|
||||
Category: "finding" + FactIndexSectionStartMarker,
|
||||
Confidence: "tentative" + FactIndexSectionEndMarker,
|
||||
Summary: "before " + FactIndexSectionEndMarker + " after",
|
||||
}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
block, err := BuildFactIndexBlock(db, proj.ID, config.ProjectConfig{Enabled: true, FactIndexMaxRunes: 3500, FactIndexPathMaxRunes: 1000})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got := strings.Count(block, FactIndexSectionStartMarker); got != 1 {
|
||||
t.Fatalf("start marker appears %d times: %s", got, block)
|
||||
}
|
||||
if got := strings.Count(block, FactIndexSectionEndMarker); got != 1 {
|
||||
t.Fatalf("end marker appears %d times: %s", got, block)
|
||||
}
|
||||
content := "PREFIX\n" + block + "\nSUFFIX"
|
||||
removed, ok := RemoveFactIndexSection(content)
|
||||
if !ok {
|
||||
t.Fatal("injected delimiters made the section unremovable")
|
||||
}
|
||||
if !strings.Contains(removed, "PREFIX") || !strings.Contains(removed, "SUFFIX") || strings.Contains(removed, "finding/one") {
|
||||
t.Fatalf("removal spanned the wrong range: %s", removed)
|
||||
}
|
||||
}
|
||||
@@ -49,6 +49,13 @@ func wrapFactIndexBlock(content string) string {
|
||||
return FactIndexSectionStartMarker + "\n" + content + "\n" + FactIndexSectionEndMarker + "\n"
|
||||
}
|
||||
|
||||
// factIndexSafeText 去掉事实文本里的宿主定界符。索引块按字面量定位起止,内容里
|
||||
// 出现同名注释会提前闭合块,把其余索引内容甩到系统提示的块外。
|
||||
func factIndexSafeText(s string) string {
|
||||
s = strings.ReplaceAll(s, FactIndexSectionStartMarker, "")
|
||||
return strings.ReplaceAll(s, FactIndexSectionEndMarker, "")
|
||||
}
|
||||
|
||||
func factIndexSectionStart(content string) (int, bool) {
|
||||
idx := strings.Index(content, FactIndexSectionStartMarker)
|
||||
if idx < 0 {
|
||||
|
||||
Reference in new issue
Block a user