From 8c410b9ea47749db839143276b8ca0c2f27bb531 Mon Sep 17 00:00:00 2001 From: agent-exec-transport Date: Tue, 20 Oct 2026 15:59:51 +0000 Subject: [PATCH] agent-exec transport 412eff9b36afd457500606d468b89f94/0636a7e1c0f0 --- cmd/storage-migrate/integration_test.go | 10 + internal/app/project_source_recheck_test.go | 97 +++++ internal/runmgr/project_context.go | 24 +- internal/runmgr/project_context_test.go | 2 +- .../runmgr/project_source_recheck_test.go | 349 ++++++++++++++++++ internal/store/context_manifest.go | 24 ++ internal/store/context_manifest_gate_test.go | 45 +++ 7 files changed, 549 insertions(+), 2 deletions(-) create mode 100644 internal/app/project_source_recheck_test.go create mode 100644 internal/runmgr/project_source_recheck_test.go create mode 100644 internal/store/context_manifest_gate_test.go diff --git a/cmd/storage-migrate/integration_test.go b/cmd/storage-migrate/integration_test.go index efc41d5..8ad3b04 100644 --- a/cmd/storage-migrate/integration_test.go +++ b/cmd/storage-migrate/integration_test.go @@ -69,6 +69,16 @@ func TestMigrateTrustedContextManifestWithoutRawScope(t *testing.T) { if err := pg.DB().QueryRow(`SELECT COUNT(*) FROM information_schema.columns WHERE table_schema=current_schema() AND table_name='context_manifests' AND column_name IN ('resolve_goal','resolve_target_id')`).Scan(&rawColumns); err != nil || rawColumns != 0 { t.Fatalf("migrated PG schema has raw prompt columns: count=%d err=%v", rawColumns, err) } + if err := cmdMigrate([]string{"--source", snapshot, "--target", target, "--migration-id", "context-copy"}); err != nil { + t.Fatalf("idempotent context migration rerun: %v", err) + } + receipts, err = pg.ListContextManifests("alice", "r1") + if err != nil || len(receipts) != 2 { + t.Fatalf("rerun duplicated or lost model receipts: count=%d err=%v", len(receipts), err) + } + if err := pg.DB().QueryRow(`SELECT COUNT(*) FROM information_schema.columns WHERE table_schema=current_schema() AND table_name='context_manifests' AND column_name IN ('resolve_goal','resolve_target_id')`).Scan(&rawColumns); err != nil || rawColumns != 0 { + t.Fatalf("rerun reintroduced raw prompt columns: count=%d err=%v", rawColumns, err) + } } // U4 集成(环境门控 AGENT_PG_TEST_DSN:CI test-pg job / 本机隔离实例): diff --git a/internal/app/project_source_recheck_test.go b/internal/app/project_source_recheck_test.go new file mode 100644 index 0000000..6f7ae57 --- /dev/null +++ b/internal/app/project_source_recheck_test.go @@ -0,0 +1,97 @@ +package app + +import ( + "context" + "errors" + "testing" + "time" + + "agent-system-backend/pkg/extension" +) + +type scriptedRecheckProvider struct { + testProjectContextProvider + recheck func(context.Context, extension.ProjectContextSourceRecheckRequest) (extension.ProjectContextSourceRecheckResult, error) +} + +func (p scriptedRecheckProvider) Recheck(ctx context.Context, req extension.ProjectContextSourceRecheckRequest) (extension.ProjectContextSourceRecheckResult, error) { + return p.recheck(ctx, req) +} + +func registeredRecheckSlot(t *testing.T, recheck func(context.Context, extension.ProjectContextSourceRecheckRequest) (extension.ProjectContextSourceRecheckResult, error)) *projectContextSlot { + t.Helper() + slot := &projectContextSlot{} + if err := (pluginRegistry{pluginID: "rdm", caps: hostcaps{contexts: slot}}).RegisterProjectContextProvider(scriptedRecheckProvider{recheck: recheck}); err != nil { + t.Fatal(err) + } + return slot +} + +func TestProjectSourceRecheckFailsClosedAtThreeSecondCeiling(t *testing.T) { + var remaining time.Duration + slot := registeredRecheckSlot(t, func(ctx context.Context, req extension.ProjectContextSourceRecheckRequest) (extension.ProjectContextSourceRecheckResult, error) { + deadline, ok := ctx.Deadline() + if !ok { + return extension.ProjectContextSourceRecheckResult{}, errors.New("unbounded recheck") + } + remaining = time.Until(deadline) + select { + case <-ctx.Done(): + case <-time.After(10 * time.Second): + } + // A late success must not authorize old machine context. + return extension.ProjectContextSourceRecheckResult{Bundle: extension.ProjectContextBundle{Status: extension.ProjectContextReady, ProjectID: req.Scope.ProjectID}}, nil + }) + started := time.Now() + _, err := slot.recheck(context.Background(), extension.ProjectContextSourceRecheckRequest{Scope: extension.ProjectContextRequest{Actor: "alice", ProjectID: "p1"}}) + if err == nil { + t.Fatal("source recheck past its deadline returned authorized context") + } + if remaining <= 0 || remaining > 3*time.Second { + t.Fatalf("source recheck deadline is not bounded by 3s: %v", remaining) + } + if elapsed := time.Since(started); elapsed > 5*time.Second { + t.Fatalf("source recheck outlived its 3s ceiling: %v", elapsed) + } +} + +func TestProjectSourceRecheckRejectsProviderRefusalAndForeignBundles(t *testing.T) { + scope := extension.ProjectContextRequest{Actor: "alice", ProjectID: "p1"} + answers := map[string]func(extension.ProjectContextSourceRecheckRequest) (extension.ProjectContextSourceRecheckResult, error){ + "refused": func(extension.ProjectContextSourceRecheckRequest) (extension.ProjectContextSourceRecheckResult, error) { + return extension.ProjectContextSourceRecheckResult{}, errors.New("provider refused") + }, + "denied": func(req extension.ProjectContextSourceRecheckRequest) (extension.ProjectContextSourceRecheckResult, error) { + return extension.ProjectContextSourceRecheckResult{Bundle: extension.ProjectContextBundle{Status: extension.ProjectContextDenied, ProjectID: req.Scope.ProjectID}}, nil + }, + "incomplete": func(req extension.ProjectContextSourceRecheckRequest) (extension.ProjectContextSourceRecheckResult, error) { + return extension.ProjectContextSourceRecheckResult{Bundle: extension.ProjectContextBundle{Status: extension.ProjectContextIncomplete, ProjectID: req.Scope.ProjectID}}, nil + }, + "other project": func(extension.ProjectContextSourceRecheckRequest) (extension.ProjectContextSourceRecheckResult, error) { + return extension.ProjectContextSourceRecheckResult{Bundle: extension.ProjectContextBundle{Status: extension.ProjectContextReady, ProjectID: "p2"}}, nil + }, + } + for name, answer := range answers { + slot := registeredRecheckSlot(t, func(_ context.Context, req extension.ProjectContextSourceRecheckRequest) (extension.ProjectContextSourceRecheckResult, error) { + return answer(req) + }) + if _, err := slot.recheck(context.Background(), extension.ProjectContextSourceRecheckRequest{Scope: scope}); err == nil { + t.Fatalf("%s source recheck accepted", name) + } + } + var sawPlugin string + slot := registeredRecheckSlot(t, func(_ context.Context, req extension.ProjectContextSourceRecheckRequest) (extension.ProjectContextSourceRecheckResult, error) { + sawPlugin = req.Scope.PluginID + return extension.ProjectContextSourceRecheckResult{Bundle: extension.ProjectContextBundle{Status: extension.ProjectContextReady, ProjectID: req.Scope.ProjectID}}, nil + }) + forged := scope + forged.PluginID = "forged-plugin" + if _, err := slot.recheck(context.Background(), extension.ProjectContextSourceRecheckRequest{Scope: forged}); err != nil || sawPlugin != "rdm" { + t.Fatalf("recheck scope plugin was not the registered holder: plugin=%q err=%v", sawPlugin, err) + } + for _, missing := range []extension.ProjectContextRequest{{ProjectID: "p1"}, {Actor: "alice"}} { + if _, err := slot.recheck(context.Background(), extension.ProjectContextSourceRecheckRequest{Scope: missing}); err == nil { + t.Fatalf("recheck without trusted actor/project accepted: %+v", missing) + } + } +} diff --git a/internal/runmgr/project_context.go b/internal/runmgr/project_context.go index 11c02b8..9c736d2 100644 --- a/internal/runmgr/project_context.go +++ b/internal/runmgr/project_context.go @@ -109,6 +109,9 @@ func (c *runCtx) recheckManifest(ctx context.Context, m extension.ProjectContext if err != nil { return err } + if err := ctx.Err(); err != nil { + return err + } current := result.Bundle expectedRefs := map[string]extension.ProjectEvidenceRef{} for _, ref := range m.OfferedReferences { @@ -127,6 +130,25 @@ func (c *runCtx) recheckManifest(ctx context.Context, m extension.ProjectContext if len(current.Items) != len(m.Included) || len(current.Evidence) != len(expectedRefs) || !slices.Equal(current.Missing, m.Missing) { return errors.New("project source recheck returned an incomplete source set") } + // Each returned item must account for exactly one recorded input; the hash + // is recomputed here, never taken from the provider's echo of the request. + claimed := make([]bool, len(m.Included)) + for _, item := range current.Items { + matched := false + for i, used := range m.Included { + if claimed[i] || item.SourceID != used.SourceID || item.Revision != used.Revision || item.Hash != used.SourceHash { + continue + } + if fmt.Sprintf("%x", sha256.Sum256([]byte(item.Text))) != used.InputSHA256 { + return fmt.Errorf("project context source %q text changed under its recorded identity", used.SourceID) + } + claimed[i], matched = true, true + break + } + if !matched { + return errors.New("project source recheck returned an unrelated or duplicate source") + } + } seenRefs := map[string]bool{} for _, ref := range current.Evidence { if ref != expectedRefs[ref.ID] || seenRefs[ref.ID] { @@ -158,7 +180,7 @@ func (c *runCtx) recheckManifest(ctx context.Context, m extension.ProjectContext return errors.New("project evidence permission or content changed") } } - return nil + return ctx.Err() } func (c *runCtx) contextRequest() extension.ProjectContextRequest { diff --git a/internal/runmgr/project_context_test.go b/internal/runmgr/project_context_test.go index 3e7b938..e5f0edd 100644 --- a/internal/runmgr/project_context_test.go +++ b/internal/runmgr/project_context_test.go @@ -466,7 +466,7 @@ func TestContextManifestPersistsByOwnerAndRun(t *testing.T) { conv := recoveryConversation(t, st) run := recoveryRun(t, st, conv, core.RunPending) rc := &runCtx{mgr: m, run: run, ctx: context.Background()} - manifest := extension.ProjectContextManifest{ProjectID: run.ProjectID, Version: "v3", Model: "model-1", AttemptID: "attempt-1", Delivery: "attempted", InputSHA256: "abc", Included: []extension.ProjectContextManifestEntry{{SourceID: "s1", Revision: "r2", InputSHA256: "def"}}} + manifest := extension.ProjectContextManifest{ProjectID: run.ProjectID, Version: "v3", Model: "model-1", AttemptID: "attempt-1", Delivery: "attempted", InputSHA256: "abc", Included: []extension.ProjectContextManifestEntry{{SourceID: "s1", Revision: "r2", SourceHash: "h2", InputSHA256: "def"}}} if err := rc.SaveContextManifest(context.Background(), manifest); err != nil { t.Fatal(err) } diff --git a/internal/runmgr/project_source_recheck_test.go b/internal/runmgr/project_source_recheck_test.go new file mode 100644 index 0000000..471e0cf --- /dev/null +++ b/internal/runmgr/project_source_recheck_test.go @@ -0,0 +1,349 @@ +package runmgr + +import ( + "context" + "crypto/sha256" + "encoding/json" + "errors" + "fmt" + "slices" + "strings" + "testing" + "time" + + "agent-system-backend/internal/projectcontext" + "agent-system-backend/pkg/extension" + core "agent-system-core" +) + +type recheckFixture struct { + rc *runCtx + mgr *Manager + run core.Run + receipt extension.ProjectContextManifest + current extension.ProjectContextBundle + ref extension.ProjectEvidenceRef +} + +func newRecheckFixture(t *testing.T) recheckFixture { + t.Helper() + st := openRecoveryStore(t) + mgr := newRecoveryManager(t, st) + conv := recoveryConversation(t, st) + run := recoveryRun(t, st, conv, core.RunPending) + mgr.SetProjectWorkAuthorization(func(context.Context, string, core.ID) (bool, error) { return true, nil }) + item := extension.ProjectContextItem{SourceID: "source-a", Revision: "r1", Hash: "ha", Text: "AUTHORIZED_A", Required: true} + ref := extension.ProjectEvidenceRef{ID: "ref-a", SourceID: "doc-a", Revision: "r1", Hash: "hd"} + bundle := extension.ProjectContextBundle{Status: extension.ProjectContextReady, ProjectID: run.ProjectID, Version: "v1", Watermark: "w1", + AuthorizationEpoch: "duty-1", PublicationEpoch: "pub-1", Items: []extension.ProjectContextItem{item}, Evidence: []extension.ProjectEvidenceRef{ref}} + _, receipt, err := projectcontext.Assemble(bundle, 900) + if err != nil { + t.Fatal(err) + } + receipt.Delivery = "responded" + receipt.ReadEvidence = []extension.ProjectContextManifestEntry{{ReferenceID: ref.ID, SourceID: ref.SourceID, Revision: ref.Revision, SourceHash: ref.Hash, + InputSHA256: fmt.Sprintf("%x", sha256.Sum256([]byte("DETAIL_A")))}} + mgr.SetProjectEvidenceReader(func(_ context.Context, req extension.ProjectEvidenceRequest) (extension.ProjectEvidence, error) { + return extension.ProjectEvidence{Reference: req.Reference, Text: "DETAIL_A"}, nil + }) + return recheckFixture{rc: &runCtx{mgr: mgr, run: run, ctx: context.Background()}, mgr: mgr, run: run, receipt: receipt, current: bundle, ref: ref} +} + +func (f recheckFixture) answer(bundle extension.ProjectContextBundle) { + f.mgr.SetProjectContextSourceRechecker(func(context.Context, extension.ProjectContextSourceRecheckRequest) (extension.ProjectContextSourceRecheckResult, error) { + return extension.ProjectContextSourceRecheckResult{Bundle: bundle}, nil + }) +} + +func TestProjectSourceRecheckRequestCarriesOnlyTrustedIdentities(t *testing.T) { + f := newRecheckFixture(t) + f.mgr.SetProjectContextResolver(func(_ context.Context, req extension.ProjectContextRequest) (extension.ProjectContextBundle, error) { + return f.current, nil + }) + // A live request goal must not leak into the later source recheck. + if _, _, err := f.rc.ProjectContext(context.Background(), "SECRET_GOAL_TEXT", 900); err != nil { + t.Fatal(err) + } + var got extension.ProjectContextSourceRecheckRequest + f.mgr.SetProjectContextSourceRechecker(func(_ context.Context, req extension.ProjectContextSourceRecheckRequest) (extension.ProjectContextSourceRecheckResult, error) { + got = req + return extension.ProjectContextSourceRecheckResult{Bundle: f.current}, nil + }) + if err := f.rc.recheckManifest(context.Background(), f.receipt, "draft"); err != nil { + t.Fatalf("authorized recheck rejected: %v", err) + } + scope := got.Scope + if scope.Actor != string(f.run.Owner) || scope.ProjectID != f.run.ProjectID || scope.ConversationID != f.run.ConversationID || + scope.RunID != f.run.ID || scope.StepID != "draft" || scope.Goal != "" || scope.TargetID != "" { + t.Fatalf("recheck scope is not the persisted run identity: %+v", scope) + } + if len(got.Included) != 1 || got.Included[0] != f.receipt.Included[0] || len(got.OfferedReferences) != 1 || got.OfferedReferences[0] != f.ref || + len(got.ReadEvidence) != 1 || got.ReadEvidence[0] != f.receipt.ReadEvidence[0] { + t.Fatalf("recheck request does not name exactly the recorded inputs: %+v", got) + } + raw, err := json.Marshal(got) + if err != nil { + t.Fatal(err) + } + for _, body := range []string{"SECRET_GOAL_TEXT", "AUTHORIZED_A", "DETAIL_A"} { + if strings.Contains(string(raw), body) { + t.Fatalf("recheck request carried body text %q", body) + } + } +} + +func TestProjectSourceRecheckRejectsUnrelatedOrDuplicateSource(t *testing.T) { + f := newRecheckFixture(t) + receipt := f.receipt + receipt.Included = append(receipt.Included, receipt.Included[0]) + padded := f.current + padded.Items = []extension.ProjectContextItem{f.current.Items[0], {SourceID: "source-x", Revision: "r9", Hash: "hx", Text: "UNRELATED"}} + f.answer(padded) + if err := f.rc.recheckManifest(context.Background(), receipt, "draft"); err == nil { + t.Fatal("unrelated source filled the slot of a recorded input") + } + echoed := f.current + echoed.Items = []extension.ProjectContextItem{{SourceID: "source-a", Revision: "r1", Hash: "ha", Text: "REPLACED"}} + f.answer(echoed) + if err := f.rc.recheckManifest(context.Background(), f.receipt, "draft"); err == nil { + t.Fatal("provider echoed recorded identity over replaced text") + } + republished := f.current + republished.PublicationEpoch = "pub-2" + f.answer(republished) + if err := f.rc.recheckManifest(context.Background(), f.receipt, "draft"); err == nil { + t.Fatal("publication change accepted") + } +} + +func TestProjectSourceRecheckFailsClosedOnRefusalDenialAndCancellation(t *testing.T) { + f := newRecheckFixture(t) + f.answer(f.current) + if err := f.rc.recheckManifest(context.Background(), f.receipt, "draft"); err != nil { + t.Fatalf("authorized baseline rejected: %v", err) + } + f.mgr.SetProjectContextSourceRechecker(func(context.Context, extension.ProjectContextSourceRecheckRequest) (extension.ProjectContextSourceRecheckResult, error) { + return extension.ProjectContextSourceRecheckResult{Bundle: f.current}, errors.New("provider refused") + }) + if err := f.rc.recheckManifest(context.Background(), f.receipt, "draft"); err == nil { + t.Fatal("provider refusal accepted") + } + denied := f.current + denied.Status = extension.ProjectContextDenied + f.answer(denied) + if err := f.rc.recheckManifest(context.Background(), f.receipt, "draft"); err == nil { + t.Fatal("denied bundle accepted") + } + ctx, cancel := context.WithCancel(context.Background()) + f.mgr.SetProjectContextSourceRechecker(func(context.Context, extension.ProjectContextSourceRecheckRequest) (extension.ProjectContextSourceRecheckResult, error) { + cancel() + return extension.ProjectContextSourceRecheckResult{Bundle: f.current}, nil + }) + if err := f.rc.recheckManifest(ctx, f.receipt, "draft"); !errors.Is(err, context.Canceled) { + t.Fatalf("source recheck cancelled in flight was accepted: %v", err) + } + f.answer(f.current) + ctx, cancel = context.WithCancel(context.Background()) + f.mgr.SetProjectEvidenceReader(func(_ context.Context, req extension.ProjectEvidenceRequest) (extension.ProjectEvidence, error) { + cancel() + return extension.ProjectEvidence{Reference: req.Reference, Text: "DETAIL_A"}, nil + }) + if err := f.rc.recheckManifest(ctx, f.receipt, "draft"); !errors.Is(err, context.Canceled) { + t.Fatalf("evidence re-read cancelled in flight was accepted: %v", err) + } +} + +func TestProjectSourceRecheckConcurrentCancellationFailsEveryWaiter(t *testing.T) { + f := newRecheckFixture(t) + const workers = 6 + entered := make(chan struct{}, workers) + release := make(chan struct{}) + f.mgr.SetProjectContextSourceRechecker(func(context.Context, extension.ProjectContextSourceRecheckRequest) (extension.ProjectContextSourceRecheckResult, error) { + entered <- struct{}{} + <-release + return extension.ProjectContextSourceRecheckResult{Bundle: f.current}, nil + }) + ctx, cancel := context.WithCancel(context.Background()) + errs := make(chan error, workers) + for i := 0; i < workers; i++ { + go func() { errs <- f.rc.recheckManifest(ctx, f.receipt, "draft") }() + } + timeout := time.After(10 * time.Second) + for i := 0; i < workers; i++ { + select { + case <-entered: + case <-timeout: + t.Fatal("concurrent rechecks did not reach the provider") + } + } + cancel() + close(release) + for i := 0; i < workers; i++ { + if err := <-errs; !errors.Is(err, context.Canceled) { + t.Fatalf("concurrent recheck survived cancellation: %v", err) + } + } +} + +func TestProjectCrossGuardRechecksEarlierRoundAfterLaterSource(t *testing.T) { + st := openRecoveryStore(t) + mgr := newRecoveryManager(t, st) + conv := recoveryConversation(t, st) + run := recoveryRun(t, st, conv, core.RunPending) + mgr.SetProjectWorkAuthorization(func(context.Context, string, core.ID) (bool, error) { return true, nil }) + items := map[string]extension.ProjectContextItem{ + "source-a": {SourceID: "source-a", Revision: "r1", Hash: "ha", Text: "AUTHORIZED_A", Required: true}, + "source-b": {SourceID: "source-b", Revision: "r1", Hash: "hb", Text: "PUBLIC_B", Required: true}, + } + withdrawn := map[string]bool{} + bundleWith := func(projectID core.ID, sources ...string) extension.ProjectContextBundle { + b := extension.ProjectContextBundle{Status: extension.ProjectContextReady, ProjectID: projectID, AuthorizationEpoch: "duty-1", PublicationEpoch: "pub-1", Watermark: "w1"} + for _, id := range sources { + if !withdrawn[id] { + b.Items = append(b.Items, items[id]) + } + } + return b + } + mgr.SetProjectContextResolver(func(_ context.Context, req extension.ProjectContextRequest) (extension.ProjectContextBundle, error) { + if strings.Contains(req.Goal, "use A") { + return bundleWith(req.ProjectID, "source-a"), nil + } + return bundleWith(req.ProjectID, "source-b"), nil + }) + var rechecked []string + mgr.SetProjectContextSourceRechecker(func(_ context.Context, req extension.ProjectContextSourceRecheckRequest) (extension.ProjectContextSourceRecheckResult, error) { + var ids []string + for _, entry := range req.Included { + ids = append(ids, entry.SourceID) + rechecked = append(rechecked, entry.SourceID) + } + return extension.ProjectContextSourceRecheckResult{Bundle: bundleWith(req.Scope.ProjectID, ids...)}, nil + }) + rc := &runCtx{mgr: mgr, run: run, ctx: context.Background()} + respond := func(goal, attempt string) extension.ProjectContextBundle { + t.Helper() + bundle, _, err := rc.ProjectContext(context.Background(), goal, 900) + if err != nil { + t.Fatal(err) + } + _, m, err := projectcontext.Assemble(bundle, 900) + if err != nil { + t.Fatal(err) + } + m.InputSHA256, m.AttemptID, m.Delivery = "input-"+attempt, attempt, "attempted" + if err := rc.SaveContextManifest(context.Background(), m); err != nil { + t.Fatal(err) + } + m.Delivery = "responded" + if err := rc.SaveContextManifest(context.Background(), m); err != nil { + t.Fatal(err) + } + return bundle + } + respond("use A", "round-a") + bundleB := respond("now B", "round-b") + if err := rc.ValidateProjectCrossGuard(context.Background(), bundleB); err != nil { + t.Fatalf("authorized multi-round run rejected: %v", err) + } + slices.Sort(rechecked) + if strings.Join(rechecked, ",") != "source-a,source-b" { + t.Fatalf("cross guard did not recheck every earlier round: %q", rechecked) + } + withdrawn["source-a"] = true + if err := rc.ValidateProjectCrossGuard(context.Background(), bundleB); err == nil { + t.Fatal("withdrawn early-round source A passed because the last round used B") + } +} + +func TestProjectStepOutputRejectsUnsettledAttemptInSameStep(t *testing.T) { + st := openRecoveryStore(t) + m := newRecoveryManager(t, st) + conv := recoveryConversation(t, st) + run := recoveryRun(t, st, conv, core.RunPending) + m.SetProjectWorkAuthorization(func(context.Context, string, core.ID) (bool, error) { return true, nil }) + bundle := func(projectID core.ID) extension.ProjectContextBundle { + return extension.ProjectContextBundle{Status: extension.ProjectContextReady, ProjectID: projectID, AuthorizationEpoch: "duty-1", PublicationEpoch: "pub-1", Watermark: "w1", + Items: []extension.ProjectContextItem{{SourceID: "source-a", Revision: "r1", Hash: "ha", Text: "AUTHORIZED_A", Required: true}}} + } + m.SetProjectContextResolver(func(_ context.Context, req extension.ProjectContextRequest) (extension.ProjectContextBundle, error) { + return bundle(req.ProjectID), nil + }) + m.SetProjectContextSourceRechecker(func(_ context.Context, req extension.ProjectContextSourceRecheckRequest) (extension.ProjectContextSourceRecheckResult, error) { + return extension.ProjectContextSourceRecheckResult{Bundle: bundle(req.Scope.ProjectID)}, nil + }) + rc := &runCtx{mgr: m, run: run, ctx: context.Background()} + started := time.Now().Add(-time.Second) + current, _, err := rc.ProjectContext(context.Background(), "draft", 900) + if err != nil { + t.Fatal(err) + } + _, receipt, err := projectcontext.Assemble(current, 900) + if err != nil { + t.Fatal(err) + } + receipt.InputSHA256, receipt.AttemptID, receipt.WorkflowStepAttemptID = "input-1", "model-1", "engine-run/draft/1" + scoped := projectcontext.WithResolveScope(context.Background(), "draft", "") + for _, delivery := range []string{"attempted", "responded"} { + receipt.Delivery = delivery + if err := rc.SaveContextManifest(scoped, receipt); err != nil { + t.Fatal(err) + } + } + output := []byte("AUTHORIZED_A report") + if err := rc.BindProjectStepOutput(context.Background(), "draft", "engine-run/draft/1", "prompt", started, output); err != nil { + t.Fatal(err) + } + if err := rc.VerifyProjectStepOutput(context.Background(), "draft", "engine-run/draft/1", "prompt", output); err != nil { + t.Fatalf("settled step rejected: %v", err) + } + later := receipt + later.InputSHA256, later.AttemptID, later.Delivery = "input-2", "model-2", "attempted" + if err := rc.SaveContextManifest(scoped, later); err != nil { + t.Fatal(err) + } + if err := rc.VerifyProjectStepOutput(context.Background(), "draft", "engine-run/draft/1", "prompt", output); err == nil { + t.Fatal("step with an unanswered model attempt passed") + } + later.Delivery = "unknown" + if err := rc.SaveContextManifest(scoped, later); err != nil { + t.Fatal(err) + } + if err := rc.VerifyProjectStepOutput(context.Background(), "draft", "engine-run/draft/1", "prompt", output); err == nil { + t.Fatal("step mixing responded and unknown model attempts passed") + } +} + +func TestSaveContextManifestRejectsUnrecheckableModelInput(t *testing.T) { + st := openRecoveryStore(t) + m := newRecoveryManager(t, st) + conv := recoveryConversation(t, st) + run := recoveryRun(t, st, conv, core.RunPending) + rc := &runCtx{mgr: m, run: run, ctx: context.Background()} + base := extension.ProjectContextManifest{ProjectID: run.ProjectID, AttemptID: "attempt-1", Delivery: "attempted", InputSHA256: "request-hash"} + cases := map[string]func(*extension.ProjectContextManifest){ + "included without source hash": func(m *extension.ProjectContextManifest) { + m.Included = []extension.ProjectContextManifestEntry{{SourceID: "s1", Revision: "r1", InputSHA256: "in"}} + }, + "included without revision": func(m *extension.ProjectContextManifest) { + m.Included = []extension.ProjectContextManifestEntry{{SourceID: "s1", SourceHash: "h1", InputSHA256: "in"}} + }, + "offered reference without id": func(m *extension.ProjectContextManifest) { + m.OfferedReferences = []extension.ProjectEvidenceRef{{SourceID: "doc", Revision: "r1", Hash: "h1"}} + }, + "read evidence without reference": func(m *extension.ProjectContextManifest) { + m.ReadEvidence = []extension.ProjectContextManifestEntry{{SourceID: "doc", Revision: "r1", SourceHash: "h1", InputSHA256: "in"}} + }, + } + for name, mutate := range cases { + manifest := base + mutate(&manifest) + if err := rc.SaveContextManifest(context.Background(), manifest); err == nil { + t.Fatalf("%s admitted as recoverable model input", name) + } + } + stored, err := st.ListContextManifests(string(run.Owner), string(run.ID)) + if err != nil || len(stored) != 0 { + t.Fatalf("unrecheckable model input wrote a receipt: %+v err=%v", stored, err) + } +} diff --git a/internal/store/context_manifest.go b/internal/store/context_manifest.go index 48e52a9..d00c8ae 100644 --- a/internal/store/context_manifest.go +++ b/internal/store/context_manifest.go @@ -38,6 +38,9 @@ func (s *Store) appendContextManifest(owner, targetID string, m extension.Projec if m.Delivery != "attempted" && m.Delivery != "responded" && m.Delivery != "unknown" { return errors.New("context manifest has invalid delivery state") } + if err := requireRecheckableSources(m); err != nil { + return err + } raw, err := json.Marshal(m) if err != nil { return err @@ -81,6 +84,27 @@ func (s *Store) appendContextManifest(owner, targetID string, m extension.Projec return tx.Commit() } +// requireRecheckableSources refuses a receipt, and therefore the model request +// it gates, when an actual input could not be reauthorized on later reuse. +func requireRecheckableSources(m extension.ProjectContextManifest) error { + for _, e := range m.Included { + if e.SourceID == "" || e.Revision == "" || e.SourceHash == "" || e.InputSHA256 == "" { + return errors.New("context manifest included source lacks stable identity") + } + } + for _, ref := range m.OfferedReferences { + if ref.ID == "" || ref.SourceID == "" || ref.Revision == "" || ref.Hash == "" { + return errors.New("context manifest offered reference lacks reauthorizable identity") + } + } + for _, e := range m.ReadEvidence { + if e.ReferenceID == "" || e.SourceID == "" || e.Revision == "" || e.SourceHash == "" || e.InputSHA256 == "" { + return errors.New("context manifest read evidence lacks reauthorizable identity") + } + } + return nil +} + // ListContextManifests reads receipts only within the authenticated owner's // scope. The target is a run ID for ordinary work, or a conversation ID for // direct WorkCompletionHost calls. diff --git a/internal/store/context_manifest_gate_test.go b/internal/store/context_manifest_gate_test.go new file mode 100644 index 0000000..1d802f3 --- /dev/null +++ b/internal/store/context_manifest_gate_test.go @@ -0,0 +1,45 @@ +package store + +import ( + "path/filepath" + "testing" + + "agent-system-backend/pkg/extension" +) + +func TestContextManifestRejectsSourcesWithoutStableIdentity(t *testing.T) { + st, err := OpenSQLite(filepath.Join(t.TempDir(), "gate.db")) + if err != nil { + t.Fatal(err) + } + defer st.Close() + complete := extension.ProjectContextManifest{ProjectID: "p1", AttemptID: "model-1", InputSHA256: "request-hash", Delivery: "attempted", + Included: []extension.ProjectContextManifestEntry{{SourceID: "s1", Revision: "r1", SourceHash: "h1", InputSHA256: "in"}}, + OfferedReferences: []extension.ProjectEvidenceRef{{ID: "ref-1", SourceID: "doc", Revision: "r1", Hash: "hd"}}, + ReadEvidence: []extension.ProjectContextManifestEntry{{ReferenceID: "ref-1", SourceID: "doc", Revision: "r1", SourceHash: "hd", InputSHA256: "body"}}} + broken := []func(*extension.ProjectContextManifest){ + func(m *extension.ProjectContextManifest) { m.Included[0].SourceID = "" }, + func(m *extension.ProjectContextManifest) { m.Included[0].SourceHash = "" }, + func(m *extension.ProjectContextManifest) { m.Included[0].InputSHA256 = "" }, + func(m *extension.ProjectContextManifest) { m.OfferedReferences[0].ID = "" }, + func(m *extension.ProjectContextManifest) { m.OfferedReferences[0].Hash = "" }, + func(m *extension.ProjectContextManifest) { m.ReadEvidence[0].ReferenceID = "" }, + func(m *extension.ProjectContextManifest) { m.ReadEvidence[0].Revision = "" }, + } + for i, mutate := range broken { + m := complete + m.Included = append([]extension.ProjectContextManifestEntry(nil), complete.Included...) + m.OfferedReferences = append([]extension.ProjectEvidenceRef(nil), complete.OfferedReferences...) + m.ReadEvidence = append([]extension.ProjectContextManifestEntry(nil), complete.ReadEvidence...) + mutate(&m) + if err := st.AppendContextManifest("alice", "conv-1", m); err == nil { + t.Fatalf("case %d: unrecheckable source admitted as model input", i) + } + } + if got, err := st.ListContextManifests("alice", "conv-1"); err != nil || len(got) != 0 { + t.Fatalf("rejected receipts persisted: %+v err=%v", got, err) + } + if err := st.AppendContextManifest("alice", "conv-1", complete); err != nil { + t.Fatalf("complete source identities rejected: %v", err) + } +}