diff --git a/docs/zh-CN/blackboard-context-memory.md b/docs/zh-CN/blackboard-context-memory.md index 626554d0..8481d5d4 100644 --- a/docs/zh-CN/blackboard-context-memory.md +++ b/docs/zh-CN/blackboard-context-memory.md @@ -16,6 +16,26 @@ 不再生成独立 `.index.md` 文件,不使用正则或关键词分类扫描快照,不增加模型调用。摘要本身承担证据检索入口;它仍可能遗漏细节,原文以快照为准。 +## 会话工作记录与项目共享事实的读取边界 + +设计依据是区分会话状态和跨会话记忆,而非把每次自动摘要直接视为共享事实。[LangChain 官方记忆概念文档](https://docs.langchain.com/oss/python/concepts/memory) 将工作状态定义为 thread-scoped,跨会话记忆使用独立 namespace;本项目保留现有项目事实结构,用保留前缀 `note/context-*` 与来源会话标识建立相应读取范围,不增加数据库迁移或额外模型提取。 + +| 内容 | 自动注入与默认事实工具 | 显式历史查询 | +|---|---|---| +| 普通项目事实(含已确认发现、手工 note) | 沿用原有项目共享行为 | 沿用原有行为 | +| 当前会话的自动续接记录(含该会话子代理/规划器/执行器) | 可见,标为当前会话工作记录 | 可见 | +| 同项目其他会话的自动续接记录 | 不自动注入,列表/搜索默认不返回,直接 get 要求历史标志 | `include_history=true` 可读,标为历史参考 | +| 缺少来源会话的旧续接记录 | 不作为当前工作记录返回 | 显式历史查询可读,标明未知来源 | +| 其他项目的记录 | 当前项目工具不可读 | 历史标志不能扩大项目范围或绕过 project:read | + +`list_project_facts`、`search_project_facts` 与 `get_project_fact` 默认使用当前会话范围;前两者支持 `source_conversation_id` 缩小查询。过滤在数据库分页前执行,避免置顶历史记录占满一页后才被丢弃。初始主 Agent 注入、子 Agent 黑板补充以及普通/原生压缩后的索引刷新,都显式传入当前会话 ID;缺少会话 ID 时不会退回注入全部自动续接记录。 + +关系速览、索引行内关系提示以及 get/upsert 的关系边返回同样过滤其他会话的自动续接 key,防止通过关系边绕过默认范围。历史正文附来源会话和更新时间,不因读取自动升级为已确认事实或新的任务授权。`note/context-*` 为系统保留前缀,事实写入工具不能覆盖;废弃/恢复工具不能修改其他会话的自动续接记录。核实后的项目知识应另写独立事实 key 并保留证据。 + +这属于上下文注入和事实检索的默认范围,不是新的文件访问权限隔离;不删除快照、不修改既有记录,也不清洗已进入旧对话历史的内容。项目管理页面及原有不带范围参数的数据库查询仍可供有权用户查看项目全量数据。现有项目权限继续由工具授权层核验。 + +验证使用同项目两会话、另一个项目及未知来源旧记录:覆盖默认/显式查询、来源筛选、分页、置顶历史记录、关系边、保留 key 写保护、权限拒绝、初始主 Agent 注入,以及真实 classic/native 压缩自动保存后的索引刷新。已有黑板预算与原始用户账本测试继续运行,确保范围标识不突破长度上限、不更改用户输入。 + ## 原始证据不会随下一次压缩被覆盖 过去所有压缩轮次写入同一个 `transcript.txt`,后一次写入会覆盖前一次记录。现在每份压缩前历史保存到 `snapshots/transcript--<时间戳>.txt`,例如 `transcript-a1b2c-20261012-010203.000000004+0800.txt`。时间戳带纳秒与时区,记录首次保存时间;相同内容的重试通过完整文件内容核对复用快照,不改变原快照的时间或路径。短 MD5 仅用于识别,不用作唯一性判断;前缀或时间戳碰撞时不覆盖旧文件。旧 SHA-256 文件名保持可读且可复用,不改名以免旧摘要的引用失效。`transcript.txt` 仍保留为最新记录的兼容入口,续接摘要引用的是具体快照。快照沿用现有会话记录格式,保留用户、助手与工具记录,省略不变的系统提示与工具/技能索引。 diff --git a/internal/app/project_fact_tools.go b/internal/app/project_fact_tools.go index 2d570919..818bdaac 100644 --- a/internal/app/project_fact_tools.go +++ b/internal/app/project_fact_tools.go @@ -5,7 +5,6 @@ import ( "fmt" "strings" - "cyberstrike-ai/internal/agent" "cyberstrike-ai/internal/config" "cyberstrike-ai/internal/database" "cyberstrike-ai/internal/mcp" @@ -16,7 +15,7 @@ import ( ) func projectIDFromConversation(db *database.DB, ctx context.Context) (string, error) { - convID := agent.ConversationIDFromContext(ctx) + convID := mcpAuthorizationConversationID(ctx) if convID == "" { return "", fmt.Errorf("无法确定当前对话,请在对话上下文中使用项目事实工具") } @@ -123,6 +122,9 @@ func registerProjectFactTools(mcpServer *mcp.Server, db *database.DB, cfg *confi } factKey, _ := args["fact_key"].(string) summary, _ := args["summary"].(string) + if database.IsContextCheckpointKey(factKey) { + return textResult("错误: note/context-* 为系统续接记录,不能用事实写入工具覆盖;项目共享事实请使用独立 fact_key", true), nil + } if strings.TrimSpace(factKey) == "" || strings.TrimSpace(summary) == "" { return textResult("错误: fact_key 与 summary 必填", true), nil } @@ -139,7 +141,7 @@ func registerProjectFactTools(mcpServer *mcp.Server, db *database.DB, cfg *confi Pinned: boolArg(args, "pinned"), RelatedVulnerabilityID: strArg(args, "related_vulnerability_id"), } - if convID := agent.ConversationIDFromContext(ctx); convID != "" { + if convID := mcpAuthorizationConversationID(ctx); convID != "" { f.SourceConversationID = convID } created, err := db.UpsertProjectFact(f) @@ -151,7 +153,7 @@ func registerProjectFactTools(mcpServer *mcp.Server, db *database.DB, cfg *confi if err != nil { return textResult("错误: "+err.Error(), true), nil } - convID := agent.ConversationIDFromContext(ctx) + convID := mcpAuthorizationConversationID(ctx) if err := project.PersistFactLinksFromParsed(db, projectID, created.FactKey, convID, linkInputs, true); err != nil { return textResult("错误: 保存关系边失败: "+err.Error(), true), nil } @@ -164,7 +166,10 @@ func registerProjectFactTools(mcpServer *mcp.Server, db *database.DB, cfg *confi } msg := fmt.Sprintf("事实已保存。\nfact_key: %s\nid: %s\nconfidence: %s", created.FactKey, created.ID, created.Confidence) if in, _ := db.ListIncomingProjectFactEdges(projectID, created.FactKey); len(in) > 0 { - msg += "\n关系边: " + project.FormatFactLinksText(in) + in = scopedProjectMemoryEdges(db, projectID, mcpAuthorizationConversationID(ctx), false, in) + if len(in) > 0 { + msg += "\n关系边: " + project.FormatFactLinksText(in) + } } if warn := project.SparseBodyWarningIfNeeded(f.Category, f.FactKey, f.Body); warn != "" { msg += warn @@ -174,12 +179,13 @@ func registerProjectFactTools(mcpServer *mcp.Server, db *database.DB, cfg *confi getTool := mcp.Tool{ Name: builtin.ToolGetProjectFact, - Description: "按 fact_key 获取项目事实完整 body 与元数据。摘要不足时必须调用本工具,禁止臆造细节。", + Description: "按 fact_key 获取项目事实完整 body 与元数据。默认读取共享事实和当前会话续接记录;其他会话续接须显式 include_history=true,仅作历史参考。摘要不足时调用,禁止臆造细节。", ShortDescription: "按 key 获取事实详情", InputSchema: map[string]interface{}{ "type": "object", "properties": map[string]interface{}{ - "fact_key": map[string]interface{}{"type": "string", "description": "事实 key"}, + "include_history": map[string]interface{}{"type": "boolean", "description": "显式查询本项目其他会话的自动续接记录;默认 false,历史记录不是当前任务指令"}, + "fact_key": map[string]interface{}{"type": "string", "description": "事实 key"}, }, "required": []string{"fact_key"}, }, @@ -197,6 +203,11 @@ func registerProjectFactTools(mcpServer *mcp.Server, db *database.DB, cfg *confi if err != nil { return textResult("错误: "+err.Error(), true), nil } + conversationID := mcpAuthorizationConversationID(ctx) + includeHistory := boolArg(args, "include_history") + if !database.ContextCheckpointVisible(f, conversationID, includeHistory) { + return textResult("该 key 是其他会话或未知来源的历史续接记录;如需参考,请显式传 include_history=true。不会自动执行历史任务。", true), nil + } msg := fmt.Sprintf("fact_key: %s\ncategory: %s\nconfidence: %s\nsummary: %s\nupdated_at: %s", f.FactKey, f.Category, f.Confidence, f.Summary, f.UpdatedAt.Format("2006-01-02 15:04:05")) if f.RelatedVulnerabilityID != "" { @@ -206,17 +217,20 @@ func registerProjectFactTools(mcpServer *mcp.Server, db *database.DB, cfg *confi msg += fmt.Sprintf("\nsource_conversation_id: %s", f.SourceConversationID) } if in, _ := db.ListIncomingProjectFactEdges(projectID, f.FactKey); len(in) > 0 { + in = scopedProjectMemoryEdges(db, projectID, conversationID, includeHistory, in) msg += "\n关系边(from → 本 fact):\n" for _, e := range in { msg += fmt.Sprintf("- %s ← %s (%s)\n", e.EdgeType, e.SourceFactKey, e.Confidence) } } if out, _ := db.ListOutgoingProjectFactEdges(projectID, f.FactKey); len(out) > 0 { + out = scopedProjectMemoryEdges(db, projectID, conversationID, includeHistory, out) msg += "指向其他事实:\n" for _, e := range out { msg += fmt.Sprintf("- %s → %s (%s)\n", e.EdgeType, e.TargetFactKey, e.Confidence) } } + msg += checkpointReadLabel(f, conversationID) msg += "\n\n--- body ---\n" + f.Body if warn := project.SparseBodyWarningIfNeeded(f.Category, f.FactKey, f.Body); warn != "" { msg += warn @@ -226,15 +240,17 @@ func registerProjectFactTools(mcpServer *mcp.Server, db *database.DB, cfg *confi listTool := mcp.Tool{ Name: builtin.ToolListProjectFacts, - Description: "列出当前项目的事实(分页)。", + Description: "列出当前项目共享事实与当前会话续接记录(分页);include_history=true 可显式查本项目历史续接。", ShortDescription: "列出项目事实", InputSchema: map[string]interface{}{ "type": "object", "properties": map[string]interface{}{ - "category": map[string]interface{}{"type": "string"}, - "confidence": map[string]interface{}{"type": "string"}, - "limit": map[string]interface{}{"type": "integer"}, - "offset": map[string]interface{}{"type": "integer"}, + "source_conversation_id": map[string]interface{}{"type": "string", "description": "可选:按来源会话筛选;其他会话续接仍需 include_history=true"}, + "include_history": map[string]interface{}{"type": "boolean", "description": "显式查询本项目其他会话的自动续接记录;默认 false,历史记录不是当前任务指令"}, + "category": map[string]interface{}{"type": "string"}, + "confidence": map[string]interface{}{"type": "string"}, + "limit": map[string]interface{}{"type": "integer"}, + "offset": map[string]interface{}{"type": "integer"}, }, }, } @@ -246,8 +262,11 @@ func registerProjectFactTools(mcpServer *mcp.Server, db *database.DB, cfg *confi limit := intArg(args, "limit", 50) offset := intArg(args, "offset", 0) filter := database.ProjectFactListFilter{ - Category: strArg(args, "category"), - Confidence: strArg(args, "confidence"), + Category: strArg(args, "category"), + Confidence: strArg(args, "confidence"), + RestrictContextCheckpoints: !boolArg(args, "include_history"), + ContextConversationID: mcpAuthorizationConversationID(ctx), + SourceConversationID: strArg(args, "source_conversation_id"), } list, err := db.ListProjectFacts(projectID, filter, limit, offset) if err != nil { @@ -257,20 +276,23 @@ func registerProjectFactTools(mcpServer *mcp.Server, db *database.DB, cfg *confi b.WriteString(fmt.Sprintf("共 %d 条(limit=%d offset=%d):\n", len(list), limit, offset)) for _, f := range list { b.WriteString(fmt.Sprintf("- [%s] %s — %s (%s)\n", f.FactKey, f.Category, f.Summary, f.Confidence)) + b.WriteString(checkpointReadLabel(f, mcpAuthorizationConversationID(ctx))) } return textResult(b.String(), false), nil }) searchTool := mcp.Tool{ Name: builtin.ToolSearchProjectFacts, - Description: "按关键词搜索项目事实(summary/body/fact_key)。", + Description: "按关键词搜索本项目共享事实和当前会话续接(summary/body/fact_key);其他会话续接需 include_history=true。", ShortDescription: "搜索项目事实", InputSchema: map[string]interface{}{ "type": "object", "properties": map[string]interface{}{ - "query": map[string]interface{}{"type": "string"}, - "limit": map[string]interface{}{"type": "integer"}, - "offset": map[string]interface{}{"type": "integer"}, + "source_conversation_id": map[string]interface{}{"type": "string", "description": "可选:按来源会话筛选;其他会话续接仍需 include_history=true"}, + "include_history": map[string]interface{}{"type": "boolean", "description": "显式查询本项目其他会话的自动续接记录;默认 false,历史记录不是当前任务指令"}, + "query": map[string]interface{}{"type": "string"}, + "limit": map[string]interface{}{"type": "integer"}, + "offset": map[string]interface{}{"type": "integer"}, }, "required": []string{"query"}, }, @@ -284,7 +306,7 @@ func registerProjectFactTools(mcpServer *mcp.Server, db *database.DB, cfg *confi if q == "" { return textResult("错误: query 必填", true), nil } - list, err := db.ListProjectFacts(projectID, database.ProjectFactListFilter{Search: q}, intArg(args, "limit", 30), intArg(args, "offset", 0)) + list, err := db.ListProjectFacts(projectID, database.ProjectFactListFilter{Search: q, RestrictContextCheckpoints: !boolArg(args, "include_history"), ContextConversationID: mcpAuthorizationConversationID(ctx), SourceConversationID: strArg(args, "source_conversation_id")}, intArg(args, "limit", 30), intArg(args, "offset", 0)) if err != nil { return textResult("错误: "+err.Error(), true), nil } @@ -292,6 +314,7 @@ func registerProjectFactTools(mcpServer *mcp.Server, db *database.DB, cfg *confi b.WriteString(fmt.Sprintf("搜索 \"%s\" 命中 %d 条:\n", q, len(list))) for _, f := range list { b.WriteString(fmt.Sprintf("- [%s] %s — %s\n", f.FactKey, f.Category, f.Summary)) + b.WriteString(checkpointReadLabel(f, mcpAuthorizationConversationID(ctx))) } return textResult(b.String(), false), nil }) @@ -314,6 +337,12 @@ func registerProjectFactTools(mcpServer *mcp.Server, db *database.DB, cfg *confi return textResult("错误: "+err.Error(), true), nil } key := strings.TrimSpace(strArg(args, "fact_key")) + if database.IsContextCheckpointKey(key) { + f, err := db.GetProjectFactByKey(projectID, key) + if err != nil || !database.ContextCheckpointVisible(f, mcpAuthorizationConversationID(ctx), false) { + return textResult("错误: 不能修改其他会话的自动续接记录", true), nil + } + } if err := db.DeprecateProjectFact(projectID, key); err != nil { return textResult("错误: "+err.Error(), true), nil } @@ -343,6 +372,12 @@ func registerProjectFactTools(mcpServer *mcp.Server, db *database.DB, cfg *confi return textResult("错误: "+err.Error(), true), nil } key := strings.TrimSpace(strArg(args, "fact_key")) + if database.IsContextCheckpointKey(key) { + f, err := db.GetProjectFactByKey(projectID, key) + if err != nil || !database.ContextCheckpointVisible(f, mcpAuthorizationConversationID(ctx), false) { + return textResult("错误: 不能修改其他会话的自动续接记录", true), nil + } + } if key == "" { return textResult("错误: fact_key 必填", true), nil } @@ -387,3 +422,49 @@ func intArg(args map[string]interface{}, key string, def int) int { return def } } + +// History is a read opt-in, not promotion to a confirmed fact or new authorization. +func checkpointReadLabel(f *database.ProjectFact, conversationID string) string { + if !database.IsContextCheckpointKey(f.FactKey) { + return "" + } + label := "当前会话工作记录" + if strings.TrimSpace(conversationID) == "" || f.SourceConversationID != conversationID { + label = "历史参考,非当前任务;核对当前授权、目标及凭据有效性" + } + source := f.SourceConversationID + if source == "" { + source = "未知来源" + } + return fmt.Sprintf("\n[续接记录:%s;来源会话=%s;更新时间=%s]\n", label, source, f.UpdatedAt.Format("2006-01-02T15:04:05Z07:00")) +} + +func scopedProjectMemoryEdges(db *database.DB, projectID, conversationID string, includeHistory bool, edges []*database.ProjectFactEdge) []*database.ProjectFactEdge { + if includeHistory { + return edges + } + var visible []*database.ProjectFactEdge + allowed := make(map[string]bool) + for _, e := range edges { + keep := true + for _, key := range []string{e.SourceFactKey, e.TargetFactKey} { + if !database.IsContextCheckpointKey(key) { + continue + } + ok, seen := allowed[key] + if !seen { + f, err := db.GetProjectFactByKey(projectID, key) + ok = err == nil && database.ContextCheckpointVisible(f, conversationID, false) + allowed[key] = ok + } + if !ok { + keep = false + break + } + } + if keep { + visible = append(visible, e) + } + } + return visible +} diff --git a/internal/app/project_memory_scope_test.go b/internal/app/project_memory_scope_test.go new file mode 100644 index 00000000..27f922c8 --- /dev/null +++ b/internal/app/project_memory_scope_test.go @@ -0,0 +1,121 @@ +package app + +import ( + "context" + "path/filepath" + "strings" + "testing" + + "cyberstrike-ai/internal/authctx" + "cyberstrike-ai/internal/config" + "cyberstrike-ai/internal/database" + "cyberstrike-ai/internal/mcp" + "cyberstrike-ai/internal/mcp/builtin" + "go.uber.org/zap" +) + +func TestProjectMemoryToolScopeAndHistoricalOptIn(t *testing.T) { + db, err := database.NewDB(filepath.Join(t.TempDir(), "memory.db"), zap.NewNop()) + if err != nil { + t.Fatal(err) + } + defer db.Close() + p, _ := db.CreateProject(&database.Project{Name: "current"}) + pOther, _ := db.CreateProject(&database.Project{Name: "other"}) + current, _ := db.CreateConversation("current", database.ConversationCreateMeta{ProjectID: p.ID}) + history, _ := db.CreateConversation("history", database.ConversationCreateMeta{ProjectID: p.ID}) + outside, _ := db.CreateConversation("outside", database.ConversationCreateMeta{ProjectID: pOther.ID}) + for _, f := range []*database.ProjectFact{ + {ProjectID: p.ID, FactKey: "note/context-own", SourceConversationID: current.ID, Summary: "own progress", Body: "own raw", Confidence: "tentative"}, + {ProjectID: p.ID, FactKey: "note/context-history", SourceConversationID: history.ID, Summary: "historic progress", Body: "HISTORIC_COOKIE", Pinned: true}, + {ProjectID: p.ID, FactKey: "note/context-legacy", Summary: "unknown old progress", Body: "UNKNOWN_RAW", Pinned: true}, + {ProjectID: p.ID, FactKey: "finding/shared", SourceConversationID: history.ID, Summary: "shared confirmed result", Body: "SHARED_PROOF", Confidence: "confirmed", Category: "finding"}, + {ProjectID: p.ID, FactKey: "note/manual", SourceConversationID: history.ID, Summary: "manual shared note"}, + {ProjectID: pOther.ID, FactKey: "note/context-outside", SourceConversationID: outside.ID, Summary: "other project", Body: "OUTSIDE_PROJECT"}, + } { + if _, err := db.UpsertProjectFact(f); err != nil { + t.Fatal(err) + } + } + if _, err := db.AddProjectFactEdge(p.ID, database.ProjectFactEdgeInput{To: "finding/shared", Type: "supports"}, "note/context-history", history.ID); err != nil { + t.Fatal(err) + } + user, err := db.CreateRBACUser("memory-test", "Memory", "hash", true, nil) + if err != nil { + t.Fatal(err) + } + principal := authctx.NewPrincipal(user.ID, user.Username, database.RBACScopeAll, map[string]bool{"project:read": true, "project:write": true}) + ctx := authctx.WithPrincipal(mcp.WithMCPConversationID(context.Background(), current.ID), principal) + server := mcp.NewServer(zap.NewNop()) + server.SetToolAuthorizer(mcpToolAuthorizer(db)) + registerProjectFactTools(server, db, &config.Config{Project: config.ProjectConfig{Enabled: true}}, zap.NewNop()) + call := func(name string, args map[string]interface{}) *mcp.ToolResult { + t.Helper() + result, _, err := server.CallTool(ctx, name, args) + if err != nil || result == nil { + t.Fatalf("%s: %v %v", name, result, err) + } + return result + } + text := func(result *mcp.ToolResult) string { + var b strings.Builder + for _, part := range result.Content { + b.WriteString(part.Text) + } + return b.String() + } + for _, name := range []string{builtin.ToolListProjectFacts, builtin.ToolSearchProjectFacts} { + args := map[string]interface{}{"query": "progress"} + result := call(name, args) + body := text(result) + if result.IsError || strings.Contains(body, "note/context-history") || strings.Contains(body, "note/context-legacy") || !strings.Contains(body, "note/context-own") { + t.Fatalf("default retrieval mixed sessions: %s", body) + } + args["include_history"] = true + args["source_conversation_id"] = history.ID + result = call(name, args) + body = text(result) + if result.IsError || !strings.Contains(body, "note/context-history") || !strings.Contains(body, "历史参考") || !strings.Contains(body, history.ID) || !strings.Contains(body, "更新时间=") || strings.Contains(body, "note/context-own") { + t.Fatalf("explicit history lost provenance: %s", body) + } + } + page := text(call(builtin.ToolListProjectFacts, map[string]interface{}{"limit": 1})) + if strings.Contains(page, "共 0 条") || strings.Contains(page, "note/context-history") || strings.Contains(page, "note/context-legacy") { + t.Fatalf("scope filtering occurred after pagination: %s", page) + } + shared := call(builtin.ToolGetProjectFact, map[string]interface{}{"fact_key": "finding/shared"}) + if shared.IsError || !strings.Contains(text(shared), "SHARED_PROOF") || strings.Contains(text(shared), "note/context-history") { + t.Fatalf("shared fact missing or relation leaked history: %s", text(shared)) + } + manual := call(builtin.ToolGetProjectFact, map[string]interface{}{"fact_key": "note/manual"}) + if manual.IsError { + t.Fatal("ordinary project notes must remain shared") + } + hidden := call(builtin.ToolGetProjectFact, map[string]interface{}{"fact_key": "note/context-history"}) + if !hidden.IsError || strings.Contains(text(hidden), "HISTORIC_COOKIE") { + t.Fatal("default get exposes foreign checkpoint") + } + visible := call(builtin.ToolGetProjectFact, map[string]interface{}{"fact_key": "note/context-history", "include_history": true}) + if visible.IsError || !strings.Contains(text(visible), "HISTORIC_COOKIE") || !strings.Contains(text(visible), "历史参考") { + t.Fatalf("explicit history unavailable: %s", text(visible)) + } + unknown := call(builtin.ToolGetProjectFact, map[string]interface{}{"fact_key": "note/context-legacy", "include_history": true}) + if unknown.IsError || !strings.Contains(text(unknown), "未知来源") || !strings.Contains(text(unknown), "历史参考") { + t.Fatal("unknown origin presented as current memory") + } + outsideRead := call(builtin.ToolGetProjectFact, map[string]interface{}{"fact_key": "note/context-outside", "include_history": true, "project_id": pOther.ID}) + if !outsideRead.IsError || strings.Contains(text(outsideRead), "OUTSIDE_PROJECT") { + t.Fatal("history opt-in crossed project boundary") + } + for _, name := range []string{builtin.ToolDeprecateProjectFact, builtin.ToolRestoreProjectFact, builtin.ToolUpsertProjectFact} { + result := call(name, map[string]interface{}{"fact_key": "note/context-history", "include_history": true, "summary": "overwrite", "body": "bad"}) + if !result.IsError { + t.Fatalf("%s modified another conversation checkpoint", name) + } + } + denied := authctx.WithPrincipal(mcp.WithMCPConversationID(context.Background(), current.ID), authctx.NewPrincipal(user.ID, user.Username, database.RBACScopeAll, map[string]bool{})) + _, _, err = server.CallTool(denied, builtin.ToolGetProjectFact, map[string]interface{}{"fact_key": "note/context-history", "include_history": true}) + if err == nil { + t.Fatal("history flag bypassed project:read permission") + } +} diff --git a/internal/database/project.go b/internal/database/project.go index 68620931..8432c059 100644 --- a/internal/database/project.go +++ b/internal/database/project.go @@ -58,11 +58,34 @@ type ProjectFact struct { // ProjectFactListFilter 事实列表筛选。 type ProjectFactListFilter struct { - Category string - Confidence string - Search string - RelatedVulnerabilityID string - ExcludeDeprecated bool // 为 true 时排除 confidence=deprecated + Category string + Confidence string + Search string + RelatedVulnerabilityID string + ExcludeDeprecated bool // 为 true 时排除 confidence=deprecated + RestrictContextCheckpoints bool // MCP defaults: hide other/unknown conversation checkpoints before pagination. + ContextConversationID string + SourceConversationID string // Optional provenance filter; it does not widen project or memory scope. +} + +// ContextCheckpointKeyPrefix is reserved for system-generated working memory. +const ContextCheckpointKeyPrefix = "note/context-" + +// IsContextCheckpointKey distinguishes working memory from ordinary shared facts. +func IsContextCheckpointKey(key string) bool { + return strings.HasPrefix(strings.TrimSpace(key), ContextCheckpointKeyPrefix) +} + +// Conversation scope affects only automatic working memory, never ordinary +// project facts. Unknown origins require an explicit historical read. +func ContextCheckpointVisible(f *ProjectFact, conversationID string, includeHistory bool) bool { + return f != nil && (!IsContextCheckpointKey(f.FactKey) || includeHistory || + (strings.TrimSpace(conversationID) != "" && f.SourceConversationID == strings.TrimSpace(conversationID))) +} + +func contextCheckpointScopeSQL(conversationID string) (string, []interface{}) { + return " AND (substr(fact_key, 1, ?) != ? OR (source_conversation_id = ? AND ? != ''))", + []interface{}{len(ContextCheckpointKeyPrefix), ContextCheckpointKeyPrefix, strings.TrimSpace(conversationID), strings.TrimSpace(conversationID)} } // CreateProject 创建项目。 @@ -336,12 +359,17 @@ func (db *DB) SetConversationProjectID(conversationID, projectID string) error { } // ListProjectFactsForIndex 列出用于黑板索引注入的事实(不含 deprecated,除非 includeDeprecated)。 -func (db *DB) ListProjectFactsForIndex(projectID string, includeDeprecated bool) ([]*ProjectFact, error) { +func (db *DB) ListProjectFactsForIndex(projectID string, includeDeprecated bool, conversationIDs ...string) ([]*ProjectFact, error) { query := `SELECT id, project_id, fact_key, category, summary, '', confidence, COALESCE(source_conversation_id,''), COALESCE(source_message_id,''), pinned, COALESCE(related_vulnerability_id,''), created_at, updated_at FROM project_facts WHERE project_id = ?` args := []interface{}{projectID} + if len(conversationIDs) > 0 { + clause, values := contextCheckpointScopeSQL(conversationIDs[0]) + query += clause + args = append(args, values...) + } if !includeDeprecated { query += " AND confidence != 'deprecated'" } @@ -364,6 +392,15 @@ func (db *DB) ListProjectFacts(projectID string, filter ProjectFactListFilter, l COALESCE(related_vulnerability_id,''), created_at, updated_at FROM project_facts WHERE project_id = ?` args := []interface{}{projectID} + if filter.RestrictContextCheckpoints { + clause, values := contextCheckpointScopeSQL(filter.ContextConversationID) + query += clause + args = append(args, values...) + } + if source := strings.TrimSpace(filter.SourceConversationID); source != "" { + query += " AND source_conversation_id = ?" + args = append(args, source) + } if c := strings.TrimSpace(filter.Category); c != "" { query += " AND category = ?" args = append(args, c) diff --git a/internal/handler/project_context.go b/internal/handler/project_context.go index cc0e6830..fb981305 100644 --- a/internal/handler/project_context.go +++ b/internal/handler/project_context.go @@ -60,7 +60,7 @@ func (h *AgentHandler) projectBlackboardBlock(conversationID string) string { if err != nil || projectID == "" { return "" } - block, err := project.BuildProjectBlackboardBlock(h.db, projectID, h.config.Project) + block, err := project.BuildProjectBlackboardBlock(h.db, projectID, h.config.Project, conversationID) if err != nil { h.logger.Warn("构建项目黑板索引失败", zap.String("conversationId", conversationID), zap.Error(err)) return "" diff --git a/internal/handler/project_memory_scope_test.go b/internal/handler/project_memory_scope_test.go new file mode 100644 index 00000000..fcf0d6a5 --- /dev/null +++ b/internal/handler/project_memory_scope_test.go @@ -0,0 +1,45 @@ +package handler + +import ( + "path/filepath" + "strings" + "testing" + + "cyberstrike-ai/internal/config" + "cyberstrike-ai/internal/database" + "go.uber.org/zap" +) + +func TestProjectBlackboardInitialInjectionUsesCurrentConversation(t *testing.T) { + db, err := database.NewDB(filepath.Join(t.TempDir(), "injection.db"), zap.NewNop()) + if err != nil { + t.Fatal(err) + } + defer db.Close() + p, err := db.CreateProject(&database.Project{Name: "same project"}) + if err != nil { + t.Fatal(err) + } + a, err := db.CreateConversation("a", database.ConversationCreateMeta{ProjectID: p.ID}) + if err != nil { + t.Fatal(err) + } + b, err := db.CreateConversation("b", database.ConversationCreateMeta{ProjectID: p.ID}) + if err != nil { + t.Fatal(err) + } + for _, f := range []*database.ProjectFact{ + {ProjectID: p.ID, FactKey: "note/context-a", SourceConversationID: a.ID, Summary: "own continuation"}, + {ProjectID: p.ID, FactKey: "note/context-b", SourceConversationID: b.ID, Summary: "other continuation"}, + {ProjectID: p.ID, FactKey: "finding/shared", Category: "finding", Summary: "shared result", Confidence: "confirmed"}, + } { + if _, err := db.UpsertProjectFact(f); err != nil { + t.Fatal(err) + } + } + h := &AgentHandler{db: db, config: &config.Config{Project: config.ProjectConfig{Enabled: true}}, logger: zap.NewNop()} + block := h.projectBlackboardBlock(a.ID) + if !strings.Contains(block, "note/context-a") || !strings.Contains(block, "finding/shared") || strings.Contains(block, "note/context-b") { + t.Fatalf("initial prompt mixed sessions: %s", block) + } +} diff --git a/internal/multiagent/eino_agentic_summarize.go b/internal/multiagent/eino_agentic_summarize.go index 049579be..801bd2b0 100644 --- a/internal/multiagent/eino_agentic_summarize.go +++ b/internal/multiagent/eino_agentic_summarize.go @@ -216,7 +216,7 @@ func newEinoAgenticSummarizationMiddleware( } out = mergeMessageIntoLeadingSystem(out, userLedger) if appCfg != nil { - out = refreshFactIndexInMessages(out, db, projectID, appCfg.Project, logger) + out = refreshFactIndexInMessages(out, db, projectID, appCfg.Project, logger, conversationID) } return EinoMessagesToAgentic(out), nil }, diff --git a/internal/multiagent/eino_context_checkpoint_test.go b/internal/multiagent/eino_context_checkpoint_test.go index 31da8c37..5162b508 100644 --- a/internal/multiagent/eino_context_checkpoint_test.go +++ b/internal/multiagent/eino_context_checkpoint_test.go @@ -134,6 +134,10 @@ func TestSummarizationMiddlewaresAutomaticallySaveCheckpoint(t *testing.T) { for _, native := range []bool{false, true} { t.Run(fmt.Sprintf("native=%v", native), func(t *testing.T) { db, pid, cid := checkpointTestProject(t) + _, err := db.UpsertProjectFact(&database.ProjectFact{ProjectID: pid, FactKey: "note/context-other", SourceConversationID: "different-conversation", Summary: "FOREIGN_PROGRESS", Body: "FOREIGN_COOKIE", Pinned: true}) + if err != nil { + t.Fatal(err) + } cfg := &config.Config{Project: config.ProjectConfig{Enabled: true}} cfg.Database.Path = filepath.Join(t.TempDir(), "runtime.db") cfg.OpenAI.Model = "gpt-4o" @@ -177,14 +181,20 @@ func TestSummarizationMiddlewaresAutomaticallySaveCheckpoint(t *testing.T) { after = state.Messages } facts, err := db.ListProjectFacts(pid, database.ProjectFactListFilter{}, 100, 0) - if err != nil || len(facts) != 1 { + if err != nil || len(facts) != 2 { t.Fatalf("automatic checkpoint missing: %v %#v", err, facts) } - if !strings.Contains(facts[0].Body, "/api/check") || strings.Contains(facts[0].Body, "scratchpad") { - t.Fatalf("wrong working memory: %s", facts[0].Body) + var checkpoint *database.ProjectFact + for _, f := range facts { + if f.SourceConversationID == cid { + checkpoint = f + } + } + if checkpoint == nil || !strings.Contains(checkpoint.Body, "/api/check") || strings.Contains(checkpoint.Body, "scratchpad") { + t.Fatalf("wrong working memory: %#v", checkpoint) } joined := joinClassicMessageContent(after) - if !strings.Contains(joined, facts[0].FactKey) || strings.Contains(joined, "(暂无事实)") { + if !strings.Contains(joined, checkpoint.FactKey) || strings.Contains(joined, "(暂无事实)") || strings.Contains(joined, "FOREIGN_PROGRESS") || strings.Contains(joined, "note/context-other") { t.Fatalf("live index did not refresh after save: %s", joined) } snapshots, err := filepath.Glob(filepath.Join(filepath.Dir(cfg.Database.Path), "conversation_artifacts", cid, "summarization", "snapshots", "*.txt")) diff --git a/internal/multiagent/eino_summarize.go b/internal/multiagent/eino_summarize.go index 57fd5e7e..76580f5e 100644 --- a/internal/multiagent/eino_summarize.go +++ b/internal/multiagent/eino_summarize.go @@ -268,7 +268,7 @@ func newEinoSummarizationMiddleware( } out = mergeMessageIntoLeadingSystem(out, userLedger) if appCfg != nil { - out = refreshFactIndexInMessages(out, db, projectID, appCfg.Project, logger) + out = refreshFactIndexInMessages(out, db, projectID, appCfg.Project, logger, conversationID) } return out, nil }, @@ -473,7 +473,7 @@ func buildPlaintextSummarizationInput( } // refreshFactIndexInMessages 在 summarization 压缩后,用 DB 最新索引替换 system 中已有的项目黑板索引段。 -func refreshFactIndexInMessages(msgs []adk.Message, db *database.DB, projectID string, cfg config.ProjectConfig, logger *zap.Logger) []adk.Message { +func refreshFactIndexInMessages(msgs []adk.Message, db *database.DB, projectID string, cfg config.ProjectConfig, logger *zap.Logger, conversationIDs ...string) []adk.Message { if db == nil || !cfg.Enabled { return msgs } @@ -481,7 +481,7 @@ func refreshFactIndexInMessages(msgs []adk.Message, db *database.DB, projectID s if projectID == "" { return msgs } - freshIndex, err := project.BuildFactIndexBlock(db, projectID, cfg) + freshIndex, err := project.BuildFactIndexBlock(db, projectID, cfg, conversationIDs...) if err != nil { if logger != nil { logger.Warn("summarization: 刷新项目黑板索引失败", zap.String("projectId", projectID), zap.Error(err)) diff --git a/internal/multiagent/runner.go b/internal/multiagent/runner.go index c4ed82c2..e853c01a 100644 --- a/internal/multiagent/runner.go +++ b/internal/multiagent/runner.go @@ -406,7 +406,7 @@ func RunDeepAgent( var taskBlackboardSupplement string if appCfg.Project.Enabled && db != nil { if pid := strings.TrimSpace(projectID); pid != "" { - if block, err := project.BuildFactIndexBlock(db, pid, appCfg.Project); err == nil { + if block, err := project.BuildFactIndexBlock(db, pid, appCfg.Project, conversationID); err == nil { taskBlackboardSupplement = strings.TrimSpace(block) } } diff --git a/internal/project/blackboard.go b/internal/project/blackboard.go index f93a0a98..9d2f173b 100644 --- a/internal/project/blackboard.go +++ b/internal/project/blackboard.go @@ -28,7 +28,7 @@ const ( ) // BuildFactIndexBlock 为 Agent 系统提示生成项目黑板索引(key + summary + 关系边 + 攻击路径,不含 body)。 -func BuildFactIndexBlock(db *database.DB, projectID string, cfg config.ProjectConfig) (string, error) { +func BuildFactIndexBlock(db *database.DB, projectID string, cfg config.ProjectConfig, conversationIDs ...string) (string, error) { if db == nil || !cfg.Enabled { return "", nil } @@ -42,11 +42,16 @@ func BuildFactIndexBlock(db *database.DB, projectID string, cfg config.ProjectCo return "", err } - facts, err := db.ListProjectFactsForIndex(projectID, cfg.DefaultInjectDeprecated) + conversationID := "" + if len(conversationIDs) > 0 { + conversationID = strings.TrimSpace(conversationIDs[0]) + } + facts, err := db.ListProjectFactsForIndex(projectID, cfg.DefaultInjectDeprecated, conversationID) if err != nil { return "", err } allEdges, _ := db.ListProjectFactEdgesByProject(projectID) + allEdges = FilterCheckpointEdges(allEdges, facts) _, incomingByTarget := indexEdgeGroupMaps(allEdges) maxRunes := cfg.FactIndexMaxRunesEffective() @@ -87,6 +92,9 @@ func BuildFactIndexBlock(db *database.DB, projectID string, cfg config.ProjectCo for _, f := range facts { line := fmt.Sprintf("- [%s] %s — %s (%s)", f.FactKey, f.Category, truncateIndexSummary(indexSingleLine(f.Summary), cfg.FactSummaryMaxRunesEffective()), f.Confidence) + if database.IsContextCheckpointKey(f.FactKey) { + line += " [当前会话工作记录]" + } line += FormatFactIndexLinksHint(f.FactKey, incomingByTarget[f.FactKey]) line += "\n" lineRunes := len([]rune(line)) @@ -112,6 +120,23 @@ func BuildFactIndexBlock(db *database.DB, projectID string, cfg config.ProjectCo return wrapFactIndexBlock(b.String()), nil } +// A hidden checkpoint must not reappear via a visible fact's relationship hint. +func FilterCheckpointEdges(edges []*database.ProjectFactEdge, visibleFacts []*database.ProjectFact) []*database.ProjectFactEdge { + visible := make(map[string]bool) + for _, f := range visibleFacts { + visible[f.FactKey] = true + } + var out []*database.ProjectFactEdge + for _, e := range edges { + if database.IsContextCheckpointKey(e.SourceFactKey) && !visible[e.SourceFactKey] || + database.IsContextCheckpointKey(e.TargetFactKey) && !visible[e.TargetFactKey] { + continue + } + out = append(out, e) + } + return out +} + func indexSingleLine(s string) string { return strings.Join(strings.Fields(s), " ") } func truncateIndexSummary(s string, maxRunes int) string { diff --git a/internal/project/blackboard_memory_scope_test.go b/internal/project/blackboard_memory_scope_test.go new file mode 100644 index 00000000..3bf57e8a --- /dev/null +++ b/internal/project/blackboard_memory_scope_test.go @@ -0,0 +1,64 @@ +package project + +import ( + "path/filepath" + "strings" + "testing" + + "cyberstrike-ai/internal/config" + "cyberstrike-ai/internal/database" + "go.uber.org/zap" +) + +func TestBlackboardWorkingMemoryIsConversationScopedIncludingEdges(t *testing.T) { + db, err := database.NewDB(filepath.Join(t.TempDir(), "scope.db"), zap.NewNop()) + if err != nil { + t.Fatal(err) + } + defer db.Close() + p, err := db.CreateProject(&database.Project{Name: "scope"}) + if err != nil { + t.Fatal(err) + } + for _, f := range []*database.ProjectFact{ + {ProjectID: p.ID, FactKey: "finding/shared", Category: "finding", Confidence: "confirmed", Summary: "shared result", Body: "RAW_SHARED"}, + {ProjectID: p.ID, FactKey: "note/context-a", SourceConversationID: "a", Summary: "progress a", Body: "RAW_A"}, + {ProjectID: p.ID, FactKey: "note/context-b", SourceConversationID: "b", Summary: "progress b", Body: "RAW_B", Pinned: true, Confidence: "confirmed"}, + {ProjectID: p.ID, FactKey: "note/context-unknown", Summary: "unknown origin", Pinned: true}, + {ProjectID: p.ID, FactKey: "note/manual", SourceConversationID: "b", Summary: "shared manual note"}, + } { + if _, err := db.UpsertProjectFact(f); err != nil { + t.Fatal(err) + } + } + if _, err := db.AddProjectFactEdge(p.ID, database.ProjectFactEdgeInput{To: "finding/shared", Type: "supports"}, "note/context-b", "b"); err != nil { + t.Fatal(err) + } + cfg := config.ProjectConfig{Enabled: true, FactIndexMaxRunes: 3500, FactIndexPathMaxRunes: 1000} + for _, conv := range []string{"a", "b", ""} { + block, err := BuildFactIndexBlock(db, p.ID, cfg, conv) + if err != nil { + t.Fatal(err) + } + if !strings.Contains(block, "finding/shared") || !strings.Contains(block, "note/manual") || strings.Contains(block, "note/context-unknown") || strings.Contains(block, "RAW_") { + t.Fatalf("scope altered shared facts or injected bodies: %s", block) + } + for _, source := range []string{"a", "b"} { + if strings.Contains(block, "note/context-"+source) != (source == conv) { + t.Fatalf("conversation %q mixes source %q: %s", conv, source, block) + } + } + if len([]rune(block)) > cfg.FactIndexMaxRunes { + t.Fatal("scope labels escaped index budget") + } + } + generic, err := BuildFactIndexBlock(db, p.ID, cfg) + if err != nil || strings.Contains(generic, "note/context-") { + t.Fatalf("missing conversation defaults to global working memory: %s %v", generic, err) + } + // Human/admin raw queries retain the existing project-wide data view. + raw, err := db.ListProjectFacts(p.ID, database.ProjectFactListFilter{}, 100, 0) + if err != nil || len(raw) != 5 { + t.Fatalf("scope destroyed or globally hid stored notes: %v %v", raw, err) + } +} diff --git a/internal/project/scope_block.go b/internal/project/scope_block.go index e52cf1ea..b709dad9 100644 --- a/internal/project/scope_block.go +++ b/internal/project/scope_block.go @@ -75,7 +75,7 @@ func truncateRunes(s string, max int) string { } // BuildProjectBlackboardBlock 组合测试范围 + 事实黑板索引。 -func BuildProjectBlackboardBlock(db *database.DB, projectID string, cfg config.ProjectConfig) (string, error) { +func BuildProjectBlackboardBlock(db *database.DB, projectID string, cfg config.ProjectConfig, conversationIDs ...string) (string, error) { projectID = strings.TrimSpace(projectID) if projectID == "" { return "", nil @@ -88,7 +88,7 @@ func BuildProjectBlackboardBlock(db *database.DB, projectID string, cfg config.P if scope := strings.TrimSpace(BuildScopeBlock(proj)); scope != "" { parts = append(parts, scope) } - index, err := BuildFactIndexBlock(db, projectID, cfg) + index, err := BuildFactIndexBlock(db, projectID, cfg, conversationIDs...) if err != nil { return "", err } diff --git a/internal/projectprompt/blackboard.go b/internal/projectprompt/blackboard.go index 4bd7a680..1a5a40c1 100644 --- a/internal/projectprompt/blackboard.go +++ b/internal/projectprompt/blackboard.go @@ -62,7 +62,7 @@ func factRecordingGuidanceBlock() string { - **category / fact_key 建议**: - 环境认知:` + "`target/`" + `、` + "`auth/`" + `、` + "`infra/`" + `、` + "`business/`" + `(body 用环境模板即可) - 发现与利用:` + "`finding/`" + `、` + "`chain/`" + `、` + "`exploit/`" + `、` + "`poc/`" + `(**必须**用攻击链模板填满 body:入口、逐步攻击链、原始请求/响应或命令、证据、关联漏洞 ID) -- **自动续接记录**:` + "`note/context-*`" + ` 是压缩时保存的工作摘要,含来源会话和原始快照读取入口;同一会话/代理更新同一 key。用 ` + "`get_project_fact`" + ` 按需恢复进度;缺少 Cookie、认证信息或精确参数时,按摘要中的路径搜索快照并分段回读,勿猜测原值。它不是已验证发现,接续时须核对当前授权范围与证据,勿据此自动执行旧任务。 +- **自动续接记录**:` + "`note/context-*`" + ` 是系统保留的会话工作摘要,不是项目已确认事实,禁止用 upsert_project_fact 覆盖该前缀。默认索引和事实工具只提供当前会话的续接记录,项目普通事实仍共享。需跨会话参考时,显式用 list_project_facts / search_project_facts / get_project_fact 的 include_history=true;列表和搜索可用 source_conversation_id 缩小来源范围。历史返回标注来源会话、更新时间及历史参考,核对当前授权、目标和凭据有效性,勿自动执行旧任务或将旧摘要当作已确认发现。缺少精确值时按正文中的快照路径分段回读,不猜测原值。同一会话/代理更新同一 key;需沉淀已核实的共享事实时另用独立 finding/auth 等 key,保留来源证据。 - **与漏洞记录分工**:` + "`record_vulnerability`" + ` 记可交付 findings;事实记**复现所需的全部上下文**(含失败尝试、绕过、依赖会话),二者可各记一次。 - 更新同一发现时保持相同 ` + "`fact_key`" + ` 覆盖写入,勿散落多个 key 导致上下文丢失。` }