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:
weichen
2026-09-26 00:56:59 +00:00
parent d0f3f76f7e
commit 83561682eb
21 changed files with 1176 additions and 15 deletions
+1
View File
@@ -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;
+8
View File
@@ -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,
+16 -1
View File
@@ -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 {
+36
View File
@@ -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)
}
}
+1
View File
@@ -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)
+17
View File
@@ -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
}
+3
View File
@@ -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"`
+4
View File
@@ -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 {
+303
View File
@@ -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])
}
+132
View File
@@ -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
}
+12 -5
View File
@@ -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")
+21 -2
View File
@@ -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)
}
}
+328
View File
@@ -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:]
}
+162
View File
@@ -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)
}
}
+14 -7
View File
@@ -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
}