Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions config.example.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -587,6 +587,7 @@ project:
# 压缩时自动保存续接摘要为 tentative note(每会话/代理更新一条,不替代已确认事实)
context_checkpoints: true
# default_project_id: "" # 可选:机器人/批量任务创建对话时的默认项目 ID
# 项目续接补充包含在事实索引预算内;工作文件候选清单另有最多 4000 字符预算
fact_index_max_runes: 65000
# 事实关系速览段预算(从索引总预算中预留)
fact_index_path_max_runes: 10000
Expand Down
3 changes: 2 additions & 1 deletion docs/zh-CN/configuration.md
Original file line number Diff line number Diff line change
Expand Up @@ -257,12 +257,13 @@ c2:
project:
enabled: true
context_checkpoints: true
# 续接补充包含在事实索引预算内;工作文件候选清单另有最多 4000 字符预算
fact_index_max_runes: 65000
```

- `c2.enabled`:关闭后不启动 C2 监听器,也不注册 C2 MCP 工具。
- WebShell 连接配置存 SQLite,没有单独的主配置开关。
- `project` 控制跨对话事实黑板注入预算。
- `project` 控制跨对话事实黑板注入预算。[项目续聊与成果复用](project-resume.md) 说明脚本登记、测试结果与续接流程。
- `project.context_checkpoints` 默认开启:已绑定项目的会话成功压缩时,将续接摘要自动保存为 `note/context-*`(tentative),按会话及主/子代理分别更新,不覆盖已确认事实。完整原始记录保存为不可变快照,后续可通过 `get_project_fact` 与 `read_file` 找回。关闭此项仅停止自动黑板记录,仍保留原始上下文快照。详见 [长上下文续接留存](blackboard-context-memory.md)。

## 机器人
Expand Down
46 changes: 46 additions & 0 deletions docs/zh-CN/project-resume.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
# 项目续聊与成果复用

同一项目第二天继续任务时,应先恢复已有成果,再执行新的工作。仅保留聊天摘要不足以恢复脚本参数、依赖、成功证据和已经验证过的失败路线。

## 自动恢复的内容

每轮用户任务都从当前数据库和磁盘重新构建上下文,单代理和多代理共用此入口:

- 项目事实索引与测试范围。先通读索引;存在省略时,使用 `list_project_facts` 分页补全索引,再读取与本次任务有关的事实正文。
- 脚本与测试记录:`artifact/*`、`test/*`、`negative/*`,以及兼容既有的 `exploit/*`、`poc/*`。脚本和测试记录分别最多展示 3 条正文预览,避免其中一类挤掉另一类;预览不代替 `get_project_fact` 返回的完整正文。
- 项目关联漏洞的 ID、标题、目标和状态,包括已修复、误报等状态;详情使用 `get_vulnerability`,其余使用 `list_vulnerabilities` 分页/筛选。没有事实的项目也会恢复已有漏洞。
- 工作目录内的脚本候选文件路径、大小和修改时间。清单不会读取或执行脚本,也不将“文件存在”当作“验证成功”。先检查 `artifacts/`,再查找其他位置;执行前仍需读取文件并核对事实记录。

项目续接补充内容置于事实索引的刷新边界内,因此摘要压缩后的索引刷新以及多代理任务上下文补充都会带上最新的数据库记录。磁盘文件清单在每轮用户任务开始时重建。

## 成果如何保存

已绑定项目的会话共用 `<agent.workspace_root_dir>/projects/<project_id>/`;未绑定项目的会话使用各自的 `conversations/<conversation_id>/`。在同一项目创建新会话或重新打开原对话,都能访问该项目已有的工作文件。

成功脚本和依赖建议放在项目工作目录的 `artifacts/` 下。完成验证后立即使用现有的 `upsert_project_fact` 工具登记:

- `category=note`、`fact_key=artifact/<稳定名称>`。
- summary 标明用途与验证状态。
- body 记录相对文件路径、目标和适用条件、调用方式、可变参数、依赖/版本、输入输出、成功证据、验证时间、来源会话和关联事实/漏洞。认证信息引用现有 `auth/*` 事实。

已完成的测试路线同样立即保存:

- `category=note`、`fact_key=test/<目标入口与方法的稳定名称>`。
- body 包含目标、入口、参数、身份/认证、测试方法、证据、结论、时间及复测触发条件。
- 阴性结论只适用于已测条件。证据充分的阴性结果仍是 `confirmed` 事实,不能因为没有发现漏洞就标记为 `deprecated`。工具错误、超时或缺少认证应记为受阻/待验证,不能记为已排除。

同一测试条件更新同一 key;条件变化时另记并关联。此约定复用现有事实表和工具,不增加数据库迁移或分类枚举。已有脚本可保留原路径,核对内容与证据后补登记。

## 继续任务时的顺序

先检查当前授权范围与用户的新需求,然后恢复事实、漏洞和工作文件。命中已有脚本时先读取说明与源码,优先修改调用参数、复用已验证的部分。执行前简述本次复用什么、跳过什么、增加什么。

相同目标、入口、参数和认证条件下有证据支撑的失败路线默认跳过。用户明确要求复测、目标/版本/入口/身份发生变化或原证据不足时,可以重新验证并说明原因。历史任务不会因为恢复记录而自动执行。

## 预算与局限

`project.fact_index_max_runes` 继续控制原有事实索引预算;续接补充最多 6500 字符,包含在索引总预算内;有事实时至少为索引行预留 800 字符,预算不足时省略补充并保留按需检索入口,文件清单另有最多 4000 字符的预算。漏洞最多查询 20 条;所有省略都会提示使用现有工具继续检索。文件遍历最多检查 1500 项、深度最多 5 层、最多列出 24 个候选文件,不跟随符号链接,跳过隐藏项及常见依赖目录。支持常见脚本扩展名;无扩展名脚本等仍需按事实中的路径查找。清单受限、目录不可读或没有命中,均不能证明成果丢失。

事实正文与脚本候选名作为 JSON 数据呈现,正文预览会压缩空白并截断;不可直接拿预览拼接执行命令。应通过详情工具和文件读取工具获取原始内容。历史文件和事实都不是新的系统指令。

这项改动提供自动恢复和复用规则,具体检索、登记与执行选择仍由模型完成,并非阻断所有重复工具调用的硬门禁。不会自动重建曾经只写在系统 `/tmp`、已被清理或从未保存的脚本,也不会从文件名推断成功。未登记的旧成果需核对后补录。项目工作目录仍属于服务端运行数据,需要随数据库一起备份;删除项目会按原有清理规则删除项目目录。
1 change: 1 addition & 0 deletions internal/app/project_fact_tools.go
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,7 @@ func registerProjectFactTools(mcpServer *mcp.Server, db *database.DB, cfg *confi
"边渗透边记录:每确认新认知(端口/入口/凭据/可利用点)后立即调用,同 fact_key 覆盖更新,勿等会话结束。" +
"禁止仅写结论:summary 须含什么+在哪+如何验证;body 须含攻击链/请求响应/命令等复现细节。" +
"发现类建议 fact_key 为 finding|chain|exploit|poc/<slug>,category 对应 finding|chain|exploit|poc,body 按攻击链模板填写。" +
"脚本成果用 category=note、artifact/<slug>,body 记工作目录相对路径、依赖、调用方式与成功证据;测试结果用 category=note、test/<slug>,body 记已测条件、证据及复测触发条件,阴性结论勿用 deprecated。" +
"环境类用 target|auth|infra|business/<slug>。同 fact_key 覆盖更新。需当前对话已绑定项目。",
ShortDescription: "写入/更新项目事实(含攻击链 body)",
InputSchema: map[string]interface{}{
Expand Down
2 changes: 1 addition & 1 deletion internal/handler/project_context.go
Original file line number Diff line number Diff line change
Expand Up @@ -41,7 +41,7 @@ func (h *AgentHandler) buildWorkspaceBlock(conversationID string) string {
}
return ""
}
return project.BuildWorkspaceBlock(abs)
return project.BuildWorkspaceBlock(abs) + "\n\n" + project.BuildWorkspaceInventoryBlock(abs)
}

// projectBlackboardBlock 根据对话 ID 构建项目事实索引块(用于注入 system prompt)。
Expand Down
78 changes: 78 additions & 0 deletions internal/handler/project_resume_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,78 @@
package handler

import (
"os"
"path/filepath"
"strings"
"testing"

"cyberstrike-ai/internal/config"
"cyberstrike-ai/internal/database"
"cyberstrike-ai/internal/project"
"go.uber.org/zap"
)

func TestSessionContextReloadsProjectResultsEveryTurn(t *testing.T) {
db, err := database.NewDB(filepath.Join(t.TempDir(), "context.db"), zap.NewNop())
if err != nil {
t.Fatal(err)
}
defer db.Close()
p, err := db.CreateProject(&database.Project{Name: "shared"})
if err != nil {
t.Fatal(err)
}
cfg := &config.Config{Project: config.ProjectConfig{Enabled: true}}
cfg.Agent.WorkspaceRootDir = t.TempDir()
h := &AgentHandler{db: db, config: cfg, logger: zap.NewNop()}
var ids []string
for _, title := range []string{"yesterday", "today"} {
c, err := db.CreateConversation(title, database.ConversationCreateMeta{ProjectID: p.ID})
if err != nil {
t.Fatal(err)
}
ids = append(ids, c.ID)
}
before := h.agentSessionContextBlock(ids[0])
if strings.Contains(before, "parse_capture.py") {
t.Fatal("unexpected old file")
}
root := project.WorkspaceRootDir(cfg.Agent.WorkspaceRootDir, p.ID, ids[0])
if err := os.MkdirAll(filepath.Join(root, "artifacts"), 0755); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(filepath.Join(root, "artifacts", "parse_capture.py"), []byte("print('done')"), 0644); err != nil {
t.Fatal(err)
}
if _, err := db.UpsertProjectFact(&database.ProjectFact{ProjectID: p.ID, FactKey: "artifact/parser", Category: "note", Summary: "verified parser", Body: "artifacts/parse_capture.py input capture.txt; output verified", Confidence: "confirmed"}); err != nil {
t.Fatal(err)
}
v, err := db.CreateVulnerability(&database.Vulnerability{ProjectID: p.ID, Title: "tracked-finding", Severity: "low"})
if err != nil {
t.Fatal(err)
}
for _, id := range ids {
block := h.agentSessionContextBlock(id)
for _, want := range []string{"artifacts/parse_capture.py", "input capture.txt", v.ID, "tracked-finding", "先恢复成果"} {
if !strings.Contains(block, want) {
t.Errorf("conversation %s missing %s", id, want)
}
}
if strings.Contains(block, "print('done')") {
t.Fatal("script content injected without reading")
}
}
// A separate project's workspace and findings must never be injected.
other, err := db.CreateProject(&database.Project{Name: "other"})
if err != nil {
t.Fatal(err)
}
c, err := db.CreateConversation("isolated", database.ConversationCreateMeta{ProjectID: other.ID})
if err != nil {
t.Fatal(err)
}
isolated := h.agentSessionContextBlock(c.ID)
if strings.Contains(isolated, "parse_capture.py") || strings.Contains(isolated, v.ID) {
t.Fatal("project results leaked")
}
}
54 changes: 54 additions & 0 deletions internal/multiagent/eino_project_resume_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,54 @@
package multiagent

import (
"path/filepath"
"strings"
"testing"

"cyberstrike-ai/internal/config"
"cyberstrike-ai/internal/database"
"cyberstrike-ai/internal/project"
"github.com/cloudwego/eino/adk"
"github.com/cloudwego/eino/schema"
"go.uber.org/zap"
)

func TestCompactionRefreshRestoresLatestProjectResults(t *testing.T) {
db, err := database.NewDB(filepath.Join(t.TempDir(), "resume.db"), zap.NewNop())
if err != nil {
t.Fatal(err)
}
defer db.Close()
p, err := db.CreateProject(&database.Project{Name: "resume"})
if err != nil {
t.Fatal(err)
}
cfg := config.ProjectConfig{Enabled: true}
old, err := project.BuildFactIndexBlock(db, p.ID, cfg)
if err != nil {
t.Fatal(err)
}
if _, err := db.UpsertProjectFact(&database.ProjectFact{ProjectID: p.ID, FactKey: "artifact/parser", Category: "note", Summary: "parser-ready", Body: "artifacts/parser.py --input file", Confidence: "confirmed"}); err != nil {
t.Fatal(err)
}
if _, err := db.UpsertProjectFact(&database.ProjectFact{ProjectID: p.ID, FactKey: "test/entry", Category: "note", Summary: "entry-negative", Body: "bounded-test-evidence", Confidence: "confirmed"}); err != nil {
t.Fatal(err)
}
v, err := db.CreateVulnerability(&database.Vulnerability{ProjectID: p.ID, Title: "existing-finding", Severity: "low", Status: "fixed"})
if err != nil {
t.Fatal(err)
}
msgs := []adk.Message{schema.SystemMessage("custom-instruction\n" + old), schema.UserMessage("continue")}
out := refreshFactIndexInMessages(msgs, db, p.ID, cfg, nil)
for _, want := range []string{"custom-instruction", "artifacts/parser.py --input file", "bounded-test-evidence", v.ID, `"status":"fixed"`} {
if !strings.Contains(out[0].Content, want) {
t.Errorf("missing %s in refreshed prompt", want)
}
}
if msgs[0].Content != "custom-instruction\n"+old {
t.Fatal("refresh mutated original system message")
}
if out[1] != msgs[1] {
t.Fatal("refresh changed user intent")
}
}
12 changes: 11 additions & 1 deletion internal/project/blackboard.go
Original file line number Diff line number Diff line change
Expand Up @@ -53,11 +53,16 @@ func BuildFactIndexBlock(db *database.DB, projectID string, cfg config.ProjectCo
allEdges, _ := db.ListProjectFactEdgesByProject(projectID)
allEdges = FilterCheckpointEdges(allEdges, facts)
_, incomingByTarget := indexEdgeGroupMaps(allEdges)
resume := buildResumeSection(db, projectID, facts)

maxRunes := cfg.FactIndexMaxRunesEffective()
header := fmt.Sprintf("## 项目黑板索引(project: %s, id: %s)\n", indexField(proj.Name), proj.ID)
if len(facts) == 0 {
block := wrapFactIndexBlock(header + "(暂无事实)\n" + factIndexFooterEmpty)
content := header + "(暂无事实)\n" + factIndexFooterEmpty
if len([]rune(wrapFactIndexBlock(content+"\n\n"+resume))) <= maxRunes {
return wrapFactIndexBlock(content + "\n\n" + resume), nil
}
block := wrapFactIndexBlock(content)
if len([]rune(block)) <= maxRunes {
return block, nil
}
Expand All @@ -68,6 +73,11 @@ func BuildFactIndexBlock(db *database.DB, projectID string, cfg config.ProjectCo

pathMaxRunes := cfg.FactIndexPathMaxRunesEffective()
footer := factIndexFooterGetDetail + "\n" + factIndexFooterWriteHint
// Continuation data shares the host index cap. Reserve space for fact rows;
// tiny budgets retain tool retrieval guidance.
if len([]rune(resume)) <= maxRunes-len([]rune(wrapFactIndexBlock(header+footer)))-800 {
footer += "\n\n" + resume
}
// Include markers, separators and the worst-case omission notice in the cap.
omissionReserve := len([]rune(factIndexOmissionNotice(len(facts))))
framingRunes := len([]rune(wrapFactIndexBlock(header+footer))) + omissionReserve
Expand Down
35 changes: 35 additions & 0 deletions internal/project/fact_template.go
Original file line number Diff line number Diff line change
Expand Up @@ -58,6 +58,12 @@ func IsSparseFactBody(category, factKey, body string) bool {

// FactBodyTemplate 按 category 返回建议的 body Markdown 骨架(供 Agent 填入真实内容)。
func FactBodyTemplate(category, factKey string) string {
if strings.HasPrefix(strings.ToLower(strings.TrimSpace(factKey)), "artifact/") {
return artifactFactBodyTemplate
}
if strings.HasPrefix(strings.ToLower(strings.TrimSpace(factKey)), "test/") || strings.HasPrefix(strings.ToLower(strings.TrimSpace(factKey)), "negative/") {
return testResultFactBodyTemplate
}
if RequiresAttackChainBody(category, factKey) {
return attackChainFactBodyTemplate
}
Expand Down Expand Up @@ -133,3 +139,32 @@ func SparseBodyWarningIfNeeded(category, factKey, body string) string {
}
return SparseBodyWarning(category, factKey)
}

const artifactFactBodyTemplate = `## 成果与验证状态
<用途、适用目标/入口;已验证成功 / 失败 / 未验证>

## 文件与调用
- 项目工作目录相对路径: artifacts/<文件名>
- 依赖与版本: <依赖文件、安装/运行环境>
- 调用方式与可变参数: <命令、输入输出;认证引用 auth/*>

## 验证证据
<验证时间、原始命令/输出、适用条件;不要从文件存在推断成功>

## 关联
<来源会话、依赖 fact_key、related_vulnerability_id>`

const testResultFactBodyTemplate = `## 已测条件
<目标、入口、参数、身份/认证、版本、测试时间>

## 方法与证据
<请求/命令、响应差异、工具输出;错误/超时须单独标明>

## 结论与边界
<成功 / 在已测条件下未发现 / 受阻 / 待验证;不扩展到未测试入口>

## 复测触发条件
<目标/入口/身份/版本变化、原证据不足或用户要求复测>

## 关联
<来源会话、脚本 artifact/*、相关事实/漏洞 ID>`
Loading
Loading