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.
This commit is contained in:
@@ -18,6 +18,7 @@
|
||||
|
||||
| owner | task | branch | worktree | mutex | status |
|
||||
|---|---|---|---|---|---|
|
||||
| pi | swboot-closeout-2026-09-26-r2 | feature/swboot-closeout-2026-09-26 | .worktrees/swboot-closeout-2026-09-26 | workflow-design | in_progress |
|
||||
| pi | chat-semantic-replace-2026-09-26 | feature/chat-semantic-replace-2026-09-26 | .worktrees/chat-semantic-replace-2026-09-26 | chat | done |
|
||||
| pi | jevdecide-soak-2026-09-26 | feature/jevdecide-soak-2026-09-26 | .worktrees/jevdecide-soak-2026-09-26 | jevdecide | done |
|
||||
| pi | dialogue-ctx-closeout-2026-09-26-r4 | feature/dialogue-ctx-closeout-2026-09-26 | .worktrees/dialogue-ctx-closeout-2026-09-26 | dialogue-ctx | done |
|
||||
|
||||
@@ -0,0 +1,54 @@
|
||||
# workflow-swboot 收口:SWBOOT-10 非 stub + L5 真实 PI + L6 UI 二次验收(short-term)
|
||||
|
||||
- **task_id**: swboot-closeout-2026-09-26
|
||||
- **owner**: pi
|
||||
- **status**: in_progress
|
||||
- **授权来源**: 用户会话指令「把 FEATURE-MATRIX 中的 workflow-rdev、workflow-swboot 推进至完成…严格按规范」——构成 W5 关闭条件中「SWBOOT-10 非 stub + 真实 PI + UI 二次验收」的待授权项授权
|
||||
- **write_scope**: internal/swboot, internal/projectworkflow, internal/server, deploy/postgres/goose, .doc
|
||||
- **mutex**: workflow-design
|
||||
- **base_dev_sha**: d0f3f76f7e979e6b4c6db5303cd9fe57ba8c9685
|
||||
- **capabilities**: docs:publish, docs:approve, env:dev
|
||||
- **policy_revision**: AGENTS 2026-09 / agent-worktree-workflow r1 / validation-contract 2026-09-21 / swboot 设计 §12(受控代码执行层安全边界)
|
||||
- **review_state**: pending
|
||||
- **integration_state**: pending
|
||||
- **deployment**: pending
|
||||
- **cleanup**: pending
|
||||
- **remote_sync**: not_requested
|
||||
|
||||
## 一、当前进度分析
|
||||
|
||||
SWBOOT-00..09 已合入并 L1–L4 验收;SWBOOT-10 为 fail-closed stub:`controlled_patch` 在 catalog 预览与 `projectworkflow.CreateProject` 双双拒绝;`RepoAllowlist.ExecuteControlledPatch/ApproveMerge` 恒拒;goose 00048 的 `repo_path_allowlist` / `project_source_bindings` 零运行时读写(Wave5 X-3 deferred)。L5(真实 PI advisor)/ L6(UI 二次验收)pending auth。
|
||||
|
||||
## 二、方案(按设计 §12 安全边界的最小闭环)
|
||||
|
||||
### 2.1 SWBOOT-10 受控补丁执行器(fail-closed)
|
||||
|
||||
1. **DB allowlist**(接线 goose 00048 表):`swboot.PGRepoAllowlist` 读 `repo_path_allowlist`(enabled 行);空表 = 拒绝一切(deny-all)。
|
||||
2. **创建门禁解锁**:`projectworkflow` 增加可选 `RepoAllowlistChecker`;`CreateProjectInput.RepoPath`——`controlled_patch` 仅当提供 RepoPath 且通过 allowlist 时放行,并在创建事务内写 `project_source_bindings`(permission_scope=controlled_patch);checker 未注入时维持拒绝(fail-closed)。catalog 预览:`controlled_patch` 仅当 `RepoAllowlisted=true` 视为有效。
|
||||
3. **执行器** `swboot.ControlledPatchExecutor`:
|
||||
- 前置:路径在 DB allowlist;base ref 显式;补丁为 unified diff 文本
|
||||
- 隔离:`git worktree add feature/swboot-<slug> <base>`(永不直接改工作树/保护分支);补丁先 `git apply --check` 再应用
|
||||
- 命令:仅允许管理员 `system_settings` 键 `swboot.test_commands`(逗号分隔前缀;默认空=零命令);clean env(仅 PATH/HOME),cwd=worktree,单命令超时与输出上限,失败即停不重试
|
||||
- 产物:变更文件清单 + sha256、命令退出码/耗时/截断输出;记录持久化到新表 `swboot_controlled_executions`(goose 00074)
|
||||
- **merge/push/deploy 永不执行**:`ApproveMerge` 改为类型化「需独立授权」边界错误(设计 §10:批准执行≠批准合并)
|
||||
4. **服务端最小面**:`POST /projects/{id}/swboot/executions`(创建者)+ `GET` 列表;`POST /projects/{id}/swboot/bindings`(创建者补绑/校验)。
|
||||
|
||||
### 2.2 L5 真实 PI
|
||||
|
||||
`RUN_PI_E2E=1 go test ./internal/app/ -run TestPiSoftwareBootstrapAdvisor_LiveMiniMax`(dev Runner :18865)在当前 HEAD 复验。
|
||||
|
||||
### 2.3 L6 UI 二次验收
|
||||
|
||||
新增 `web/e2e/swboot-wizard.spec.ts`(RUN_UI_ACCEPTANCE=1 门禁,真实 dev 栈):登录 → 创建向导切「纯软件」→ solo 未评审横幅 → catalog 类型选择 → 必填问题/用户/结果 → 创建成功进入项目(无医疗污染词);注册 README 矩阵与 npm 脚本;Playwright docker 容器实跑。
|
||||
|
||||
## 三、计划
|
||||
|
||||
1. [x] 分析(§一)
|
||||
2. [ ] 实现 §2.1(迁移/allowlist/executor/门禁/端点/设置键)+ 单测
|
||||
3. [ ] 工程门禁 + 候选集成 dev
|
||||
4. [ ] L5 复验 + L6 e2e 实跑(docker playwright)
|
||||
5. [ ] 测试报告 + 矩阵行升 done + 收口清理
|
||||
|
||||
## 四、结果登记
|
||||
|
||||
(完成后回填)
|
||||
@@ -127,6 +127,7 @@ imports:
|
||||
- projectworkflow
|
||||
- storage
|
||||
- supplementimpact
|
||||
- swboot
|
||||
- sysadmin
|
||||
- taskrun
|
||||
- traceimpact
|
||||
|
||||
@@ -0,0 +1,29 @@
|
||||
-- +goose Up
|
||||
-- SWBOOT-10: controlled patch execution records. The executor never merges,
|
||||
-- pushes or deploys; rows are audit evidence for patch/test candidates only.
|
||||
|
||||
CREATE TABLE IF NOT EXISTS swboot_controlled_executions (
|
||||
id BIGSERIAL PRIMARY KEY,
|
||||
project_id BIGINT NOT NULL REFERENCES projects(id) ON DELETE CASCADE,
|
||||
requested_by BIGINT REFERENCES users(id) ON DELETE SET NULL,
|
||||
repo_path TEXT NOT NULL,
|
||||
base_ref TEXT NOT NULL DEFAULT '',
|
||||
worktree_path TEXT NOT NULL DEFAULT '',
|
||||
branch TEXT NOT NULL DEFAULT '',
|
||||
status TEXT NOT NULL DEFAULT 'pending',
|
||||
patches_applied INT NOT NULL DEFAULT 0,
|
||||
files_json JSONB NOT NULL DEFAULT '[]'::jsonb,
|
||||
commands_json JSONB NOT NULL DEFAULT '[]'::jsonb,
|
||||
error TEXT NOT NULL DEFAULT '',
|
||||
started_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
finished_at TIMESTAMPTZ,
|
||||
CONSTRAINT chk_swboot_exec_status
|
||||
CHECK (status IN ('pending', 'succeeded', 'failed'))
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_swboot_exec_project
|
||||
ON swboot_controlled_executions (project_id, started_at DESC);
|
||||
|
||||
-- +goose Down
|
||||
-- record retention: do not drop evidence tables
|
||||
SELECT 1;
|
||||
@@ -0,0 +1,10 @@
|
||||
-- +goose Up
|
||||
-- SWBOOT-10: per-prefix admin-registered test/build command allowlist.
|
||||
-- Empty value (default) allows no commands; executor fail-closed.
|
||||
|
||||
ALTER TABLE repo_path_allowlist
|
||||
ADD COLUMN IF NOT EXISTS test_commands TEXT NOT NULL DEFAULT '';
|
||||
|
||||
-- +goose Down
|
||||
ALTER TABLE repo_path_allowlist
|
||||
DROP COLUMN IF EXISTS test_commands;
|
||||
@@ -38,6 +38,7 @@ import (
|
||||
"chat-agent-system/internal/projectworkflow"
|
||||
"chat-agent-system/internal/storage"
|
||||
"chat-agent-system/internal/supplementimpact"
|
||||
"chat-agent-system/internal/swboot"
|
||||
"chat-agent-system/internal/sysadmin"
|
||||
"chat-agent-system/internal/taskrun"
|
||||
"chat-agent-system/internal/traceimpact"
|
||||
@@ -212,6 +213,13 @@ func Assemble(cfg *config.Config, svc *bizdev.Service) (*Runtime, error) {
|
||||
instSvc.SetDocTaskBusyChecker(&docTaskBusyCheckerAdapter{gov: parts.Gov, taskRuns: taskMgr})
|
||||
}
|
||||
wsCap := workspace.NewCapability(cfg.DataDir, workspace.NewLeaseTable())
|
||||
// SWBOOT-10: unlock controlled_patch creation only for admin-allowlisted
|
||||
// repo bindings (repo_path_allowlist); nil pool keeps fail-closed rejection.
|
||||
if pool != nil {
|
||||
if al := swboot.NewPGRepoAllowlist(pool, 30*time.Second); al != nil {
|
||||
pwSvc.RepoAllowlistChecker = al.IsAllowlisted
|
||||
}
|
||||
}
|
||||
cmds := &taskrun.Commands{
|
||||
Ctx: parts.Ctx,
|
||||
PW: pwSvc,
|
||||
|
||||
@@ -15,6 +15,10 @@ import (
|
||||
type Service struct {
|
||||
store Store
|
||||
catalog Catalog
|
||||
// RepoAllowlistChecker unlocks execution_mode=controlled_patch at
|
||||
// creation when the bound repo path passes the admin allowlist (SWBOOT-10).
|
||||
// nil keeps the fail-closed rejection.
|
||||
RepoAllowlistChecker func(repoPath string) bool
|
||||
}
|
||||
|
||||
// NewService 构造项目工作流服务
|
||||
@@ -82,7 +86,10 @@ func (s *Service) CreateProject(ctx context.Context, user *auth.UserAccount, in
|
||||
if strings.TrimSpace(in.ProductDomain) == "non_medical_software" {
|
||||
stage = "S0_boundary"
|
||||
if strings.TrimSpace(in.ExecutionMode) == "controlled_patch" {
|
||||
return nil, fmt.Errorf("%w: controlled_patch requires SWBOOT-10 executor", ErrInvalidInput)
|
||||
repoPath := strings.TrimSpace(in.RepoPath)
|
||||
if repoPath == "" || s.RepoAllowlistChecker == nil || !s.RepoAllowlistChecker(repoPath) {
|
||||
return nil, fmt.Errorf("%w: controlled_patch requires an allowlisted repo binding (SWBOOT-10)", ErrInvalidInput)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -123,6 +130,14 @@ func (s *Service) CreateProject(ctx context.Context, user *auth.UserAccount, in
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// SWBOOT-10: persist the repo binding for controlled_patch projects so
|
||||
// the executor can verify it on every later execution.
|
||||
if strings.TrimSpace(in.ExecutionMode) == "controlled_patch" && strings.TrimSpace(in.RepoPath) != "" {
|
||||
if err := s.store.SaveProjectSourceBinding(ctx, created.ID, strings.TrimSpace(in.RepoPath), "controlled_patch", user.ID); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
|
||||
// 按产品域挂载目录工作流,避免软件项目克隆医疗 key。
|
||||
allKeys := WorkflowKeysForProductDomain(in.ProductDomain)
|
||||
if len(allKeys) == 0 {
|
||||
|
||||
@@ -3,6 +3,7 @@ package projectworkflow
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"strings"
|
||||
"testing"
|
||||
"unicode/utf8"
|
||||
|
||||
@@ -397,3 +398,38 @@ func TestCreateProject_ClientKeyIdempotentAndDuplicateName(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// SWBOOT-10: controlled_patch creation requires an allowlisted repo binding;
|
||||
// checker nil or path not allowlisted keeps the fail-closed rejection.
|
||||
func TestCreateProject_ControlledPatchGating(t *testing.T) {
|
||||
store := NewStore(nil)
|
||||
svc := NewService(store, nil)
|
||||
|
||||
mk := func(execMode, repo string) CreateProjectInput {
|
||||
return CreateProjectInput{
|
||||
Name: "SWBOOT-10 门禁验证", ProductDomain: "non_medical_software",
|
||||
ProjectTypeKey: "greenfield", ExecutionMode: execMode, RepoPath: repo,
|
||||
}
|
||||
}
|
||||
user := &auth.UserAccount{ID: 42, Name: "t"}
|
||||
|
||||
// fail-closed without checker
|
||||
if _, err := svc.CreateProject(context.Background(), user, mk("controlled_patch", "/repo/x")); err == nil || !strings.Contains(err.Error(), "controlled_patch") {
|
||||
t.Fatalf("expected rejection without checker: %v", err)
|
||||
}
|
||||
// checker rejecting the path
|
||||
svc.RepoAllowlistChecker = func(p string) bool { return p == "/allow/repo" }
|
||||
if _, err := svc.CreateProject(context.Background(), user, mk("controlled_patch", "/repo/x")); err == nil {
|
||||
t.Fatal("expected rejection for non-allowlisted path")
|
||||
}
|
||||
// checker passing + repo path -> created and binding persisted
|
||||
created, err := svc.CreateProject(context.Background(), user, mk("controlled_patch", "/allow/repo"))
|
||||
if err != nil || created == nil {
|
||||
t.Fatalf("expected controlled_patch creation with allowlisted binding: %v", err)
|
||||
}
|
||||
// docs_only projects need no repo binding (unchanged default path)
|
||||
docsInput := mk("docs_only", "")
|
||||
docsInput.Name = "SWBOOT-10 门禁验证-文档型"
|
||||
if _, err := svc.CreateProject(context.Background(), user, docsInput); err != nil {
|
||||
t.Fatalf("docs_only must not require binding: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -9,6 +9,7 @@ import (
|
||||
// Store 数据存储层接口
|
||||
type Store interface {
|
||||
CreateProject(ctx context.Context, p *Project) (*Project, error)
|
||||
SaveProjectSourceBinding(ctx context.Context, projectID int64, repoPath, permissionScope string, createdBy int64) error
|
||||
GetProjectByID(ctx context.Context, id int64) (*Project, error)
|
||||
GetProjectByCode(ctx context.Context, code string) (*Project, error)
|
||||
GetProjectByClientKey(ctx context.Context, creatorUserID int64, clientKey string) (*Project, error)
|
||||
|
||||
@@ -20,6 +20,7 @@ type memStore struct {
|
||||
byClientKey map[string]int64 // creatorID|clientKey -> projectID
|
||||
workflows map[int64]map[string]*ProjectWorkflow
|
||||
runs map[string]*WorkflowRun
|
||||
bindings map[int64]memBinding
|
||||
audits []*WorkflowActivationAudit
|
||||
lineages []*DocumentLineage
|
||||
nextProjID int64
|
||||
@@ -480,3 +481,19 @@ func (m *memStore) MarkDownstreamStale(ctx context.Context, upstreamDocID string
|
||||
}
|
||||
return updated, nil
|
||||
}
|
||||
|
||||
func (m *memStore) SaveProjectSourceBinding(ctx context.Context, projectID int64, repoPath, permissionScope string, createdBy int64) error {
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
if m.bindings == nil {
|
||||
m.bindings = make(map[int64]memBinding)
|
||||
}
|
||||
m.bindings[projectID] = memBinding{RepoPath: repoPath, PermissionScope: permissionScope, CreatedBy: createdBy}
|
||||
return nil
|
||||
}
|
||||
|
||||
type memBinding struct {
|
||||
RepoPath string
|
||||
PermissionScope string
|
||||
CreatedBy int64
|
||||
}
|
||||
|
||||
@@ -331,3 +331,15 @@ func (s *pgStore) ListAllProjects(ctx context.Context) ([]*Project, error) {
|
||||
}
|
||||
return list, rows.Err()
|
||||
}
|
||||
|
||||
func (s *pgStore) SaveProjectSourceBinding(ctx context.Context, projectID int64, repoPath, permissionScope string, createdBy int64) error {
|
||||
if projectID <= 0 || strings.TrimSpace(repoPath) == "" {
|
||||
return fmt.Errorf("invalid project source binding")
|
||||
}
|
||||
_, err := s.pool.Exec(ctx, `
|
||||
INSERT INTO project_source_bindings (project_id, source_type, repo_path, permission_scope, created_by)
|
||||
VALUES ($1, 'git', $2, $3, NULLIF($4,0))
|
||||
ON CONFLICT DO NOTHING
|
||||
`, projectID, strings.TrimSpace(repoPath), permissionScope, createdBy)
|
||||
return err
|
||||
}
|
||||
|
||||
@@ -113,6 +113,9 @@ type CreateProjectInput struct {
|
||||
GovernanceProfile string `json:"governance_profile,omitempty"`
|
||||
TeamMode string `json:"team_mode,omitempty"`
|
||||
ExecutionMode string `json:"execution_mode,omitempty"`
|
||||
// RepoPath binds a software repository for execution_mode=controlled_patch
|
||||
// (SWBOOT-10): must pass the admin repo_path_allowlist at creation.
|
||||
RepoPath string `json:"repo_path,omitempty"`
|
||||
DataSensitivity string `json:"data_sensitivity,omitempty"`
|
||||
NetworkPolicy string `json:"network_policy,omitempty"`
|
||||
BudgetClass string `json:"budget_class,omitempty"`
|
||||
|
||||
@@ -37,6 +37,7 @@ type Server struct {
|
||||
rt *app.Runtime
|
||||
cfg *config.Config
|
||||
sessions map[string]*chat.Session
|
||||
swbootRT swbootRuntime
|
||||
sessionsMu sync.RWMutex
|
||||
supplementImpact SupplementImpactService
|
||||
// intentPlansOverride 仅测试注入用(SetIntentPlansForTest);为空回落 rt.IntentPlans。
|
||||
@@ -193,6 +194,9 @@ func (s *Server) Routes() http.Handler {
|
||||
mux.HandleFunc("POST /api/v1/workflow-instances/{id}/terminate", s.handleTerminateWorkflowInstance)
|
||||
mux.HandleFunc("GET /api/v1/workflows", s.handleListWorkflowDefinitions)
|
||||
mux.HandleFunc("GET /api/v1/workflows/catalog", s.handleWorkflowCatalog)
|
||||
mux.HandleFunc("POST /api/v1/projects/{id}/swboot/executions", s.handleSwbootExecutions)
|
||||
mux.HandleFunc("GET /api/v1/projects/{id}/swboot/executions", s.handleSwbootExecutions)
|
||||
mux.HandleFunc("POST /api/v1/projects/{id}/swboot/bindings", s.handleSwbootBinding)
|
||||
mux.HandleFunc("POST /api/v1/projects/{id}/workflows/{workflow_key}/run", s.handleRunProjectWorkflow)
|
||||
mux.HandleFunc("GET /api/v1/projects/{id}/workflows/{workflow_key}/runs", s.handleListProjectWorkflowRuns)
|
||||
mux.HandleFunc("GET /api/v1/projects/{id}/lineage", s.handleGetProjectLineage)
|
||||
|
||||
@@ -3,6 +3,8 @@ package server
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"chat-agent-system/internal/swboot"
|
||||
@@ -51,6 +53,16 @@ func (s *Server) handleProjectCreationPreview(w http.ResponseWriter, r *http.Req
|
||||
writeSafeError(w, r, http.StatusBadRequest, `{"error":"ERR_INVALID_PARAM","message":"无效的 JSON 请求体"}`)
|
||||
return
|
||||
}
|
||||
// SWBOOT-10: controlled_patch is only preview-valid with an admin-allowlisted
|
||||
// repo binding; fail-closed when the allowlist is not configured.
|
||||
if strings.TrimSpace(req.ExecutionMode) == swboot.ExecutionControlledPatch && strings.TrimSpace(req.RepoPath) != "" {
|
||||
if exec, err := s.swbootExec(); err == nil {
|
||||
if abs, absErr := filepath.Abs(strings.TrimSpace(req.RepoPath)); absErr == nil {
|
||||
req.RepoPath = abs
|
||||
req.RepoAllowlisted = exec.Allowlist.IsAllowlisted(abs)
|
||||
}
|
||||
}
|
||||
}
|
||||
res := swboot.BuildCreationPreview(req, time.Now().UTC())
|
||||
status := http.StatusOK
|
||||
if !res.Valid {
|
||||
|
||||
@@ -0,0 +1,303 @@
|
||||
package server
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"path/filepath"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"chat-agent-system/internal/swboot"
|
||||
)
|
||||
|
||||
// SWBOOT-10 controlled execution surface (fail-closed):
|
||||
// POST /api/v1/projects/{id}/swboot/executions — creator-only patch execution
|
||||
// GET /api/v1/projects/{id}/swboot/executions — execution evidence list
|
||||
// POST /api/v1/projects/{id}/swboot/bindings — creator repo binding upsert
|
||||
//
|
||||
// The executor works on isolated git worktrees, never merges/pushes, and only
|
||||
// runs admin-registered test commands (repo_path_allowlist.test_commands).
|
||||
|
||||
type swbootRuntime struct {
|
||||
mu sync.Mutex
|
||||
allowlist *swboot.PGRepoAllowlist
|
||||
executor *swboot.ControlledPatchExecutor
|
||||
}
|
||||
|
||||
func (s *Server) swbootExec() (*swboot.ControlledPatchExecutor, error) {
|
||||
if s == nil || s.rt == nil || s.rt.Pool == nil {
|
||||
return nil, fmt.Errorf("swboot: database required")
|
||||
}
|
||||
s.swbootRT.mu.Lock()
|
||||
defer s.swbootRT.mu.Unlock()
|
||||
if s.swbootRT.executor != nil {
|
||||
return s.swbootRT.executor, nil
|
||||
}
|
||||
allow := swboot.NewPGRepoAllowlist(s.rt.Pool, 30*time.Second)
|
||||
exec := &swboot.ControlledPatchExecutor{
|
||||
Allowlist: allow,
|
||||
Pool: s.rt.Pool,
|
||||
CommandAllow: func(cmd string) bool {
|
||||
// The repo path is not known here; commands are validated per
|
||||
// execution by the handler against the bound repo's prefixes.
|
||||
return false
|
||||
},
|
||||
}
|
||||
s.swbootRT.allowlist = allow
|
||||
s.swbootRT.executor = exec
|
||||
return exec, nil
|
||||
}
|
||||
|
||||
type swbootExecutionRequest struct {
|
||||
RepoPath string `json:"repo_path"`
|
||||
BaseRef string `json:"base_ref"`
|
||||
Patches []string `json:"patches"`
|
||||
Commands []string `json:"commands,omitempty"`
|
||||
}
|
||||
|
||||
func (s *Server) handleSwbootExecutions(w http.ResponseWriter, r *http.Request) {
|
||||
user, ok := s.requirePrincipal(w, r)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
projectID, err := strconv.ParseInt(r.PathValue("id"), 10, 64)
|
||||
if err != nil || projectID <= 0 {
|
||||
writeSafeError(w, r, http.StatusBadRequest, "invalid project id")
|
||||
return
|
||||
}
|
||||
if !s.requireProjectCreator(w, r, projectID, user.ID) {
|
||||
return
|
||||
}
|
||||
|
||||
switch r.Method {
|
||||
case http.MethodGet:
|
||||
rows, err := s.listSwbootExecutions(r.Context(), projectID)
|
||||
if err != nil {
|
||||
writeSafeError(w, r, http.StatusInternalServerError, "list executions failed")
|
||||
return
|
||||
}
|
||||
writeJSONResponse(w, http.StatusOK, map[string]any{"executions": rows})
|
||||
|
||||
case http.MethodPost:
|
||||
exec, err := s.swbootExec()
|
||||
if err != nil {
|
||||
writeSafeError(w, r, http.StatusServiceUnavailable, err.Error())
|
||||
return
|
||||
}
|
||||
var req swbootExecutionRequest
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||
writeSafeError(w, r, http.StatusBadRequest, "invalid request body")
|
||||
return
|
||||
}
|
||||
repoPath := strings.TrimSpace(req.RepoPath)
|
||||
if repoPath == "" {
|
||||
repoPath = s.boundRepoPath(r.Context(), projectID)
|
||||
}
|
||||
if repoPath == "" {
|
||||
writeJSONResponse(w, http.StatusConflict, map[string]string{
|
||||
"error_code": "ERR_REPO_BINDING_MISSING",
|
||||
"message": "项目未绑定仓库或未提供 repo_path;controlled_patch 需先绑定 allowlist 内仓库",
|
||||
})
|
||||
return
|
||||
}
|
||||
// Commands must be registered by the admin for the covering allowlist
|
||||
// prefix (longest cover wins; empty registration = no commands).
|
||||
allowedPrefixes := s.allowlistFor(repoPath)
|
||||
for _, cmd := range req.Commands {
|
||||
if !commandAllowed(cmd, allowedPrefixes) {
|
||||
writeJSONResponse(w, http.StatusForbidden, map[string]string{
|
||||
"error_code": "ERR_COMMAND_NOT_ALLOWLISTED",
|
||||
"message": "命令不在管理员 test_commands 白名单内:" + firstSegment(cmd),
|
||||
})
|
||||
return
|
||||
}
|
||||
}
|
||||
res, err := exec.Execute(r.Context(), swboot.ControlledPatchRequest{
|
||||
ProjectID: projectID,
|
||||
RequestedBy: user.ID,
|
||||
RepoPath: repoPath,
|
||||
BaseRef: req.BaseRef,
|
||||
Patches: req.Patches,
|
||||
Commands: req.Commands,
|
||||
})
|
||||
if err != nil {
|
||||
writeJSONResponse(w, http.StatusBadRequest, map[string]any{
|
||||
"error_code": "ERR_EXECUTION_FAILED",
|
||||
"message": "受控执行失败(证据已记录)",
|
||||
"execution": res,
|
||||
})
|
||||
return
|
||||
}
|
||||
writeJSONResponse(w, http.StatusCreated, res)
|
||||
}
|
||||
}
|
||||
|
||||
type swbootBindingRequest struct {
|
||||
RepoPath string `json:"repo_path"`
|
||||
Scope string `json:"permission_scope,omitempty"`
|
||||
}
|
||||
|
||||
func (s *Server) handleSwbootBinding(w http.ResponseWriter, r *http.Request) {
|
||||
user, ok := s.requirePrincipal(w, r)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
projectID, err := strconv.ParseInt(r.PathValue("id"), 10, 64)
|
||||
if err != nil || projectID <= 0 {
|
||||
writeSafeError(w, r, http.StatusBadRequest, "invalid project id")
|
||||
return
|
||||
}
|
||||
if !s.requireProjectCreator(w, r, projectID, user.ID) {
|
||||
return
|
||||
}
|
||||
if r.Method != http.MethodPost {
|
||||
writeSafeError(w, r, http.StatusMethodNotAllowed, "method not allowed")
|
||||
return
|
||||
}
|
||||
exec, err := s.swbootExec()
|
||||
if err != nil {
|
||||
writeSafeError(w, r, http.StatusServiceUnavailable, err.Error())
|
||||
return
|
||||
}
|
||||
var req swbootBindingRequest
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil || strings.TrimSpace(req.RepoPath) == "" {
|
||||
writeSafeError(w, r, http.StatusBadRequest, "repo_path required")
|
||||
return
|
||||
}
|
||||
scope := strings.TrimSpace(req.Scope)
|
||||
if scope == "" {
|
||||
scope = "controlled_patch"
|
||||
}
|
||||
repoPath, err := filepath.Abs(strings.TrimSpace(req.RepoPath))
|
||||
if err != nil {
|
||||
writeSafeError(w, r, http.StatusBadRequest, "invalid repo_path")
|
||||
return
|
||||
}
|
||||
if !exec.Allowlist.IsAllowlisted(repoPath) {
|
||||
writeJSONResponse(w, http.StatusForbidden, map[string]string{
|
||||
"error_code": "ERR_REPO_PATH_NOT_ALLOWLISTED",
|
||||
"message": "仓库路径不在管理员 repo_path_allowlist 内",
|
||||
})
|
||||
return
|
||||
}
|
||||
if err := s.pwSvc().Store().SaveProjectSourceBinding(r.Context(), projectID, repoPath, scope, user.ID); err != nil {
|
||||
writeSafeError(w, r, http.StatusInternalServerError, "save binding failed")
|
||||
return
|
||||
}
|
||||
writeJSONResponse(w, http.StatusCreated, map[string]any{"success": true, "repo_path": repoPath, "permission_scope": scope})
|
||||
}
|
||||
|
||||
func (s *Server) requireProjectCreator(w http.ResponseWriter, r *http.Request, projectID, userID int64) bool {
|
||||
creator, err := s.pwSvc().IsProjectCreator(r.Context(), projectID, userID)
|
||||
if err != nil || !creator {
|
||||
writeJSONResponse(w, http.StatusForbidden, map[string]string{
|
||||
"error_code": "ERR_FORBIDDEN",
|
||||
"message": "仅项目创建者可管理受控工程执行",
|
||||
})
|
||||
return false
|
||||
}
|
||||
if err := s.authorizer().RequireProjectAccess(r.Context(), projectID, userID); err != nil {
|
||||
writeJSONResponse(w, http.StatusForbidden, map[string]string{
|
||||
"error_code": "ERR_FORBIDDEN",
|
||||
"message": "无权访问该项目",
|
||||
})
|
||||
return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
func (s *Server) allowlistFor(repoPath string) []string {
|
||||
s.swbootRT.mu.Lock()
|
||||
allow := s.swbootRT.allowlist
|
||||
s.swbootRT.mu.Unlock()
|
||||
if allow == nil {
|
||||
return nil
|
||||
}
|
||||
return allow.AllowedCommands(repoPath)
|
||||
}
|
||||
|
||||
func (s *Server) boundRepoPath(ctx context.Context, projectID int64) string {
|
||||
if s == nil || s.rt == nil || s.rt.Pool == nil {
|
||||
return ""
|
||||
}
|
||||
cctx, cancel := context.WithTimeout(ctx, 5*time.Second)
|
||||
defer cancel()
|
||||
var repoPath string
|
||||
err := s.rt.Pool.QueryRow(cctx, `
|
||||
SELECT repo_path FROM project_source_bindings
|
||||
WHERE project_id=$1 AND permission_scope='controlled_patch'
|
||||
ORDER BY created_at DESC LIMIT 1
|
||||
`, projectID).Scan(&repoPath)
|
||||
if err != nil {
|
||||
return ""
|
||||
}
|
||||
return repoPath
|
||||
}
|
||||
|
||||
func (s *Server) listSwbootExecutions(ctx context.Context, projectID int64) ([]map[string]any, error) {
|
||||
cctx, cancel := context.WithTimeout(ctx, 5*time.Second)
|
||||
defer cancel()
|
||||
rows, err := s.rt.Pool.Query(cctx, `
|
||||
SELECT id, status, repo_path, base_ref, branch, worktree_path, patches_applied,
|
||||
files_json, commands_json, error, started_at, finished_at
|
||||
FROM swboot_controlled_executions
|
||||
WHERE project_id=$1
|
||||
ORDER BY started_at DESC
|
||||
LIMIT 50
|
||||
`, projectID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
out := make([]map[string]any, 0, 4)
|
||||
for rows.Next() {
|
||||
var (
|
||||
id int64
|
||||
status, repo, base, branch, wt, errTxt string
|
||||
patches int
|
||||
filesJSON, cmdsJSON []byte
|
||||
started, finished time.Time
|
||||
)
|
||||
if err := rows.Scan(&id, &status, &repo, &base, &branch, &wt, &patches,
|
||||
&filesJSON, &cmdsJSON, &errTxt, &started, &finished); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out = append(out, map[string]any{
|
||||
"id": id, "status": status, "repo_path": repo, "base_ref": base,
|
||||
"branch": branch, "worktree_path": wt, "patches_applied": patches,
|
||||
"files": json.RawMessage(filesJSON), "commands": json.RawMessage(cmdsJSON),
|
||||
"error": errTxt, "started_at": started, "finished_at": finished,
|
||||
})
|
||||
}
|
||||
return out, rows.Err()
|
||||
}
|
||||
|
||||
func commandAllowed(cmd string, prefixes []string) bool {
|
||||
cmd = strings.TrimSpace(cmd)
|
||||
if cmd == "" {
|
||||
return false
|
||||
}
|
||||
seg := firstSegment(cmd)
|
||||
for _, p := range prefixes {
|
||||
p = strings.TrimSpace(p)
|
||||
if p == "" {
|
||||
continue
|
||||
}
|
||||
if seg == p || strings.HasPrefix(seg, p) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func firstSegment(cmd string) string {
|
||||
fields := strings.Fields(strings.TrimSpace(cmd))
|
||||
if len(fields) == 0 {
|
||||
return ""
|
||||
}
|
||||
return filepath.Base(fields[0])
|
||||
}
|
||||
@@ -0,0 +1,132 @@
|
||||
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
|
||||
}
|
||||
@@ -69,9 +69,14 @@ type CreationPreviewInput struct {
|
||||
DataSensitivity string `json:"data_sensitivity,omitempty"`
|
||||
NetworkPolicy string `json:"network_policy,omitempty"`
|
||||
ExecutionMode string `json:"execution_mode"`
|
||||
BudgetClass string `json:"budget_class,omitempty"`
|
||||
InitialWorkflowKey string `json:"initial_workflow_key,omitempty"`
|
||||
CatalogVersionHint string `json:"expected_catalog_version,omitempty"`
|
||||
// RepoAllowlisted is set by the transport layer after checking the admin
|
||||
// repo_path_allowlist for RepoPath; controlled_patch is only valid when a
|
||||
// bound repo path passed that check (fail-closed otherwise).
|
||||
RepoAllowlisted bool `json:"repo_allowlisted,omitempty"`
|
||||
RepoPath string `json:"repo_path,omitempty"`
|
||||
BudgetClass string `json:"budget_class,omitempty"`
|
||||
InitialWorkflowKey string `json:"initial_workflow_key,omitempty"`
|
||||
CatalogVersionHint string `json:"expected_catalog_version,omitempty"`
|
||||
}
|
||||
|
||||
// CreationPreviewResult is returned by POST /projects/creation-preview.
|
||||
@@ -205,8 +210,10 @@ func BuildCreationPreview(in CreationPreviewInput, now time.Time) CreationPrevie
|
||||
case ExecutionDocsOnly, ExecutionReadOnlyRepo:
|
||||
// ok for SWBOOT-00..09
|
||||
case ExecutionControlledPatch:
|
||||
res.Valid = false
|
||||
res.Issues = append(res.Issues, "ERR_EXECUTION_MODE_DISABLED: controlled_patch requires SWBOOT-10 executor")
|
||||
if !in.RepoAllowlisted || strings.TrimSpace(in.RepoPath) == "" {
|
||||
res.Valid = false
|
||||
res.Issues = append(res.Issues, "ERR_EXECUTION_MODE_DISABLED: controlled_patch requires an allowlisted repo binding (SWBOOT-10)")
|
||||
}
|
||||
default:
|
||||
res.Valid = false
|
||||
res.Issues = append(res.Issues, "invalid execution_mode")
|
||||
|
||||
@@ -82,8 +82,8 @@ func TestRepoAllowlistStub(t *testing.T) {
|
||||
if err := al.ExecuteControlledPatch("/home/weichen/projects/Agent-System", nil); !errors.Is(err, ErrPatchExecutionUnimplemented) {
|
||||
t.Fatalf("expected stub rejection, got %v", err)
|
||||
}
|
||||
if err := ApproveMerge("feature/x"); !errors.Is(err, ErrPatchExecutionUnimplemented) {
|
||||
t.Fatalf("expected merge stub rejection, got %v", err)
|
||||
if err := ApproveMerge("feature/x"); !errors.Is(err, ErrMergeRequiresSeparateAuthorization) {
|
||||
t.Fatalf("merge must stay behind separate authorization, got %v", err)
|
||||
}
|
||||
empty := DefaultRepoAllowlist()
|
||||
if empty.IsAllowlisted("/home/weichen/projects/Agent-System") {
|
||||
@@ -103,3 +103,22 @@ func TestSuggestDeliveryAfterBootstrapNeverActivates(t *testing.T) {
|
||||
t.Fatal("expected operator-facing summary")
|
||||
}
|
||||
}
|
||||
|
||||
// SWBOOT-10: controlled_patch preview is valid only with an admin-allowlisted
|
||||
// repo binding; without the flag it stays the fail-closed rejection.
|
||||
func TestPreview_ControlledPatchRequiresAllowlistedBinding(t *testing.T) {
|
||||
mk := func(allowlisted bool, repo string) CreationPreviewInput {
|
||||
return CreationPreviewInput{
|
||||
Name: "受控补丁预览", ProductDomain: DomainNonMedicalSoftware,
|
||||
ProjectTypeKey: "greenfield", ProblemStatement: "p", TargetUsers: "u", TargetOutcome: "o",
|
||||
ExecutionMode: ExecutionControlledPatch, RepoAllowlisted: allowlisted, RepoPath: repo,
|
||||
}
|
||||
}
|
||||
if res := BuildCreationPreview(mk(false, "/repo/x"), time.Now().UTC()); res.Valid {
|
||||
t.Fatalf("controlled_patch without allowlisted binding must be invalid: %+v", res.Issues)
|
||||
}
|
||||
res := BuildCreationPreview(mk(true, "/allow/repo"), time.Now().UTC())
|
||||
if !res.Valid {
|
||||
t.Fatalf("controlled_patch with allowlisted binding must preview valid: %+v", res.Issues)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,328 @@
|
||||
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:]
|
||||
}
|
||||
@@ -0,0 +1,162 @@
|
||||
package swboot
|
||||
|
||||
import (
|
||||
"context"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
type fakeAllowlist struct {
|
||||
paths map[string]bool
|
||||
commands map[string][]string
|
||||
}
|
||||
|
||||
func (f *fakeAllowlist) IsAllowlisted(p string) bool { return f.paths[filepath.Clean(p)] }
|
||||
func (f *fakeAllowlist) AllowedCommands(p string) []string {
|
||||
return f.commands[filepath.Clean(p)]
|
||||
}
|
||||
|
||||
func initTestRepo(t *testing.T) string {
|
||||
t.Helper()
|
||||
dir := t.TempDir()
|
||||
run := func(args ...string) {
|
||||
c := exec.Command("git", args...)
|
||||
c.Dir = dir
|
||||
c.Env = []string{"PATH=" + os.Getenv("PATH"), "GIT_TERMINAL_PROMPT=0",
|
||||
"GIT_AUTHOR_NAME=t", "GIT_AUTHOR_EMAIL=t@t", "GIT_COMMITTER_NAME=t", "GIT_COMMITTER_EMAIL=t@t",
|
||||
"GIT_CONFIG_GLOBAL=/dev/null", "GIT_CONFIG_SYSTEM=/dev/null"}
|
||||
if out, err := c.CombinedOutput(); err != nil {
|
||||
t.Fatalf("git %v: %v: %s", args, err, out)
|
||||
}
|
||||
}
|
||||
run("init", "-q", "-b", "main")
|
||||
if err := os.WriteFile(filepath.Join(dir, "hello.txt"), []byte("hello\n"), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
run("add", ".")
|
||||
run("commit", "-q", "-m", "init")
|
||||
return dir
|
||||
}
|
||||
|
||||
const samplePatch = `--- a/hello.txt
|
||||
+++ b/hello.txt
|
||||
@@ -1 +1 @@
|
||||
-hello
|
||||
+hello swboot
|
||||
`
|
||||
|
||||
func newExecutor(allow RepoAllowlistReader) *ControlledPatchExecutor {
|
||||
return &ControlledPatchExecutor{Allowlist: allow}
|
||||
}
|
||||
|
||||
func TestExecutor_RejectsNonAllowlistedPath(t *testing.T) {
|
||||
repo := initTestRepo(t)
|
||||
e := newExecutor(&fakeAllowlist{paths: map[string]bool{"/definitely/not/allowed": true}})
|
||||
res, err := e.Execute(context.Background(), ControlledPatchRequest{
|
||||
ProjectID: 1, RepoPath: repo, BaseRef: "HEAD", Patches: []string{samplePatch},
|
||||
})
|
||||
if err == nil || res.Status != "failed" || !strings.Contains(res.Error, "ERR_REPO_PATH_NOT_ALLOWLISTED") {
|
||||
t.Fatalf("expected allowlist rejection, got res=%+v err=%v", res, err)
|
||||
}
|
||||
if res.WorktreePath != "" {
|
||||
t.Fatalf("no worktree must be created on rejection")
|
||||
}
|
||||
}
|
||||
|
||||
func TestExecutor_AppliesPatchOnIsolatedWorktree(t *testing.T) {
|
||||
repo := initTestRepo(t)
|
||||
// Baseline untouched.
|
||||
e := newExecutor(&fakeAllowlist{paths: map[string]bool{repo: true}})
|
||||
res, err := e.Execute(context.Background(), ControlledPatchRequest{
|
||||
ProjectID: 7, RepoPath: repo, BaseRef: "HEAD", Patches: []string{samplePatch},
|
||||
})
|
||||
if err != nil || res.Status != "succeeded" {
|
||||
t.Fatalf("expected success, got res=%+v err=%v", res, err)
|
||||
}
|
||||
if res.PatchesApplied != 1 || len(res.Files) != 1 || res.Files[0].Path != "hello.txt" || res.Files[0].Sha == "" {
|
||||
t.Fatalf("unexpected files evidence: %+v", res.Files)
|
||||
}
|
||||
if !strings.HasPrefix(res.Branch, "feature/swboot-") || res.WorktreePath == "" {
|
||||
t.Fatalf("expected isolated branch/worktree: %+v", res)
|
||||
}
|
||||
// Patch landed only in the isolated worktree.
|
||||
patched, err := os.ReadFile(filepath.Join(res.WorktreePath, "hello.txt"))
|
||||
if err != nil || !strings.Contains(string(patched), "hello swboot") {
|
||||
t.Fatalf("worktree not patched: %v %q", err, patched)
|
||||
}
|
||||
base, _ := os.ReadFile(filepath.Join(repo, "hello.txt"))
|
||||
if strings.Contains(string(base), "swboot") {
|
||||
t.Fatalf("repository working tree must stay untouched")
|
||||
}
|
||||
}
|
||||
|
||||
func TestExecutor_RunsOnlyAllowlistedCommands(t *testing.T) {
|
||||
repo := initTestRepo(t)
|
||||
e := newExecutor(&fakeAllowlist{
|
||||
paths: map[string]bool{repo: true},
|
||||
commands: map[string][]string{repo: {"echo"}},
|
||||
})
|
||||
res, err := e.Execute(context.Background(), ControlledPatchRequest{
|
||||
ProjectID: 8, RepoPath: repo, BaseRef: "HEAD",
|
||||
Patches: []string{samplePatch},
|
||||
Commands: []string{"echo ci-ok"},
|
||||
})
|
||||
if err != nil || res.Status != "succeeded" {
|
||||
t.Fatalf("expected success: %+v %v", res, err)
|
||||
}
|
||||
if len(res.Commands) != 1 || res.Commands[0].ExitCode != 0 || !strings.Contains(res.Commands[0].Output, "ci-ok") {
|
||||
t.Fatalf("command evidence wrong: %+v", res.Commands)
|
||||
}
|
||||
|
||||
res2, err2 := e.Execute(context.Background(), ControlledPatchRequest{
|
||||
ProjectID: 8, RepoPath: repo, BaseRef: "HEAD",
|
||||
Patches: []string{samplePatch},
|
||||
Commands: []string{"curl http://169.254.169.254/latest/meta-data"},
|
||||
})
|
||||
if err2 == nil || res2.Status != "failed" || !strings.Contains(res2.Error, "not in admin allowlist") {
|
||||
t.Fatalf("non-allowlisted command must fail closed: %+v %v", res2, err2)
|
||||
}
|
||||
}
|
||||
|
||||
func TestExecutor_RejectsBrokenPatchAndKeepsEvidence(t *testing.T) {
|
||||
repo := initTestRepo(t)
|
||||
e := newExecutor(&fakeAllowlist{paths: map[string]bool{repo: true}})
|
||||
res, err := e.Execute(context.Background(), ControlledPatchRequest{
|
||||
ProjectID: 9, RepoPath: repo, BaseRef: "HEAD",
|
||||
Patches: []string{"not a unified diff"},
|
||||
})
|
||||
if err == nil || res.Status != "failed" {
|
||||
t.Fatalf("broken patch must fail: %+v %v", res, err)
|
||||
}
|
||||
if res.PatchesApplied != 0 {
|
||||
t.Fatalf("no patch applied on failure: %+v", res)
|
||||
}
|
||||
}
|
||||
|
||||
func TestExecutor_CommandFailureStopsPipeline(t *testing.T) {
|
||||
repo := initTestRepo(t)
|
||||
e := newExecutor(&fakeAllowlist{
|
||||
paths: map[string]bool{repo: true},
|
||||
commands: map[string][]string{repo: {"false", "echo"}},
|
||||
})
|
||||
res, err := e.Execute(context.Background(), ControlledPatchRequest{
|
||||
ProjectID: 10, RepoPath: repo, BaseRef: "HEAD",
|
||||
Patches: []string{samplePatch},
|
||||
Commands: []string{"false", "echo unreachable"},
|
||||
})
|
||||
if err == nil || res.Status != "failed" {
|
||||
t.Fatalf("failing command must stop: %+v %v", res, err)
|
||||
}
|
||||
if len(res.Commands) != 1 || res.Commands[0].ExitCode == 0 {
|
||||
t.Fatalf("first command must record failure and stop: %+v", res.Commands)
|
||||
}
|
||||
}
|
||||
|
||||
func TestApproveMerge_NeverExecutes(t *testing.T) {
|
||||
if err := ApproveMerge("anything"); err == nil || !strings.Contains(err.Error(), "ERR_MERGE_REQUIRES_SEPARATE_AUTHORIZATION") {
|
||||
t.Fatalf("merge must stay behind separate authorization: %v", err)
|
||||
}
|
||||
}
|
||||
@@ -7,10 +7,13 @@ import (
|
||||
"strings"
|
||||
)
|
||||
|
||||
// SWBOOT-10 stub errors — real patch/merge is intentionally unimplemented.
|
||||
// SWBOOT-10 boundary errors. Since the 2026-09-26 closeout the real
|
||||
// controlled-patch pipeline lives in ControlledPatchExecutor (isolated
|
||||
// worktree + admin allowlists + evidence records). These errors remain the
|
||||
// fail-closed surface for everything outside that pipeline.
|
||||
var (
|
||||
ErrRepoPathNotAllowlisted = errors.New("ERR_REPO_PATH_NOT_ALLOWLISTED")
|
||||
ErrControlledPatchDisabled = errors.New("ERR_EXECUTION_MODE_DISABLED")
|
||||
ErrRepoPathNotAllowlisted = errors.New("ERR_REPO_PATH_NOT_ALLOWLISTED")
|
||||
ErrControlledPatchDisabled = errors.New("ERR_EXECUTION_MODE_DISABLED")
|
||||
ErrPatchExecutionUnimplemented = errors.New("ERR_SWBOOT10_STUB_ONLY")
|
||||
)
|
||||
|
||||
@@ -66,15 +69,19 @@ func (a *RepoAllowlist) CheckReadOnlyAccess(absPath string) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
// ExecuteControlledPatch is the SWBOOT-10 stub: always rejects real patch/merge.
|
||||
// ExecuteControlledPatch on the static allowlist is not the execution
|
||||
// pipeline: real patch execution requires the DB allowlist + isolated
|
||||
// worktree + evidence record — see ControlledPatchExecutor.
|
||||
func (a *RepoAllowlist) ExecuteControlledPatch(absPath string, _ []string) error {
|
||||
if err := a.CheckReadOnlyAccess(absPath); err != nil {
|
||||
return err
|
||||
}
|
||||
return fmt.Errorf("%w: controlled patch/merge not implemented; docs_only/read_only_repo only", ErrPatchExecutionUnimplemented)
|
||||
return fmt.Errorf("%w: use ControlledPatchExecutor (DB allowlist + isolated worktree + record)", ErrPatchExecutionUnimplemented)
|
||||
}
|
||||
|
||||
// ApproveMerge is the SWBOOT-10 stub: approve-execution never implies merge.
|
||||
// ApproveMerge is a by-design boundary, not a stub: approving execution
|
||||
// never implies merge. merge/push/deploy always require a separate explicit
|
||||
// authorization path (design §10) and are never executed by this system.
|
||||
func ApproveMerge(_ string) error {
|
||||
return fmt.Errorf("%w: merge/push/deploy require a separate future approval path", ErrPatchExecutionUnimplemented)
|
||||
return ErrMergeRequiresSeparateAuthorization
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user