Refuse a plan command outside project policy when the plan seals

The brief tells the planner "a command outside its policy is refused when you
seal, not later". It was not. The only caller of VerificationPolicy.Allows was
PlanPhaseCommands, which runs when the implementer asks to verify: one phase,
one session and one rotation after the planner could have fixed it.

Run 9 sealed ["bash", "scripts/test_healthcheck.sh"] against a policy that
allows neither shape, and the phase request was accepted.

The check now runs beside citation resolution, on the coordinator, where the
project is already in scope. A project with no verification policy can still
seal a plan; it cannot seal one that declares run: lines, which matches what
an absent policy already meant at verification time.

Test fixtures gained a policy for the same reason.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CVbaKucEYBjMqVeUgJUsc1
This commit is contained in:
2026-08-28 15:27:39 +04:00
parent 98f1b2dccc
commit fb7135e1d9
10 changed files with 87 additions and 27 deletions
+1 -1
View File
@@ -74,7 +74,7 @@ func setup(t *testing.T) (*store.Store, registry.Registry, string) {
t.Fatal(err) t.Fatal(err)
} }
r, err := registry.New(registry.Config{ r, err := registry.New(registry.Config{
Projects: []registry.Project{{ID: "p", MachineAffinity: []string{"m"}}}, Projects: []registry.Project{{ID: "p", MachineAffinity: []string{"m"}, Verification: registry.VerificationPolicy{Allowed: [][]string{{"go", "test", "*"}}}}},
Machines: []registry.Machine{{ID: "m", Address: "unused"}}, Machines: []registry.Machine{{ID: "m", Address: "unused"}},
Herdrs: []registry.Herdr{{ID: "h1", MachineID: "m", Capabilities: []string{"go"}, Concurrency: 1, QuotaLimit: 100}}, Herdrs: []registry.Herdr{{ID: "h1", MachineID: "m", Capabilities: []string{"go"}, Concurrency: 1, QuotaLimit: 100}},
}) })
+1 -1
View File
@@ -91,7 +91,7 @@ func TestPhaseRequestPathSealsAndFences(t *testing.T) {
} }
leased, _ := s.Task(task.ID) leased, _ := s.Task(task.ID)
epoch := leased.Lease.Epoch epoch := leased.Lease.Epoch
project := registry.Project{ID: "p"} project := registry.Project{ID: "p", Verification: registry.VerificationPolicy{Allowed: [][]string{{"go", "test", "*"}}}}
if _, err := operations.RequestWorkPhase(s, project, task.ID, epoch, "op-1", domain.WorkPhaseFrame, domain.WorkPhaseResearch, nil); err != nil { if _, err := operations.RequestWorkPhase(s, project, task.ID, epoch, "op-1", domain.WorkPhaseFrame, domain.WorkPhaseResearch, nil); err != nil {
t.Fatal(err) t.Fatal(err)
+4 -4
View File
@@ -26,7 +26,7 @@ func request(q string) domain.DecisionRequest {
func TestDecisionRequestBlocksAndResumes(t *testing.T) { func TestDecisionRequestBlocksAndResumes(t *testing.T) {
s, id := phaseStore(t) s, id := phaseStore(t)
lease(t, s, id) lease(t, s, id)
project := registry.Project{ID: "p"} project := registry.Project{ID: "p", Verification: registry.VerificationPolicy{Allowed: [][]string{{"go", "test", "*"}}}}
if _, err := RequestHumanDecision(s, project, id, request("should the old cache contract stay compatible?")); err != nil { if _, err := RequestHumanDecision(s, project, id, request("should the old cache contract stay compatible?")); err != nil {
t.Fatal(err) t.Fatal(err)
@@ -78,7 +78,7 @@ func TestDecisionRequestBlocksAndResumes(t *testing.T) {
func TestResolvedQuestionIsNotRepeatedAfterRotation(t *testing.T) { func TestResolvedQuestionIsNotRepeatedAfterRotation(t *testing.T) {
s, id := phaseStore(t) s, id := phaseStore(t)
lease(t, s, id) lease(t, s, id)
project := registry.Project{ID: "p"} project := registry.Project{ID: "p", Verification: registry.VerificationPolicy{Allowed: [][]string{{"go", "test", "*"}}}}
if _, err := RequestHumanDecision(s, project, id, request("preserve compatibility?")); err != nil { if _, err := RequestHumanDecision(s, project, id, request("preserve compatibility?")); err != nil {
t.Fatal(err) t.Fatal(err)
} }
@@ -100,7 +100,7 @@ func TestResolvedQuestionIsNotRepeatedAfterRotation(t *testing.T) {
// The budget stops a task turning into an interview. // The budget stops a task turning into an interview.
func TestDecisionBudgetBecomesOperatorRequired(t *testing.T) { func TestDecisionBudgetBecomesOperatorRequired(t *testing.T) {
s, id := phaseStore(t) s, id := phaseStore(t)
project := registry.Project{ID: "p"} project := registry.Project{ID: "p", Verification: registry.VerificationPolicy{Allowed: [][]string{{"go", "test", "*"}}}}
project.HumanDecisions.MaxRequestsPerTask = 2 project.HumanDecisions.MaxRequestsPerTask = 2
for i, q := range []string{"first?", "second?"} { for i, q := range []string{"first?", "second?"} {
@@ -134,7 +134,7 @@ func TestDecisionBudgetBecomesOperatorRequired(t *testing.T) {
// Bounds are the whole defence against an interview arriving as one request. // Bounds are the whole defence against an interview arriving as one request.
func TestDecisionRequestBoundsRejectInterviews(t *testing.T) { func TestDecisionRequestBoundsRejectInterviews(t *testing.T) {
s, id := phaseStore(t) s, id := phaseStore(t)
project := registry.Project{ID: "p"} project := registry.Project{ID: "p", Verification: registry.VerificationPolicy{Allowed: [][]string{{"go", "test", "*"}}}}
cases := map[string]domain.DecisionRequest{ cases := map[string]domain.DecisionRequest{
"no question": {Why: "w"}, "no question": {Why: "w"},
"no why": {Question: "q"}, "no why": {Question: "q"},
+8 -8
View File
@@ -23,7 +23,7 @@ func epochOf(t *testing.T, s *store.Store, id string) string {
func TestRequestWorkPhaseAdvancesAndSealsEachArtifact(t *testing.T) { func TestRequestWorkPhaseAdvancesAndSealsEachArtifact(t *testing.T) {
s, id := phaseStore(t) s, id := phaseStore(t)
project := registry.Project{ID: "p"} project := registry.Project{ID: "p", Verification: registry.VerificationPolicy{Allowed: [][]string{{"go", "test", "*"}}}}
lease(t, s, id) lease(t, s, id)
epoch := epochOf(t, s, id) epoch := epochOf(t, s, id)
@@ -66,7 +66,7 @@ func TestRequestWorkPhaseRefusesASkippedPhase(t *testing.T) {
// frame -> implement is a legal domain transition, but not the next step // frame -> implement is a legal domain transition, but not the next step
// on this project's declared path. The agent is refused rather than // on this project's declared path. The agent is refused rather than
// silently corrected. // silently corrected.
_, err := RequestWorkPhase(s, registry.Project{ID: "p"}, id, epochOf(t, s, id), "op-1", domain.WorkPhaseFrame, domain.WorkPhaseImplement, nil) _, err := RequestWorkPhase(s, registry.Project{ID: "p", Verification: registry.VerificationPolicy{Allowed: [][]string{{"go", "test", "*"}}}}, id, epochOf(t, s, id), "op-1", domain.WorkPhaseFrame, domain.WorkPhaseImplement, nil)
if !errors.Is(err, ErrPhaseRequest) { if !errors.Is(err, ErrPhaseRequest) {
t.Fatalf("err = %v", err) t.Fatalf("err = %v", err)
} }
@@ -79,12 +79,12 @@ func TestRequestWorkPhaseRefusesAStalePhaseBelief(t *testing.T) {
s, id := phaseStore(t) s, id := phaseStore(t)
lease(t, s, id) lease(t, s, id)
epoch := epochOf(t, s, id) epoch := epochOf(t, s, id)
if _, err := RequestWorkPhase(s, registry.Project{ID: "p"}, id, epoch, "op-1", domain.WorkPhaseFrame, domain.WorkPhaseResearch, nil); err != nil { if _, err := RequestWorkPhase(s, registry.Project{ID: "p", Verification: registry.VerificationPolicy{Allowed: [][]string{{"go", "test", "*"}}}}, id, epoch, "op-1", domain.WorkPhaseFrame, domain.WorkPhaseResearch, nil); err != nil {
t.Fatal(err) t.Fatal(err)
} }
// The agent still believes it is framing. Acting on this would advance a // The agent still believes it is framing. Acting on this would advance a
// phase it never ran. // phase it never ran.
_, err := RequestWorkPhase(s, registry.Project{ID: "p"}, id, epoch, "op-2", domain.WorkPhaseFrame, domain.WorkPhaseResearch, nil) _, err := RequestWorkPhase(s, registry.Project{ID: "p", Verification: registry.VerificationPolicy{Allowed: [][]string{{"go", "test", "*"}}}}, id, epoch, "op-2", domain.WorkPhaseFrame, domain.WorkPhaseResearch, nil)
if !errors.Is(err, ErrPhaseRequest) { if !errors.Is(err, ErrPhaseRequest) {
t.Fatalf("err = %v", err) t.Fatalf("err = %v", err)
} }
@@ -93,7 +93,7 @@ func TestRequestWorkPhaseRefusesAStalePhaseBelief(t *testing.T) {
func TestRequestWorkPhaseRefusesAStaleLeaseEpoch(t *testing.T) { func TestRequestWorkPhaseRefusesAStaleLeaseEpoch(t *testing.T) {
s, id := phaseStore(t) s, id := phaseStore(t)
lease(t, s, id) lease(t, s, id)
_, err := RequestWorkPhase(s, registry.Project{ID: "p"}, id, "not-the-epoch", "op-1", domain.WorkPhaseFrame, domain.WorkPhaseResearch, nil) _, err := RequestWorkPhase(s, registry.Project{ID: "p", Verification: registry.VerificationPolicy{Allowed: [][]string{{"go", "test", "*"}}}}, id, "not-the-epoch", "op-1", domain.WorkPhaseFrame, domain.WorkPhaseResearch, nil)
if !errors.Is(err, domain.ErrConflict) { if !errors.Is(err, domain.ErrConflict) {
t.Fatalf("err = %v", err) t.Fatalf("err = %v", err)
} }
@@ -105,7 +105,7 @@ func TestRequestWorkPhaseRefusesAStaleLeaseEpoch(t *testing.T) {
func TestRequestWorkPhaseRequiresAnOperationID(t *testing.T) { func TestRequestWorkPhaseRequiresAnOperationID(t *testing.T) {
s, id := phaseStore(t) s, id := phaseStore(t)
lease(t, s, id) lease(t, s, id)
_, err := RequestWorkPhase(s, registry.Project{ID: "p"}, id, epochOf(t, s, id), "", domain.WorkPhaseFrame, domain.WorkPhaseResearch, nil) _, err := RequestWorkPhase(s, registry.Project{ID: "p", Verification: registry.VerificationPolicy{Allowed: [][]string{{"go", "test", "*"}}}}, id, epochOf(t, s, id), "", domain.WorkPhaseFrame, domain.WorkPhaseResearch, nil)
if !errors.Is(err, domain.ErrInvalid) { if !errors.Is(err, domain.ErrInvalid) {
t.Fatalf("err = %v", err) t.Fatalf("err = %v", err)
} }
@@ -115,7 +115,7 @@ func TestRequestWorkPhaseRequiresAnOperationID(t *testing.T) {
// the same request and must not advance the phase a second time. // the same request and must not advance the phase a second time.
func TestRequestWorkPhaseIsIdempotentPerOperationID(t *testing.T) { func TestRequestWorkPhaseIsIdempotentPerOperationID(t *testing.T) {
s, id := phaseStore(t) s, id := phaseStore(t)
project := registry.Project{ID: "p"} project := registry.Project{ID: "p", Verification: registry.VerificationPolicy{Allowed: [][]string{{"go", "test", "*"}}}}
lease(t, s, id) lease(t, s, id)
epoch := epochOf(t, s, id) epoch := epochOf(t, s, id)
@@ -149,7 +149,7 @@ func TestRequestWorkPhaseIsIdempotentPerOperationID(t *testing.T) {
func TestRequestWorkPhaseRecordsTheOperationID(t *testing.T) { func TestRequestWorkPhaseRecordsTheOperationID(t *testing.T) {
s, id := phaseStore(t) s, id := phaseStore(t)
lease(t, s, id) lease(t, s, id)
e, err := RequestWorkPhase(s, registry.Project{ID: "p"}, id, epochOf(t, s, id), "op-1", domain.WorkPhaseFrame, domain.WorkPhaseResearch, nil) e, err := RequestWorkPhase(s, registry.Project{ID: "p", Verification: registry.VerificationPolicy{Allowed: [][]string{{"go", "test", "*"}}}}, id, epochOf(t, s, id), "op-1", domain.WorkPhaseFrame, domain.WorkPhaseResearch, nil)
if err != nil { if err != nil {
t.Fatal(err) t.Fatal(err)
} }
-1
View File
@@ -316,4 +316,3 @@ func assertNoMismatchRecorded(t *testing.T, s *store.Store, id string) {
} }
} }
} }
+5 -5
View File
@@ -50,7 +50,7 @@ func finding(id string, sev review.Severity) review.Finding {
// Minor findings are reported and the task stays eligible. The review is bound // Minor findings are reported and the task stays eligible. The review is bound
// to the commit it examined. // to the commit it examined.
func TestMinorOnlyReviewIsAccepted(t *testing.T) { func TestMinorOnlyReviewIsAccepted(t *testing.T) {
project := registry.Project{ID: "p", QualityGate: "go test ./..."} project := registry.Project{ID: "p", QualityGate: "go test ./...", Verification: registry.VerificationPolicy{Allowed: [][]string{{"go", "test", "*"}}}}
s, id := atImplement(t, project) s, id := atImplement(t, project)
if _, err := EnterReview(s, project, id, evidence(shaA)); err != nil { if _, err := EnterReview(s, project, id, evidence(shaA)); err != nil {
t.Fatal(err) t.Fatal(err)
@@ -77,7 +77,7 @@ func TestMinorOnlyReviewIsAccepted(t *testing.T) {
// A blocking finding returns the task to implementation, and the old review // A blocking finding returns the task to implementation, and the old review
// cannot satisfy the new commit. // cannot satisfy the new commit.
func TestBlockingReviewReturnsWorkAndGoesStale(t *testing.T) { func TestBlockingReviewReturnsWorkAndGoesStale(t *testing.T) {
project := registry.Project{ID: "p", QualityGate: "go test ./..."} project := registry.Project{ID: "p", QualityGate: "go test ./...", Verification: registry.VerificationPolicy{Allowed: [][]string{{"go", "test", "*"}}}}
s, id := atImplement(t, project) s, id := atImplement(t, project)
if _, err := EnterReview(s, project, id, evidence(shaA)); err != nil { if _, err := EnterReview(s, project, id, evidence(shaA)); err != nil {
t.Fatal(err) t.Fatal(err)
@@ -119,7 +119,7 @@ func TestBlockingReviewReturnsWorkAndGoesStale(t *testing.T) {
// A review sealed against a commit other than the one under review is // A review sealed against a commit other than the one under review is
// rejected while the reviewing session still exists to redo it. // rejected while the reviewing session still exists to redo it.
func TestReviewForTheWrongCommitIsRejected(t *testing.T) { func TestReviewForTheWrongCommitIsRejected(t *testing.T) {
project := registry.Project{ID: "p", QualityGate: "go test ./..."} project := registry.Project{ID: "p", QualityGate: "go test ./...", Verification: registry.VerificationPolicy{Allowed: [][]string{{"go", "test", "*"}}}}
s, id := atImplement(t, project) s, id := atImplement(t, project)
if _, err := EnterReview(s, project, id, evidence(shaA)); err != nil { if _, err := EnterReview(s, project, id, evidence(shaA)); err != nil {
t.Fatal(err) t.Fatal(err)
@@ -139,7 +139,7 @@ func TestReviewForTheWrongCommitIsRejected(t *testing.T) {
} }
func TestReviewEntryConditions(t *testing.T) { func TestReviewEntryConditions(t *testing.T) {
project := registry.Project{ID: "p", QualityGate: "go test ./..."} project := registry.Project{ID: "p", QualityGate: "go test ./...", Verification: registry.VerificationPolicy{Allowed: [][]string{{"go", "test", "*"}}}}
// Wrong phase. // Wrong phase.
s, id := phaseStore(t) s, id := phaseStore(t)
@@ -175,7 +175,7 @@ func TestReviewEntryConditions(t *testing.T) {
// A review can only be sealed by a reviewing session, and only in a shape that // A review can only be sealed by a reviewing session, and only in a shape that
// is actually reviewable. // is actually reviewable.
func TestReviewResultRejections(t *testing.T) { func TestReviewResultRejections(t *testing.T) {
project := registry.Project{ID: "p", QualityGate: "go test ./..."} project := registry.Project{ID: "p", QualityGate: "go test ./...", Verification: registry.VerificationPolicy{Allowed: [][]string{{"go", "test", "*"}}}}
s, id := atImplement(t, project) s, id := atImplement(t, project)
// Not in the review phase yet. // Not in the review phase yet.
+1 -1
View File
@@ -52,7 +52,7 @@ func gate(sha string) domain.GateResult {
// reviewed walks a task to a reviewed state at one commit. // reviewed walks a task to a reviewed state at one commit.
func reviewed(t *testing.T, findings ...review.Finding) (*store.Store, string, registry.Project) { func reviewed(t *testing.T, findings ...review.Finding) (*store.Store, string, registry.Project) {
t.Helper() t.Helper()
project := registry.Project{ID: "p", QualityGate: "go test ./..."} project := registry.Project{ID: "p", QualityGate: "go test ./...", Verification: registry.VerificationPolicy{Allowed: [][]string{{"go", "test", "*"}}}}
s, id := atImplement(t, project) s, id := atImplement(t, project)
if _, err := EnterReview(s, project, id, evidence(shaA)); err != nil { if _, err := EnterReview(s, project, id, evidence(shaA)); err != nil {
t.Fatal(err) t.Fatal(err)
+2 -2
View File
@@ -13,7 +13,7 @@ import (
) )
func gatedProject() registry.Project { func gatedProject() registry.Project {
return registry.Project{ID: "p", TrajectoryGate: map[string]string{"plan_to_implement": "required"}} return registry.Project{ID: "p", Verification: registry.VerificationPolicy{Allowed: [][]string{{"go", "test", "*"}}}, TrajectoryGate: map[string]string{"plan_to_implement": "required"}}
} }
func humanReply(t *testing.T, s *store.Store, taskID, id, value string) { func humanReply(t *testing.T, s *store.Store, taskID, id, value string) {
@@ -150,7 +150,7 @@ None.
// An ungated project never stops. // An ungated project never stops.
func TestUngatedProjectAdvances(t *testing.T) { func TestUngatedProjectAdvances(t *testing.T) {
s, id := phaseStore(t) s, id := phaseStore(t)
project := registry.Project{ID: "p"} project := registry.Project{ID: "p", Verification: registry.VerificationPolicy{Allowed: [][]string{{"go", "test", "*"}}}}
if _, err := AdvanceWorkPhase(s, project, id, nil); err != nil { if _, err := AdvanceWorkPhase(s, project, id, nil); err != nil {
t.Fatal(err) t.Fatal(err)
} }
+23
View File
@@ -84,6 +84,15 @@ func advanceWorkPhase(s *store.Store, project registry.Project, taskID string, a
if err := resolvePlanReferences(s, t, doc); err != nil { if err := resolvePlanReferences(s, t, doc); err != nil {
return domain.Event{}, err return domain.Event{}, err
} }
// The project decides what a plan command may execute, and the
// brief promises the planner it learns that at seal time. Run 9
// sealed ["bash", "scripts/test_healthcheck.sh"] against a policy
// that allows neither shape: the only check lived in
// PlanPhaseCommands, which runs when the implementer asks to
// verify, one phase and one session too late.
if err := resolvePlanCommands(project, doc); err != nil {
return domain.Event{}, err
}
} }
ref, err := s.PutArtifact(artifact) ref, err := s.PutArtifact(artifact)
if err != nil { if err != nil {
@@ -180,6 +189,20 @@ func phaseOperation(s *store.Store, taskID, operationID string) (domain.Event, b
return domain.Event{}, false return domain.Event{}, false
} }
// resolvePlanCommands refuses a plan whose automated checks fall outside the
// project's verification policy. It fails on the planner, whose session is
// still alive to correct it, rather than on the implementer that inherits it.
func resolvePlanCommands(project registry.Project, doc workphase.PlanDoc) error {
for _, phase := range doc.Phases {
for _, argv := range phase.Automated {
if allowed, why := project.Verification.Allows(argv); !allowed {
return fmt.Errorf("%w: %s verification: %s", domain.ErrInvalid, phase.ID, why)
}
}
}
return nil
}
// resolvePlanReferences refuses a plan that cites research the task never // resolvePlanReferences refuses a plan that cites research the task never
// sealed. Its cost is one CAS read against a ref the coordinator already // sealed. Its cost is one CAS read against a ref the coordinator already
// holds. // holds.
+42 -4
View File
@@ -3,6 +3,7 @@ package operations
import ( import (
"encoding/json" "encoding/json"
"errors" "errors"
"strings"
"testing" "testing"
"time" "time"
@@ -47,6 +48,7 @@ func sealed(t *testing.T, v interface{ Validate() error }) []byte {
} }
var research = workphase.Research{Findings: []workphase.Finding{{ID: "r1", Confidence: workphase.Fact, Claim: "runs per figure", Evidence: "attr.go:88"}}} var research = workphase.Research{Findings: []workphase.Finding{{ID: "r1", Confidence: workphase.Fact, Claim: "runs per figure", Evidence: "attr.go:88"}}}
// planDoc is a real sealed specification. It cites research:r1, which the // planDoc is a real sealed specification. It cites research:r1, which the
// research fixture above contains, so the reference check has something to // research fixture above contains, so the reference check has something to
// resolve. // resolve.
@@ -94,7 +96,7 @@ None.
func TestFullPhasePathSealsEachArtifact(t *testing.T) { func TestFullPhasePathSealsEachArtifact(t *testing.T) {
s, id := phaseStore(t) s, id := phaseStore(t)
project := registry.Project{ID: "p"} project := registry.Project{ID: "p", Verification: registry.VerificationPolicy{Allowed: [][]string{{"go", "test", "*"}}}}
// frame -> research needs no artifact: framing produces none. // frame -> research needs no artifact: framing produces none.
if _, err := AdvanceWorkPhase(s, project, id, nil); err != nil { if _, err := AdvanceWorkPhase(s, project, id, nil); err != nil {
@@ -154,7 +156,7 @@ func TestFullPhasePathSealsEachArtifact(t *testing.T) {
// A project that declares a short path skips the phases it omits. // A project that declares a short path skips the phases it omits.
func TestProjectPathSkipsUndeclaredPhases(t *testing.T) { func TestProjectPathSkipsUndeclaredPhases(t *testing.T) {
s, id := phaseStore(t) s, id := phaseStore(t)
project := registry.Project{ID: "p", WorkPhases: []domain.WorkPhase{domain.WorkPhaseFrame, domain.WorkPhaseImplement, domain.WorkPhaseReview}} project := registry.Project{ID: "p", Verification: registry.VerificationPolicy{Allowed: [][]string{{"go", "test", "*"}}}, WorkPhases: []domain.WorkPhase{domain.WorkPhaseFrame, domain.WorkPhaseImplement, domain.WorkPhaseReview}}
if _, err := AdvanceWorkPhase(s, project, id, nil); err != nil { if _, err := AdvanceWorkPhase(s, project, id, nil); err != nil {
t.Fatal(err) t.Fatal(err)
} }
@@ -180,7 +182,7 @@ func TestIllegalTransitionRejectedAtTheAppendBoundary(t *testing.T) {
func TestEndOfPathIsRefused(t *testing.T) { func TestEndOfPathIsRefused(t *testing.T) {
s, id := phaseStore(t) s, id := phaseStore(t)
project := registry.Project{ID: "p", WorkPhases: []domain.WorkPhase{domain.WorkPhaseFrame}} project := registry.Project{ID: "p", Verification: registry.VerificationPolicy{Allowed: [][]string{{"go", "test", "*"}}}, WorkPhases: []domain.WorkPhase{domain.WorkPhaseFrame}}
if _, err := AdvanceWorkPhase(s, project, id, nil); !errors.Is(err, domain.ErrInvalid) { if _, err := AdvanceWorkPhase(s, project, id, nil); !errors.Is(err, domain.ErrInvalid) {
t.Fatalf("want ErrInvalid at the end of the path, got %v", err) t.Fatalf("want ErrInvalid at the end of the path, got %v", err)
} }
@@ -192,7 +194,7 @@ func TestPhaseChangeDoesNotTouchLifecycle(t *testing.T) {
t.Fatal(err) t.Fatal(err)
} }
before, _ := s.Task(id) before, _ := s.Task(id)
if _, err := AdvanceWorkPhase(s, registry.Project{ID: "p"}, id, nil); err != nil { if _, err := AdvanceWorkPhase(s, registry.Project{ID: "p", Verification: registry.VerificationPolicy{Allowed: [][]string{{"go", "test", "*"}}}}, id, nil); err != nil {
t.Fatal(err) t.Fatal(err)
} }
after, _ := s.Task(id) after, _ := s.Task(id)
@@ -206,3 +208,39 @@ func TestPhaseChangeDoesNotTouchLifecycle(t *testing.T) {
t.Fatalf("phase = %q", after.WorkPhase) t.Fatalf("phase = %q", after.WorkPhase)
} }
} }
// The brief promises the planner that a command outside the project's policy
// is refused when the plan seals. Run 9 sealed
// ["bash", "scripts/test_healthcheck.sh"] against a policy allowing neither
// shape: the only check lived in PlanPhaseCommands, one phase too late.
func TestPlanSealRefusesACommandOutsideProjectPolicy(t *testing.T) {
s, id := phaseStore(t)
project := registry.Project{ID: "p", Verification: registry.VerificationPolicy{Allowed: [][]string{{"go", "vet", "./..."}}}}
if _, err := AdvanceWorkPhase(s, project, id, nil); err != nil {
t.Fatal(err)
}
if _, err := AdvanceWorkPhase(s, project, id, sealed(t, research)); err != nil {
t.Fatal(err)
}
_, err := AdvanceWorkPhase(s, project, id, planDoc)
if !errors.Is(err, domain.ErrInvalid) {
t.Fatalf("a plan command outside policy must be refused at seal, got %v", err)
}
for _, want := range []string{"phase-1", "go test"} {
if !strings.Contains(err.Error(), want) {
t.Errorf("refusal never names %q: %v", want, err)
}
}
if got, _ := s.Task(id); got.PlanRef != "" || got.WorkPhase != domain.WorkPhasePlan {
t.Fatalf("a refused plan was sealed anyway: %+v", got)
}
// The same plan seals once the project allows the command.
project.Verification.Allowed = append(project.Verification.Allowed, []string{"go", "test", "*"})
if _, err := AdvanceWorkPhase(s, project, id, planDoc); err != nil {
t.Fatal(err)
}
if got, _ := s.Task(id); got.PlanRef == "" {
t.Fatal("an allowed plan did not seal")
}
}