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.
329 lines
10 KiB
Go
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:]
|
|
}
|