From d7b75a111284b5e431e2ab578af6d0ee9c43f6bc Mon Sep 17 00:00:00 2001 From: Yoilun Date: Mon, 25 May 2026 21:26:37 +0800 Subject: [PATCH] fix: harden agent writeback safety --- internal/agents/writeback.go | 192 +++++++++++++++++++++++++----- internal/agents/writeback_test.go | 58 +++++++++ internal/server/server.go | 51 +++++++- internal/server/server_test.go | 63 ++++++++++ progress.md | 10 ++ task_plan.md | 1 + 6 files changed, 341 insertions(+), 34 deletions(-) diff --git a/internal/agents/writeback.go b/internal/agents/writeback.go index 1303bf8..1718ba3 100644 --- a/internal/agents/writeback.go +++ b/internal/agents/writeback.go @@ -10,6 +10,8 @@ import ( "regexp" "sort" "strings" + "sync" + "syscall" "time" "codex-agent-manager/internal/codexhome" @@ -19,29 +21,47 @@ var ErrWriteConflict = errors.New("目标文件已在校验后发生变化") var safeAgentID = regexp.MustCompile(`^[A-Za-z0-9][A-Za-z0-9_-]*$`) +var writebackMu sync.Mutex +var writebackTestHookBeforeBackup func() +var writebackTestHookAfterBackup func() + +type fileIdentity struct { + dev uint64 + ino uint64 + mode os.FileMode +} + +type writeTarget struct { + path string + content []byte + mode os.FileMode + agentsIdentity fileIdentity + targetIdentity fileIdentity +} + func (s Store) ValidateDraft(id string, content string) (DraftValidation, error) { - targetPath, current, _, err := s.readWriteTarget(id) + target, err := s.readWriteTarget(id) if err != nil { return DraftValidation{}, err } result := DraftValidation{ - TargetPath: targetPath, - CurrentHash: hashBytes(current), + TargetPath: target.path, + CurrentHash: hashBytes(target.content), } - currentFields, currentErr := parseSimpleTOML(string(current)) + currentFields, currentErr := parseSimpleTOML(string(target.content)) draftFields, draftErr := parseSimpleTOML(content) if draftErr != nil { result.Valid = false result.Errors = []string{draftErr.Error()} - result.Diff = simpleDiff(string(current), content) + result.Diff = simpleDiff(string(target.content), content) return result, nil } result.Valid = true result.Errors = []string{} - result.Diff = simpleDiff(string(current), content) + result.Diff = simpleDiff(string(target.content), content) if currentErr == nil { result.FieldChanges = changedFields(currentFields, draftFields) } @@ -49,6 +69,9 @@ func (s Store) ValidateDraft(id string, content string) (DraftValidation, error) } func (s Store) WriteDraft(id string, content string, expectedHash string) (WriteResult, error) { + writebackMu.Lock() + defer writebackMu.Unlock() + validation, err := s.ValidateDraft(id, content) if err != nil { return WriteResult{}, err @@ -60,58 +83,164 @@ func (s Store) WriteDraft(id string, content string, expectedHash string) (Write return WriteResult{}, ErrWriteConflict } - targetPath, current, mode, err := s.readWriteTarget(id) + target, err := s.readWriteTarget(id) if err != nil { return WriteResult{}, err } - if hashBytes(current) != expectedHash { + if hashBytes(target.content) != expectedHash { return WriteResult{}, ErrWriteConflict } - backupPath, err := s.createBackup(targetPath, current, mode) + if writebackTestHookBeforeBackup != nil { + writebackTestHookBeforeBackup() + } + if _, err := s.verifyWriteTarget(id, target, expectedHash); err != nil { + return WriteResult{}, err + } + backupPath, err := s.createBackup(target.path, target.content, target.mode) if err != nil { return WriteResult{}, err } - if err := atomicWrite(targetPath, []byte(content), mode); err != nil { + if writebackTestHookAfterBackup != nil { + writebackTestHookAfterBackup() + } + if err := atomicWrite(target, []byte(content), func() error { + _, err := s.verifyWriteTarget(id, target, expectedHash) + return err + }); err != nil { return WriteResult{}, err } return WriteResult{ Status: "written", - TargetPath: targetPath, + TargetPath: target.path, BackupPath: backupPath, CurrentHash: hashBytes([]byte(content)), }, nil } -func (s Store) readWriteTarget(id string) (string, []byte, os.FileMode, error) { +func (s Store) readWriteTarget(id string) (writeTarget, error) { if !safeAgentID.MatchString(id) { - return "", nil, 0, codexhome.ErrForbiddenPath + return writeTarget{}, codexhome.ErrForbiddenPath } agentsPath := filepath.Join(s.CodexHome, "agents") - if info, err := os.Lstat(agentsPath); err != nil { - return "", nil, 0, err - } else if info.Mode()&os.ModeSymlink != 0 || !info.IsDir() { - return "", nil, 0, codexhome.ErrForbiddenPath + agentsInfo, err := os.Lstat(agentsPath) + if err != nil { + return writeTarget{}, err + } else if agentsInfo.Mode()&os.ModeSymlink != 0 || !agentsInfo.IsDir() { + return writeTarget{}, codexhome.ErrForbiddenPath + } + agentsIdentity, err := identityOf(agentsInfo) + if err != nil { + return writeTarget{}, err } fileName := id + ".toml" targetPath, err := codexhome.ResolveAgentTOML(s.CodexHome, fileName) if err != nil { - return "", nil, 0, err + return writeTarget{}, err } - info, err := os.Lstat(targetPath) + targetInfo, err := os.Lstat(targetPath) if err != nil { - return "", nil, 0, err + return writeTarget{}, err } - if info.Mode()&os.ModeSymlink != 0 || !info.Mode().IsRegular() { - return "", nil, 0, codexhome.ErrForbiddenPath + if targetInfo.Mode()&os.ModeSymlink != 0 || !targetInfo.Mode().IsRegular() { + return writeTarget{}, codexhome.ErrForbiddenPath + } + targetIdentity, err := identityOf(targetInfo) + if err != nil { + return writeTarget{}, err } data, err := os.ReadFile(targetPath) if err != nil { - return "", nil, 0, err + return writeTarget{}, err } - return targetPath, data, info.Mode().Perm(), nil + target := writeTarget{ + path: targetPath, + content: data, + mode: targetInfo.Mode().Perm(), + agentsIdentity: agentsIdentity, + targetIdentity: targetIdentity, + } + if _, err := s.verifyWriteTarget(id, target, hashBytes(data)); err != nil { + return writeTarget{}, err + } + return target, nil +} + +func (s Store) verifyWriteTarget(id string, expected writeTarget, expectedHash string) (writeTarget, error) { + current, err := s.readWriteTargetUnchecked(id) + if err != nil { + if os.IsNotExist(err) { + return writeTarget{}, ErrWriteConflict + } + return writeTarget{}, err + } + if current.path != expected.path || + current.agentsIdentity != expected.agentsIdentity || + current.targetIdentity != expected.targetIdentity { + return writeTarget{}, ErrWriteConflict + } + if hashBytes(current.content) != expectedHash { + return writeTarget{}, ErrWriteConflict + } + return current, nil +} + +func (s Store) readWriteTargetUnchecked(id string) (writeTarget, error) { + if !safeAgentID.MatchString(id) { + return writeTarget{}, codexhome.ErrForbiddenPath + } + agentsPath := filepath.Join(s.CodexHome, "agents") + agentsInfo, err := os.Lstat(agentsPath) + if err != nil { + return writeTarget{}, err + } + if agentsInfo.Mode()&os.ModeSymlink != 0 || !agentsInfo.IsDir() { + return writeTarget{}, codexhome.ErrForbiddenPath + } + agentsIdentity, err := identityOf(agentsInfo) + if err != nil { + return writeTarget{}, err + } + targetPath, err := codexhome.ResolveAgentTOML(s.CodexHome, id+".toml") + if err != nil { + return writeTarget{}, err + } + targetInfo, err := os.Lstat(targetPath) + if err != nil { + return writeTarget{}, err + } + if targetInfo.Mode()&os.ModeSymlink != 0 || !targetInfo.Mode().IsRegular() { + return writeTarget{}, codexhome.ErrForbiddenPath + } + targetIdentity, err := identityOf(targetInfo) + if err != nil { + return writeTarget{}, err + } + data, err := os.ReadFile(targetPath) + if err != nil { + return writeTarget{}, err + } + return writeTarget{ + path: targetPath, + content: data, + mode: targetInfo.Mode().Perm(), + agentsIdentity: agentsIdentity, + targetIdentity: targetIdentity, + }, nil +} + +func identityOf(info os.FileInfo) (fileIdentity, error) { + stat, ok := info.Sys().(*syscall.Stat_t) + if !ok { + return fileIdentity{}, errors.New("无法确认文件身份") + } + return fileIdentity{ + dev: uint64(stat.Dev), + ino: uint64(stat.Ino), + mode: info.Mode(), + }, nil } func (s Store) createBackup(targetPath string, content []byte, mode os.FileMode) (string, error) { @@ -132,9 +261,9 @@ func (s Store) createBackup(targetPath string, content []byte, mode os.FileMode) return backupPath, nil } -func atomicWrite(targetPath string, content []byte, mode os.FileMode) error { - dir := filepath.Dir(targetPath) - base := filepath.Base(targetPath) +func atomicWrite(target writeTarget, content []byte, beforeRename func() error) error { + dir := filepath.Dir(target.path) + base := filepath.Base(target.path) tmp, err := os.CreateTemp(dir, "."+base+".tmp-*") if err != nil { return err @@ -144,7 +273,7 @@ func atomicWrite(targetPath string, content []byte, mode os.FileMode) error { _ = os.Remove(tmpPath) }() - if err := tmp.Chmod(mode); err != nil { + if err := tmp.Chmod(target.mode); err != nil { _ = tmp.Close() return err } @@ -155,7 +284,12 @@ func atomicWrite(targetPath string, content []byte, mode os.FileMode) error { if err := tmp.Close(); err != nil { return err } - return os.Rename(tmpPath, targetPath) + if beforeRename != nil { + if err := beforeRename(); err != nil { + return err + } + } + return os.Rename(tmpPath, target.path) } func hashBytes(data []byte) string { diff --git a/internal/agents/writeback_test.go b/internal/agents/writeback_test.go index 4f703ca..41dcc89 100644 --- a/internal/agents/writeback_test.go +++ b/internal/agents/writeback_test.go @@ -6,6 +6,8 @@ import ( "path/filepath" "strings" "testing" + + "codex-agent-manager/internal/codexhome" ) func TestValidateInvalidTOMLReturnsInvalidAndDoesNotWrite(t *testing.T) { @@ -78,6 +80,62 @@ func TestWriteExpectedHashMismatchRejectsAndLeavesOriginal(t *testing.T) { assertFileContent(t, target, `name = "用户已改"`+"\n") } +func TestWriteRejectsAgentsDirectoryReplacementBeforeBackup(t *testing.T) { + store, target := writebackFixture(t, `name = "旧名称"`+"\n") + validation, err := store.ValidateDraft("backend", `name = "新名称"`+"\n") + if err != nil { + t.Fatal(err) + } + root := filepath.Dir(filepath.Dir(target)) + agentsDir := filepath.Join(root, "agents") + realAgentsDir := filepath.Join(root, "agents-real") + externalDir := filepath.Join(root, "external") + if err := os.MkdirAll(externalDir, 0o755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(externalDir, "backend.toml"), []byte(`name = "外部"`+"\n"), 0o644); err != nil { + t.Fatal(err) + } + + writebackTestHookBeforeBackup = func() { + if err := os.Rename(agentsDir, realAgentsDir); err != nil { + t.Fatal(err) + } + if err := os.Symlink(externalDir, agentsDir); err != nil { + t.Fatal(err) + } + } + defer func() { writebackTestHookBeforeBackup = nil }() + + _, err = store.WriteDraft("backend", `name = "新名称"`+"\n", validation.CurrentHash) + if !errors.Is(err, ErrWriteConflict) && !errors.Is(err, codexhome.ErrForbiddenPath) { + t.Fatalf("expected directory replacement to be rejected, got %v", err) + } + assertFileContent(t, filepath.Join(realAgentsDir, "backend.toml"), `name = "旧名称"`+"\n") + assertFileContent(t, filepath.Join(externalDir, "backend.toml"), `name = "外部"`+"\n") +} + +func TestWriteRejectsTargetChangeAfterBackup(t *testing.T) { + store, target := writebackFixture(t, `name = "旧名称"`+"\n") + validation, err := store.ValidateDraft("backend", `name = "新名称"`+"\n") + if err != nil { + t.Fatal(err) + } + + writebackTestHookAfterBackup = func() { + if err := os.WriteFile(target, []byte(`name = "用户已改"`+"\n"), 0o644); err != nil { + t.Fatal(err) + } + } + defer func() { writebackTestHookAfterBackup = nil }() + + _, err = store.WriteDraft("backend", `name = "新名称"`+"\n", validation.CurrentHash) + if !errors.Is(err, ErrWriteConflict) { + t.Fatalf("expected post-backup target change to be rejected, got %v", err) + } + assertFileContent(t, target, `name = "用户已改"`+"\n") +} + func TestWriteSuccessCreatesBackupAndAtomicallyWrites(t *testing.T) { store, target := writebackFixture(t, `name = "旧名称"`+"\n") validation, err := store.ValidateDraft("backend", `name = "新名称"`+"\n") diff --git a/internal/server/server.go b/internal/server/server.go index c9c4908..599b5f5 100644 --- a/internal/server/server.go +++ b/internal/server/server.go @@ -3,7 +3,9 @@ package server import ( "encoding/json" "errors" + "io" "net/http" + "os" "strings" "codex-agent-manager/internal/agents" @@ -14,6 +16,8 @@ import ( "codex-agent-manager/internal/workflow" ) +const agentDraftBodyLimit = 1 << 20 + func New(cfg app.Config) http.Handler { mux := http.NewServeMux() agentStore := agents.Store{CodexHome: cfg.CodexHome} @@ -129,8 +133,7 @@ func parseAgentActionPath(path string) (string, string, bool) { func handleAgentValidate(w http.ResponseWriter, r *http.Request, store agents.Store, id string) { var body validateRequest - if err := json.NewDecoder(r.Body).Decode(&body); err != nil { - writeJSON(w, http.StatusBadRequest, map[string]string{"error": "请求体不是有效 JSON"}) + if !decodeAgentJSON(w, r, &body) { return } result, err := store.ValidateDraft(id, body.Content) @@ -143,8 +146,7 @@ func handleAgentValidate(w http.ResponseWriter, r *http.Request, store agents.St func handleAgentWrite(w http.ResponseWriter, r *http.Request, store agents.Store, id string) { var body writeRequest - if err := json.NewDecoder(r.Body).Decode(&body); err != nil { - writeJSON(w, http.StatusBadRequest, map[string]string{"error": "请求体不是有效 JSON"}) + if !decodeAgentJSON(w, r, &body) { return } result, err := store.WriteDraft(id, body.Content, body.ExpectedHash) @@ -155,14 +157,53 @@ func handleAgentWrite(w http.ResponseWriter, r *http.Request, store agents.Store writeJSON(w, http.StatusOK, result) } +func decodeAgentJSON(w http.ResponseWriter, r *http.Request, dest any) bool { + r.Body = http.MaxBytesReader(w, r.Body, agentDraftBodyLimit) + decoder := json.NewDecoder(r.Body) + if err := decoder.Decode(dest); err != nil { + writeAgentDecodeError(w, err) + return false + } + var extra any + if err := decoder.Decode(&extra); err != io.EOF { + if err != nil { + writeAgentDecodeError(w, err) + return false + } + writeJSON(w, http.StatusBadRequest, map[string]string{"error": "请求体不是有效 JSON"}) + return false + } + return true +} + +func writeAgentDecodeError(w http.ResponseWriter, err error) { + var maxBytesErr *http.MaxBytesError + if errors.As(err, &maxBytesErr) { + writeJSON(w, http.StatusRequestEntityTooLarge, map[string]string{"error": "请求体过大"}) + return + } + writeJSON(w, http.StatusBadRequest, map[string]string{"error": "请求体不是有效 JSON"}) +} + func writeAgentError(w http.ResponseWriter, err error) { status := http.StatusBadRequest + message := "写回失败" if errors.Is(err, agents.ErrWriteConflict) { status = http.StatusConflict + message = "内容已变化,请重新校验" } else if errors.Is(err, codexhome.ErrForbiddenPath) || errors.Is(err, codexhome.ErrOutsideCodexHome) { status = http.StatusForbidden + message = "路径不安全" + } else if os.IsNotExist(err) { + status = http.StatusNotFound + message = "目标智能体不存在" + } else if strings.Contains(err.Error(), "第 ") || + strings.Contains(err.Error(), "TOML") || + strings.Contains(err.Error(), "字符串字段") || + strings.Contains(err.Error(), "仅支持字符串字段") { + message = "TOML 无效:" + err.Error() } - writeJSON(w, status, map[string]string{"error": err.Error()}) + writeJSON(w, status, map[string]string{"error": message}) } func writeJSON(w http.ResponseWriter, status int, body any) { diff --git a/internal/server/server_test.go b/internal/server/server_test.go index e9d4302..5489fec 100644 --- a/internal/server/server_test.go +++ b/internal/server/server_test.go @@ -250,6 +250,69 @@ func TestAgentValidateEndpointReturnsBadRequestForInvalidBody(t *testing.T) { } } +func TestAgentValidateEndpointRejectsOversizeBody(t *testing.T) { + root := t.TempDir() + agentsDir := filepath.Join(root, "agents") + if err := os.MkdirAll(agentsDir, 0o755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(agentsDir, "backend.toml"), []byte(`name = "旧名称"`+"\n"), 0o644); err != nil { + t.Fatal(err) + } + body := `{"content":"` + strings.Repeat("a", 1024*1024+1) + `"}` + + req := httptest.NewRequest(http.MethodPost, "/api/agents/backend/validate", bytes.NewBufferString(body)) + rec := httptest.NewRecorder() + New(app.Config{CodexHome: root, HTTPAddr: "127.0.0.1:0"}).ServeHTTP(rec, req) + + if rec.Code != http.StatusRequestEntityTooLarge { + t.Fatalf("status = %d, want %d, body = %s", rec.Code, http.StatusRequestEntityTooLarge, rec.Body.String()) + } + if !strings.Contains(rec.Body.String(), "请求体过大") { + t.Fatalf("expected Chinese oversize error, got %s", rec.Body.String()) + } +} + +func TestAgentValidateEndpointRejectsTrailingJSON(t *testing.T) { + root := t.TempDir() + agentsDir := filepath.Join(root, "agents") + if err := os.MkdirAll(agentsDir, 0o755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(agentsDir, "backend.toml"), []byte(`name = "旧名称"`+"\n"), 0o644); err != nil { + t.Fatal(err) + } + + req := httptest.NewRequest(http.MethodPost, "/api/agents/backend/validate", bytes.NewBufferString(`{"content":"name = \"新名称\"\n"} {}`)) + rec := httptest.NewRecorder() + New(app.Config{CodexHome: root, HTTPAddr: "127.0.0.1:0"}).ServeHTTP(rec, req) + + if rec.Code != http.StatusBadRequest { + t.Fatalf("status = %d, want %d, body = %s", rec.Code, http.StatusBadRequest, rec.Body.String()) + } + if !strings.Contains(rec.Body.String(), "请求体不是有效 JSON") { + t.Fatalf("expected Chinese invalid JSON error, got %s", rec.Body.String()) + } +} + +func TestAgentWritebackErrorsAreSanitized(t *testing.T) { + root := t.TempDir() + if err := os.MkdirAll(filepath.Join(root, "agents"), 0o755); err != nil { + t.Fatal(err) + } + + req := httptest.NewRequest(http.MethodPost, "/api/agents/missing/validate", bytes.NewBufferString(`{"content":"name = \"新名称\"\n"}`)) + rec := httptest.NewRecorder() + New(app.Config{CodexHome: root, HTTPAddr: "127.0.0.1:0"}).ServeHTTP(rec, req) + + if rec.Code != http.StatusNotFound { + t.Fatalf("status = %d, want %d, body = %s", rec.Code, http.StatusNotFound, rec.Body.String()) + } + if strings.Contains(rec.Body.String(), root) || strings.Contains(rec.Body.String(), "no such file") || !strings.Contains(rec.Body.String(), "目标智能体不存在") { + t.Fatalf("error leaked path or raw OS text: %s", rec.Body.String()) + } +} + func TestAgentWriteEndpointCreatesBackupAndRejectsConflicts(t *testing.T) { root := t.TempDir() agentsDir := filepath.Join(root, "agents") diff --git a/progress.md b/progress.md index 3497a35..05a269d 100644 --- a/progress.md +++ b/progress.md @@ -29,6 +29,7 @@ | 2026-05-25 | 5 | quality review | 代码质量审查未通过:未知后端枚举值会直接进入 UI label | coding agent 按 blocking 范围修复 | | 2026-05-25 | 6 | coding agent | TDD 实现智能体草稿校验、diff、hash 冲突检测、备份和原子写回 | 完成;待最终全量验证 | | 2026-05-25 | 6 | spec review | 规格审查未通过:TOML 字符串解析错误泄漏英文 `invalid syntax` | coding agent 按 blocking 范围修复 | +| 2026-05-25 | 6 | security review | 安全审查未通过:写回存在 TOCTOU、备份后 CAS 缺失、POST body 无限制、错误响应泄漏路径/英文 | coding agent 按 blocking 范围修复 | ## Test Results @@ -161,6 +162,13 @@ | 2026-05-25 | `cd web && pnpm test` | PASS | Phase 6 规格修复后前端单测验证通过;共 13 个单测 | | 2026-05-25 | `cd web && pnpm build` | PASS | Phase 6 规格修复后前端生产构建通过 | | 2026-05-25 | `git diff --check` | PASS | Phase 6 规格修复 whitespace 检查通过 | +| 2026-05-25 | `go test ./internal/agents ./internal/server` | FAIL | TDD 红灯:缺少写回 hook;server 超大 body 返回 200,trailing JSON 返回 200,缺失目标泄漏绝对路径和 `no such file` | +| 2026-05-25 | `go test ./internal/agents ./internal/server` | PASS | Phase 6 安全修复目标包测试通过 | +| 2026-05-25 | `go test ./internal/agents ./internal/server` | PASS | Phase 6 安全修复后指定后端目标包验证通过 | +| 2026-05-25 | `go test ./...` | PASS | Phase 6 安全修复后全量 Go 验证通过 | +| 2026-05-25 | `cd web && pnpm test` | PASS | Phase 6 安全修复后前端单测验证通过;共 13 个单测 | +| 2026-05-25 | `cd web && pnpm build` | PASS | Phase 6 安全修复后前端生产构建通过 | +| 2026-05-25 | `git diff --check` | PASS | Phase 6 安全修复 whitespace 检查通过 | ## Bug Loop @@ -190,3 +198,5 @@ | 6 | 写回可能覆盖校验后用户修改的文件 | validate 返回当前 sha256;write 重新读取并比较 expectedHash,不匹配返回冲突且不写回 | `go test ./internal/agents ./internal/server` PASS | | 6 | 无效 TOML 或 unsafe id/symlink 可能进入写回路径 | write 重新执行 TOML 校验,id 只允许安全 bare stem,拒绝 leaf symlink 和 symlinked agents 目录 | `go test ./internal/agents ./internal/server` PASS | | 6 | TOML 未闭合字符串错误会把 `strconv.Unquote` 的英文 `invalid syntax` 返回给 UI/API | 在 parser 层将字符串字段语法错误包装为中文并带行号;List/Validate/Write 增加中文错误断言 | `go test ./internal/agents ./internal/server` PASS | +| 6 | 写回备份/rename 前路径身份可能变化,且备份后并发修改可能被覆盖 | 写回加进程内临界区,记录 agents 目录和目标文件 inode identity;备份前和 rename 前复核 identity 与 expectedHash | `go test ./internal/agents ./internal/server` PASS | +| 6 | validate/write POST 可接收超大 body、trailing JSON,且错误响应透传路径和英文系统错误 | validate/write 使用 1MiB `MaxBytesReader`、拒绝 trailing JSON,并将错误映射为安全中文响应 | `go test ./internal/agents ./internal/server` PASS | diff --git a/task_plan.md b/task_plan.md index 79f5d23..a69923f 100644 --- a/task_plan.md +++ b/task_plan.md @@ -40,3 +40,4 @@ | 2026-05-25 | 5 | 代码质量审查发现未知后端枚举值会直接暴露到 UI | TDD 补 unknown source/confidence/status/trust 测试后将未知 label 统一降级为中文兜底 | 待复审 | | 2026-05-25 | 5 | 质量复审发现未知 workflow phase status 会导致真实阶段被过滤消失 | TDD 补 `in_progress` phase 测试后只过滤表头/伪行,未知状态显示“未知” | 待复审 | | 2026-05-25 | 6 | 规格审查发现 malformed TOML 会通过 `strconv.Unquote` 泄漏英文 `invalid syntax` | TDD 补 List/Validate/Write 中文错误断言后包装字符串解析错误 | 待最终验证 | +| 2026-05-25 | 6 | 安全审查发现写回 TOCTOU、备份后 CAS 缺失、POST body 无限制、错误响应泄漏路径/英文 | TDD 补目录替换、备份后修改、请求体限制和错误脱敏测试后加身份复核/CAS/MaxBytesReader/中文错误映射 | 待最终验证 |