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

+15
View File
@@ -422,6 +422,9 @@ func (h *ProjectHandler) GetFactGraph(c *gin.Context) {
c.JSON(http.StatusOK, graph)
}
// note/context-* 由压缩流程独占写入;手工改写会让目标会话把它当成当前工作记录。
const reservedContextCheckpointError = "note/context-* 为系统续接记录,不能在控制台创建或改写;项目共享事实请使用独立 fact_key"
// CreateFact POST /api/projects/:id/facts
func (h *ProjectHandler) CreateFact(c *gin.Context) {
var req upsertFactRequest
@@ -429,6 +432,10 @@ func (h *ProjectHandler) CreateFact(c *gin.Context) {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
if database.IsContextCheckpointKey(req.FactKey) {
c.JSON(http.StatusBadRequest, gin.H{"error": reservedContextCheckpointError})
return
}
projectID := c.Param("id")
f := &database.ProjectFact{
ProjectID: projectID,
@@ -463,6 +470,10 @@ func (h *ProjectHandler) UpdateFact(c *gin.Context) {
return
}
oldFactKey := existing.FactKey
if database.IsContextCheckpointKey(oldFactKey) {
c.JSON(http.StatusBadRequest, gin.H{"error": reservedContextCheckpointError})
return
}
var req updateFactRequest
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
@@ -470,6 +481,10 @@ func (h *ProjectHandler) UpdateFact(c *gin.Context) {
}
if req.FactKey != nil {
if k := strings.TrimSpace(*req.FactKey); k != "" {
if database.IsContextCheckpointKey(k) {
c.JSON(http.StatusBadRequest, gin.H{"error": reservedContextCheckpointError})
return
}
existing.FactKey = k
}
}
@@ -1,12 +1,15 @@
package handler
import (
"net/http"
"net/http/httptest"
"path/filepath"
"strings"
"testing"
"cyberstrike-ai/internal/config"
"cyberstrike-ai/internal/database"
"github.com/gin-gonic/gin"
"go.uber.org/zap"
)
@@ -43,3 +46,61 @@ func TestProjectBlackboardInitialInjectionUsesCurrentConversation(t *testing.T)
t.Fatalf("initial prompt mixed sessions: %s", block)
}
}
func TestConsoleCannotCreateOrRewriteReservedCheckpointKeys(t *testing.T) {
db, err := database.NewDB(filepath.Join(t.TempDir(), "reserved.db"), zap.NewNop())
if err != nil {
t.Fatal(err)
}
defer db.Close()
p, err := db.CreateProject(&database.Project{Name: "reserved"})
if err != nil {
t.Fatal(err)
}
checkpoint, err := db.UpsertProjectFact(&database.ProjectFact{
ProjectID: p.ID, FactKey: "note/context-victim", SourceConversationID: "victim",
Summary: "original progress", Body: "ORIGINAL_BODY",
})
if err != nil {
t.Fatal(err)
}
ordinary, err := db.UpsertProjectFact(&database.ProjectFact{ProjectID: p.ID, FactKey: "finding/ordinary", Summary: "shared"})
if err != nil {
t.Fatal(err)
}
h := NewProjectHandler(db, zap.NewNop())
request := func(method, factID, payload string) (*httptest.ResponseRecorder, *gin.Context) {
w := httptest.NewRecorder()
c, _ := gin.CreateTestContext(w)
c.Params = gin.Params{{Key: "id", Value: p.ID}, {Key: "factId", Value: factID}}
c.Request = httptest.NewRequest(method, "/", strings.NewReader(payload))
c.Request.Header.Set("Content-Type", "application/json")
return w, c
}
w, c := request(http.MethodPost, "", `{"fact_key":"note/context-forged","summary":"forged current work"}`)
h.CreateFact(c)
if w.Code != http.StatusBadRequest {
t.Fatalf("console created a reserved key: %d %s", w.Code, w.Body.String())
}
w, c = request(http.MethodPut, ordinary.ID, `{"fact_key":"note/context-forged"}`)
h.UpdateFact(c)
if w.Code != http.StatusBadRequest {
t.Fatalf("console renamed a fact into the reserved prefix: %d %s", w.Code, w.Body.String())
}
w, c = request(http.MethodPut, checkpoint.ID, `{"summary":"rewritten","body":"REWRITTEN"}`)
h.UpdateFact(c)
if w.Code != http.StatusBadRequest {
t.Fatalf("console rewrote another conversation's checkpoint: %d %s", w.Code, w.Body.String())
}
stored, err := db.GetProjectFact(checkpoint.ID)
if err != nil || stored.Summary != "original progress" || stored.Body != "ORIGINAL_BODY" || stored.SourceConversationID != "victim" {
t.Fatalf("rejected requests still modified the checkpoint: %+v %v", stored, err)
}
if renamed, err := db.GetProjectFact(ordinary.ID); err != nil || renamed.FactKey != "finding/ordinary" {
t.Fatalf("rejected rename still applied: %+v %v", renamed, err)
}
}