- FIND-SEC-01: GetUserContext requires session ownership (handler 403 + SQL/memory owner filter); in-memory HTTP + isolated-PG regressions - FIND-ARCH-01: archtest fails closed on untracked table writes (+noise exemption) and registers 50 existing writer tables in table_owners - FIND-SEC-02: Authorizer without pool denies by default; explicit NewInMemoryAuthorizer seam opt-in via ALLOW_IN_MEMORY at composition - FIND-SEC-03: swboot commands run argv-exec (no sh -c) with exact basename allowlist match; prefix-spoof and no-shell-eval tests - FIND-FLOW-01: confirm rejects validated_only plans; ExecuteClaimed hard-refuses validated_only steps (new error codes + HTTP mapping) - FIND-QUAL-01: intentflow.ValidateFlagConsistency fail-fast at Assemble
337 lines
10 KiB
Go
337 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
|
||
|
||
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:]
|
||
}
|