From c60a575fdad134227a64c76439a61dd81fdfc3a9 Mon Sep 17 00:00:00 2001 From: Mikhail Date: Sun, 23 Aug 2026 01:36:03 +0300 Subject: [PATCH] feat: add isolated governed scaffold candidate --- contracts/work/self_development.go | 45 ++++++++++++++ dev/ACTIVE_TASK.json | 4 +- ...000187-scaffold-task-packet-candidate.json | 26 ++++++++ modules/work/domain/self_development.go | 59 +++++++++++++++++++ modules/work/domain/self_development_test.go | 16 +++++ .../self_development_contract_test.go | 7 +++ 6 files changed, 155 insertions(+), 2 deletions(-) create mode 100644 dev/tasks/DEV-000187-scaffold-task-packet-candidate.json diff --git a/contracts/work/self_development.go b/contracts/work/self_development.go index 366bef5..61f7036 100644 --- a/contracts/work/self_development.go +++ b/contracts/work/self_development.go @@ -82,3 +82,48 @@ type ExtensionProposal struct { Assumptions []string CreatedAtUnixMillis int64 } + +type DevelopmentScaffoldStage string + +const DevelopmentScaffoldSandboxed DevelopmentScaffoldStage = "SANDBOXED" + +// DevelopmentScaffoldRequest describes the boundary a later control plane +// would use to create an isolated worktree. This value object itself performs +// no checkout or filesystem operation. +type DevelopmentScaffoldRequest struct { + ID string + ProposalRef string + BaseRepoRevision string + WorktreeRef string + TaskIntent string + Modules []string + AllowedPaths []string + ForbiddenPaths []string + ProtectedPaths []string + RequiredTests []string + ArtifactLineageRefs []string + ProtectedMainAccess string + ProductionAccess string + SecretAccess string + CreatedAt time.Time +} + +// DevelopmentScaffold is a provenance-linked task-packet candidate. It has +// no executable instruction, credential, checkout handle or authority grant. +type DevelopmentScaffold struct { + ID string + Stage DevelopmentScaffoldStage + ProposalRef string + BaseRepoRevision string + WorktreeRef string + TaskIntent string + Modules []string + AllowedPaths []string + ForbiddenPaths []string + RequiredTests []string + ArtifactLineageRefs []string + ProtectedMainAccess string + ProductionAccess string + SecretAccess string + CreatedAtUnixMillis int64 +} diff --git a/dev/ACTIVE_TASK.json b/dev/ACTIVE_TASK.json index 7aba33d..ac653cb 100644 --- a/dev/ACTIVE_TASK.json +++ b/dev/ACTIVE_TASK.json @@ -1,5 +1,5 @@ { - "task_file": "dev/tasks/DEV-000186-extension-proposal-generator.json", - "task_id": "DEV-000186", + "task_file": "dev/tasks/DEV-000187-scaffold-task-packet-candidate.json", + "task_id": "DEV-000187", "task_revision": 1 } diff --git a/dev/tasks/DEV-000187-scaffold-task-packet-candidate.json b/dev/tasks/DEV-000187-scaffold-task-packet-candidate.json new file mode 100644 index 0000000..9525c9b --- /dev/null +++ b/dev/tasks/DEV-000187-scaffold-task-packet-candidate.json @@ -0,0 +1,26 @@ +{ + "task_id": "DEV-000187", + "task_revision": 1, + "architecture_revision": "ADR-0018", + "base_repo_revision": "a09a7e8", + "risk_tier": "E", + "intent": "Add a fail-closed typed scaffold candidate factory that produces an isolated task-packet descriptor without creating files, reading secrets, or accessing protected main/production.", + "requirement_ids": ["WP-602", "Scoped task packet candidate", "Isolated worktree provenance", "Protected path denial"], + "allowed_change_scope": { + "modules": ["work-agent-execution"], + "paths": ["contracts/work/self_development.go", "modules/work/domain/self_development.go", "modules/work/domain/self_development_test.go", "tests/semantic_evolution/self_development_contract_test.go", "dev/ACTIVE_TASK.json", "dev/tasks/DEV-000187-scaffold-task-packet-candidate.json"] + }, + "forbidden_change_scope": {"paths": ["architecture/**", "security/**", "database/**", "cmd/**", "adapters/**", "deploy/**", "contracts/authority/**", ".gitea/**"]}, + "allowed_architecture_delta": {"new_cross_module_dependencies": 0, "new_canonical_owners": 0, "new_privileges": 0, "new_third_party_dependencies": 0, "new_database_owner_roles": 0}, + "contracts": {"consume": ["Existing ExtensionProposal", "ADR-0022 governed development boundary"], "may_change": ["typed Work scaffold candidate value objects"]}, + "semantic_owner": "work-agent-execution", + "canonical_state": ["No durable state; isolated scaffold candidate value object only"], + "state_transitions": ["proposal -> SANDBOXED task-packet candidate", "invalid scope/policy -> rejected result"], + "side_effects_and_privileges": ["No filesystem mutation, source checkout, secret access, provider call, deployment, promotion, or authority expansion"], + "persistence_and_migrations": ["No schema or migration changes"], + "failure_retry_restart": ["Deterministic validation is rerunnable; invalid or protected scope fails closed"], + "assumptions": ["A later external development control plane performs actual worktree creation", "Human governance remains promotion authority"], + "acceptance_scenarios": ["valid proposal produces scoped SANDBOXED descriptor", "absolute/traversal/protected path is rejected", "production and secret access policies must be DENIED", "missing provenance or test plan is rejected", "descriptor contains no command or credential material"], + "required_tests": ["go test ./modules/work/domain ./tests/semantic_evolution", "go vet ./...", "go run ./cmd/devctl preflight -task dev/tasks/DEV-000187-scaffold-task-packet-candidate.json", "go run ./cmd/devctl check-diff -task dev/tasks/DEV-000187-scaffold-task-packet-candidate.json", "go run ./cmd/devctl check-architecture", "go run ./cmd/devctl independent", "git diff --check", "CI"], + "terminal_gap_states": ["BLOCKED_BY_ARCHITECTURE_GAP", "BLOCKED_BY_SPEC_GAP", "BLOCKED_BY_CONFLICT"] +} diff --git a/modules/work/domain/self_development.go b/modules/work/domain/self_development.go index 0bea924..a99ad4b 100644 --- a/modules/work/domain/self_development.go +++ b/modules/work/domain/self_development.go @@ -11,6 +11,7 @@ import ( var ErrInvalidDevelopmentGate = errors.New("invalid governed self-development gate") var ErrInsufficientExtensionEvidence = errors.New("insufficient repeated extension evidence") var ErrInvalidExtensionProposal = errors.New("invalid extension proposal") +var ErrInvalidDevelopmentScaffold = errors.New("invalid development scaffold candidate") // BuildExtensionProposal deterministically turns a repeated evidence signal // into a proposal. It retains every distinct evidence reference for lineage, @@ -86,6 +87,64 @@ func cleanStrings(values []string) []string { return result } +// BuildDevelopmentScaffold creates only a typed, isolated candidate. The +// caller supplies the protected path set so this domain function cannot infer +// or widen repository authority from ambient process state. +func BuildDevelopmentScaffold(request work.DevelopmentScaffoldRequest) (work.DevelopmentScaffold, error) { + modules := cleanStrings(request.Modules) + allowed := cleanPaths(request.AllowedPaths) + forbidden := cleanPaths(request.ForbiddenPaths) + protected := cleanPaths(request.ProtectedPaths) + tests := cleanStrings(request.RequiredTests) + lineage := cleanStrings(request.ArtifactLineageRefs) + if request.ID == "" || request.ProposalRef == "" || strings.TrimSpace(request.BaseRepoRevision) == "" || strings.TrimSpace(request.WorktreeRef) == "" || strings.TrimSpace(request.TaskIntent) == "" || request.CreatedAt.IsZero() || len(modules) == 0 || len(allowed) == 0 || len(tests) == 0 || len(lineage) == 0 || request.ProtectedMainAccess != "DENIED" || request.ProductionAccess != "DENIED" || request.SecretAccess != "DENIED" { + return work.DevelopmentScaffold{}, ErrInvalidDevelopmentScaffold + } + for _, path := range allowed { + if unsafePath(path) || pathMatchesAny(path, forbidden) || pathMatchesAny(path, protected) { + return work.DevelopmentScaffold{}, ErrInvalidDevelopmentScaffold + } + } + worktree := strings.ToLower(strings.TrimSpace(request.WorktreeRef)) + if worktree == "main" || worktree == "protected-main" || worktree == "production" { + return work.DevelopmentScaffold{}, ErrInvalidDevelopmentScaffold + } + return work.DevelopmentScaffold{ID: request.ID, Stage: work.DevelopmentScaffoldSandboxed, ProposalRef: request.ProposalRef, BaseRepoRevision: strings.TrimSpace(request.BaseRepoRevision), WorktreeRef: strings.TrimSpace(request.WorktreeRef), TaskIntent: strings.TrimSpace(request.TaskIntent), Modules: modules, AllowedPaths: allowed, ForbiddenPaths: forbidden, RequiredTests: tests, ArtifactLineageRefs: lineage, ProtectedMainAccess: "DENIED", ProductionAccess: "DENIED", SecretAccess: "DENIED", CreatedAtUnixMillis: request.CreatedAt.UnixMilli()}, nil +} + +func cleanPaths(values []string) []string { + result := make([]string, 0, len(values)) + seen := make(map[string]struct{}, len(values)) + for _, value := range values { + value = strings.TrimSpace(strings.ReplaceAll(value, "\\", "/")) + if value == "" { + continue + } + if _, ok := seen[value]; ok { + continue + } + seen[value] = struct{}{} + result = append(result, value) + } + return result +} + +func unsafePath(path string) bool { + return strings.HasPrefix(path, "/") || strings.HasPrefix(path, "~") || (len(path) > 1 && path[1] == ':') || slices.Contains(strings.Split(path, "/"), "..") +} + +func pathMatchesAny(path string, scopes []string) bool { + for _, scope := range scopes { + scope = strings.TrimSuffix(scope, "/") + scope = strings.TrimSuffix(scope, "**") + scope = strings.TrimSuffix(scope, "/") + if path == scope || strings.HasPrefix(path, scope+"/") { + return true + } + } + return false +} + func AdvanceDevelopmentGate(current work.DevelopmentGate, next work.DevelopmentStage) (work.DevelopmentGate, error) { if err := ValidateDevelopmentGate(current); err != nil { return work.DevelopmentGate{}, err diff --git a/modules/work/domain/self_development_test.go b/modules/work/domain/self_development_test.go index 2348dcf..c8ec565 100644 --- a/modules/work/domain/self_development_test.go +++ b/modules/work/domain/self_development_test.go @@ -51,6 +51,22 @@ func TestBuildExtensionProposalRejectsSingleFailureAndIncompleteGovernance(t *te } } +func TestBuildDevelopmentScaffoldFailsClosedForProtectedScope(t *testing.T) { + base := work.DevelopmentScaffoldRequest{ID: "scaffold-1", ProposalRef: "proposal-1", BaseRepoRevision: "abc123", WorktreeRef: "worktree-1", TaskIntent: "add reviewed adapter", Modules: []string{"capability"}, AllowedPaths: []string{"modules/capability/**"}, ForbiddenPaths: []string{"security/**"}, ProtectedPaths: []string{"architecture/**", "deploy/**"}, RequiredTests: []string{"go test ./..."}, ArtifactLineageRefs: []string{"proposal-1"}, ProtectedMainAccess: "DENIED", ProductionAccess: "DENIED", SecretAccess: "DENIED", CreatedAt: time.UnixMilli(1)} + scaffold, err := BuildDevelopmentScaffold(base) + if err != nil || scaffold.Stage != work.DevelopmentScaffoldSandboxed { + t.Fatalf("valid scaffold rejected: %+v %v", scaffold, err) + } + base.AllowedPaths = []string{"deploy/docker-compose.prod.yml"} + if _, err := BuildDevelopmentScaffold(base); err != ErrInvalidDevelopmentScaffold { + t.Fatalf("protected path must fail closed, got %v", err) + } + base.AllowedPaths = []string{"modules/capability/../security"} + if _, err := BuildDevelopmentScaffold(base); err != ErrInvalidDevelopmentScaffold { + t.Fatalf("traversal path must fail closed, got %v", err) + } +} + func TestGovernedSelfDevelopmentRejectsSameProducerAndVerifier(t *testing.T) { gate := work.DevelopmentGate{ID: "dev-1", Stage: work.DevelopmentIsolated, EvidenceRefs: []string{"obs-1"}, ProducerRunRefs: []work.AgentRunID{"same"}, VerifierRunRefs: []work.AgentRunID{"same"}, VerificationEvidence: "verify-1"} if _, err := AdvanceDevelopmentGate(gate, work.DevelopmentVerified); err == nil { diff --git a/tests/semantic_evolution/self_development_contract_test.go b/tests/semantic_evolution/self_development_contract_test.go index 9e3c2d8..b45c43e 100644 --- a/tests/semantic_evolution/self_development_contract_test.go +++ b/tests/semantic_evolution/self_development_contract_test.go @@ -26,3 +26,10 @@ func TestExtensionProposalIsProposalOnlyAndDeduplicatesSignals(t *testing.T) { t.Fatalf("unexpected proposal stage/keys: %+v", proposal) } } + +func TestDevelopmentScaffoldRequiresExplicitDeniedAccessPolicies(t *testing.T) { + _, err := workdomain.BuildDevelopmentScaffold(work.DevelopmentScaffoldRequest{ID: "s", ProposalRef: "p", BaseRepoRevision: "r", WorktreeRef: "isolated", TaskIntent: "test", Modules: []string{"work"}, AllowedPaths: []string{"modules/work/**"}, RequiredTests: []string{"go test ./..."}, ArtifactLineageRefs: []string{"p"}, ProtectedMainAccess: "DENIED", ProductionAccess: "DENIED", SecretAccess: "ALLOW", CreatedAt: time.UnixMilli(1)}) + if err == nil { + t.Fatal("secret access must be denied explicitly") + } +} -- 2.54.0