package swboot import ( "bytes" "context" "crypto/sha256" "encoding/hex" "encoding/json" "fmt" "os" "os/exec" "path/filepath" "strings" "time" "github.com/jackc/pgx/v5/pgxpool" ) // ErrMergeRequiresSeparateAuthorization is a by-design boundary (design §10: // approving execution never implies approving merge): the executor produces // patch/test candidates only; merge/push/deploy always need a separate // explicit authorization path and are never executed here. var ErrMergeRequiresSeparateAuthorization = fmt.Errorf("ERR_MERGE_REQUIRES_SEPARATE_AUTHORIZATION: merge/push/deploy are never executed by the controlled patch executor; approve-execution does not imply merge") // ControlledPatchRequest is one fail-closed execution unit. type ControlledPatchRequest struct { ProjectID int64 RequestedBy int64 RepoPath string // absolute path of the bound repository BaseRef string // explicit base commit-ish for the isolated worktree Patches []string Commands []string // must match admin-registered command prefixes } // ExecutionFile records one changed file with its content hash. type ExecutionFile struct { Path string `json:"path"` Sha string `json:"sha256"` } // ExecutionCommand records one allowlisted command run. type ExecutionCommand struct { Command string `json:"command"` ExitCode int `json:"exit_code"` DurationMs int64 `json:"duration_ms"` Output string `json:"output,omitempty"` Truncated bool `json:"truncated,omitempty"` } // ControlledPatchResult is the evidence artifact of one execution. type ControlledPatchResult struct { ID int64 `json:"id"` ProjectID int64 `json:"project_id"` Status string `json:"status"` // succeeded | failed RepoPath string `json:"repo_path"` BaseRef string `json:"base_ref"` Branch string `json:"branch"` WorktreePath string `json:"worktree_path"` PatchesApplied int `json:"patches_applied"` Files []ExecutionFile `json:"files"` Commands []ExecutionCommand `json:"commands"` Error string `json:"error,omitempty"` } // RepoAllowlistReader is the executor's view of the admin allowlist. type RepoAllowlistReader interface { IsAllowlisted(absPath string) bool AllowedCommands(absPath string) []string } // ControlledPatchExecutor applies reviewed patches on an isolated git // worktree and runs admin-allowlisted test commands. It never writes the // repository working tree directly, never merges/pushes, stops on first // failure without retry, and caps command time/output. type ControlledPatchExecutor struct { Allowlist RepoAllowlistReader Pool *pgxpool.Pool // execution record persistence; nil = no persistence CommandAllow func(cmd string) bool CommandTimeout time.Duration MaxOutputBytes int GitBin string } const ( defaultCommandTimeout = 10 * time.Minute defaultMaxOutputBytes = 1 << 20 maxPatches = 32 ) // Execute runs the fail-closed pipeline and persists the record. func (e *ControlledPatchExecutor) Execute(ctx context.Context, req ControlledPatchRequest) (*ControlledPatchResult, error) { res := &ControlledPatchResult{ ProjectID: req.ProjectID, RepoPath: filepath.Clean(strings.TrimSpace(req.RepoPath)), BaseRef: strings.TrimSpace(req.BaseRef), Status: "failed", } fail := func(err error) (*ControlledPatchResult, error) { res.Error = err.Error() e.persist(ctx, req, res) return res, err } if e == nil || e.Allowlist == nil { return fail(fmt.Errorf("swboot: executor not configured")) } if !e.Allowlist.IsAllowlisted(res.RepoPath) { return fail(fmt.Errorf("%w: path %q not in admin allowlist", ErrRepoPathNotAllowlisted, res.RepoPath)) } if res.BaseRef == "" { return fail(fmt.Errorf("swboot: base_ref required")) } if len(req.Patches) == 0 || len(req.Patches) > maxPatches { return fail(fmt.Errorf("swboot: 1..%d patches required", maxPatches)) } gitBin := e.GitBin if gitBin == "" { gitBin = "git" } slug := fmt.Sprintf("swboot-p%d-%d", req.ProjectID, time.Now().UnixNano()) branch := "feature/" + slug wtDir := filepath.Join(filepath.Dir(res.RepoPath), ".swboot-worktrees", slug) out, err := e.git(ctx, gitBin, res.RepoPath, "worktree", "add", "-b", branch, wtDir, res.BaseRef) if err != nil { return fail(fmt.Errorf("swboot: isolated worktree create failed: %v: %s", err, tail(out, 200))) } res.Branch = branch res.WorktreePath = wtDir // The worktree is evidence until separately cleaned; never auto-remove. for i, patch := range req.Patches { out, err := e.gitWithStdin(ctx, gitBin, wtDir, patch, "apply", "--check", "-") if err == nil { _, err = e.gitWithStdin(ctx, gitBin, wtDir, patch, "apply", "-") } if err != nil { return fail(fmt.Errorf("swboot: patch %d rejected: %v: %s", i+1, err, tail(out, 200))) } res.PatchesApplied = i + 1 } files, err := changedFilesWithHashes(ctx, gitBin, wtDir) if err != nil { return fail(err) } res.Files = files allowedNames := e.Allowlist.AllowedCommands(res.RepoPath) for _, cmd := range req.Commands { if !commandAllowed(cmd, allowedNames) { return fail(fmt.Errorf("swboot: command not in admin allowlist: %.80s", cmd)) } rec, err := e.runCommand(ctx, wtDir, cmd) res.Commands = append(res.Commands, rec) if err != nil { res.Error = fmt.Sprintf("command failed: %.200s", cmd) e.persist(ctx, req, res) return res, fmt.Errorf("swboot: %w", err) } } res.Status = "succeeded" e.persist(ctx, req, res) return res, nil } func (e *ControlledPatchExecutor) runCommand(ctx context.Context, dir, cmdLine string) (ExecutionCommand, error) { timeout := e.CommandTimeout if timeout <= 0 { timeout = defaultCommandTimeout } maxOut := e.MaxOutputBytes if maxOut <= 0 { maxOut = defaultMaxOutputBytes } rec := ExecutionCommand{Command: cmdLine} // FIND-SEC-03 回归:argv 化执行,不经 shell。命令行中的元字符 //(;、&&、|、$() 等)只会成为 argv 参数,不会被求值。 fields := strings.Fields(strings.TrimSpace(cmdLine)) if len(fields) == 0 { return rec, fmt.Errorf("swboot: empty command") } cctx, cancel := context.WithTimeout(ctx, timeout) defer cancel() c := exec.CommandContext(cctx, fields[0], fields[1:]...) c.Dir = dir c.Env = []string{ "PATH=" + os.Getenv("PATH"), "HOME=" + dir, "GIT_TERMINAL_PROMPT=0", } var buf bytes.Buffer c.Stdout = &limitedWriter{w: &buf, max: maxOut} c.Stderr = c.Stdout start := time.Now() err := c.Run() rec.DurationMs = time.Since(start).Milliseconds() rec.Output = tail(buf.String(), 4000) rec.Truncated = buf.Len() >= maxOut if ee, ok := err.(*exec.ExitError); ok { rec.ExitCode = ee.ExitCode() } if err != nil { if cctx.Err() == context.DeadlineExceeded { return rec, fmt.Errorf("command timed out after %s: %.80s", timeout, cmdLine) } return rec, fmt.Errorf("command exit: %v", err) } return rec, nil } type limitedWriter struct { w *bytes.Buffer max int exceed bool } func (l *limitedWriter) Write(p []byte) (int, error) { if l.w.Len()+len(p) > l.max { room := l.max - l.w.Len() if room > 0 { _, _ = l.w.Write(p[:room]) } l.exceed = true return len(p), nil // swallow the rest } return l.w.Write(p) } func (e *ControlledPatchExecutor) git(ctx context.Context, bin, dir string, args ...string) (string, error) { return e.gitWithStdin(ctx, bin, dir, "", args...) } func (e *ControlledPatchExecutor) gitWithStdin(ctx context.Context, bin, dir, stdin string, args ...string) (string, error) { cctx, cancel := context.WithTimeout(ctx, 2*time.Minute) defer cancel() c := exec.CommandContext(cctx, bin, args...) c.Dir = dir c.Env = []string{"PATH=" + os.Getenv("PATH"), "GIT_TERMINAL_PROMPT=0"} if stdin != "" { c.Stdin = strings.NewReader(stdin) } var out bytes.Buffer c.Stdout, c.Stderr = &out, &out if err := c.Run(); err != nil { return out.String(), err } return out.String(), nil } func changedFilesWithHashes(ctx context.Context, gitBin, wtDir string) ([]ExecutionFile, error) { cctx, cancel := context.WithTimeout(ctx, 2*time.Minute) defer cancel() c := exec.CommandContext(cctx, gitBin, "status", "--porcelain") c.Dir = wtDir c.Env = []string{"PATH=" + os.Getenv("PATH"), "GIT_TERMINAL_PROMPT=0"} var out bytes.Buffer c.Stdout = &out if err := c.Run(); err != nil { return nil, fmt.Errorf("swboot: git status failed: %v", err) } var files []ExecutionFile for _, line := range strings.Split(out.String(), "\n") { if len(line) < 4 { continue } path := strings.TrimSpace(line[3:]) if path == "" { continue } full := filepath.Join(wtDir, path) data, err := os.ReadFile(full) if err != nil { // Deleted or binary-unreadable entries still count as changed. files = append(files, ExecutionFile{Path: path}) continue } sum := sha256.Sum256(data) files = append(files, ExecutionFile{Path: path, Sha: hex.EncodeToString(sum[:])}) } return files, nil } func (e *ControlledPatchExecutor) persist(ctx context.Context, req ControlledPatchRequest, res *ControlledPatchResult) { if e == nil || e.Pool == nil || res == nil { return } filesJSON, _ := json.Marshal(res.Files) cmdsJSON, _ := json.Marshal(res.Commands) pctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) defer cancel() _ = req var id int64 err := e.Pool.QueryRow(pctx, ` INSERT INTO swboot_controlled_executions (project_id, requested_by, repo_path, base_ref, worktree_path, branch, status, patches_applied, files_json, commands_json, error, started_at, finished_at) VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,now(),now()) RETURNING id `, res.ProjectID, req.RequestedBy, res.RepoPath, res.BaseRef, res.WorktreePath, res.Branch, res.Status, res.PatchesApplied, filesJSON, cmdsJSON, res.Error).Scan(&id) if err == nil { res.ID = id } } // commandAllowed 判定命令是否在 admin 登记的命令名白名单内。 // FIND-SEC-03 回归:只允许首段 basename 与白名单条目「精确相等」; // 前缀匹配(go 放行 go-evil)与 shell 拼接均已移除。 func commandAllowed(cmd string, allowed []string) bool { fields := strings.Fields(strings.TrimSpace(cmd)) if len(fields) == 0 { return false } seg := filepath.Base(fields[0]) for _, p := range allowed { p = strings.TrimSpace(p) if p == "" { continue } if seg == p || seg == filepath.Base(p) { return true } } return false } func tail(s string, n int) string { s = strings.TrimSpace(s) if len(s) <= n { return s } return "..." + s[len(s)-n:] }