Files
Agent-System/internal/swboot/executor.go
T
weichen 83561682eb feat(swboot): wire SWBOOT-10 controlled patch executor (fail-closed)
DB-backed repo_path_allowlist reader (deny-all until admin seeds), per-prefix
admin test_commands (goose 00075), execution evidence table (goose 00074),
isolated git-worktree patch executor with command allowlist/time/output caps
and failure-stops evidence; controlled_patch creation/preview unlocked only
with an allowlisted repo binding (projectworkflow gate + catalog flag);
creator-only server endpoints for bindings/executions; merge/push/deploy stay
behind a typed separate-authorization boundary. Module boundary: app->swboot
composition import registered.
2026-09-26 00:56:59 +00:00

329 lines
10 KiB
Go

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
allowedPrefixes := e.Allowlist.AllowedCommands(res.RepoPath)
for _, cmd := range req.Commands {
if !commandMatchesPrefixes(cmd, allowedPrefixes) {
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}
cctx, cancel := context.WithTimeout(ctx, timeout)
defer cancel()
// sh -c with a sanitized environment: no secrets, no repo .env leakage.
c := exec.CommandContext(cctx, "sh", "-c", cmdLine)
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
}
}
func commandMatchesPrefixes(cmd string, prefixes []string) bool {
fields := strings.Fields(strings.TrimSpace(cmd))
if len(fields) == 0 {
return false
}
seg := filepath.Base(fields[0])
for _, p := range prefixes {
p = strings.TrimSpace(p)
if p == "" {
continue
}
if seg == p || strings.HasPrefix(seg, 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:]
}