Compare commits
8
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c60a575fda | ||
|
|
a09a7e811f | ||
|
|
ecbb34627a | ||
|
|
cdbccb6238 | ||
|
|
6e28e02f98 | ||
|
|
5812974a80 | ||
|
|
0b6a249178 | ||
|
|
7ab4b02afa |
@@ -7,6 +7,8 @@ import (
|
||||
"fmt"
|
||||
"net/http"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"git.cin.su/m/Assistant/adapters/agentbridge"
|
||||
@@ -39,14 +41,14 @@ func main() {
|
||||
}
|
||||
defer repository.Close()
|
||||
var executor ports.AgentExecutor
|
||||
if bridgeURL := os.Getenv("AGENT_EXECUTION_BRIDGE_URL"); bridgeURL != "" {
|
||||
if bridgeURL, bridgeConfigured := loadProtectedBridgeURL(); bridgeConfigured {
|
||||
executor, err = agentbridge.NewHTTPExecutor(bridgeURL, &http.Client{Timeout: *leaseTTL})
|
||||
if err != nil {
|
||||
fmt.Fprintln(os.Stderr, err)
|
||||
os.Exit(1)
|
||||
}
|
||||
} else {
|
||||
fmt.Fprintln(os.Stderr, "agent execution bridge is not configured; dispatch claims are disabled; recovery-only mode")
|
||||
fmt.Fprintln(os.Stderr, "protected agent execution bridge URL is not configured; dispatch claims are disabled; recovery-only mode")
|
||||
}
|
||||
go func() {
|
||||
if serveErr := http.ListenAndServe(*statusAddr, workerStatusHandler(executor != nil)); serveErr != nil {
|
||||
@@ -81,6 +83,27 @@ func main() {
|
||||
}
|
||||
}
|
||||
|
||||
func loadProtectedBridgeURL() (string, bool) {
|
||||
return loadProtectedBridgeURLFrom("/run/secrets", "/run/secrets/agent_execution_bridge_url")
|
||||
}
|
||||
|
||||
func loadProtectedBridgeURLFrom(root, path string) (string, bool) {
|
||||
clean := filepath.Clean(path)
|
||||
cleanRoot := filepath.Clean(root)
|
||||
if clean != cleanRoot && !strings.HasPrefix(clean, cleanRoot+string(os.PathSeparator)) {
|
||||
return "", false
|
||||
}
|
||||
payload, err := os.ReadFile(clean)
|
||||
if err != nil {
|
||||
return "", false
|
||||
}
|
||||
value := strings.TrimSpace(string(payload))
|
||||
if value == "" || strings.ContainsAny(value, "\r\n") {
|
||||
return "", false
|
||||
}
|
||||
return value, true
|
||||
}
|
||||
|
||||
func resolveDatabasePassword(name, refName string, consumer secretcontracts.RuntimeConsumer) (string, error) {
|
||||
if _, ok := os.LookupEnv(name); ok {
|
||||
return "", fmt.Errorf("%s direct environment secret is forbidden", name)
|
||||
|
||||
@@ -19,7 +19,7 @@ func workerStatusHandler(executorConfigured bool) http.Handler {
|
||||
ExecutorConfigured: executorConfigured,
|
||||
Mode: "recovery-only",
|
||||
Ready: false,
|
||||
Reason: "AGENT_EXECUTION_BRIDGE_URL is not configured",
|
||||
Reason: "protected agent execution bridge URL is not configured",
|
||||
}
|
||||
if executorConfigured {
|
||||
status.Mode = "execution"
|
||||
|
||||
@@ -4,9 +4,28 @@ import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestLoadProtectedBridgeURL(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
path := filepath.Join(root, "agent_execution_bridge_url")
|
||||
if err := os.WriteFile(path, []byte("http://bridge.local\n"), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if value, configured := loadProtectedBridgeURLFrom(root, path); !configured || value != "http://bridge.local" {
|
||||
t.Fatalf("protected bridge was not loaded: %q %v", value, configured)
|
||||
}
|
||||
if value, configured := loadProtectedBridgeURLFrom(root, filepath.Join(root, "missing")); configured || value != "" {
|
||||
t.Fatalf("missing bridge was accepted: %q %v", value, configured)
|
||||
}
|
||||
if value, configured := loadProtectedBridgeURLFrom(root, "/tmp/not-protected"); configured || value != "" {
|
||||
t.Fatalf("path outside protected root was accepted: %q %v", value, configured)
|
||||
}
|
||||
}
|
||||
|
||||
func TestWorkerStatusReportsRecoveryOnlyWithoutExecutor(t *testing.T) {
|
||||
response := httptest.NewRecorder()
|
||||
workerStatusHandler(false).ServeHTTP(response, httptest.NewRequest(http.MethodGet, "/status", nil))
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
package work
|
||||
|
||||
import "time"
|
||||
|
||||
type DevelopmentStage string
|
||||
|
||||
const (
|
||||
@@ -27,3 +29,101 @@ type DevelopmentGate struct {
|
||||
PromotionRef string
|
||||
RollbackRef string
|
||||
}
|
||||
|
||||
// ExtensionProposalStage is deliberately proposal-only in this contract. A
|
||||
// proposal does not authorize source changes, provider calls, deployment or
|
||||
// promotion; those stages require the existing governed development gate.
|
||||
type ExtensionProposalStage string
|
||||
|
||||
const ExtensionProposalProposed ExtensionProposalStage = "PROPOSED"
|
||||
|
||||
// ExtensionEvidence is an immutable reference to an observation that points
|
||||
// at a repeated or corroborated improvement signal. The payload is owned by
|
||||
// its source module; Work stores only the typed reference and stable signal
|
||||
// key.
|
||||
type ExtensionEvidence struct {
|
||||
Ref string
|
||||
SignalKey string
|
||||
}
|
||||
|
||||
// ExtensionProposalRequest contains the operator/governance information
|
||||
// needed to decide whether an evidence pattern is worth reviewing. Empty
|
||||
// fields are rejected by the proposal generator rather than inferred.
|
||||
type ExtensionProposalRequest struct {
|
||||
ID string
|
||||
DesiredOutcome string
|
||||
SmallestChangeLayer string
|
||||
AffectedOwners []string
|
||||
AffectedContracts []string
|
||||
RiskTier string
|
||||
EvaluationPlan []string
|
||||
Alternatives []string
|
||||
RollbackPlan string
|
||||
Assumptions []string
|
||||
CreatedAt time.Time
|
||||
}
|
||||
|
||||
// ExtensionProposal is a bounded, evidence-bearing candidate for governed
|
||||
// development. It intentionally contains no executable command, credential,
|
||||
// authority grant, implementation body or promotion decision.
|
||||
type ExtensionProposal struct {
|
||||
ID string
|
||||
Stage ExtensionProposalStage
|
||||
EvidenceRefs []string
|
||||
DeduplicatedSignalKeys []string
|
||||
DesiredOutcome string
|
||||
SmallestChangeLayer string
|
||||
AffectedOwners []string
|
||||
AffectedContracts []string
|
||||
RiskTier string
|
||||
EvaluationPlan []string
|
||||
Alternatives []string
|
||||
RollbackPlan string
|
||||
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
|
||||
}
|
||||
|
||||
@@ -114,7 +114,6 @@ services:
|
||||
WORK_POSTGRES_CREDENTIAL_REF: credential://domovoy/work/db-password/v1
|
||||
WORK_POSTGRES_PASSWORD_FILE: /run/secrets/work_db_password
|
||||
SECRET_BACKEND_ROOT: /run/secrets
|
||||
AGENT_EXECUTION_BRIDGE_URL: ${AGENT_EXECUTION_BRIDGE_URL:-}
|
||||
depends_on: { postgres: { condition: service_healthy } }
|
||||
volumes:
|
||||
- assistant_core_secrets:/run/secrets:ro
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
{
|
||||
"task_file": "dev/tasks/DEV-000184-gpu-openai-protected-provider.json",
|
||||
"task_id": "DEV-000184",
|
||||
"task_file": "dev/tasks/DEV-000187-scaffold-task-packet-candidate.json",
|
||||
"task_id": "DEV-000187",
|
||||
"task_revision": 1
|
||||
}
|
||||
|
||||
@@ -0,0 +1,23 @@
|
||||
{
|
||||
"task_id": "DEV-000185",
|
||||
"task_revision": 1,
|
||||
"architecture_revision": "ADR-0017",
|
||||
"base_repo_revision": "7ab4b02afa78d5c65eb1545bfd6e117cc1cdfad7",
|
||||
"risk_tier": "E",
|
||||
"intent": "Load the optional external Agent Execution bridge endpoint from Domovoy protected storage and preserve recovery-only operation when it is absent or invalid.",
|
||||
"requirement_ids": ["protected bridge configuration", "degraded recovery without bridge", "durable dispatch when bridge is configured"],
|
||||
"allowed_change_scope": {"modules": ["agent-worker", "deployment"], "paths": ["cmd/agent-worker/**", "deploy/docker-compose.prod.yml", "dev/ACTIVE_TASK.json", "dev/tasks/DEV-000185-agent-bridge-protected-config.json"]},
|
||||
"forbidden_change_scope": {"paths": ["architecture/**", "database/**", "contracts/**", "security/**", ".gitea/**"]},
|
||||
"allowed_architecture_delta": {"new_cross_module_dependencies": 0, "new_canonical_owners": 0, "new_privileges": 1, "new_third_party_dependencies": 0, "new_database_owner_roles": 0},
|
||||
"contracts": {"consume": ["existing agentbridge HTTP executor", "protected runtime configuration files", "existing work dispatch contract"], "may_change": []},
|
||||
"semantic_owner": "work agent execution adapter",
|
||||
"canonical_state": [],
|
||||
"state_transitions": ["missing/invalid protected bridge URL -> recovery-only", "valid protected bridge URL -> execution mode"],
|
||||
"side_effects_and_privileges": ["outbound HTTP calls only to protected configured bridge endpoint"],
|
||||
"persistence_and_migrations": ["no schema changes; protected file storage only"],
|
||||
"failure_retry_restart": ["bridge errors remain handled by existing worker cycle", "restart reloads protected URL", "missing bridge never prevents recovery"],
|
||||
"assumptions": ["protected secret volume is mounted read-only", "bridge URL file is deployment-owned"],
|
||||
"acceptance_scenarios": ["protected URL enables execution mode", "missing URL keeps recovery-only mode", "URL is not read from environment", "URL value never appears in status"],
|
||||
"required_tests": ["go test ./cmd/agent-worker/...", "go vet ./...", "go run ./cmd/devctl preflight -task dev/tasks/DEV-000185-agent-bridge-protected-config.json", "go run ./cmd/devctl check-diff -task dev/tasks/DEV-000185-agent-bridge-protected-config.json", "go run ./cmd/devctl check-architecture", "go run ./cmd/devctl independent", "git diff --check", "CI", "production status and recovery checks"],
|
||||
"terminal_gap_states": ["BLOCKED_BY_MISSING_CAPABILITY", "BLOCKED_BY_SPEC_GAP", "BLOCKED_BY_CONFLICT"]
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
{
|
||||
"task_id": "DEV-000186",
|
||||
"task_revision": 1,
|
||||
"architecture_revision": "ADR-0018",
|
||||
"base_repo_revision": "cdbccb6",
|
||||
"risk_tier": "E",
|
||||
"intent": "Add a typed, proposal-only ExtensionProposal generator from repeated or corroborated evidence without granting mutation, authority, deployment, or promotion capability.",
|
||||
"requirement_ids": ["WP-601", "Extension proposal generator", "Evidence deduplication", "Governed rollback plan"],
|
||||
"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-000186-extension-proposal-generator.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": ["ADR-0022 governed self-development boundary", "existing Work DevelopmentGate"],
|
||||
"may_change": ["typed Work ExtensionProposal value objects"]
|
||||
},
|
||||
"semantic_owner": "work-agent-execution",
|
||||
"canonical_state": ["No durable canonical state; proposal value object only"],
|
||||
"state_transitions": ["repeated evidence -> PROPOSED ExtensionProposal", "insufficient/conflicting/incomplete evidence -> rejected result"],
|
||||
"side_effects_and_privileges": ["No source mutation, provider invocation, secret access, deployment, promotion, or authority expansion"],
|
||||
"persistence_and_migrations": ["No schema or migration changes"],
|
||||
"failure_retry_restart": ["Deterministic generation is rerunnable; unique evidence refs and signal keys preserve lineage; one failure cannot generate a proposal"],
|
||||
"assumptions": ["Human governance remains the promotion authority", "A later task may add durable proposal persistence only after owner and migration review"],
|
||||
"acceptance_scenarios": ["one failure is rejected", "repeated same-signal evidence produces one proposal with deduplicated signal keys and retained refs", "missing assumptions/evaluation/rollback fails closed", "proposal contains no executable authority or mutation field"],
|
||||
"required_tests": ["go test ./modules/work/domain ./tests/semantic_evolution", "go vet ./...", "go run ./cmd/devctl preflight -task dev/tasks/DEV-000186-extension-proposal-generator.json", "go run ./cmd/devctl check-diff -task dev/tasks/DEV-000186-extension-proposal-generator.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"]
|
||||
}
|
||||
@@ -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"]
|
||||
}
|
||||
@@ -3,11 +3,147 @@ package domain
|
||||
import (
|
||||
"errors"
|
||||
"slices"
|
||||
"strings"
|
||||
|
||||
"git.cin.su/m/Assistant/contracts/work"
|
||||
)
|
||||
|
||||
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,
|
||||
// while deduplicating signal keys so repeated reports do not create duplicate
|
||||
// proposals. It never creates a task, mutates an implementation or grants
|
||||
// authority.
|
||||
func BuildExtensionProposal(evidence []work.ExtensionEvidence, request work.ExtensionProposalRequest) (work.ExtensionProposal, error) {
|
||||
owners := cleanStrings(request.AffectedOwners)
|
||||
contracts := cleanStrings(request.AffectedContracts)
|
||||
evaluation := cleanStrings(request.EvaluationPlan)
|
||||
alternatives := cleanStrings(request.Alternatives)
|
||||
assumptions := cleanStrings(request.Assumptions)
|
||||
if request.ID == "" || request.CreatedAt.IsZero() || strings.TrimSpace(request.DesiredOutcome) == "" || strings.TrimSpace(request.SmallestChangeLayer) == "" || strings.TrimSpace(request.RiskTier) == "" || strings.TrimSpace(request.RollbackPlan) == "" || len(owners) == 0 || len(contracts) == 0 || len(evaluation) == 0 || len(alternatives) == 0 || len(assumptions) == 0 {
|
||||
return work.ExtensionProposal{}, ErrInvalidExtensionProposal
|
||||
}
|
||||
refs := make([]string, 0, len(evidence))
|
||||
refSeen := make(map[string]struct{}, len(evidence))
|
||||
counts := make(map[string]int, len(evidence))
|
||||
keys := make([]string, 0, len(evidence))
|
||||
keySeen := make(map[string]struct{}, len(evidence))
|
||||
for _, item := range evidence {
|
||||
ref := strings.TrimSpace(item.Ref)
|
||||
key := strings.TrimSpace(item.SignalKey)
|
||||
if ref == "" || key == "" {
|
||||
return work.ExtensionProposal{}, ErrInvalidExtensionProposal
|
||||
}
|
||||
if _, ok := refSeen[ref]; !ok {
|
||||
refs = append(refs, ref)
|
||||
refSeen[ref] = struct{}{}
|
||||
}
|
||||
counts[key]++
|
||||
if _, ok := keySeen[key]; !ok {
|
||||
keys = append(keys, key)
|
||||
keySeen[key] = struct{}{}
|
||||
}
|
||||
}
|
||||
if len(refs) < 2 {
|
||||
return work.ExtensionProposal{}, ErrInsufficientExtensionEvidence
|
||||
}
|
||||
repeated := false
|
||||
for _, count := range counts {
|
||||
if count >= 2 {
|
||||
repeated = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !repeated {
|
||||
return work.ExtensionProposal{}, ErrInsufficientExtensionEvidence
|
||||
}
|
||||
return work.ExtensionProposal{
|
||||
ID: request.ID, Stage: work.ExtensionProposalProposed, EvidenceRefs: refs, DeduplicatedSignalKeys: keys,
|
||||
DesiredOutcome: strings.TrimSpace(request.DesiredOutcome), SmallestChangeLayer: strings.TrimSpace(request.SmallestChangeLayer),
|
||||
AffectedOwners: owners, AffectedContracts: contracts,
|
||||
RiskTier: strings.TrimSpace(request.RiskTier), EvaluationPlan: evaluation, Alternatives: alternatives,
|
||||
RollbackPlan: strings.TrimSpace(request.RollbackPlan), Assumptions: assumptions, CreatedAtUnixMillis: request.CreatedAt.UnixMilli(),
|
||||
}, nil
|
||||
}
|
||||
|
||||
func cleanStrings(values []string) []string {
|
||||
result := make([]string, 0, len(values))
|
||||
seen := make(map[string]struct{}, len(values))
|
||||
for _, value := range values {
|
||||
value = strings.TrimSpace(value)
|
||||
if value == "" {
|
||||
continue
|
||||
}
|
||||
if _, ok := seen[value]; ok {
|
||||
continue
|
||||
}
|
||||
seen[value] = struct{}{}
|
||||
result = append(result, value)
|
||||
}
|
||||
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 {
|
||||
|
||||
@@ -2,6 +2,7 @@ package domain
|
||||
|
||||
import (
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"git.cin.su/m/Assistant/contracts/work"
|
||||
)
|
||||
@@ -20,6 +21,52 @@ func TestGovernedSelfDevelopmentRequiresIndependentVerifierBeforeCanary(t *testi
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildExtensionProposalRequiresRepeatedEvidenceAndRetainsLineage(t *testing.T) {
|
||||
proposal, err := BuildExtensionProposal([]work.ExtensionEvidence{
|
||||
{Ref: "reflection-1", SignalKey: "send-email-gap"},
|
||||
{Ref: "reflection-2", SignalKey: "send-email-gap"},
|
||||
{Ref: "reflection-2", SignalKey: "send-email-gap"},
|
||||
}, work.ExtensionProposalRequest{
|
||||
ID: "proposal-1", DesiredOutcome: "connect a reviewed email capability", SmallestChangeLayer: "capability-adapter",
|
||||
AffectedOwners: []string{"capability", "commit"}, AffectedContracts: []string{"CapabilityImplementation@v1"}, RiskTier: "D",
|
||||
EvaluationPlan: []string{"fake transport", "adversarial corpus"}, Alternatives: []string{"keep capability gap"},
|
||||
RollbackPlan: "restore previous implementation reference", Assumptions: []string{"human promotion required"}, CreatedAt: time.UnixMilli(42),
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if proposal.Stage != work.ExtensionProposalProposed || len(proposal.EvidenceRefs) != 2 || len(proposal.DeduplicatedSignalKeys) != 1 || proposal.CreatedAtUnixMillis != 42 {
|
||||
t.Fatalf("unexpected proposal: %+v", proposal)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildExtensionProposalRejectsSingleFailureAndIncompleteGovernance(t *testing.T) {
|
||||
request := work.ExtensionProposalRequest{ID: "proposal-1", DesiredOutcome: "x", SmallestChangeLayer: "skill", AffectedOwners: []string{"capability"}, AffectedContracts: []string{"Skill@v1"}, RiskTier: "B", EvaluationPlan: []string{"test"}, Alternatives: []string{"keep current behavior"}, RollbackPlan: "disable", Assumptions: []string{"human review"}, CreatedAt: time.UnixMilli(42)}
|
||||
if _, err := BuildExtensionProposal([]work.ExtensionEvidence{{Ref: "failure-1", SignalKey: "same"}}, request); err != ErrInsufficientExtensionEvidence {
|
||||
t.Fatalf("single failure must be rejected, got %v", err)
|
||||
}
|
||||
request.Assumptions = nil
|
||||
if _, err := BuildExtensionProposal([]work.ExtensionEvidence{{Ref: "failure-1", SignalKey: "same"}, {Ref: "failure-2", SignalKey: "same"}}, request); err != ErrInvalidExtensionProposal {
|
||||
t.Fatalf("missing assumptions must be rejected, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
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 {
|
||||
|
||||
@@ -2,6 +2,7 @@ package semantic_evolution_test
|
||||
|
||||
import (
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"git.cin.su/m/Assistant/contracts/work"
|
||||
workdomain "git.cin.su/m/Assistant/modules/work/domain"
|
||||
@@ -13,3 +14,22 @@ func TestSelfDevelopmentRollbackIsAvailableOnlyFromCanary(t *testing.T) {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestExtensionProposalIsProposalOnlyAndDeduplicatesSignals(t *testing.T) {
|
||||
proposal, err := workdomain.BuildExtensionProposal([]work.ExtensionEvidence{{Ref: "gap-1", SignalKey: "printer-driver"}, {Ref: "gap-2", SignalKey: "printer-driver"}}, work.ExtensionProposalRequest{
|
||||
ID: "proposal-printer", DesiredOutcome: "review printer adapter", SmallestChangeLayer: "capability-adapter", AffectedOwners: []string{"capability"}, AffectedContracts: []string{"CapabilityImplementation@v1"}, RiskTier: "C", EvaluationPlan: []string{"contract tests"}, Alternatives: []string{"retain capability gap"}, RollbackPlan: "disable candidate", Assumptions: []string{"human promotion"}, CreatedAt: time.UnixMilli(7),
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if proposal.Stage != work.ExtensionProposalProposed || len(proposal.DeduplicatedSignalKeys) != 1 {
|
||||
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")
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user