fix: harden agent writeback safety
This commit is contained in:
@@ -10,6 +10,8 @@ import (
|
|||||||
"regexp"
|
"regexp"
|
||||||
"sort"
|
"sort"
|
||||||
"strings"
|
"strings"
|
||||||
|
"sync"
|
||||||
|
"syscall"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"codex-agent-manager/internal/codexhome"
|
"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 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) {
|
func (s Store) ValidateDraft(id string, content string) (DraftValidation, error) {
|
||||||
targetPath, current, _, err := s.readWriteTarget(id)
|
target, err := s.readWriteTarget(id)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return DraftValidation{}, err
|
return DraftValidation{}, err
|
||||||
}
|
}
|
||||||
|
|
||||||
result := DraftValidation{
|
result := DraftValidation{
|
||||||
TargetPath: targetPath,
|
TargetPath: target.path,
|
||||||
CurrentHash: hashBytes(current),
|
CurrentHash: hashBytes(target.content),
|
||||||
}
|
}
|
||||||
|
|
||||||
currentFields, currentErr := parseSimpleTOML(string(current))
|
currentFields, currentErr := parseSimpleTOML(string(target.content))
|
||||||
draftFields, draftErr := parseSimpleTOML(content)
|
draftFields, draftErr := parseSimpleTOML(content)
|
||||||
if draftErr != nil {
|
if draftErr != nil {
|
||||||
result.Valid = false
|
result.Valid = false
|
||||||
result.Errors = []string{draftErr.Error()}
|
result.Errors = []string{draftErr.Error()}
|
||||||
result.Diff = simpleDiff(string(current), content)
|
result.Diff = simpleDiff(string(target.content), content)
|
||||||
return result, nil
|
return result, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
result.Valid = true
|
result.Valid = true
|
||||||
result.Errors = []string{}
|
result.Errors = []string{}
|
||||||
result.Diff = simpleDiff(string(current), content)
|
result.Diff = simpleDiff(string(target.content), content)
|
||||||
if currentErr == nil {
|
if currentErr == nil {
|
||||||
result.FieldChanges = changedFields(currentFields, draftFields)
|
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) {
|
func (s Store) WriteDraft(id string, content string, expectedHash string) (WriteResult, error) {
|
||||||
|
writebackMu.Lock()
|
||||||
|
defer writebackMu.Unlock()
|
||||||
|
|
||||||
validation, err := s.ValidateDraft(id, content)
|
validation, err := s.ValidateDraft(id, content)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return WriteResult{}, err
|
return WriteResult{}, err
|
||||||
@@ -60,58 +83,164 @@ func (s Store) WriteDraft(id string, content string, expectedHash string) (Write
|
|||||||
return WriteResult{}, ErrWriteConflict
|
return WriteResult{}, ErrWriteConflict
|
||||||
}
|
}
|
||||||
|
|
||||||
targetPath, current, mode, err := s.readWriteTarget(id)
|
target, err := s.readWriteTarget(id)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return WriteResult{}, err
|
return WriteResult{}, err
|
||||||
}
|
}
|
||||||
if hashBytes(current) != expectedHash {
|
if hashBytes(target.content) != expectedHash {
|
||||||
return WriteResult{}, ErrWriteConflict
|
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 {
|
if err != nil {
|
||||||
return WriteResult{}, err
|
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{}, err
|
||||||
}
|
}
|
||||||
|
|
||||||
return WriteResult{
|
return WriteResult{
|
||||||
Status: "written",
|
Status: "written",
|
||||||
TargetPath: targetPath,
|
TargetPath: target.path,
|
||||||
BackupPath: backupPath,
|
BackupPath: backupPath,
|
||||||
CurrentHash: hashBytes([]byte(content)),
|
CurrentHash: hashBytes([]byte(content)),
|
||||||
}, nil
|
}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s Store) readWriteTarget(id string) (string, []byte, os.FileMode, error) {
|
func (s Store) readWriteTarget(id string) (writeTarget, error) {
|
||||||
if !safeAgentID.MatchString(id) {
|
if !safeAgentID.MatchString(id) {
|
||||||
return "", nil, 0, codexhome.ErrForbiddenPath
|
return writeTarget{}, codexhome.ErrForbiddenPath
|
||||||
}
|
}
|
||||||
agentsPath := filepath.Join(s.CodexHome, "agents")
|
agentsPath := filepath.Join(s.CodexHome, "agents")
|
||||||
if info, err := os.Lstat(agentsPath); err != nil {
|
agentsInfo, err := os.Lstat(agentsPath)
|
||||||
return "", nil, 0, err
|
if err != nil {
|
||||||
} else if info.Mode()&os.ModeSymlink != 0 || !info.IsDir() {
|
return writeTarget{}, err
|
||||||
return "", nil, 0, codexhome.ErrForbiddenPath
|
} 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"
|
fileName := id + ".toml"
|
||||||
targetPath, err := codexhome.ResolveAgentTOML(s.CodexHome, fileName)
|
targetPath, err := codexhome.ResolveAgentTOML(s.CodexHome, fileName)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return "", nil, 0, err
|
return writeTarget{}, err
|
||||||
}
|
}
|
||||||
info, err := os.Lstat(targetPath)
|
targetInfo, err := os.Lstat(targetPath)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return "", nil, 0, err
|
return writeTarget{}, err
|
||||||
}
|
}
|
||||||
if info.Mode()&os.ModeSymlink != 0 || !info.Mode().IsRegular() {
|
if targetInfo.Mode()&os.ModeSymlink != 0 || !targetInfo.Mode().IsRegular() {
|
||||||
return "", nil, 0, codexhome.ErrForbiddenPath
|
return writeTarget{}, codexhome.ErrForbiddenPath
|
||||||
|
}
|
||||||
|
targetIdentity, err := identityOf(targetInfo)
|
||||||
|
if err != nil {
|
||||||
|
return writeTarget{}, err
|
||||||
}
|
}
|
||||||
data, err := os.ReadFile(targetPath)
|
data, err := os.ReadFile(targetPath)
|
||||||
if err != nil {
|
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) {
|
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
|
return backupPath, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func atomicWrite(targetPath string, content []byte, mode os.FileMode) error {
|
func atomicWrite(target writeTarget, content []byte, beforeRename func() error) error {
|
||||||
dir := filepath.Dir(targetPath)
|
dir := filepath.Dir(target.path)
|
||||||
base := filepath.Base(targetPath)
|
base := filepath.Base(target.path)
|
||||||
tmp, err := os.CreateTemp(dir, "."+base+".tmp-*")
|
tmp, err := os.CreateTemp(dir, "."+base+".tmp-*")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
@@ -144,7 +273,7 @@ func atomicWrite(targetPath string, content []byte, mode os.FileMode) error {
|
|||||||
_ = os.Remove(tmpPath)
|
_ = os.Remove(tmpPath)
|
||||||
}()
|
}()
|
||||||
|
|
||||||
if err := tmp.Chmod(mode); err != nil {
|
if err := tmp.Chmod(target.mode); err != nil {
|
||||||
_ = tmp.Close()
|
_ = tmp.Close()
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
@@ -155,7 +284,12 @@ func atomicWrite(targetPath string, content []byte, mode os.FileMode) error {
|
|||||||
if err := tmp.Close(); err != nil {
|
if err := tmp.Close(); err != nil {
|
||||||
return err
|
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 {
|
func hashBytes(data []byte) string {
|
||||||
|
|||||||
@@ -6,6 +6,8 @@ import (
|
|||||||
"path/filepath"
|
"path/filepath"
|
||||||
"strings"
|
"strings"
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
|
"codex-agent-manager/internal/codexhome"
|
||||||
)
|
)
|
||||||
|
|
||||||
func TestValidateInvalidTOMLReturnsInvalidAndDoesNotWrite(t *testing.T) {
|
func TestValidateInvalidTOMLReturnsInvalidAndDoesNotWrite(t *testing.T) {
|
||||||
@@ -78,6 +80,62 @@ func TestWriteExpectedHashMismatchRejectsAndLeavesOriginal(t *testing.T) {
|
|||||||
assertFileContent(t, target, `name = "用户已改"`+"\n")
|
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) {
|
func TestWriteSuccessCreatesBackupAndAtomicallyWrites(t *testing.T) {
|
||||||
store, target := writebackFixture(t, `name = "旧名称"`+"\n")
|
store, target := writebackFixture(t, `name = "旧名称"`+"\n")
|
||||||
validation, err := store.ValidateDraft("backend", `name = "新名称"`+"\n")
|
validation, err := store.ValidateDraft("backend", `name = "新名称"`+"\n")
|
||||||
|
|||||||
@@ -3,7 +3,9 @@ package server
|
|||||||
import (
|
import (
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"errors"
|
"errors"
|
||||||
|
"io"
|
||||||
"net/http"
|
"net/http"
|
||||||
|
"os"
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
"codex-agent-manager/internal/agents"
|
"codex-agent-manager/internal/agents"
|
||||||
@@ -14,6 +16,8 @@ import (
|
|||||||
"codex-agent-manager/internal/workflow"
|
"codex-agent-manager/internal/workflow"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
const agentDraftBodyLimit = 1 << 20
|
||||||
|
|
||||||
func New(cfg app.Config) http.Handler {
|
func New(cfg app.Config) http.Handler {
|
||||||
mux := http.NewServeMux()
|
mux := http.NewServeMux()
|
||||||
agentStore := agents.Store{CodexHome: cfg.CodexHome}
|
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) {
|
func handleAgentValidate(w http.ResponseWriter, r *http.Request, store agents.Store, id string) {
|
||||||
var body validateRequest
|
var body validateRequest
|
||||||
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
|
if !decodeAgentJSON(w, r, &body) {
|
||||||
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "请求体不是有效 JSON"})
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
result, err := store.ValidateDraft(id, body.Content)
|
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) {
|
func handleAgentWrite(w http.ResponseWriter, r *http.Request, store agents.Store, id string) {
|
||||||
var body writeRequest
|
var body writeRequest
|
||||||
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
|
if !decodeAgentJSON(w, r, &body) {
|
||||||
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "请求体不是有效 JSON"})
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
result, err := store.WriteDraft(id, body.Content, body.ExpectedHash)
|
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)
|
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) {
|
func writeAgentError(w http.ResponseWriter, err error) {
|
||||||
status := http.StatusBadRequest
|
status := http.StatusBadRequest
|
||||||
|
message := "写回失败"
|
||||||
if errors.Is(err, agents.ErrWriteConflict) {
|
if errors.Is(err, agents.ErrWriteConflict) {
|
||||||
status = http.StatusConflict
|
status = http.StatusConflict
|
||||||
|
message = "内容已变化,请重新校验"
|
||||||
} else if errors.Is(err, codexhome.ErrForbiddenPath) || errors.Is(err, codexhome.ErrOutsideCodexHome) {
|
} else if errors.Is(err, codexhome.ErrForbiddenPath) || errors.Is(err, codexhome.ErrOutsideCodexHome) {
|
||||||
status = http.StatusForbidden
|
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) {
|
func writeJSON(w http.ResponseWriter, status int, body any) {
|
||||||
|
|||||||
@@ -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) {
|
func TestAgentWriteEndpointCreatesBackupAndRejectsConflicts(t *testing.T) {
|
||||||
root := t.TempDir()
|
root := t.TempDir()
|
||||||
agentsDir := filepath.Join(root, "agents")
|
agentsDir := filepath.Join(root, "agents")
|
||||||
|
|||||||
10
progress.md
10
progress.md
@@ -29,6 +29,7 @@
|
|||||||
| 2026-05-25 | 5 | quality review | 代码质量审查未通过:未知后端枚举值会直接进入 UI label | coding agent 按 blocking 范围修复 |
|
| 2026-05-25 | 5 | quality review | 代码质量审查未通过:未知后端枚举值会直接进入 UI label | coding agent 按 blocking 范围修复 |
|
||||||
| 2026-05-25 | 6 | coding agent | TDD 实现智能体草稿校验、diff、hash 冲突检测、备份和原子写回 | 完成;待最终全量验证 |
|
| 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 | spec review | 规格审查未通过:TOML 字符串解析错误泄漏英文 `invalid syntax` | coding agent 按 blocking 范围修复 |
|
||||||
|
| 2026-05-25 | 6 | security review | 安全审查未通过:写回存在 TOCTOU、备份后 CAS 缺失、POST body 无限制、错误响应泄漏路径/英文 | coding agent 按 blocking 范围修复 |
|
||||||
|
|
||||||
## Test Results
|
## Test Results
|
||||||
|
|
||||||
@@ -161,6 +162,13 @@
|
|||||||
| 2026-05-25 | `cd web && pnpm test` | PASS | Phase 6 规格修复后前端单测验证通过;共 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 | `cd web && pnpm build` | PASS | Phase 6 规格修复后前端生产构建通过 |
|
||||||
| 2026-05-25 | `git diff --check` | PASS | Phase 6 规格修复 whitespace 检查通过 |
|
| 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
|
## Bug Loop
|
||||||
|
|
||||||
@@ -190,3 +198,5 @@
|
|||||||
| 6 | 写回可能覆盖校验后用户修改的文件 | validate 返回当前 sha256;write 重新读取并比较 expectedHash,不匹配返回冲突且不写回 | `go test ./internal/agents ./internal/server` PASS |
|
| 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 或 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 | 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 |
|
||||||
|
|||||||
@@ -40,3 +40,4 @@
|
|||||||
| 2026-05-25 | 5 | 代码质量审查发现未知后端枚举值会直接暴露到 UI | TDD 补 unknown source/confidence/status/trust 测试后将未知 label 统一降级为中文兜底 | 待复审 |
|
| 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 | 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 | 规格审查发现 malformed TOML 会通过 `strconv.Unquote` 泄漏英文 `invalid syntax` | TDD 补 List/Validate/Write 中文错误断言后包装字符串解析错误 | 待最终验证 |
|
||||||
|
| 2026-05-25 | 6 | 安全审查发现写回 TOCTOU、备份后 CAS 缺失、POST body 无限制、错误响应泄漏路径/英文 | TDD 补目录替换、备份后修改、请求体限制和错误脱敏测试后加身份复核/CAS/MaxBytesReader/中文错误映射 | 待最终验证 |
|
||||||
|
|||||||
Reference in New Issue
Block a user