Files
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

133 lines
3.4 KiB
Go

package swboot
import (
"context"
"path/filepath"
"strings"
"sync"
"time"
"github.com/jackc/pgx/v5/pgxpool"
)
// allowlistEntry is one enabled admin-seeded row: a path prefix plus the
// test/build command prefixes registered for it (goose 00048 + 00075).
type allowlistEntry struct {
prefix string
testCommands []string
}
// PGRepoAllowlist is the runtime reader for the admin-seeded
// repo_path_allowlist table. Empty table (or empty prefixes list) denies
// everything — SWBOOT-10 stays fail-closed until an admin seeds enabled path
// prefixes.
type PGRepoAllowlist struct {
Pool *pgxpool.Pool
mu sync.RWMutex
cache map[string]allowlistEntry
cacheAt time.Time
cacheTTL time.Duration
}
// NewPGRepoAllowlist builds the DB-backed allowlist. ttl <= 0 disables caching.
func NewPGRepoAllowlist(pool *pgxpool.Pool, ttl time.Duration) *PGRepoAllowlist {
return &PGRepoAllowlist{Pool: pool, cacheTTL: ttl}
}
// IsAllowlisted reports whether absPath falls under an enabled prefix.
// With no pool it denies everything (no in-memory fallback in production).
func (a *PGRepoAllowlist) IsAllowlisted(absPath string) bool {
if a == nil || a.Pool == nil {
return false
}
clean := filepath.Clean(strings.TrimSpace(absPath))
if clean == "" || !filepath.IsAbs(clean) {
return false
}
m, err := a.entries(context.Background())
if err != nil || len(m) == 0 {
return false
}
for p := range m {
if clean == p || strings.HasPrefix(clean, p+string(filepath.Separator)) {
return true
}
}
return false
}
func (a *PGRepoAllowlist) entries(ctx context.Context) (map[string]allowlistEntry, error) {
a.mu.RLock()
if a.cacheTTL > 0 && a.cache != nil && time.Since(a.cacheAt) < a.cacheTTL {
out := make(map[string]allowlistEntry, len(a.cache))
for k, v := range a.cache {
out[k] = v
}
a.mu.RUnlock()
return out, nil
}
a.mu.RUnlock()
if a.Pool == nil {
return nil, nil
}
ctx, cancel := context.WithTimeout(ctx, 5*time.Second)
defer cancel()
rows, err := a.Pool.Query(ctx, `SELECT path_prefix, COALESCE(test_commands, '') FROM repo_path_allowlist WHERE enabled = true`)
if err != nil {
return nil, err
}
defer rows.Close()
out := make(map[string]allowlistEntry, 4)
for rows.Next() {
var p, cmds string
if err := rows.Scan(&p, &cmds); err != nil {
return nil, err
}
p = filepath.Clean(strings.TrimSpace(p))
if p == "" {
continue
}
var cmdList []string
for _, c := range strings.Split(cmds, ",") {
c = strings.TrimSpace(c)
if c != "" {
cmdList = append(cmdList, c)
}
}
out[p] = allowlistEntry{prefix: p, testCommands: cmdList}
}
if a.cacheTTL > 0 {
a.mu.Lock()
a.cache, a.cacheAt = out, time.Now()
a.mu.Unlock()
}
return out, rows.Err()
}
// AllowedCommands returns the admin-registered test/build command prefixes
// covering absPath. Empty means no command may run (patch-only execution).
func (a *PGRepoAllowlist) AllowedCommands(absPath string) []string {
if a == nil || a.Pool == nil {
return nil
}
clean := filepath.Clean(strings.TrimSpace(absPath))
if clean == "" || !filepath.IsAbs(clean) {
return nil
}
m, err := a.entries(context.Background())
if err != nil {
return nil
}
// Longest covering prefix wins.
var best allowlistEntry
for _, e := range m {
if clean == e.prefix || strings.HasPrefix(clean, e.prefix+string(filepath.Separator)) {
if len(e.prefix) > len(best.prefix) {
best = e
}
}
}
return best.testCommands
}