From 83561682eb2d332f5a548803215e827756c1d6c3 Mon Sep 17 00:00:00 2001 From: weichen Date: Sat, 26 Sep 2026 00:56:59 +0000 Subject: [PATCH] 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. --- .doc/ops/CURRENT.md | 1 + .../short-term/2026-09-26-swboot-closeout.md | 54 +++ .doc/system-design/module-boundaries.yaml | 1 + ...026-09-26-swboot-controlled-executions.sql | 29 ++ ...026-09-26-repo-allowlist-test-commands.sql | 10 + internal/app/app.go | 8 + internal/projectworkflow/service.go | 17 +- internal/projectworkflow/service_test.go | 36 ++ internal/projectworkflow/store.go | 1 + internal/projectworkflow/store_mem.go | 17 + internal/projectworkflow/store_pg_project.go | 12 + internal/projectworkflow/types.go | 3 + internal/server/server.go | 4 + internal/server/software_project_handlers.go | 12 + internal/server/swboot_handlers.go | 303 ++++++++++++++++ internal/swboot/allowlist_store.go | 132 +++++++ internal/swboot/catalog.go | 17 +- internal/swboot/catalog_test.go | 23 +- internal/swboot/executor.go | 328 ++++++++++++++++++ internal/swboot/executor_test.go | 162 +++++++++ internal/swboot/repo_allowlist.go | 21 +- 21 files changed, 1176 insertions(+), 15 deletions(-) create mode 100644 .doc/ops/short-term/2026-09-26-swboot-closeout.md create mode 100644 deploy/postgres/goose/00074_2026-09-26-swboot-controlled-executions.sql create mode 100644 deploy/postgres/goose/00075_2026-09-26-repo-allowlist-test-commands.sql create mode 100644 internal/server/swboot_handlers.go create mode 100644 internal/swboot/allowlist_store.go create mode 100644 internal/swboot/executor.go create mode 100644 internal/swboot/executor_test.go diff --git a/.doc/ops/CURRENT.md b/.doc/ops/CURRENT.md index 08236834..9e0c1604 100644 --- a/.doc/ops/CURRENT.md +++ b/.doc/ops/CURRENT.md @@ -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 | diff --git a/.doc/ops/short-term/2026-09-26-swboot-closeout.md b/.doc/ops/short-term/2026-09-26-swboot-closeout.md new file mode 100644 index 00000000..b09b282c --- /dev/null +++ b/.doc/ops/short-term/2026-09-26-swboot-closeout.md @@ -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- `(永不直接改工作树/保护分支);补丁先 `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 + 收口清理 + +## 四、结果登记 + +(完成后回填) diff --git a/.doc/system-design/module-boundaries.yaml b/.doc/system-design/module-boundaries.yaml index 63013449..a432b7e0 100644 --- a/.doc/system-design/module-boundaries.yaml +++ b/.doc/system-design/module-boundaries.yaml @@ -127,6 +127,7 @@ imports: - projectworkflow - storage - supplementimpact + - swboot - sysadmin - taskrun - traceimpact diff --git a/deploy/postgres/goose/00074_2026-09-26-swboot-controlled-executions.sql b/deploy/postgres/goose/00074_2026-09-26-swboot-controlled-executions.sql new file mode 100644 index 00000000..372bcce2 --- /dev/null +++ b/deploy/postgres/goose/00074_2026-09-26-swboot-controlled-executions.sql @@ -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; diff --git a/deploy/postgres/goose/00075_2026-09-26-repo-allowlist-test-commands.sql b/deploy/postgres/goose/00075_2026-09-26-repo-allowlist-test-commands.sql new file mode 100644 index 00000000..08a44b25 --- /dev/null +++ b/deploy/postgres/goose/00075_2026-09-26-repo-allowlist-test-commands.sql @@ -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; diff --git a/internal/app/app.go b/internal/app/app.go index 6a0e0021..7845e5ca 100644 --- a/internal/app/app.go +++ b/internal/app/app.go @@ -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, diff --git a/internal/projectworkflow/service.go b/internal/projectworkflow/service.go index cec123ee..3794cdf4 100644 --- a/internal/projectworkflow/service.go +++ b/internal/projectworkflow/service.go @@ -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 { diff --git a/internal/projectworkflow/service_test.go b/internal/projectworkflow/service_test.go index f58a0629..511902e6 100644 --- a/internal/projectworkflow/service_test.go +++ b/internal/projectworkflow/service_test.go @@ -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) + } +} diff --git a/internal/projectworkflow/store.go b/internal/projectworkflow/store.go index 37db764f..7fb52506 100644 --- a/internal/projectworkflow/store.go +++ b/internal/projectworkflow/store.go @@ -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) diff --git a/internal/projectworkflow/store_mem.go b/internal/projectworkflow/store_mem.go index 3888db4a..2f7f3532 100644 --- a/internal/projectworkflow/store_mem.go +++ b/internal/projectworkflow/store_mem.go @@ -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 +} diff --git a/internal/projectworkflow/store_pg_project.go b/internal/projectworkflow/store_pg_project.go index 7dac4018..1ab8cd59 100644 --- a/internal/projectworkflow/store_pg_project.go +++ b/internal/projectworkflow/store_pg_project.go @@ -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 +} diff --git a/internal/projectworkflow/types.go b/internal/projectworkflow/types.go index 679f6911..3b802c73 100644 --- a/internal/projectworkflow/types.go +++ b/internal/projectworkflow/types.go @@ -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"` diff --git a/internal/server/server.go b/internal/server/server.go index 517fccf4..8667fd20 100644 --- a/internal/server/server.go +++ b/internal/server/server.go @@ -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) diff --git a/internal/server/software_project_handlers.go b/internal/server/software_project_handlers.go index 35d0667f..4a47bd84 100644 --- a/internal/server/software_project_handlers.go +++ b/internal/server/software_project_handlers.go @@ -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 { diff --git a/internal/server/swboot_handlers.go b/internal/server/swboot_handlers.go new file mode 100644 index 00000000..fc9b666a --- /dev/null +++ b/internal/server/swboot_handlers.go @@ -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]) +} diff --git a/internal/swboot/allowlist_store.go b/internal/swboot/allowlist_store.go new file mode 100644 index 00000000..bfcd0e01 --- /dev/null +++ b/internal/swboot/allowlist_store.go @@ -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 +} diff --git a/internal/swboot/catalog.go b/internal/swboot/catalog.go index 7f88fd6b..7ff4f91a 100644 --- a/internal/swboot/catalog.go +++ b/internal/swboot/catalog.go @@ -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") diff --git a/internal/swboot/catalog_test.go b/internal/swboot/catalog_test.go index 7661e267..ad686776 100644 --- a/internal/swboot/catalog_test.go +++ b/internal/swboot/catalog_test.go @@ -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) + } +} diff --git a/internal/swboot/executor.go b/internal/swboot/executor.go new file mode 100644 index 00000000..36435c7e --- /dev/null +++ b/internal/swboot/executor.go @@ -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:] +} diff --git a/internal/swboot/executor_test.go b/internal/swboot/executor_test.go new file mode 100644 index 00000000..58d37bf1 --- /dev/null +++ b/internal/swboot/executor_test.go @@ -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) + } +} diff --git a/internal/swboot/repo_allowlist.go b/internal/swboot/repo_allowlist.go index 57c40d4b..577e8394 100644 --- a/internal/swboot/repo_allowlist.go +++ b/internal/swboot/repo_allowlist.go @@ -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 }