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:
Sycun committed 2026-10-13 00:10:33 +08:00
1 parent 16189e0b76
commit 3d2dfdac36
10 files changed
+341 -15

No files matched your search

+8 -5
View File
@@ -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)
}
}
+7
View File
@@ -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 {