Files
Agent-System/internal/swboot/executor.go
T
weichen 6a3a0dc201 fix(security): harden bypass authz, gate blind spot, command exec, validated_only (review P0/P1)
- 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
2026-09-26 02:00:14 +00:00

337 lines
10 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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:]
}