Why: Shared project reads previously doubled as write permission, project mutations bypassed the domain lifecycle, and coarse retrieval shared private resources. Evidence: SELF red-before-implementation logs cover projection, lifecycle, private work, resource filtering, canonical retrieval scope, publication source and namespace. SELF go test ./..., isolated PostgreSQL go test ./internal/store -count=1, and CGO_ENABLED=0 go build ./cmd/agent-system passed; task owner owns replay/CI. Impact: Add optional actor-scoped projection/lifecycle capabilities; registered providers control public lifecycle and live work. Preserve owned conversation history and ordinary unregistered behavior. Authorize knowledge by stored ID/source, map current host scopes through trusted service metadata, and filter artifact lists. Refs: Dev project-member-access implementation plan r1 Task 1; approved design r0.1; 2026-10-13 trusted publication Source hardening ruling
106 lines
4.5 KiB
Go
106 lines
4.5 KiB
Go
package app
|
|
|
|
import (
|
|
"agent-system-backend/internal/rag"
|
|
"agent-system-backend/internal/settings"
|
|
"agent-system-backend/internal/store"
|
|
"agent-system-backend/pkg/extension"
|
|
core "agent-system-core"
|
|
"context"
|
|
"testing"
|
|
)
|
|
|
|
func TestHostKnowledgeV2AndProjectReplacement(t *testing.T) {
|
|
st, err := store.OpenSQLite(t.TempDir() + "/t.db")
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
defer st.Close()
|
|
pipe := rag.NewPipeline(st, settings.NewService(st), nil, nil)
|
|
auth := &projectAuthz{}
|
|
alice := inprocHost{owner: "alice", pluginID: "rdm", caps: hostcaps{st: st, pipe: pipe, authz: auth}}
|
|
first := []core.KnowledgeDoc{{ID: "slice-1", ProjectID: "domain-1", Ref: "notes.md", Chunk: "sharedneedle one"}}
|
|
second := []core.KnowledgeDoc{{ID: "slice-2", ProjectID: "domain-2", Ref: "notes.md", Chunk: "sharedneedle two"}}
|
|
for _, ds := range [][]core.KnowledgeDoc{first, second, second} {
|
|
if err := alice.KnowledgePublish(context.Background(), ds, false); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
}
|
|
own, err := st.SearchKnowledge("alice", core.RetrievalQuery{Text: "sharedneedle"})
|
|
if err != nil || len(own) != 2 {
|
|
t.Fatalf("cross project publish replaced first: %+v %v", own, err)
|
|
}
|
|
auth.set("rdm", func(string, core.ID) (bool, error) { return true, nil })
|
|
auth.setV2("rdm", func(q extension.ResourceAuthzQuery) (bool, error) {
|
|
return q.ResourceType == "project" || (q.ResourceType == "knowledge" && q.ResourceID == "slice-1" && q.ProjectID == "domain-1" && q.Actor == "bob" && q.Owner == "alice"), nil
|
|
})
|
|
bob := inprocHost{owner: "bob", pluginID: "rdm", caps: alice.caps}
|
|
hits, err := bob.KnowledgeSearch(context.Background(), "", "sharedneedle", 5)
|
|
if err != nil || len(hits) != 1 || hits[0].Doc.ID != "slice-1" {
|
|
t.Fatalf("host V2=%+v %v", hits, err)
|
|
}
|
|
}
|
|
|
|
func TestHostKnowledgeResolvesTrustedProjectScope(t *testing.T) {
|
|
st, err := store.OpenSQLite(t.TempDir() + "/t.db")
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
defer st.Close()
|
|
if err := st.AddKnowledge(core.KnowledgeDoc{ID: "slice-current", Owner: "alice", ProjectID: "domain-current", Chunk: "scopeneedle current"}, core.KnowledgeDoc{ID: "slice-other", Owner: "alice", ProjectID: "domain-other", Chunk: "scopeneedle other"}); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
slot := &projectLifecycleSlot{}
|
|
provider := completeLifecycle()
|
|
provider.List = func(_ context.Context, h extension.Host) ([]core.Project, error) {
|
|
if h.Owner() != "bob" {
|
|
t.Fatalf("wrong scope actor %q", h.Owner())
|
|
}
|
|
return []core.Project{{ID: "host-current", Settings: map[string]any{"rdm.projectId": "domain-current", "rdm.state": "ready"}}}, nil
|
|
}
|
|
caps := hostcaps{st: st, pipe: rag.NewPipeline(st, settings.NewService(st), nil, nil), authz: &projectAuthz{}, projects: slot}
|
|
if err := (pluginRegistry{pluginID: "rdm", caps: caps}).RegisterProjectLifecycle(provider); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
caps.authz.setV2("rdm", func(extension.ResourceAuthzQuery) (bool, error) { return true, nil })
|
|
h := inprocHost{owner: "bob", pluginID: "rdm", caps: caps}
|
|
hits, err := h.KnowledgeSearch(context.Background(), "host-current", "scopeneedle", 5)
|
|
if err != nil || len(hits) != 1 || hits[0].Doc.ID != "slice-current" {
|
|
t.Fatalf("host scope: %+v %v", hits, err)
|
|
}
|
|
}
|
|
|
|
func TestRegisteredProjectHostRejectsForeignKnowledgeSource(t *testing.T) {
|
|
st, err := store.OpenSQLite(t.TempDir() + "/t.db")
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
defer st.Close()
|
|
slot := &projectLifecycleSlot{}
|
|
caps := hostcaps{st: st, projects: slot}
|
|
if err := (pluginRegistry{pluginID: "rdm", caps: caps}).RegisterProjectLifecycle(completeLifecycle()); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
h := inprocHost{owner: "alice", pluginID: "other", caps: caps}
|
|
for _, publish := range []bool{false, true} {
|
|
docs := []core.KnowledgeDoc{{ID: "claimed-published", ProjectID: "domain", Source: "plugin:rdm:published", Ref: "ref", Chunk: "spoofneedle"}}
|
|
var err error
|
|
if publish {
|
|
err = h.KnowledgePublish(context.Background(), docs, false)
|
|
} else {
|
|
err = h.KnowledgeWrite(context.Background(), docs, false)
|
|
}
|
|
if err == nil {
|
|
t.Fatalf("foreign namespace accepted publish=%v", publish)
|
|
}
|
|
}
|
|
if hits, err := st.SearchKnowledge("alice", core.RetrievalQuery{Text: "spoofneedle"}); err != nil || len(hits) != 0 {
|
|
t.Fatalf("foreign source wrote data: %+v %v", hits, err)
|
|
}
|
|
// Existing ordinary Hosts preserve their documented caller-provided Source.
|
|
h.caps.projects = nil
|
|
if err := h.KnowledgeWrite(context.Background(), []core.KnowledgeDoc{{Source: "manual", Chunk: "ordinaryneedle"}}, false); err != nil {
|
|
t.Fatalf("ordinary source semantics changed: %v", err)
|
|
}
|
|
}
|