From 7cf8f02399a66ef2c871f6af4066ee1a95808d0a Mon Sep 17 00:00:00 2001 From: weichen Date: Tue, 13 Oct 2026 08:43:02 +0000 Subject: [PATCH 1/6] Add project lifecycle delegation and private member work 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 --- internal/app/app.go | 33 ++- internal/app/hostcaps.go | 53 +++- internal/app/knowledge_authorization_test.go | 105 +++++++ internal/app/project_lifecycle.go | 121 ++++++++ internal/app/project_lifecycle_test.go | 77 +++++ internal/projectscope/projectscope.go | 35 +++ internal/rag/hybrid.go | 56 +++- internal/runmgr/knowledge_resource_test.go | 66 +++++ internal/runmgr/runmgr.go | 83 +++++- internal/server/borrow.go | 4 + internal/server/platform_api.go | 14 + internal/server/project_lifecycle.go | 73 +++++ internal/server/project_lifecycle_test.go | 199 +++++++++++++ .../server/project_resource_filter_test.go | 100 +++++++ .../server/resource_authz_consumption_test.go | 2 +- internal/server/server.go | 269 +++++++++++++++--- internal/store/content.go | 35 ++- internal/store/project_projection.go | 149 ++++++++++ internal/store/project_projection_test.go | 83 ++++++ internal/store/vectors.go | 16 +- pkg/extension/account_lister_test.go | 11 +- pkg/extension/extension.go | 20 +- pkg/extension/projects.go | 35 +++ pkg/extension/surface.txt | 11 + 24 files changed, 1572 insertions(+), 78 deletions(-) create mode 100644 internal/app/knowledge_authorization_test.go create mode 100644 internal/app/project_lifecycle.go create mode 100644 internal/app/project_lifecycle_test.go create mode 100644 internal/projectscope/projectscope.go create mode 100644 internal/runmgr/knowledge_resource_test.go create mode 100644 internal/server/project_lifecycle.go create mode 100644 internal/server/project_lifecycle_test.go create mode 100644 internal/server/project_resource_filter_test.go create mode 100644 internal/store/project_projection.go create mode 100644 internal/store/project_projection_test.go create mode 100644 pkg/extension/projects.go diff --git a/internal/app/app.go b/internal/app/app.go index 5955026..67587f3 100644 --- a/internal/app/app.go +++ b/internal/app/app.go @@ -55,6 +55,7 @@ import ( "agent-system-backend/internal/surfacesvc" "agent-system-backend/internal/tools" "agent-system-backend/internal/workflow" + "agent-system-backend/pkg/extension" core "agent-system-core" ) @@ -594,7 +595,8 @@ func Run(opt Options) error { pluginHost.OnCommands = bus.RegisterPluginCommands // hostcaps:hostapi 闭包与进程内插件句柄共用的 owner 作用域能力(EXT-1 批注 1)。 authzSlot := &projectAuthz{} - caps := hostcaps{st: st, pipe: pipe, set: setSvc, authz: authzSlot, surfaces: surfaceSvc} + projectSlot := &projectLifecycleSlot{} + caps := hostcaps{projects: projectSlot, st: st, pipe: pipe, set: setSvc, authz: authzSlot, surfaces: surfaceSvc} // EXT-13:回合内检索(knowledge_search / @knowledge 注入)与 HTTP/Host 检索面 // 同一适配器授权;槽为共享指针,扩展在 applyCompiled 期间注册后即生效。 mgr.SetSharedKnowledgeAllow(func(owner string, projectID core.ID) bool { @@ -607,6 +609,16 @@ func Run(opt Options) error { // EXT-11:公开受限 Host 工厂(下游恢复路径经 extension.NewScopedHost 重建句柄)。 wireScopedHostFactory(caps) compiledSummary := applyCompiled(skillsRoot, bus, caps) + mgr.SetProjectWorkAuthorization(projectSlot.work()) + mgr.SetKnowledgeProjectScope(func(ctx context.Context, actor string, id core.ID) (core.ID, error) { + return projectSlot.resolve(ctx, caps, actor, id) + }) + if authzSlot.hasV2() { + mgr.SetKnowledgeResourceAllow(func(actor string, d core.KnowledgeDoc) bool { + ok, err := authzSlot.AuthorizeResource(extension.ResourceAuthzQuery{Actor: actor, Action: "model-context", ResourceType: "knowledge", ResourceID: d.ID, Owner: d.Owner, ProjectID: d.ProjectID, Source: d.Source}) + return err == nil && ok + }) + } mgr.OnUsage = func(owner string, tokens int) { budget := int(setSvc.Effective(owner, "").GetNumber("llm.dailyTokenBudget", 0)) status, aerr := st.AccountTokens(owner, tokens, budget) @@ -631,28 +643,21 @@ func Run(opt Options) error { } // EXT-8 余项:hostapi 检索面同样吃适配器共享(本人 + 放行的他人项目知识)。 hostSrv.SharedSearch = func(ctx context.Context, owner, text string) ([]core.RetrievalHit, error) { - own, err := caps.searchDefault(ctx, owner, text) - if err != nil { - return nil, err - } - shared, err := st.SearchKnowledgeShared(owner, core.RetrievalQuery{Text: text, TopK: 5}, func(docOwner, projectID core.ID) bool { - ok, aerr := authzSlot.Authorize(owner, projectID) - return aerr == nil && ok - }) - if err != nil { - return nil, err - } - return append(own, shared...), nil + return caps.searchDefault(ctx, owner, text) } + hostSrv.Complete = func(ctx context.Context, owner, prompt string) (string, error) { return caps.complete(ctx, owner, prompt) } srv := server.New(st, mgr, reg, profSvc, setSvc, pluginHost) + projectSlot.wire(srv, caps) // EXT-8:装载期注册的资源授权适配器注入读取入口(未注册=默认拒绝,行为不变)。 srv.SetProjectAuthorizer(authzSlot.Authorize) // EXT-8b:资源级适配器注入读面(未注册=server 回落 V1,行为不变)。 - srv.SetResourceAuthorizerV2(authzSlot.AuthorizeResource) + if authzSlot.hasV2() { + srv.SetResourceAuthorizerV2(authzSlot.AuthorizeResource) + } // EXT-9:宿主可关闭上传自动索引(发布决定移交下游命令层)。 srv.DisableUploadIndexing = opt.DisableUploadIndexing // EXT-12 最小开关:会话卡不镜像进项目公共流(cardSvc 在上方已建)。 diff --git a/internal/app/hostcaps.go b/internal/app/hostcaps.go index 9c75678..62cebe5 100644 --- a/internal/app/hostcaps.go +++ b/internal/app/hostcaps.go @@ -21,6 +21,7 @@ import ( // 同一实现同时服务 hostapi.Server 的 Search/Complete 闭包与进程内 // inprocHost(EXT-1 批注 1:复用现有 owner 作用域函数,不新造句柄)。 type hostcaps struct { + projects *projectLifecycleSlot st *store.Store pipe *rag.Pipeline set *settings.Service @@ -71,10 +72,16 @@ func (a *projectAuthz) AuthorizeResource(q extension.ResourceAuthzQuery) (bool, if fn1 == nil || q.Owner == "" || q.ProjectID == "" { return false, nil } - return fn1(q.Owner, q.ProjectID) + return fn1(q.Actor, q.ProjectID) } // Authorize owner×project 共享读取判定;未注册一律 false(默认拒绝)。 +func (a *projectAuthz) hasV2() bool { + a.mu.Lock() + defer a.mu.Unlock() + return a.fn2 != nil +} + func (a *projectAuthz) Authorize(owner string, projectID core.ID) (bool, error) { a.mu.Lock() fn := a.fn @@ -103,6 +110,19 @@ type sharedRetriever interface { func (c hostcaps) search(ctx context.Context, owner string, projectID core.ID, text string, topK int) ([]core.RetrievalHit, error) { q := core.RetrievalQuery{ProjectID: projectID, Text: text, TopK: topK} + if c.projects != nil && projectID != "" { + id, err := c.projects.resolve(ctx, c, owner, projectID) + if err != nil { + return nil, err + } + q.ProjectID = id + } + if c.authz != nil && c.authz.hasV2() { + return c.pipe.RetrieveAuthorized(ctx, core.ID(owner), q, func(d core.KnowledgeDoc) bool { + ok, err := c.authz.AuthorizeResource(extension.ResourceAuthzQuery{Actor: owner, Action: "search", ResourceType: "knowledge", ResourceID: d.ID, Owner: d.Owner, ProjectID: d.ProjectID, Source: d.Source}) + return err == nil && ok + }) + } // EXT-13:进程内 Host 检索面与回合内检索同一授权口径——适配器已注册时走 // 共享通道(本人两路如常 + 放行的他人项目知识,与 HTTP 检索面一致)。 if c.authz != nil { @@ -265,6 +285,20 @@ func (h inprocHost) KnowledgeSearch(ctx context.Context, projectID core.ID, text return h.caps.search(ctx, h.owner, projectID, text, topK) } +// knowledgeSource preserves ordinary host Source semantics. A lifecycle provider +// introduces shared publication provenance, so plugins may only assert their own +// namespace; another plugin cannot impersonate an RDM published row. +func (h inprocHost) knowledgeSource(source string) error { + if h.caps.projects == nil || !h.caps.projects.active() { + return nil + } + prefix := "plugin:" + h.pluginID + if source == prefix || strings.HasPrefix(source, prefix+":") { + return nil + } + return fmt.Errorf("extension: knowledge source must belong to plugin %q", h.pluginID) +} + // KnowledgeWrite 强制 owner 归属:Owner 改写为本次分发 owner,Source 为空补 // "plugin:";embed=false 只写全文行,embed=true 走 IndexChunks 建向量 // (embedding 失败降级纯全文,不阻断写入)。 @@ -278,6 +312,9 @@ func (h inprocHost) KnowledgeWrite(ctx context.Context, docs []core.KnowledgeDoc if docs[i].Source == "" { docs[i].Source = src } + if err := h.knowledgeSource(docs[i].Source); err != nil { + return err + } } if !embed { return h.caps.st.AddKnowledge(docs...) @@ -292,24 +329,26 @@ func (h inprocHost) KnowledgePublish(ctx context.Context, docs []core.KnowledgeD if len(docs) == 0 { return nil } - refs := map[string]bool{} + type publicationRef struct{ projectID, ref string } + refs := map[publicationRef]bool{} for i := range docs { docs[i].Owner = core.ID(h.owner) if docs[i].Source == "" { docs[i].Source = "plugin:" + h.pluginID } + if err := h.knowledgeSource(docs[i].Source); err != nil { + return err + } if docs[i].Ref != "" { - refs[docs[i].Ref] = true + refs[publicationRef{docs[i].ProjectID, docs[i].Ref}] = true } } for ref := range refs { - if err := h.caps.st.DeleteKnowledgeByRef(h.owner, ref); err != nil { - return err - } - if err := h.caps.st.DeleteKnowledgeVectors(ctx, h.owner, ref); err != nil { + if err := h.caps.st.DeleteProjectKnowledgeByRef(ctx, h.owner, ref.projectID, ref.ref); err != nil { return err } } + return h.KnowledgeWrite(ctx, docs, embed) } diff --git a/internal/app/knowledge_authorization_test.go b/internal/app/knowledge_authorization_test.go new file mode 100644 index 0000000..7df87d3 --- /dev/null +++ b/internal/app/knowledge_authorization_test.go @@ -0,0 +1,105 @@ +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) + } +} diff --git a/internal/app/project_lifecycle.go b/internal/app/project_lifecycle.go new file mode 100644 index 0000000..883d5c2 --- /dev/null +++ b/internal/app/project_lifecycle.go @@ -0,0 +1,121 @@ +package app + +import ( + "agent-system-backend/internal/projectscope" + "agent-system-backend/internal/server" + "agent-system-backend/internal/store" + "agent-system-backend/pkg/extension" + core "agent-system-core" + "context" + "errors" + "sync" +) + +type projectLifecycleSlot struct { + mu sync.Mutex + holder string + provider *extension.ProjectLifecycle +} + +func (r compiledRegistry) RegisterProjectLifecycle(extension.ProjectLifecycle) error { + return errors.New("extension: declarative plugins cannot register project lifecycle") +} +func (r pluginRegistry) RegisterProjectLifecycle(p extension.ProjectLifecycle) error { + if r.caps.projects == nil || r.pluginID == "" { + return errors.New("extension: project lifecycle slot unavailable") + } + if p.List == nil || p.Create == nil || p.Rename == nil || p.Archive == nil || p.Work == nil { + return errors.New("extension: project lifecycle requires all callbacks") + } + slot := r.caps.projects + slot.mu.Lock() + defer slot.mu.Unlock() + if slot.provider != nil { + return errors.New("extension: project lifecycle already registered") + } + slot.holder, slot.provider = r.pluginID, &p + return nil +} +func (r recordRegistry) RegisterProjectLifecycle(p extension.ProjectLifecycle) error { + reg, ok := r.Registry.(extension.ProjectLifecycleRegistry) + if !ok { + return errors.New("extension: project lifecycle registration unavailable") + } + return reg.RegisterProjectLifecycle(p) +} +func (slot *projectLifecycleSlot) wire(srv *server.Server, caps hostcaps) { + slot.mu.Lock() + defer slot.mu.Unlock() + if slot.provider == nil { + return + } + holder := slot.holder + srv.SetProjectLifecycle(*slot.provider, func(owner string) extension.Host { + return inprocHost{caps: caps, owner: owner, actor: owner, pluginID: holder} + }) +} + +func (slot *projectLifecycleSlot) active() bool { + slot.mu.Lock() + defer slot.mu.Unlock() + return slot.provider != nil +} + +func (slot *projectLifecycleSlot) work() func(context.Context, string, core.ID) (bool, error) { + slot.mu.Lock() + defer slot.mu.Unlock() + if slot.provider == nil { + return nil + } + return slot.provider.Work +} + +func (slot *projectLifecycleSlot) resolve(ctx context.Context, caps hostcaps, owner string, id core.ID) (core.ID, error) { + slot.mu.Lock() + if slot.provider == nil { + slot.mu.Unlock() + return id, nil + } + p, holder := *slot.provider, slot.holder + slot.mu.Unlock() + return projectscope.Resolve(ctx, inprocHost{caps: caps, owner: owner, actor: owner, pluginID: holder}, p, id) +} + +var _ extension.ProjectHost = inprocHost{} + +func (h inprocHost) OwnedProjects() ([]core.Project, error) { + ps, err := h.caps.st.ListProjects(h.owner) + for i := range ps { + ps[i].Owner = h.owner + } + return ps, err +} +func (h inprocHost) OwnedProject(id string) (core.Project, error) { + return h.caps.st.GetProject(h.owner, id) +} +func (h inprocHost) ProjectMetadata(id string) (core.Project, error) { + p, err := h.caps.st.ProjectMetadata(id) + if err != nil { + return core.Project{}, err + } + if p.Owner == h.owner { + return p, nil + } + if h.caps.authz == nil { + return core.Project{}, store.ErrNotFound + } + ok, err := h.caps.authz.AuthorizeResource(extension.ResourceAuthzQuery{Actor: h.owner, Action: "read", ResourceType: "project", ResourceID: core.ID(id), Owner: p.Owner, ProjectID: core.ID(id)}) + if err != nil || !ok { + return core.Project{}, store.ErrNotFound + } + return p, nil +} +func (h inprocHost) EnsureProject(id, name string) (core.Project, error) { + return h.caps.st.EnsureProject(h.owner, id, name) +} +func (h inprocHost) RenameOwnedProject(id, name string) (core.Project, error) { + return h.caps.st.RenameOwnedProject(h.owner, id, name) +} +func (h inprocHost) ProjectNameAvailable(name, exceptID string) (bool, error) { + return h.caps.st.ProjectNameAvailable(name, exceptID) +} diff --git a/internal/app/project_lifecycle_test.go b/internal/app/project_lifecycle_test.go new file mode 100644 index 0000000..ad261c0 --- /dev/null +++ b/internal/app/project_lifecycle_test.go @@ -0,0 +1,77 @@ +package app + +import ( + "agent-system-backend/internal/store" + "agent-system-backend/pkg/extension" + core "agent-system-core" + "context" + "errors" + "testing" +) + +func completeLifecycle() extension.ProjectLifecycle { + return extension.ProjectLifecycle{ + List: func(context.Context, extension.Host) ([]core.Project, error) { return nil, nil }, + Create: func(context.Context, extension.Host, string) (core.Project, error) { return core.Project{}, nil }, + Rename: func(context.Context, extension.Host, string, string) (core.Project, error) { + return core.Project{}, nil + }, + Archive: func(context.Context, extension.Host, string) error { return nil }, + Work: func(context.Context, string, core.ID) (bool, error) { return true, nil }, + } +} +func TestProjectLifecycleOptionalRegistration(t *testing.T) { + slot := &projectLifecycleSlot{} + reg := pluginRegistry{pluginID: "rdm", caps: hostcaps{projects: slot}} + if err := reg.RegisterProjectLifecycle(extension.ProjectLifecycle{}); err == nil { + t.Fatal("incomplete registration accepted") + } + if err := reg.RegisterProjectLifecycle(completeLifecycle()); err != nil { + t.Fatal(err) + } + if err := reg.RegisterProjectLifecycle(completeLifecycle()); err == nil { + t.Fatal("duplicate registration accepted") + } + if err := (compiledRegistry{}).RegisterProjectLifecycle(completeLifecycle()); err == nil { + t.Fatal("declarative callback accepted") + } + rr := recordRegistry{Registry: pluginRegistry{pluginID: "other", caps: hostcaps{projects: &projectLifecycleSlot{}}}} + optional, ok := any(rr).(extension.ProjectLifecycleRegistry) + if !ok { + t.Fatal("record registry hides optional registration") + } + if err := optional.RegisterProjectLifecycle(completeLifecycle()); err != nil { + t.Fatal(err) + } +} +func TestProjectHostMetadataDoesNotGrantMutation(t *testing.T) { + st, err := store.OpenSQLite(t.TempDir() + "/t.db") + if err != nil { + t.Fatal(err) + } + defer st.Close() + alice := inprocHost{owner: "alice", caps: hostcaps{st: st}} + p, err := alice.EnsureProject("stable", "Shared") + if err != nil { + t.Fatal(err) + } + auth := &projectAuthz{} + auth.set("rdm", func(owner string, id core.ID) (bool, error) { return owner == "bob" && id == core.ID(p.ID), nil }) + bob := inprocHost{owner: "bob", caps: hostcaps{st: st, authz: auth}} + if _, err := bob.ProjectMetadata(p.ID); err != nil { + t.Fatal(err) + } + if _, err := bob.OwnedProject(p.ID); !errors.Is(err, store.ErrNotFound) { + t.Fatalf("owned lookup=%v", err) + } + if _, err := bob.RenameOwnedProject(p.ID, "Other"); !errors.Is(err, store.ErrNotFound) { + t.Fatalf("shared rename=%v", err) + } + if _, err := bob.EnsureProject(p.ID, p.Name); !errors.Is(err, store.ErrNotFound) { + t.Fatalf("shared ensure=%v", err) + } + auth.setV2("rdm", func(q extension.ResourceAuthzQuery) (bool, error) { return false, nil }) + if _, err := bob.ProjectMetadata(p.ID); !errors.Is(err, store.ErrNotFound) { + t.Fatalf("V2 denial ignored=%v", err) + } +} diff --git a/internal/projectscope/projectscope.go b/internal/projectscope/projectscope.go new file mode 100644 index 0000000..25bc722 --- /dev/null +++ b/internal/projectscope/projectscope.go @@ -0,0 +1,35 @@ +// Package projectscope resolves host retrieval filters using service-provided +// lifecycle metadata. Stored/client settings never supply an authority mapping. +package projectscope + +import ( + "agent-system-backend/internal/store" + "agent-system-backend/pkg/extension" + core "agent-system-core" + "context" +) + +func Resolve(ctx context.Context, host extension.Host, p extension.ProjectLifecycle, id core.ID) (core.ID, error) { + if id == "" { + return id, nil + } + if p.List == nil { + return "", store.ErrNotFound + } + projects, err := p.List(ctx, host) + if err != nil { + return "", err + } + for _, project := range projects { + domainID, _ := project.Settings["rdm.projectId"].(string) + if project.ID != id && domainID != id { + continue + } + state, _ := project.Settings["rdm.state"].(string) + if domainID == "" || state != "ready" { + return "", store.ErrNotFound + } + return core.ID(domainID), nil + } + return "", store.ErrNotFound +} diff --git a/internal/rag/hybrid.go b/internal/rag/hybrid.go index ba4c976..e9438d2 100644 --- a/internal/rag/hybrid.go +++ b/internal/rag/hybrid.go @@ -174,19 +174,66 @@ func (p *Pipeline) RetrieveShared(ctx context.Context, owner core.ID, q core.Ret return merged, nil } +// RetrieveAuthorized is the resource-level path for HTTP, Host and model +// context. It filters owned and shared candidates before external reranking. +func (p *Pipeline) RetrieveAuthorized(ctx context.Context, owner core.ID, q core.RetrievalQuery, allow func(core.KnowledgeDoc) bool) ([]core.RetrievalHit, error) { + own, err := p.retrieve(ctx, owner, q, allow) + if err != nil { + return nil, err + } + shared, err := p.st.SearchKnowledgeSharedAuthorized(string(owner), q, allow) + if err != nil { + return nil, err + } + if p.embedder != nil { + if vectors, e := p.embedder.Embed(ctx, []string{q.Text}); e == nil && len(vectors) > 0 && len(vectors[0]) > 0 { + topK := q.TopK + if topK <= 0 { + topK = 5 + } + vs, e := p.st.SearchKnowledgeVectorsSharedAuthorized(ctx, string(owner), embedderModel(p.embedder), vectors[0], topK, q, allow) + if e == nil { + shared = rrfMerge(vs, shared) + } + } + } + merged := rrfMerge(own, shared) + if limit := q.TopK; limit > 0 && len(merged) > limit { + merged = merged[:limit] + } + return merged, nil +} +func authorizedHits(hits []core.RetrievalHit, allow func(core.KnowledgeDoc) bool) []core.RetrievalHit { + if allow == nil { + return hits + } + out := make([]core.RetrievalHit, 0, len(hits)) + for _, h := range hits { + if allow(h.Doc) { + out = append(out, h) + } + } + return out +} + // Retrieve 统一检索(core.Retriever)。 func (p *Pipeline) Retrieve(ctx context.Context, owner core.ID, q core.RetrievalQuery) ([]core.RetrievalHit, error) { + return p.retrieve(ctx, owner, q, nil) +} +func (p *Pipeline) retrieve(ctx context.Context, owner core.ID, q core.RetrievalQuery, allow func(core.KnowledgeDoc) bool) ([]core.RetrievalHit, error) { topK := q.TopK if topK <= 0 { topK = 5 } if p.embedder == nil { - return p.st.SearchKnowledge(owner, q) + hits, err := p.st.SearchKnowledge(owner, q) + return authorizedHits(hits, allow), err } route := p.embeddingRoute(ctx) offline, _ := p.embedder.(interface{ Offline() bool }) if !route.OK() && (offline == nil || !offline.Offline()) { - return p.st.SearchKnowledge(owner, q) + hits, err := p.st.SearchKnowledge(owner, q) + return authorizedHits(hits, allow), err } qvecs, err := p.embedder.Embed(ctx, []string{q.Text}) if err != nil || len(qvecs) == 0 || len(qvecs[0]) == 0 { @@ -194,7 +241,8 @@ func (p *Pipeline) Retrieve(ctx context.Context, owner core.ID, q core.Retrieval if err != nil { log.Printf("[rag] query embedding fallback to fulltext: %v", err) } - return p.st.SearchKnowledge(owner, q) + hits, err := p.st.SearchKnowledge(owner, q) + return authorizedHits(hits, allow), err } q.TopK = topK * 2 // 每路多取,融合后再裁剪 vhits, err := p.st.SearchKnowledgeVectorsModel(ctx, owner, embedderModel(p.embedder), qvecs[0], topK*2, q) @@ -205,7 +253,7 @@ func (p *Pipeline) Retrieve(ctx context.Context, owner core.ID, q core.Retrieval if err != nil { return nil, err } - merged := rrfMerge(vhits, fhits) + merged := authorizedHits(rrfMerge(vhits, fhits), allow) if p.reranker != nil && p.rerankRoute(ctx).OK() && len(merged) > 0 { docs := make([]core.KnowledgeDoc, len(merged)) for i, h := range merged { diff --git a/internal/runmgr/knowledge_resource_test.go b/internal/runmgr/knowledge_resource_test.go new file mode 100644 index 0000000..70c2a59 --- /dev/null +++ b/internal/runmgr/knowledge_resource_test.go @@ -0,0 +1,66 @@ +package runmgr + +import ( + "agent-system-backend/internal/rag" + "agent-system-backend/internal/store" + core "agent-system-core" + "context" + "encoding/json" + "errors" + "testing" +) + +func TestRunKnowledgeResourceAuthorization(t *testing.T) { + rc, st := newExecEnv(t) + if err := st.AddKnowledge(core.KnowledgeDoc{ID: "allowed-slice", Owner: "bob", ProjectID: rc.run.ProjectID, Chunk: "resourcequery published"}, core.KnowledgeDoc{ID: "private-slice", Owner: "bob", ProjectID: rc.run.ProjectID, Chunk: "resourcequery private"}, core.KnowledgeDoc{ID: "restricted-self", Owner: "alice", ProjectID: rc.run.ProjectID, Chunk: "resourcequery self"}); err != nil { + t.Fatal(err) + } + rc.mgr.SetRetriever(rag.NewPipeline(st, rc.mgr.settings, nil, nil)) + rc.mgr.SetSharedKnowledgeAllow(func(string, core.ID) bool { return true }) + rc.mgr.SetKnowledgeResourceAllow(func(actor string, d core.KnowledgeDoc) bool { + return actor == "alice" && d.ID == "allowed-slice" && d.Owner == "bob" + }) + hits, err := rc.Retrieve(core.RetrievalQuery{Text: "resourcequery"}) + if err != nil || len(hits) != 1 || hits[0].Doc.ID != "allowed-slice" { + t.Fatalf("model-context resources: %+v %v", hits, err) + } +} + +func TestProjectWorkRevocationGuardsDirectExecution(t *testing.T) { + rc, st := newExecEnv(t) + rc.mgr.SetProjectWorkAuthorization(func(context.Context, string, core.ID) (bool, error) { return false, nil }) + conv, err := st.GetConversation("alice", rc.run.ConversationID) + if err != nil { + t.Fatal(err) + } + if _, err := rc.mgr.StartConversationRun(conv, core.Message{}, nil, nil); !errors.Is(err, store.ErrNotFound) { + t.Fatalf("direct run should reject: %v", err) + } + res := rc.ExecuteTool(core.ToolCallRequest{Name: "write_artifact", Arguments: json.RawMessage(`{"name":"forbidden.md","content":"x"}`)}) + if res.Status != "error" { + t.Fatalf("revoked execution: %+v", res) + } + artifacts, err := st.ListArtifacts("alice", rc.run.ProjectID) + if err != nil || len(artifacts) != 0 { + t.Fatalf("revoked tool wrote: %+v %v", artifacts, err) + } +} + +func TestRunKnowledgeResolvesTrustedProjectScope(t *testing.T) { + rc, st := newExecEnv(t) + if err := st.AddKnowledge(core.KnowledgeDoc{ID: "slice-current", Owner: "bob", ProjectID: "domain-current", Chunk: "scopeneedle current"}, core.KnowledgeDoc{ID: "slice-other", Owner: "bob", ProjectID: "domain-other", Chunk: "scopeneedle other"}); err != nil { + t.Fatal(err) + } + rc.mgr.SetRetriever(rag.NewPipeline(st, rc.mgr.settings, nil, nil)) + rc.mgr.SetKnowledgeResourceAllow(func(string, core.KnowledgeDoc) bool { return true }) + rc.mgr.SetKnowledgeProjectScope(func(_ context.Context, actor string, id core.ID) (core.ID, error) { + if actor != "alice" || id != rc.run.ProjectID { + t.Fatalf("untrusted scope actor=%s project=%s", actor, id) + } + return "domain-current", nil + }) + hits, err := rc.Retrieve(core.RetrievalQuery{Text: "scopeneedle"}) + if err != nil || len(hits) != 1 || hits[0].Doc.ID != "slice-current" { + t.Fatalf("model current scope: %+v %v", hits, err) + } +} diff --git a/internal/runmgr/runmgr.go b/internal/runmgr/runmgr.go index 1086b73..b9d7ddb 100644 --- a/internal/runmgr/runmgr.go +++ b/internal/runmgr/runmgr.go @@ -57,7 +57,10 @@ type Manager struct { retriever core.Retriever // 可选混合检索(nil = store 全文) // sharedKnowledgeAllow EXT-13:回合内检索的共享授权谓词(请求 owner × 知识所属 // projectID);nil = 未注册(现状:仅本人两路,不触碰共享通道)。 - sharedKnowledgeAllow func(owner string, projectID core.ID) bool + sharedKnowledgeAllow func(owner string, projectID core.ID) bool + knowledgeResourceAllow func(owner string, doc core.KnowledgeDoc) bool + knowledgeProjectScope func(context.Context, string, core.ID) (core.ID, error) + projectWorkAuthorization func(context.Context, string, core.ID) (bool, error) // commands EXT-14:命令即工具的投影与分发源(nil = 未接线,命令不进工具清单)。 commands CommandSource indexer core.DocumentIndexer // 可选索引入口(nil = store 整文档单块) @@ -197,6 +200,37 @@ func (m *Manager) SetSharedKnowledgeAllow(fn func(owner string, projectID core.I m.sharedKnowledgeAllow = fn } +// SetKnowledgeResourceAllow makes resource-level authorization authoritative for +// model context, including owned rows. The callback receives stored identities. +func (m *Manager) SetKnowledgeResourceAllow(fn func(string, core.KnowledgeDoc) bool) { + m.knowledgeResourceAllow = fn +} + +// SetProjectWorkAuthorization is the live domain guard for starting runs and +// executing tools. Nil preserves the ordinary unregistered host behavior. +func (m *Manager) SetProjectWorkAuthorization(fn func(context.Context, string, core.ID) (bool, error)) { + m.projectWorkAuthorization = fn +} +func (m *Manager) authorizeProjectWork(ctx context.Context, owner string, id core.ID) error { + if m.projectWorkAuthorization == nil { + return nil + } + ok, err := m.projectWorkAuthorization(ctx, owner, id) + if err != nil { + return err + } + if !ok { + return store.ErrNotFound + } + return nil +} + +// SetKnowledgeProjectScope binds host project filters to the provider's trusted +// canonical domain metadata without widening the current conversation scope. +func (m *Manager) SetKnowledgeProjectScope(fn func(context.Context, string, core.ID) (core.ID, error)) { + m.knowledgeProjectScope = fn +} + // SetCommandSource 注入命令即工具源(EXT-14;组合根以命令总线适配)。nil = 现状。 func (m *Manager) SetCommandSource(src CommandSource) { m.commands = src } @@ -902,6 +936,40 @@ func (c *runCtx) Retrieve(q core.RetrievalQuery) ([]core.RetrievalHit, error) { if q.ProjectID == "" { q.ProjectID = c.run.ProjectID } + if c.mgr.knowledgeProjectScope != nil { + id, err := c.mgr.knowledgeProjectScope(c.ctx, c.run.Owner, q.ProjectID) + if err != nil { + return nil, err + } + q.ProjectID = id + } + if c.mgr.knowledgeResourceAllow != nil { + allow := func(d core.KnowledgeDoc) bool { return c.mgr.knowledgeResourceAllow(string(c.run.Owner), d) } + if rs, ok := c.mgr.retriever.(interface { + RetrieveAuthorized(context.Context, core.ID, core.RetrievalQuery, func(core.KnowledgeDoc) bool) ([]core.RetrievalHit, error) + }); ok { + return rs.RetrieveAuthorized(c.ctx, c.run.Owner, q, allow) + } + hits, err := c.mgr.st.SearchKnowledge(c.run.Owner, q) + if err != nil { + return nil, err + } + out := []core.RetrievalHit{} + for _, h := range hits { + if allow(h.Doc) { + out = append(out, h) + } + } + shared, err := c.mgr.st.SearchKnowledgeSharedAuthorized(c.run.Owner, q, allow) + if err != nil { + return nil, err + } + out = append(out, shared...) + if len(out) > q.TopK { + out = out[:q.TopK] + } + return out, nil + } if c.mgr.retriever != nil { if c.mgr.sharedKnowledgeAllow != nil { if rs, ok := c.mgr.retriever.(sharedRetriever); ok { @@ -917,6 +985,9 @@ func (c *runCtx) Retrieve(q core.RetrievalQuery) ([]core.RetrievalHit, error) { // ExecuteTool 执行画像策略:deny→错误;confirm→HITL confirm 卡片;执行→截断转工件。 func (c *runCtx) ExecuteTool(call core.ToolCallRequest) core.ToolCallResult { + if err := c.mgr.authorizeProjectWork(c.ctx, c.run.Owner, c.run.ProjectID); err != nil { + return core.ToolCallResult{CallID: call.CallID, Status: "error", Output: err.Error()} + } policy := c.prof.Tools if !policy.ToolAllowed(call.Name) { return core.ToolCallResult{CallID: call.CallID, Status: "error", Output: fmt.Sprintf("tool %q denied by profile policy", call.Name)} @@ -1141,6 +1212,9 @@ func selectConversationRunner(m *Manager, eff core.EffectiveSettings, launch *wo // attachments 为已解析的附件文档(server 层经 ingest 处理)。 // inputs 是同一批附件的原件,落到会话工作区 inputs/,供模板和仿制使用。 func (m *Manager) StartConversationRun(conv core.Conversation, userMessage core.Message, attachments []core.ParsedDocument, inputs []WorkspaceFile) (core.Run, error) { + if err := m.authorizeProjectWork(context.Background(), conv.Owner, conv.ProjectID); err != nil { + return core.Run{}, err + } if err := m.claimConversation(conv.ID); err != nil { return core.Run{}, err } @@ -1150,6 +1224,9 @@ func (m *Manager) StartConversationRun(conv core.Conversation, userMessage core. // StartWorkflowRun 启动一条绑定工作流的会话 run。定义在这里拷贝, // 之后插件卸载不会改到已经在跑的这份。 func (m *Manager) StartWorkflowRun(conv core.Conversation, userMessage core.Message, def core.WorkflowDef, slots map[string]string, missing []workflow.SlotQuestion) (core.Run, error) { + if err := m.authorizeProjectWork(context.Background(), conv.Owner, conv.ProjectID); err != nil { + return core.Run{}, err + } if err := m.claimConversation(conv.ID); err != nil { return core.Run{}, err } @@ -1259,6 +1336,10 @@ func (m *Manager) claimConversation(conversationID string) error { } func (m *Manager) startClaimedRun(conv core.Conversation, userMessage core.Message, attachments []core.ParsedDocument, inputs []WorkspaceFile, launch *workflow.Launch) (core.Run, error) { + if err := m.authorizeProjectWork(context.Background(), conv.Owner, conv.ProjectID); err != nil { + m.releaseConversation(conv.ID) + return core.Run{}, err + } prof, _ := m.profiles.Get(conv.Owner, conv.ProfileID) eff := m.settings.Effective(conv.Owner, conv.ProjectID) run := core.Run{ diff --git a/internal/server/borrow.go b/internal/server/borrow.go index e0147fb..1ad8fd1 100644 --- a/internal/server/borrow.go +++ b/internal/server/borrow.go @@ -164,6 +164,10 @@ func (s *Server) regenerateMessage(w http.ResponseWriter, r *http.Request) { writeStoreErr(w, err) return } + if err := s.projectWork(r.Context(), owner, conv.ProjectID); err != nil { + writeProjectErr(w, err) + return + } var req struct { MessageID string `json:"messageId"` } diff --git a/internal/server/platform_api.go b/internal/server/platform_api.go index ee5ae69..bd7d9e9 100644 --- a/internal/server/platform_api.go +++ b/internal/server/platform_api.go @@ -273,6 +273,20 @@ func (s *Server) submitSurfaceAction(w http.ResponseWriter, r *http.Request) { http.Error(w, "surface capability not enabled", http.StatusNotImplemented) return } + if s.projectLifecycle != nil { + snap, err := s.Surfaces.Get(r.Context(), s.owner(r), core.ID(r.PathValue("id"))) + if err != nil { + s.writeSurfaceError(w, err) + return + } + if snap.Identity.ProjectID != "" { + if err := s.projectWork(r.Context(), s.owner(r), snap.Identity.ProjectID); err != nil { + writeProjectErr(w, err) + return + } + } + } + // 注意:本端点不绑定任何 confirmed 类字段——高危确认只能经既有确认卡回道 // (bus.Confirm)置位,客户端/模型无法伪造(U5 提案第二节第 2 条红测)。 var body struct { diff --git a/internal/server/project_lifecycle.go b/internal/server/project_lifecycle.go new file mode 100644 index 0000000..eb4d747 --- /dev/null +++ b/internal/server/project_lifecycle.go @@ -0,0 +1,73 @@ +package server + +import ( + "agent-system-backend/internal/store" + "agent-system-backend/pkg/extension" + core "agent-system-core" + "context" + "errors" + "net/http" +) + +// SetProjectLifecycle is wired only for a registered in-process provider. The +// optional host factory constructs a capability scoped to this request's actor. +func (s *Server) SetProjectLifecycle(p extension.ProjectLifecycle, host func(string) extension.Host) { + s.projectLifecycle = &p + s.projectHost = host +} +func (s *Server) lifecycleHost(owner string) extension.Host { + if s.projectHost == nil { + return nil + } + return s.projectHost(owner) +} +func (s *Server) projectWork(ctx context.Context, owner, projectID string) error { + if s.projectLifecycle == nil { + return nil + } + if s.projectLifecycle.Work == nil { + return store.ErrNotFound + } + ok, err := s.projectLifecycle.Work(ctx, owner, core.ID(projectID)) + if err != nil { + return err + } + if !ok { + return store.ErrNotFound + } + return nil +} + +// createPrivateConversation is shared by the API and /new. Only live work +// authorization grants member creation; the resulting conversation stays owned. +func (s *Server) createPrivateConversation(ctx context.Context, owner, projectID, title, profile string) (core.Conversation, error) { + if s.projectLifecycle != nil { + if err := s.projectWork(ctx, owner, projectID); err != nil { + return core.Conversation{}, err + } + return s.st.CreateConversationInProject(owner, projectID, title, profile) + } + c, err := s.st.CreateConversation(owner, projectID, title, profile) + if errors.Is(err, store.ErrNotFound) && s.authorizeSharedResource(owner, "read", "project", core.ID(projectID), "", core.ID(projectID)) { + return s.st.CreateConversationInProject(owner, projectID, title, profile) + } + return c, err +} +func writeProjectErr(w http.ResponseWriter, err error) { + var coded interface{ StatusCode() int } + if errors.As(err, &coded) { + switch code := coded.StatusCode(); code { + case 400, 403, 404, 409, 503: + http.Error(w, err.Error(), code) + return + } + } + writeStoreErr(w, err) +} +func writeProjectJSON(w http.ResponseWriter, p any, err error) { + if err != nil { + writeProjectErr(w, err) + return + } + writeJSON(w, http.StatusOK, p) +} diff --git a/internal/server/project_lifecycle_test.go b/internal/server/project_lifecycle_test.go new file mode 100644 index 0000000..61ea6dc --- /dev/null +++ b/internal/server/project_lifecycle_test.go @@ -0,0 +1,199 @@ +package server + +import ( + "agent-system-backend/internal/cmdbus" + "agent-system-backend/internal/surfacesvc" + "agent-system-backend/pkg/extension" + core "agent-system-core" + "bytes" + "context" + "encoding/json" + "errors" + "net/http" + "net/http/httptest" + "testing" + "time" +) + +type lifecycleStatusError int + +func (e lifecycleStatusError) Error() string { return "provider refusal" } +func (e lifecycleStatusError) StatusCode() int { return int(e) } +func lifecycleRequest(t *testing.T, s *Server, key, method, path, body string) *httptest.ResponseRecorder { + t.Helper() + r := httptest.NewRequest(method, path, bytes.NewBufferString(body)) + r.Header.Set("Authorization", "Bearer "+key) + w := httptest.NewRecorder() + s.Handler().ServeHTTP(w, r) + return w +} +func TestProjectLifecycleDelegatesAndPreservesArchiveHistory(t *testing.T) { + s, st := newAuthTestServer(t) + key, _ := st.CreateAPIKey("alice", "test") + p, _ := st.CreateProject("alice", "Original") + c, _ := st.CreateConversation("alice", p.ID, "Private", "") + calls := []string{} + s.SetProjectLifecycle(extension.ProjectLifecycle{ + List: func(context.Context, extension.Host) ([]core.Project, error) { + calls = append(calls, "list") + return []core.Project{{ID: p.ID, Owner: "alice", Name: "Decorated", Settings: map[string]any{"rdm.state": "ready"}}}, nil + }, + Create: func(_ context.Context, _ extension.Host, name string) (core.Project, error) { + calls = append(calls, "create") + return core.Project{ID: "provider-created", Name: name}, nil + }, + Rename: func(_ context.Context, _ extension.Host, id, name string) (core.Project, error) { + calls = append(calls, "rename") + return core.Project{ID: id, Name: name}, nil + }, + Archive: func(context.Context, extension.Host, string) error { calls = append(calls, "archive"); return nil }, + Work: func(context.Context, string, core.ID) (bool, error) { return true, nil }, + }, nil) + for _, q := range []struct{ method, path, body string }{{"GET", "/api/projects", ""}, {"POST", "/api/projects", `{"name":" New "}`}, {"PATCH", "/api/projects/" + p.ID, `{"name":"Renamed"}`}, {"DELETE", "/api/projects/" + p.ID, ""}} { + w := lifecycleRequest(t, s, key, q.method, q.path, q.body) + if w.Code != 200 { + t.Fatalf("%s: %d %s", q.method, w.Code, w.Body.String()) + } + } + if len(calls) != 4 || calls[0] != "list" || calls[1] != "create" || calls[2] != "rename" || calls[3] != "archive" { + t.Fatalf("calls=%v", calls) + } + if _, err := st.GetConversation("alice", c.ID); err != nil { + t.Fatalf("archive removed history: %v", err) + } + s.SetProjectLifecycle(extension.ProjectLifecycle{Create: func(context.Context, extension.Host, string) (core.Project, error) { + return core.Project{}, fmtWrap(lifecycleStatusError(409)) + }}, nil) + if w := lifecycleRequest(t, s, key, "POST", "/api/projects", `{"name":"dup"}`); w.Code != 409 { + t.Fatalf("trusted status=%d %s", w.Code, w.Body.String()) + } +} +func fmtWrap(err error) error { return errors.Join(errors.New("wrapped"), err) } +func TestProjectLifecycleMemberPrivateWork(t *testing.T) { + s, st := newAuthTestServer(t) + alice, _ := st.CreateAPIKey("alice", "test") + bob, _ := st.CreateAPIKey("bob", "test") + mallory, _ := st.CreateAPIKey("mallory", "test") + p, _ := st.CreateProject("alice", "Shared") + ac, _ := st.CreateConversation("alice", p.ID, "Alice private", "") + ready := true + s.SetProjectAuthorizer(func(string, core.ID) (bool, error) { return true, nil }) + s.SetProjectLifecycle(extension.ProjectLifecycle{Work: func(_ context.Context, actor string, id core.ID) (bool, error) { + return ready && (actor == "alice" || actor == "bob") && id == core.ID(p.ID), nil + }}, nil) + w := lifecycleRequest(t, s, bob, "POST", "/api/projects/"+p.ID+"/conversations", `{"title":"Bob private"}`) + if w.Code != 200 { + t.Fatalf("member create=%d %s", w.Code, w.Body.String()) + } + var bc core.Conversation + if err := json.Unmarshal(w.Body.Bytes(), &bc); err != nil { + t.Fatal(err) + } + w = lifecycleRequest(t, s, bob, "GET", "/api/projects/"+p.ID+"/conversations", "") + var cs []core.Conversation + _ = json.Unmarshal(w.Body.Bytes(), &cs) + if w.Code != 200 || len(cs) != 1 || cs[0].ID != bc.ID { + t.Fatalf("member list=%d %+v", w.Code, cs) + } + if w := lifecycleRequest(t, s, bob, "GET", "/api/conversations/"+ac.ID, ""); w.Code != 404 { + t.Fatalf("private history=%d", w.Code) + } + if w := lifecycleRequest(t, s, mallory, "POST", "/api/projects/"+p.ID+"/conversations", `{}`); w.Code != 404 { + t.Fatalf("nonmember create=%d", w.Code) + } + if w := lifecycleRequest(t, s, mallory, "GET", "/api/projects/"+p.ID+"/conversations", ""); w.Code != 404 { + t.Fatalf("nonmember list=%d", w.Code) + } + s.Bus = cmdbus.New(st, nil, func() time.Time { return time.Now() }) + s.WireBuiltinCommands() + w = lifecycleRequest(t, s, bob, "POST", "/api/conversations/"+bc.ID+"/messages", `{"text":"/new"}`) + if w.Code != 200 || !bytes.Contains(w.Body.Bytes(), []byte(`"conversation"`)) { + t.Fatalf("/new=%d %s", w.Code, w.Body.String()) + } + ready = false + for _, q := range []struct{ key, method, path, body string }{{bob, "POST", "/api/projects/" + p.ID + "/conversations", `{}`}, {bob, "GET", "/api/projects/" + p.ID + "/conversations", ""}, {alice, "POST", "/api/conversations/" + ac.ID + "/messages", `{"text":"new work"}`}, {bob, "POST", "/api/commands/conversation.create", `{"conversationId":"` + bc.ID + `"}`}} { + w := lifecycleRequest(t, s, q.key, q.method, q.path, q.body) + if w.Code != 404 { + t.Fatalf("revoked %s=%d %s", q.path, w.Code, w.Body.String()) + } + } + if w := lifecycleRequest(t, s, bob, "GET", "/api/conversations/"+bc.ID, ""); w.Code != 200 { + t.Fatalf("owned revoked history=%d", w.Code) + } +} +func TestProjectLifecycleReadOnlyAuthorizerDoesNotGrantWrite(t *testing.T) { + s, st := newAuthTestServer(t) + key, _ := st.CreateAPIKey("bob", "test") + p, _ := st.CreateProject("alice", "Shared") + s.SetProjectAuthorizer(func(string, core.ID) (bool, error) { return true, nil }) + s.SetProjectLifecycle(extension.ProjectLifecycle{}, nil) + if w := lifecycleRequest(t, s, key, http.MethodPost, "/api/projects/"+p.ID+"/conversations", `{}`); w.Code != 404 { + t.Fatalf("read-only write=%d %s", w.Code, w.Body.String()) + } + if cs, err := st.ListConversations("bob", p.ID); err != nil || len(cs) != 0 { + t.Fatalf("denial wrote state: %v %v", cs, err) + } +} + +func TestProjectLifecycleRevocationGuardsExecutionResumption(t *testing.T) { + s, st := newAuthTestServer(t) + key, _ := st.CreateAPIKey("alice", "test") + p, _ := st.CreateProject("alice", "Project") + conv, _ := st.CreateConversation("alice", p.ID, "Private", "") + s.SetProjectLifecycle(extension.ProjectLifecycle{Work: func(context.Context, string, core.ID) (bool, error) { return false, nil }}, nil) + card := core.HITLCard{ID: "hitl-revoked", ConversationID: conv.ID, RunID: "run-revoked", Kind: "confirm"} + if err := st.SaveHITLCard("alice", &card); err != nil { + t.Fatal(err) + } + for _, q := range []struct{ path, body string }{{"/api/conversations/" + conv.ID + "/regenerate", `{}`}, {"/api/hitl/" + card.ID + "/resolve", `{"choiceId":"yes"}`}} { + w := lifecycleRequest(t, s, key, "POST", q.path, q.body) + if w.Code != 404 { + t.Fatalf("resumption %s=%d %s", q.path, w.Code, w.Body.String()) + } + } +} + +func TestProjectLifecycleRevocationGuardsSurfaceAction(t *testing.T) { + s, st := newAuthTestServer(t) + key, _ := st.CreateAPIKey("alice", "test") + p, _ := st.CreateProject("alice", "Project") + conv, _ := st.CreateConversation("alice", p.ID, "Private", "") + svc := surfacesvc.New(st, nil) + s.Surfaces = svc + if _, err := svc.SetActivation(context.Background(), "alice", "generative-ui", true); err != nil { + t.Fatal(err) + } + snap, err := svc.Submit(context.Background(), "alice", "generative-ui", surfacesvc.SubmitRequest{ConversationID: conv.ID, Spec: json.RawMessage(`{"title":"Page","nodes":[{"type":"actions","id":"a1","actions":[{"id":"generative-ui.submit","label":"Submit"}]}]}`)}) + if err != nil { + t.Fatal(err) + } + s.SetProjectLifecycle(extension.ProjectLifecycle{Work: func(context.Context, string, core.ID) (bool, error) { return false, nil }}, nil) + w := lifecycleRequest(t, s, key, "POST", "/api/surfaces/"+snap.Identity.SurfaceID+"/actions", `{"actionId":"generative-ui.submit","revision":1,"requestId":"request"}`) + if w.Code != 404 { + t.Fatalf("revoked surface action=%d %s", w.Code, w.Body.String()) + } +} + +func TestProjectLifecycleSettingsRetainServiceMetadata(t *testing.T) { + s, st := newAuthTestServer(t) + key, _ := st.CreateAPIKey("alice", "test") + p, _ := st.CreateProject("alice", "Project") + s.SetProjectLifecycle(extension.ProjectLifecycle{List: func(context.Context, extension.Host) ([]core.Project, error) { + current, err := st.GetProject("alice", p.ID) + if err != nil { + return nil, err + } + if current.Settings == nil { + current.Settings = map[string]any{} + } + current.Settings["rdm.projectId"] = "canonical" + current.Settings["rdm.state"] = "ready" + return []core.Project{current}, nil + }}, nil) + w := lifecycleRequest(t, s, key, "PATCH", "/api/projects/"+p.ID, `{"settings":{"rdm.projectId":"forged","example":true}}`) + var got core.Project + _ = json.Unmarshal(w.Body.Bytes(), &got) + if w.Code != 200 || got.Settings["rdm.projectId"] != "canonical" || got.Settings["example"] != true { + t.Fatalf("settings lost service metadata: %d %s", w.Code, w.Body.String()) + } +} diff --git a/internal/server/project_resource_filter_test.go b/internal/server/project_resource_filter_test.go new file mode 100644 index 0000000..581f147 --- /dev/null +++ b/internal/server/project_resource_filter_test.go @@ -0,0 +1,100 @@ +package server + +import ( + "agent-system-backend/pkg/extension" + core "agent-system-core" + "context" + "encoding/json" + "testing" +) + +func TestKnowledgeV2UsesDocumentIdentity(t *testing.T) { + s, st := newAuthTestServer(t) + key, _ := st.CreateAPIKey("bob", "test") + docs := []core.KnowledgeDoc{{ID: "published-slice", Owner: "alice", ProjectID: "domain-p", Chunk: "sharedneedle published"}, {ID: "private-draft", Owner: "alice", ProjectID: "domain-p", Chunk: "sharedneedle draft"}, {ID: "self-denied", Owner: "bob", ProjectID: "domain-p", Chunk: "sharedneedle own restricted"}, {ID: "self-allowed", Owner: "bob", ProjectID: "domain-p", Chunk: "sharedneedle own"}} + if err := st.AddKnowledge(docs...); err != nil { + t.Fatal(err) + } + s.SetProjectAuthorizer(func(string, core.ID) (bool, error) { return true, nil }) + seen := map[string]extension.ResourceAuthzQuery{} + s.SetResourceAuthorizerV2(func(q extension.ResourceAuthzQuery) (bool, error) { + seen[q.ResourceID] = q + return q.ResourceType == "project" || q.ResourceID == "published-slice" || q.ResourceID == "self-allowed", nil + }) + w := lifecycleRequest(t, s, key, "GET", "/api/knowledge/search?q=sharedneedle", "") + var hits []core.RetrievalHit + _ = json.Unmarshal(w.Body.Bytes(), &hits) + if w.Code != 200 || len(hits) != 2 { + t.Fatalf("V2 filtered hits=%d %s", w.Code, w.Body.String()) + } + for _, hit := range hits { + if hit.Doc.ID != "published-slice" && hit.Doc.ID != "self-allowed" { + t.Fatalf("private leak: %+v", hit.Doc) + } + } + q := seen["published-slice"] + if q.ResourceType != "knowledge" || q.Actor != "bob" || q.Action != "search" || q.Owner != "alice" || q.ProjectID != "domain-p" { + t.Fatalf("trusted doc query: %+v", q) + } +} +func TestArtifactListV2FiltersEachResource(t *testing.T) { + s, st := newAuthTestServer(t) + key, _ := st.CreateAPIKey("bob", "test") + p, _ := st.CreateProject("alice", "Project") + private := core.Artifact{Owner: "alice", ProjectID: p.ID, Name: "Private", MIME: "text/plain"} + public := core.Artifact{Owner: "alice", ProjectID: p.ID, Name: "Shared", MIME: "text/plain"} + if err := st.CreateArtifact(&private, []byte("private")); err != nil { + t.Fatal(err) + } + if err := st.CreateArtifact(&public, []byte("shared")); err != nil { + t.Fatal(err) + } + s.SetResourceAuthorizerV2(func(q extension.ResourceAuthzQuery) (bool, error) { + return q.ResourceType == "project" || (q.ResourceType == "artifact" && q.Action == "list" && q.ResourceID == public.ID && q.Owner == "alice"), nil + }) + w := lifecycleRequest(t, s, key, "GET", "/api/projects/"+p.ID+"/artifacts", "") + var as []core.Artifact + _ = json.Unmarshal(w.Body.Bytes(), &as) + if w.Code != 200 || len(as) != 1 || as[0].ID != public.ID { + t.Fatalf("artifact list=%d %s", w.Code, w.Body.String()) + } +} + +func TestKnowledgeV2ResolvesTrustedProjectScope(t *testing.T) { + s, st := newAuthTestServer(t) + key, _ := st.CreateAPIKey("bob", "test") + 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) + } + s.SetResourceAuthorizerV2(func(q extension.ResourceAuthzQuery) (bool, error) { return q.ResourceType == "knowledge", nil }) + s.SetProjectLifecycle(extension.ProjectLifecycle{List: func(context.Context, extension.Host) ([]core.Project, error) { + return []core.Project{{ID: "host-current", Settings: map[string]any{"rdm.projectId": "domain-current", "rdm.state": "ready"}}, {ID: "host-other", Settings: map[string]any{"rdm.projectId": "domain-other", "rdm.state": "ready"}}}, nil + }}, nil) + w := lifecycleRequest(t, s, key, "GET", "/api/knowledge/search?q=scopeneedle&project=host-current", "") + var hits []core.RetrievalHit + _ = json.Unmarshal(w.Body.Bytes(), &hits) + if w.Code != 200 || len(hits) != 1 || hits[0].Doc.ID != "slice-current" { + t.Fatalf("current scope=%d %s", w.Code, w.Body.String()) + } + if w := lifecycleRequest(t, s, key, "GET", "/api/knowledge/search?q=scopeneedle&project=unknown", ""); w.Code != 404 { + t.Fatalf("unknown scope=%d %s", w.Code, w.Body.String()) + } +} + +func TestKnowledgeV2UsesStoredPublicationSource(t *testing.T) { + s, st := newAuthTestServer(t) + key, _ := st.CreateAPIKey("bob", "test") + // A known published identity replaced by a private row must lose sharing. + if err := st.AddKnowledge(core.KnowledgeDoc{ID: "known-published-id", Owner: "alice", ProjectID: "domain", Source: "manual", Chunk: "sourceneedle private"}, core.KnowledgeDoc{ID: "real-published-id", Owner: "alice", ProjectID: "domain", Source: "plugin:rdm:published", Chunk: "sourceneedle public"}); err != nil { + t.Fatal(err) + } + s.SetResourceAuthorizerV2(func(q extension.ResourceAuthzQuery) (bool, error) { + return q.ResourceType == "knowledge" && q.Source == "plugin:rdm:published", nil + }) + w := lifecycleRequest(t, s, key, "GET", "/api/knowledge/search?q=sourceneedle", "") + var hits []core.RetrievalHit + _ = json.Unmarshal(w.Body.Bytes(), &hits) + if w.Code != 200 || len(hits) != 1 || hits[0].Doc.ID != "real-published-id" { + t.Fatalf("actual stored source=%d %s", w.Code, w.Body.String()) + } +} diff --git a/internal/server/resource_authz_consumption_test.go b/internal/server/resource_authz_consumption_test.go index c29b832..eebdead 100644 --- a/internal/server/resource_authz_consumption_test.go +++ b/internal/server/resource_authz_consumption_test.go @@ -77,7 +77,7 @@ func TestSharedReadPathsConsultV2WithResourceContext(t *testing.T) { t.Fatalf("download 查询六要素不符: %+v", dl) } ls := byAction["list"] - if ls.Actor != "bob" || ls.ResourceType != "project" || ls.ResourceID != pid || ls.ProjectID != pid { + if ls.Actor != "bob" || ls.ResourceType != "artifact" || ls.ResourceID != art.ID || ls.ProjectID != pid || ls.Owner != "alice" { t.Fatalf("list 查询不符: %+v", ls) } rd := byAction["read"] diff --git a/internal/server/server.go b/internal/server/server.go index c3f0596..c36f113 100644 --- a/internal/server/server.go +++ b/internal/server/server.go @@ -26,6 +26,7 @@ import ( "agent-system-backend/internal/intentgate" "agent-system-backend/internal/plugins" "agent-system-backend/internal/profiles" + "agent-system-backend/internal/projectscope" "agent-system-backend/internal/prompts" "agent-system-backend/internal/ratelimit" "agent-system-backend/internal/route" @@ -41,24 +42,26 @@ import ( ) type Server struct { - st *store.Store - Auth *Authenticator // API key 认证(nil/未启用 = 默认租户;组合根注入) - Retriever core.Retriever // 混合检索(nil = store 全文;组合根注入) - Indexer core.DocumentIndexer // 检索索引入口(nil = 整文档单块;组合根注入) - Limiter ratelimit.Limiter // 租户限流(nil = 不限;组合根注入) - LimitLookup func() int // 当前限流值(次/分;<=0 不限;组合根缓存注入) - mgr *runmgr.Manager - Cards *cardsvc.Service // 收件箱任务卡(nil = 不启用;组合根注入) - Surfaces *surfacesvc.Service // Generative UI Surface(nil = 不启用;EXT-17) - Bus *cmdbus.Bus // 命令总线(nil = 不启用;组合根注入;四入口同源 D27) - Gate NLIntentGate // 意图闸门(chat 入口同源判定;nil = 不接) - Workflows *workflow.Registry // 已装载工作流(nil = 不接) - Prompts *prompts.Service // 提示词库(nil = 不启用 API 与技能命令;执行器另有回退) - reg *tools.Registry - prof *profiles.Service - set *settings.Service - plugins *plugins.Host - static fs.FS + projectLifecycle *extension.ProjectLifecycle + projectHost func(string) extension.Host + st *store.Store + Auth *Authenticator // API key 认证(nil/未启用 = 默认租户;组合根注入) + Retriever core.Retriever // 混合检索(nil = store 全文;组合根注入) + Indexer core.DocumentIndexer // 检索索引入口(nil = 整文档单块;组合根注入) + Limiter ratelimit.Limiter // 租户限流(nil = 不限;组合根注入) + LimitLookup func() int // 当前限流值(次/分;<=0 不限;组合根缓存注入) + mgr *runmgr.Manager + Cards *cardsvc.Service // 收件箱任务卡(nil = 不启用;组合根注入) + Surfaces *surfacesvc.Service // Generative UI Surface(nil = 不启用;EXT-17) + Bus *cmdbus.Bus // 命令总线(nil = 不启用;组合根注入;四入口同源 D27) + Gate NLIntentGate // 意图闸门(chat 入口同源判定;nil = 不接) + Workflows *workflow.Registry // 已装载工作流(nil = 不接) + Prompts *prompts.Service // 提示词库(nil = 不启用 API 与技能命令;执行器另有回退) + reg *tools.Registry + prof *profiles.Service + set *settings.Service + plugins *plugins.Host + static fs.FS // Subscribe 注入 bus 订阅(事件在线转发;重放走 store 游标)。 Subscribe func(conversationID string) (<-chan core.StreamEvent, func()) // DisableUploadIndexing EXT-9:true 时上传端点不再自动写检索索引(原件与 @@ -206,7 +209,7 @@ func (s *Server) WireBuiltinCommands() { if err != nil { return nil, err } - c, err := s.st.CreateConversation(conv.Owner, conv.ProjectID, "新会话", conv.ProfileID) + c, err := s.createPrivateConversation(ctx, conv.Owner, conv.ProjectID, "新会话", conv.ProfileID) if err != nil { return nil, err } @@ -612,6 +615,23 @@ type projectView struct { func (s *Server) listProjects(w http.ResponseWriter, r *http.Request) { owner := s.owner(r) + if s.projectLifecycle != nil { + if s.projectLifecycle.List == nil { + writeProjectErr(w, store.ErrNotFound) + return + } + ps, err := s.projectLifecycle.List(r.Context(), s.lifecycleHost(owner)) + if err != nil { + writeProjectErr(w, err) + return + } + out := make([]projectView, 0, len(ps)) + for _, p := range ps { + out = append(out, projectView{Project: p, Shared: p.Owner != owner}) + } + writeJSON(w, http.StatusOK, out) + return + } ps, err := s.st.ListProjects(owner) if err != nil { writeStoreErr(w, err) @@ -648,6 +668,15 @@ func (s *Server) createProject(w http.ResponseWriter, r *http.Request) { http.Error(w, "name required", http.StatusBadRequest) return } + if s.projectLifecycle != nil { + if s.projectLifecycle.Create == nil { + writeProjectErr(w, store.ErrNotFound) + return + } + p, err := s.projectLifecycle.Create(r.Context(), s.lifecycleHost(s.owner(r)), strings.TrimSpace(req.Name)) + writeProjectJSON(w, p, err) + return + } p, err := s.st.CreateProject(s.owner(r), strings.TrimSpace(req.Name)) writeJSONOr(w, p, err) } @@ -662,6 +691,50 @@ func (s *Server) patchProject(w http.ResponseWriter, r *http.Request) { http.Error(w, err.Error(), http.StatusBadRequest) return } + if s.projectLifecycle != nil { + var p core.Project + var err error + if req.Name != nil { + if strings.TrimSpace(*req.Name) == "" { + http.Error(w, "name required", http.StatusBadRequest) + return + } + if s.projectLifecycle.Rename == nil { + writeProjectErr(w, store.ErrNotFound) + return + } + p, err = s.projectLifecycle.Rename(r.Context(), s.lifecycleHost(s.owner(r)), id, strings.TrimSpace(*req.Name)) + if err != nil { + writeProjectErr(w, err) + return + } + } + if req.Settings != nil { + if err := s.st.UpdateProjectSettings(s.owner(r), id, req.Settings); err != nil { + writeStoreErr(w, err) + return + } + } + if req.Name == nil || req.Settings != nil { + if s.projectLifecycle.List == nil { + writeProjectErr(w, store.ErrNotFound) + return + } + var projects []core.Project + projects, err = s.projectLifecycle.List(r.Context(), s.lifecycleHost(s.owner(r))) + if err == nil { + err = store.ErrNotFound + for _, candidate := range projects { + if candidate.ID == id { + p, err = candidate, nil + break + } + } + } + } + writeProjectJSON(w, p, err) + return + } if req.Name != nil { if err := s.st.RenameProject(s.owner(r), id, *req.Name); err != nil { writeStoreErr(w, err) @@ -679,12 +752,29 @@ func (s *Server) patchProject(w http.ResponseWriter, r *http.Request) { } func (s *Server) deleteProject(w http.ResponseWriter, r *http.Request) { + if s.projectLifecycle != nil { + if s.projectLifecycle.Archive == nil { + writeProjectErr(w, store.ErrNotFound) + return + } + if err := s.projectLifecycle.Archive(r.Context(), s.lifecycleHost(s.owner(r)), r.PathValue("id")); err != nil { + writeProjectErr(w, err) + return + } + writeJSON(w, http.StatusOK, map[string]any{"ok": true}) + return + } writeOKOr(w, s.st.DeleteProject(s.owner(r), r.PathValue("id"))) } func (s *Server) listConversations(w http.ResponseWriter, r *http.Request) { owner := s.owner(r) - if _, err := s.st.GetProject(owner, r.PathValue("id")); err != nil { + if s.projectLifecycle != nil { + if err := s.projectWork(r.Context(), owner, r.PathValue("id")); err != nil { + writeProjectErr(w, err) + return + } + } else if _, err := s.st.GetProject(owner, r.PathValue("id")); err != nil { writeStoreErr(w, err) return } @@ -728,13 +818,8 @@ func (s *Server) createConversation(w http.ResponseWriter, r *http.Request) { if req.Title == "" { req.Title = "新会话" } - c, err := s.st.CreateConversation(s.owner(r), r.PathValue("id"), req.Title, req.ProfileID) - // EXT-15:自有未命中且适配器放行(成员视角的共享项目)→ 会话仍归属创建者, - // 只是落在他人项目下;撤销授权后此路径立即 404(不泄露存在性)。 - if errors.Is(err, store.ErrNotFound) && s.authorizeSharedResource(s.owner(r), "read", "project", core.ID(r.PathValue("id")), "", core.ID(r.PathValue("id"))) { - c, err = s.st.CreateConversationInProject(s.owner(r), r.PathValue("id"), req.Title, req.ProfileID) - } - writeJSONOr(w, c, err) + c, err := s.createPrivateConversation(r.Context(), s.owner(r), r.PathValue("id"), req.Title, req.ProfileID) + writeProjectJSON(w, c, err) } // conversationDetail 终态对账权威:消息树 + runs + 活动 HITL 卡片。 @@ -813,6 +898,10 @@ func (s *Server) postMessage(w http.ResponseWriter, r *http.Request) { writeStoreErr(w, err) return } + if err := s.projectWork(r.Context(), owner, conv.ProjectID); err != nil { + writeProjectErr(w, err) + return + } var req struct { Text string `json:"text"` AttachmentIDs []string `json:"attachmentIds"` @@ -968,7 +1057,7 @@ func (s *Server) postMessage(w http.ResponseWriter, r *http.Request) { } if startErr != nil { s.mgr.ReleaseConversation(convID) - writeStoreErr(w, startErr) + writeProjectErr(w, startErr) return } _ = s.st.SetMessageRunID(msg.ID, run.ID) @@ -1073,10 +1162,21 @@ func (s *Server) execCommand(w http.ResponseWriter, r *http.Request) { } var hostProject string if body.ConversationID != "" { - if conv, err := s.st.GetConversation(s.owner(r), body.ConversationID); err == nil { + conv, err := s.st.GetConversation(s.owner(r), body.ConversationID) + if err != nil { + if s.projectLifecycle != nil { + writeStoreErr(w, err) + return + } + } else { hostProject = string(conv.ProjectID) + if err := s.projectWork(r.Context(), s.owner(r), hostProject); err != nil { + writeProjectErr(w, err) + return + } } } + resp := s.Bus.Dispatch(r.Context(), cmdbus.Request{ ID: r.PathValue("id"), Params: body.Params, Entrance: body.Entrance, Actor: s.owner(r), Owner: s.owner(r), ConversationID: body.ConversationID, AnchorMessageID: body.AnchorMessageID, @@ -1155,6 +1255,22 @@ func (s *Server) resolveHITL(w http.ResponseWriter, r *http.Request) { return } resp.CardID = r.PathValue("id") + if s.projectLifecycle != nil { + card, err := s.st.GetHITLCard(s.owner(r), resp.CardID) + if err != nil { + writeStoreErr(w, err) + return + } + conv, err := s.st.GetConversation(s.owner(r), card.ConversationID) + if err != nil { + writeStoreErr(w, err) + return + } + if err := s.projectWork(r.Context(), s.owner(r), conv.ProjectID); err != nil { + writeProjectErr(w, err) + return + } + } pivotText, pivoted, err := s.mgr.ResolveHITL(s.owner(r), string(resp.CardID), resp) if err != nil && s.st.Dialect() == "postgres" { // 跨实例 HITL(U5/KTD5):本地无执行句柄——写持久命令,执行实例 @@ -1221,6 +1337,28 @@ func (s *Server) cardToProject(w http.ResponseWriter, r *http.Request) { // cardAction 处理卡上按钮:resolved + 回执消息进对话时间线(确认即留痕)。 func (s *Server) cardAction(w http.ResponseWriter, r *http.Request) { owner := s.owner(r) + if s.projectLifecycle != nil { + card, err := s.st.GetCard(owner, r.PathValue("id")) + if err != nil { + writeStoreErr(w, err) + return + } + pid := card.ProjectID + if card.ConversationID != "" { + conv, err := s.st.GetConversation(owner, card.ConversationID) + if err != nil { + writeStoreErr(w, err) + return + } + pid = conv.ProjectID + } + if pid != "" { + if err := s.projectWork(r.Context(), owner, pid); err != nil { + writeProjectErr(w, err) + return + } + } + } var body struct { FreeText string `json:"freeText,omitempty"` } @@ -1345,20 +1483,32 @@ func kindOf(filename string) string { } func (s *Server) listArtifacts(w http.ResponseWriter, r *http.Request) { - owner := s.owner(r) - pid := r.PathValue("id") - if _, err := s.st.GetProject(owner, pid); err != nil { - // EXT-8b:资源级咨询(list×project);放行则返回项目全部工件(只读共享)。 + owner, pid := s.owner(r), r.PathValue("id") + var as []core.Artifact + var err error + if _, ownedErr := s.st.GetProject(owner, pid); ownedErr != nil { if !s.authorizeSharedResource(owner, "list", "project", core.ID(pid), "", core.ID(pid)) { - writeStoreErr(w, err) + writeStoreErr(w, ownedErr) return } - as, err := s.st.ListProjectArtifactsAll(pid) - writeJSONOr(w, as, err) + as, err = s.st.ListProjectArtifactsAll(pid) + } else { + as, err = s.st.ListArtifacts(owner, pid) + } + if err != nil { + writeStoreErr(w, err) return } - as, err := s.st.ListArtifacts(owner, pid) - writeJSONOr(w, as, err) + if s.ProjectAuthzV2 != nil { + filtered := make([]core.Artifact, 0, len(as)) + for _, a := range as { + if s.authorizeSharedResource(owner, "list", "artifact", a.ID, a.Owner, a.ProjectID) { + filtered = append(filtered, a) + } + } + as = filtered + } + writeJSON(w, http.StatusOK, as) } // sharedArtifactGet EXT-8 工件读取回退:owner 自有未命中 → 取元数据问适配器, @@ -1847,6 +1997,49 @@ func (s *Server) searchKnowledge(w http.ResponseWriter, r *http.Request) { topK, _ := strconv.Atoi(r.URL.Query().Get("topK")) query := core.RetrievalQuery{ProjectID: r.URL.Query().Get("project"), Text: q, TopK: topK} owner := s.owner(r) + if s.projectLifecycle != nil && query.ProjectID != "" { + id, err := projectscope.Resolve(r.Context(), s.lifecycleHost(owner), *s.projectLifecycle, query.ProjectID) + if err != nil { + writeProjectErr(w, err) + return + } + query.ProjectID = id + } + if s.ProjectAuthzV2 != nil { + allow := func(d core.KnowledgeDoc) bool { + ok, err := s.ProjectAuthzV2(extension.ResourceAuthzQuery{Actor: owner, Action: "search", ResourceType: "knowledge", ResourceID: d.ID, Owner: d.Owner, ProjectID: d.ProjectID, Source: d.Source}) + return err == nil && ok + } + if rs, ok := s.Retriever.(interface { + RetrieveAuthorized(context.Context, core.ID, core.RetrievalQuery, func(core.KnowledgeDoc) bool) ([]core.RetrievalHit, error) + }); ok { + hits, err := rs.RetrieveAuthorized(r.Context(), owner, query, allow) + writeJSONOr(w, hits, err) + return + } + hits, err := s.st.SearchKnowledge(owner, query) + if err != nil { + writeStoreErr(w, err) + return + } + filtered := make([]core.RetrievalHit, 0, len(hits)) + for _, h := range hits { + if allow(h.Doc) { + filtered = append(filtered, h) + } + } + shared, err := s.st.SearchKnowledgeSharedAuthorized(owner, query, allow) + if err != nil { + writeStoreErr(w, err) + return + } + filtered = append(filtered, shared...) + if query.TopK > 0 && len(filtered) > query.TopK { + filtered = filtered[:query.TopK] + } + writeJSON(w, http.StatusOK, filtered) + return + } // EXT-8 余项:Retriever 支持共享版(向量+全文两路追加)时整体走共享版; // 否则维持切片 2 行为(本人检索 + 全文共享追加)。 allow := func(docOwner, projectID core.ID) bool { diff --git a/internal/store/content.go b/internal/store/content.go index 10331c2..df16303 100644 --- a/internal/store/content.go +++ b/internal/store/content.go @@ -381,13 +381,46 @@ func sharedVisible(requester string, q core.RetrievalQuery, allow func(docOwner, // 经 allow(docOwner, projectID) 放行的命中(全文通道;向量通道共享仍留后续切片)。 // allow 出错由调用方前置处理——本层只见布尔。 func (s *Store) SearchKnowledgeShared(requester string, q core.RetrievalQuery, allow func(docOwner, projectID core.ID) bool) ([]core.RetrievalHit, error) { - pred := sharedVisible(requester, q, allow) + return s.SearchKnowledgeSharedAuthorized(requester, q, func(d core.KnowledgeDoc) bool { return allow(d.Owner, d.ProjectID) }) +} + +// SearchKnowledgeSharedAuthorized evaluates the trusted document identity before +// returning a shared row. Project-level permissions cannot grant private content. +func (s *Store) SearchKnowledgeSharedAuthorized(requester string, q core.RetrievalQuery, allow func(core.KnowledgeDoc) bool) ([]core.RetrievalHit, error) { + pred := func(d core.KnowledgeDoc) bool { + return d.Owner != "" && d.Owner != requester && d.ProjectID != "" && + (q.ProjectID == "" || d.ProjectID == q.ProjectID) && + (len(q.Sources) == 0 || containsStr(q.Sources, d.Source)) && allow(d) + } if s.dialect.name() == "postgres" { return s.searchKnowledgePGShared(requester, q, pred) } return s.searchKnowledgeSQLiteShared(requester, q, pred) } +// DeleteProjectKnowledgeByRef replaces one publication scope and its vectors in +// a single transaction. Empty project is the legacy unscoped publication domain. +func (s *Store) DeleteProjectKnowledgeByRef(ctx context.Context, owner, projectID, ref string) error { + tx, err := s.db.BeginTx(ctx, nil) + if err != nil { + return err + } + defer tx.Rollback() + args := []any{owner, ref} + pred := "owner=? AND ref=?" + if projectID != "" { + pred += " AND project_id=?" + args = append(args, projectID) + } + if _, err := tx.ExecContext(ctx, s.q("DELETE FROM knowledge_vectors WHERE doc_id IN (SELECT id FROM knowledge WHERE "+pred+")"), args...); err != nil { + return err + } + if _, err := tx.ExecContext(ctx, s.q("DELETE FROM knowledge WHERE "+pred), args...); err != nil { + return err + } + return tx.Commit() +} + func (s *Store) searchKnowledgeSQLite(owner string, q core.RetrievalQuery) ([]core.RetrievalHit, error) { out, err := s.searchKnowledgeFTS(ownerVisible(owner, q), q) if err != nil { diff --git a/internal/store/project_projection.go b/internal/store/project_projection.go new file mode 100644 index 0000000..13aa99d --- /dev/null +++ b/internal/store/project_projection.go @@ -0,0 +1,149 @@ +package store + +import ( + core "agent-system-core" + "database/sql" + "errors" + "strings" + "time" +) + +// lockProjectNames serializes projection identity and name admission across all +// owners. SQLite acquires its write reservation before any reads; PostgreSQL +// uses a transaction-scoped advisory lock shared by every projection mutation. +func (s *Store) lockProjectNames(tx *sql.Tx) error { + if s.dialect.name() == "sqlite" { + _, err := tx.Exec(`UPDATE projects SET id=id WHERE 1=0`) + return err + } + return s.dialect.lockScope(tx, "", "host-project-names") +} + +// EnsureProject creates a stable projection or verifies an identical retry. +func (s *Store) EnsureProject(owner, id, name string) (core.Project, error) { + name = strings.TrimSpace(name) + if owner == "" || id == "" || name == "" { + return core.Project{}, errors.New("store: project owner, id and name required") + } + tx, err := s.db.Begin() + if err != nil { + return core.Project{}, err + } + defer tx.Rollback() + if err = s.lockProjectNames(tx); err != nil { + return core.Project{}, err + } + p, err := s.projectMetadata(tx, id) + if err == nil { + if p.Owner != owner { + return core.Project{}, ErrNotFound + } + if p.Name != name { + return core.Project{}, ErrConflict + } + return p, tx.Commit() + } + if !errors.Is(err, ErrNotFound) { + return core.Project{}, err + } + available, err := s.projectNameAvailable(tx, name, id) + if err != nil { + return core.Project{}, err + } + if !available { + return core.Project{}, ErrConflict + } + now := time.Now().UTC() + p = core.Project{ID: id, Owner: owner, Name: name, CreatedAt: now, UpdatedAt: now, Settings: map[string]any{}} + _, err = tx.Exec(s.q(`INSERT INTO projects(id,owner,name,settings,created_at,updated_at) VALUES(?,?,?,?,?,?)`), id, owner, name, "{}", now.Format(time.RFC3339Nano), now.Format(time.RFC3339Nano)) + if err != nil { + return core.Project{}, err + } + return p, tx.Commit() +} + +func (s *Store) RenameOwnedProject(owner, id, name string) (core.Project, error) { + name = strings.TrimSpace(name) + if name == "" { + return core.Project{}, errors.New("store: project name required") + } + tx, err := s.db.Begin() + if err != nil { + return core.Project{}, err + } + defer tx.Rollback() + if err = s.lockProjectNames(tx); err != nil { + return core.Project{}, err + } + p, err := s.projectMetadata(tx, id) + if err != nil { + return core.Project{}, err + } + if p.Owner != owner { + return core.Project{}, ErrNotFound + } + available, err := s.projectNameAvailable(tx, name, id) + if err != nil { + return core.Project{}, err + } + if !available { + return core.Project{}, ErrConflict + } + p.Name, p.UpdatedAt = name, time.Now().UTC() + _, err = tx.Exec(s.q(`UPDATE projects SET name=?,updated_at=? WHERE id=? AND owner=?`), p.Name, p.UpdatedAt.Format(time.RFC3339Nano), id, owner) + if err != nil { + return core.Project{}, err + } + return p, tx.Commit() +} + +type projectQuerier interface { + QueryRow(string, ...any) *sql.Row + Query(string, ...any) (*sql.Rows, error) +} + +func (s *Store) projectNameAvailable(q projectQuerier, name, exceptID string) (bool, error) { + // Use the same Go whitespace rule as admission, including legacy raw names. + rows, err := q.Query(s.q(`SELECT name FROM projects WHERE id<>?`), exceptID) + if err != nil { + return false, err + } + defer rows.Close() + name = strings.TrimSpace(name) + for rows.Next() { + var candidate string + if err := rows.Scan(&candidate); err != nil { + return false, err + } + if strings.TrimSpace(candidate) == name { + return false, nil + } + } + return true, rows.Err() +} + +func (s *Store) ProjectNameAvailable(name, exceptID string) (bool, error) { + return s.projectNameAvailable(s.db, name, exceptID) +} +func (s *Store) projectMetadata(q projectQuerier, id string) (core.Project, error) { + var p core.Project + var settings, ca, ua string + err := q.QueryRow(s.q(`SELECT id,owner,name,settings,created_at,updated_at FROM projects WHERE id=?`), id).Scan(&p.ID, &p.Owner, &p.Name, &settings, &ca, &ua) + if errors.Is(err, sql.ErrNoRows) { + return p, ErrNotFound + } + if err != nil { + return p, err + } + p.CreatedAt, _ = time.Parse(time.RFC3339Nano, ca) + p.UpdatedAt, _ = time.Parse(time.RFC3339Nano, ua) + p.Settings = map[string]any{} + if settings != "" && settings != "{}" { + p.Settings, _ = unmarshalJSON[map[string]any](settings) + } + return p, nil +} + +// ProjectMetadata is an internal trusted lookup. Capabilities must authorize it +// before exposing metadata to another owner. +func (s *Store) ProjectMetadata(id string) (core.Project, error) { return s.projectMetadata(s.db, id) } diff --git a/internal/store/project_projection_test.go b/internal/store/project_projection_test.go new file mode 100644 index 0000000..199aa21 --- /dev/null +++ b/internal/store/project_projection_test.go @@ -0,0 +1,83 @@ +package store + +import ( + "errors" + "sync" + "testing" +) + +func TestProjectProjectionIdentityAndOwnership(t *testing.T) { + st := openTestStore(t) + p, err := st.EnsureProject("alice", "host-stable", " Global ") + if err != nil || p.ID != "host-stable" || p.Name != "Global" || p.Owner != "alice" { + t.Fatalf("ensure: %+v %v", p, err) + } + retry, err := st.EnsureProject("alice", p.ID, "Global") + if err != nil || retry.ID != p.ID { + t.Fatalf("retry: %+v %v", retry, err) + } + if _, err = st.EnsureProject("bob", p.ID, "Global"); !errors.Is(err, ErrNotFound) { + t.Fatalf("other owner: %v", err) + } + if _, err = st.EnsureProject("alice", p.ID, "Different"); !errors.Is(err, ErrConflict) { + t.Fatalf("different retry name: %v", err) + } + if _, err = st.RenameOwnedProject("bob", p.ID, "Other"); !errors.Is(err, ErrNotFound) { + t.Fatalf("rename owner: %v", err) + } +} + +func TestProjectProjectionGlobalNameAdmission(t *testing.T) { + st := openTestStore(t) + if _, err := st.CreateProject("legacy", "Taken"); err != nil { + t.Fatal(err) + } + if _, err := st.EnsureProject("alice", "a", " Taken "); !errors.Is(err, ErrConflict) { + t.Fatalf("legacy collision: %v", err) + } + var wg sync.WaitGroup + results := make(chan error, 2) + for _, owner := range []string{"alice", "bob"} { + wg.Add(1) + go func(owner string) { + defer wg.Done() + _, err := st.EnsureProject(owner, "p-"+owner, "Race") + results <- err + }(owner) + } + wg.Wait() + close(results) + successes, conflicts := 0, 0 + for err := range results { + if err == nil { + successes++ + } else if errors.Is(err, ErrConflict) { + conflicts++ + } else { + t.Fatal(err) + } + } + if successes != 1 || conflicts != 1 { + t.Fatalf("successes=%d conflicts=%d", successes, conflicts) + } + if _, err := st.EnsureProject("alice", "rename-a", "Rename source"); err != nil { + t.Fatal(err) + } + if _, err := st.RenameOwnedProject("alice", "rename-a", "Taken"); !errors.Is(err, ErrConflict) { + t.Fatalf("rename collision: %v", err) + } + available, err := st.ProjectNameAvailable("Taken", "") + if err != nil || available { + t.Fatalf("availability=%v %v", available, err) + } +} + +func TestProjectProjectionAdmissionTrimsLegacyWhitespace(t *testing.T) { + st := openTestStore(t) + if _, err := st.CreateProject("legacy", "\tReserved\u00a0"); err != nil { + t.Fatal(err) + } + if _, err := st.EnsureProject("other", "new-id", "Reserved"); !errors.Is(err, ErrConflict) { + t.Fatalf("trimmed legacy admission: %v", err) + } +} diff --git a/internal/store/vectors.go b/internal/store/vectors.go index 02d653a..e98c544 100644 --- a/internal/store/vectors.go +++ b/internal/store/vectors.go @@ -121,7 +121,16 @@ func (s *Store) SearchKnowledgeVectorsSharedModel(ctx context.Context, requester return hits, nil } } - return s.searchKnowledgeVectorsSharedScan(ctx, requester, model, vec, topK, filter, allow) + return s.SearchKnowledgeVectorsSharedAuthorized(ctx, requester, model, vec, topK, filter, func(d core.KnowledgeDoc) bool { return allow == nil || allow(d.Owner, d.ProjectID) }) +} + +// SearchKnowledgeVectorsSharedAuthorized applies per-document authorization to +// shared vector candidates before ranking, including their actual stored ID. +func (s *Store) SearchKnowledgeVectorsSharedAuthorized(ctx context.Context, requester, model string, vec []float32, topK int, filter core.RetrievalQuery, allow func(core.KnowledgeDoc) bool) ([]core.RetrievalHit, error) { + if topK <= 0 { + topK = 5 + } + return s.searchKnowledgeVectorsSharedAuthorizedScan(ctx, requester, model, vec, topK, filter, allow) } // allowAll 判定谓词是否恒真(nil 或恒放行)——库内路径的启用条件。 @@ -187,6 +196,9 @@ func (s *Store) searchKnowledgeVectorsPG(ctx context.Context, owner, model strin // searchKnowledgeVectorsSharedScan 原进程内共享扫描(降级/谓词路径)。 func (s *Store) searchKnowledgeVectorsSharedScan(ctx context.Context, requester, model string, vec []float32, topK int, filter core.RetrievalQuery, allow func(docOwner, projectID core.ID) bool) ([]core.RetrievalHit, error) { + return s.searchKnowledgeVectorsSharedAuthorizedScan(ctx, requester, model, vec, topK, filter, func(d core.KnowledgeDoc) bool { return allow == nil || allow(d.Owner, d.ProjectID) }) +} +func (s *Store) searchKnowledgeVectorsSharedAuthorizedScan(ctx context.Context, requester, model string, vec []float32, topK int, filter core.RetrievalQuery, allow func(core.KnowledgeDoc) bool) ([]core.RetrievalHit, error) { q := ` SELECT k.id,k.owner,k.project_id,k.source,k.title,k.ref,k.chunk,k.created_at, v.vec FROM knowledge_vectors v JOIN knowledge k ON k.id = v.doc_id @@ -215,7 +227,7 @@ func (s *Store) searchKnowledgeVectorsSharedScan(ctx context.Context, requester, } d.Owner = own d.CreatedAt, _ = parseTime(ca) - if d.ProjectID == "" || !allow(d.Owner, d.ProjectID) { + if d.ProjectID == "" || !allow(d) { continue } if filter.ProjectID != "" && d.ProjectID != filter.ProjectID { diff --git a/pkg/extension/account_lister_test.go b/pkg/extension/account_lister_test.go index d448879..3932178 100644 --- a/pkg/extension/account_lister_test.go +++ b/pkg/extension/account_lister_test.go @@ -5,14 +5,21 @@ package extension // 不含姓名/邮箱/联系方式)。红:类型未定义,编译红先行。 import ( "encoding/json" + "strconv" "strings" "testing" ) func TestAccountListerSurface(t *testing.T) { - if !strings.HasPrefix(SurfaceVersion, "2.11.0") { - t.Fatalf("SurfaceVersion 应升至 2.11.0-experimental(新增导出符号须升版),got %s", SurfaceVersion) + parts := strings.Split(SurfaceVersion, ".") + minor := 0 + if len(parts) > 1 { + minor, _ = strconv.Atoi(parts[1]) } + if len(parts) < 2 || parts[0] != "2" || minor < 11 { + t.Fatalf("AccountLister requires experimental version 2.11 or later, got %s", SurfaceVersion) + } + // 接口方法签名即契约:游标分页 + 最小字段。 var _ AccountLister = (AccountLister)(nil) e := AccountEntry{Owner: "u1", Valid: true} diff --git a/pkg/extension/extension.go b/pkg/extension/extension.go index 976880d..2454e4e 100644 --- a/pkg/extension/extension.go +++ b/pkg/extension/extension.go @@ -32,7 +32,9 @@ import ( // 2.9.0-experimental:Registry.AddAdminZone 与 AdminZoneSpec(WP3-I3/KTD4:编译 // 插件向基座管理区声明展示分区;分区入口不做基座级权限判定,数据能力由 // 分区组件自身的命令面 fail-closed)。 -const SurfaceVersion = "2.11.0-experimental" +// 2.12.0-experimental: optional ProjectHost/ProjectLifecycleRegistry and trusted +// ResourceAuthzQuery.Source for per-document publication authorization. +const SurfaceVersion = "2.12.0-experimental" // ErrNotLinked 表示进程没有空白导入 link 包,Main 无法启动服务。 var ErrNotLinked = errors.New("extension: runner not linked; blank-import agent-system-backend/pkg/extension/link") @@ -43,15 +45,17 @@ var ErrNotLinked = errors.New("extension: runner not linked; blank-import agent- type ResourceAuthorizer func(owner string, projectID core.ID) (bool, error) // ResourceAuthzQuery 是 EXT-8b 资源级授权查询(U9):字段由执行端从受信上下文 -//(认证会话、受信存储)构造,不采信请求自报的归属;下游只回答布尔,不接触基座 +// (认证会话、受信存储)构造,不采信请求自报的归属;下游只回答布尔,不接触基座 // 内部状态;返回 error 视为拒绝(fail-closed)。读取路径专用;写路径一律 owner 自有。 type ResourceAuthzQuery struct { - Actor string `json:"actor"` // 认证主体(发起读取的会话) - Action string `json:"action"` // read|download|list|search|stream|model-context - ResourceType string `json:"resourceType"` // 基座资源类型(artifact/attachment/...) - ResourceID core.ID `json:"resourceId"` // 资源稳定 ID - Owner string `json:"owner"` // 资源属主(受信存储解析,非请求自报) - ProjectID core.ID `json:"projectId,omitempty"` // 所属项目;空=无项目私域 + Actor string `json:"actor"` // 认证主体(发起读取的会话) + Action string `json:"action"` // read|download|list|search|stream|model-context + ResourceType string `json:"resourceType"` // 基座资源类型(artifact/attachment/...) + ResourceID core.ID `json:"resourceId"` // 资源稳定 ID + Owner string `json:"owner"` // 资源属主(受信存储解析,非请求自报) + // Source is optional trusted resource provenance, populated from stored knowledge rows. + Source string `json:"source,omitempty"` + ProjectID core.ID `json:"projectId,omitempty"` // 所属项目;空=无项目私域 } // ResourceAuthorizerV2 EXT-8b 资源级授权适配器:V1(owner×project 布尔)之上的 diff --git a/pkg/extension/projects.go b/pkg/extension/projects.go new file mode 100644 index 0000000..259e4dd --- /dev/null +++ b/pkg/extension/projects.go @@ -0,0 +1,35 @@ +package extension + +import ( + core "agent-system-core" + "context" +) + +// ProjectHost is an optional actor-scoped host projection capability. Metadata +// can be shared by an authorizer; owned lookups and mutations remain owner-only. +type ProjectHost interface { + Owner() string + OwnedProjects() ([]core.Project, error) + ProjectMetadata(projectID string) (core.Project, error) + OwnedProject(projectID string) (core.Project, error) + EnsureProject(projectID, name string) (core.Project, error) + RenameOwnedProject(projectID, name string) (core.Project, error) + ProjectNameAvailable(name, exceptID string) (bool, error) +} + +// ProjectLifecycle supplies the domain's public project lifecycle and live work +// authorization. Each Host is scoped to the authenticated actor. Errors with a +// StatusCode() int method may report 400, 403, 404, 409, or 503 to HTTP clients. +type ProjectLifecycle struct { + List func(context.Context, Host) ([]core.Project, error) + Create func(context.Context, Host, string) (core.Project, error) + Rename func(context.Context, Host, string, string) (core.Project, error) + Archive func(context.Context, Host, string) error + Work func(context.Context, string, core.ID) (bool, error) +} + +// ProjectLifecycleRegistry is optional so older Registry implementations retain +// their contract. Only in-process plugins can register one complete provider. +type ProjectLifecycleRegistry interface { + RegisterProjectLifecycle(ProjectLifecycle) error +} diff --git a/pkg/extension/surface.txt b/pkg/extension/surface.txt index 8f4cfaf..7d88ba4 100644 --- a/pkg/extension/surface.txt +++ b/pkg/extension/surface.txt @@ -32,6 +32,17 @@ Main NewHostClient NewScopedHost Options +ProjectHost +ProjectHost.EnsureProject +ProjectHost.OwnedProject +ProjectHost.OwnedProjects +ProjectHost.Owner +ProjectHost.ProjectMetadata +ProjectHost.ProjectNameAvailable +ProjectHost.RenameOwnedProject +ProjectLifecycle +ProjectLifecycleRegistry +ProjectLifecycleRegistry.RegisterProjectLifecycle RegisterCompiled Registry Registry.AddAdminZone -- 2.54.0 From b9bea5bee080b1e23ea5fa33af1878146ecd5e04 Mon Sep 17 00:00:00 2001 From: weichen Date: Tue, 13 Oct 2026 08:47:19 +0000 Subject: [PATCH 2/6] Filter private knowledge before applying shared result limits Why: Higher-ranked private rows could exhaust SQL candidate limits and hide an otherwise authorized published slice from a member's current-project search. Evidence: SELF TestAuthorizedKnowledgeFiltersBeforeTopK failed with no shared hits before the change and passed after it. SELF go test ./... and the isolated PostgreSQL Store suite passed; independent replay and current-HEAD CI remain owner gates. Impact: Scan the resource-authorized shared fulltext path until enough allowed results are found. Preserve ordinary legacy retrieval candidate limits and semantics. Refs: Dev project-member-access plan r1 Task 1; R5 published knowledge availability --- internal/store/content.go | 47 +++++++++++++++---- .../store/knowledge_authorization_test.go | 22 +++++++++ internal/store/search_postgres.go | 15 ++++-- 3 files changed, 72 insertions(+), 12 deletions(-) create mode 100644 internal/store/knowledge_authorization_test.go diff --git a/internal/store/content.go b/internal/store/content.go index df16303..f203824 100644 --- a/internal/store/content.go +++ b/internal/store/content.go @@ -381,7 +381,11 @@ func sharedVisible(requester string, q core.RetrievalQuery, allow func(docOwner, // 经 allow(docOwner, projectID) 放行的命中(全文通道;向量通道共享仍留后续切片)。 // allow 出错由调用方前置处理——本层只见布尔。 func (s *Store) SearchKnowledgeShared(requester string, q core.RetrievalQuery, allow func(docOwner, projectID core.ID) bool) ([]core.RetrievalHit, error) { - return s.SearchKnowledgeSharedAuthorized(requester, q, func(d core.KnowledgeDoc) bool { return allow(d.Owner, d.ProjectID) }) + pred := sharedVisible(requester, q, allow) + if s.dialect.name() == "postgres" { + return s.searchKnowledgePGShared(requester, q, pred) + } + return s.searchKnowledgeSQLiteShared(requester, q, pred) } // SearchKnowledgeSharedAuthorized evaluates the trusted document identity before @@ -393,9 +397,16 @@ func (s *Store) SearchKnowledgeSharedAuthorized(requester string, q core.Retriev (len(q.Sources) == 0 || containsStr(q.Sources, d.Source)) && allow(d) } if s.dialect.name() == "postgres" { - return s.searchKnowledgePGShared(requester, q, pred) + return s.searchKnowledgePGSharedCandidates(requester, q, pred, false) } - return s.searchKnowledgeSQLiteShared(requester, q, pred) + out, err := s.searchKnowledgeFTSCandidates(pred, q, false) + if err != nil { + return nil, err + } + if len(out) == 0 && q.Text != "" { + return s.searchKnowledgeLikeSharedCandidates(requester, q, pred, false) + } + return out, nil } // DeleteProjectKnowledgeByRef replaces one publication scope and its vectors in @@ -452,14 +463,23 @@ func (s *Store) searchKnowledgeSQLiteShared(requester string, q core.RetrievalQu // searchKnowledgeFTS FTS 通道(owner 作用域与共享作用域共用:可见性谓词由调用方注入)。 func (s *Store) searchKnowledgeFTS(pred knowledgePred, q core.RetrievalQuery) ([]core.RetrievalHit, error) { + return s.searchKnowledgeFTSCandidates(pred, q, true) +} + +func (s *Store) searchKnowledgeFTSCandidates(pred knowledgePred, q core.RetrievalQuery, bounded bool) ([]core.RetrievalHit, error) { topK := q.TopK if topK <= 0 { topK = 5 } - rows, err := s.db.Query(s.q(` - SELECT k.id,k.owner,k.project_id,k.source,k.title,k.ref,k.chunk,k.created_at, rank - FROM knowledge_fts f JOIN knowledge k ON k.rowid=f.rowid - WHERE knowledge_fts MATCH ? ORDER BY rank LIMIT ?`), ftsQuery(q.Text), topK*2) + query := `SELECT k.id,k.owner,k.project_id,k.source,k.title,k.ref,k.chunk,k.created_at, rank + FROM knowledge_fts f JOIN knowledge k ON k.rowid=f.rowid + WHERE knowledge_fts MATCH ? ORDER BY rank` + args := []any{ftsQuery(q.Text)} + if bounded { + query += " LIMIT ?" + args = append(args, topK*2) + } + rows, err := s.db.Query(s.q(query), args...) if err != nil { return nil, err } @@ -482,12 +502,21 @@ func (s *Store) searchKnowledgeFTS(pred knowledgePred, q core.RetrievalQuery) ([ } func (s *Store) searchKnowledgeLikeShared(requester string, q core.RetrievalQuery, pred knowledgePred) ([]core.RetrievalHit, error) { + return s.searchKnowledgeLikeSharedCandidates(requester, q, pred, true) +} + +func (s *Store) searchKnowledgeLikeSharedCandidates(requester string, q core.RetrievalQuery, pred knowledgePred, bounded bool) ([]core.RetrievalHit, error) { topK := q.TopK if topK <= 0 { topK = 5 } - rows, err := s.db.Query(s.q(`SELECT id,owner,project_id,source,title,ref,chunk,created_at FROM knowledge WHERE owner<>? AND owner<>'' AND (chunk LIKE ? OR title LIKE ?) LIMIT ?`), - requester, "%"+q.Text+"%", "%"+q.Text+"%", topK) + query := `SELECT id,owner,project_id,source,title,ref,chunk,created_at FROM knowledge WHERE owner<>? AND owner<>'' AND (chunk LIKE ? OR title LIKE ?)` + args := []any{requester, "%" + q.Text + "%", "%" + q.Text + "%"} + if bounded { + query += " LIMIT ?" + args = append(args, topK) + } + rows, err := s.db.Query(s.q(query), args...) if err != nil { return nil, err } diff --git a/internal/store/knowledge_authorization_test.go b/internal/store/knowledge_authorization_test.go new file mode 100644 index 0000000..2ef80c1 --- /dev/null +++ b/internal/store/knowledge_authorization_test.go @@ -0,0 +1,22 @@ +package store + +import ( + core "agent-system-core" + "testing" +) + +func TestAuthorizedKnowledgeFiltersBeforeTopK(t *testing.T) { + st := openTestStore(t) + docs := []core.KnowledgeDoc{} + for i := 0; i < 20; i++ { + docs = append(docs, core.KnowledgeDoc{Owner: "alice", ProjectID: "domain", Source: "private", Chunk: "sharedterm sharedterm sharedterm sharedterm"}) + } + docs = append(docs, core.KnowledgeDoc{ID: "published", Owner: "alice", ProjectID: "domain", Source: "plugin:rdm:published", Chunk: "sharedterm plus additional explanatory words for this lower scoring published source"}) + if err := st.AddKnowledge(docs...); err != nil { + t.Fatal(err) + } + hits, err := st.SearchKnowledgeSharedAuthorized("bob", core.RetrievalQuery{Text: "sharedterm", ProjectID: "domain", TopK: 1}, func(d core.KnowledgeDoc) bool { return d.ID == "published" }) + if err != nil || len(hits) != 1 || hits[0].Doc.ID != "published" { + t.Fatalf("denied rows consumed top-k: %+v %v", hits, err) + } +} diff --git a/internal/store/search_postgres.go b/internal/store/search_postgres.go index 0620c9f..f5ffea5 100644 --- a/internal/store/search_postgres.go +++ b/internal/store/search_postgres.go @@ -53,19 +53,28 @@ func (s *Store) searchKnowledgePG(owner string, q core.RetrievalQuery) ([]core.R // searchKnowledgePGShared EXT-8 切片 2 共享版:他人 owner 的行 + 谓词后置判定 // (allow 回调由上层注入;排序与自检索同构)。 func (s *Store) searchKnowledgePGShared(requester string, q core.RetrievalQuery, pred knowledgePred) ([]core.RetrievalHit, error) { + return s.searchKnowledgePGSharedCandidates(requester, q, pred, true) +} + +func (s *Store) searchKnowledgePGSharedCandidates(requester string, q core.RetrievalQuery, pred knowledgePred, bounded bool) ([]core.RetrievalHit, error) { topK := q.TopK if topK <= 0 { topK = 5 } text := q.Text - rows, err := s.db.Query(s.q(` + query := ` SELECT k.id,k.owner,k.project_id,k.source,k.title,k.ref,k.chunk,k.created_at, ts_rank(k.tsv, websearch_to_tsquery('simple', ?)) AS r FROM knowledge k WHERE (k.owner<>? AND k.owner<>'') AND (? = '' OR k.tsv @@ websearch_to_tsquery('simple', ?) OR k.chunk LIKE ? OR k.title LIKE ?) - ORDER BY r DESC, k.created_at DESC - LIMIT ?`), text, requester, text, text, "%"+text+"%", "%"+text+"%", topK*2) + ORDER BY r DESC, k.created_at DESC` + args := []any{text, requester, text, text, "%" + text + "%", "%" + text + "%"} + if bounded { + query += " LIMIT ?" + args = append(args, topK*2) + } + rows, err := s.db.Query(s.q(query), args...) if err != nil { return nil, err } -- 2.54.0 From 4f72aaf3d8eaef1ae08e3df2d3c94f7bf913d615 Mon Sep 17 00:00:00 2001 From: weichen Date: Tue, 13 Oct 2026 09:04:06 +0000 Subject: [PATCH 3/6] Recheck resumed project work and isolate publication replacement Why: Membership could be revoked while a tool waited for confirmation or a queued HITL reply waited for consumption. Publication replacement also crossed an empty project scope or another plugin's source namespace for the same Ref. Evidence: SELF replay of all four independent review reproductions failed before the fix and passed unchanged afterward. Committed regressions cover direct and queued HITL, yes/session confirmation, direct artifact writes, protected vectors, exact empty project scopes, literal wildcard IDs and unregistered compatibility. SELF uncached go test ./..., isolated PostgreSQL Store tests and static build passed. Impact: Recheck trusted live owner/project authorization when HITL resumes and at write boundaries. Registered publishers replace only exact owner/project/ref/plugin rows and vectors; ordinary unregistered publication semantics are preserved. Refs: Dev project-member-access implementation plan r1 Task 1; independent Backend review findings against b9bea5bee080b1e23ea5fa33af1878146ecd5e04 --- internal/app/hostcaps.go | 15 +++- internal/app/knowledge_authorization_test.go | 61 +++++++++++++++ internal/runmgr/knowledge_resource_test.go | 77 +++++++++++++++++++ internal/runmgr/runmgr.go | 32 +++++++- internal/store/content.go | 30 ++++++-- .../store/knowledge_authorization_test.go | 48 ++++++++++++ 6 files changed, 249 insertions(+), 14 deletions(-) diff --git a/internal/app/hostcaps.go b/internal/app/hostcaps.go index 62cebe5..866a4f4 100644 --- a/internal/app/hostcaps.go +++ b/internal/app/hostcaps.go @@ -322,9 +322,9 @@ func (h inprocHost) KnowledgeWrite(ctx context.Context, docs []core.KnowledgeDoc return h.caps.pipe.IndexChunks(ctx, core.ID(h.owner), docs) } -// KnowledgePublish 幂等发布(EXT-9):按 docs 的 ref 整批替换——先清该 owner -// 下同 ref 旧行与旧向量(跨来源也清:ref 是发布单元),再走 KnowledgeWrite -// 语义(Owner/Source 强制同彼)。重试产生相同结果。 +// KnowledgePublish replaces refs and their vectors within the publication scope. +// A registered lifecycle scopes replacement to the exact owner/project/plugin; +// ordinary unregistered Hosts retain the legacy cross-source ref semantics. func (h inprocHost) KnowledgePublish(ctx context.Context, docs []core.KnowledgeDoc, embed bool) error { if len(docs) == 0 { return nil @@ -343,8 +343,15 @@ func (h inprocHost) KnowledgePublish(ctx context.Context, docs []core.KnowledgeD refs[publicationRef{docs[i].ProjectID, docs[i].Ref}] = true } } + scoped := h.caps.projects != nil && h.caps.projects.active() for ref := range refs { - if err := h.caps.st.DeleteProjectKnowledgeByRef(ctx, h.owner, ref.projectID, ref.ref); err != nil { + var err error + if scoped { + err = h.caps.st.DeletePluginProjectKnowledgeByRef(ctx, h.owner, ref.projectID, ref.ref, h.pluginID) + } else { + err = h.caps.st.DeleteProjectKnowledgeByRef(ctx, h.owner, ref.projectID, ref.ref) + } + if err != nil { return err } } diff --git a/internal/app/knowledge_authorization_test.go b/internal/app/knowledge_authorization_test.go index 7df87d3..e28dff3 100644 --- a/internal/app/knowledge_authorization_test.go +++ b/internal/app/knowledge_authorization_test.go @@ -103,3 +103,64 @@ func TestRegisteredProjectHostRejectsForeignKnowledgeSource(t *testing.T) { t.Fatalf("ordinary source semantics changed: %v", err) } } + +func TestProjectPublishPreservesForeignPluginAndScope(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) + } + original := core.KnowledgeDoc{ID: "rdm-published", Owner: "alice", ProjectID: "domain", Source: "plugin:rdm:published", Ref: "notes.md", Chunk: "originalneedle"} + if err := st.AddKnowledge(original); err != nil { + t.Fatal(err) + } + if err := st.UpsertKnowledgeVector(context.Background(), "alice", original.ID, original.Ref, []float32{1, 0}); err != nil { + t.Fatal(err) + } + h := inprocHost{owner: "alice", pluginID: "other", caps: caps} + replacement := []core.KnowledgeDoc{{ID: "other", ProjectID: "domain", Source: "plugin:other", Ref: "notes.md", Chunk: "replacementneedle"}} + for i := 0; i < 2; i++ { + if err := h.KnowledgePublish(context.Background(), replacement, false); err != nil { + t.Fatal(err) + } + } + if hits, err := st.SearchKnowledge("alice", core.RetrievalQuery{Text: "originalneedle"}); err != nil || len(hits) != 1 { + t.Fatalf("foreign plugin deleted RDM publication: %+v %v", hits, err) + } + if hits, err := st.SearchKnowledgeVectors(context.Background(), "alice", []float32{1, 0}, 5, core.RetrievalQuery{ProjectID: "domain"}); err != nil || len(hits) != 1 || hits[0].Doc.ID != original.ID { + t.Fatalf("foreign plugin deleted RDM vector: %+v %v", hits, err) + } + h.pluginID = "rdm" + if err := h.KnowledgePublish(context.Background(), []core.KnowledgeDoc{{ID: "unscoped", Source: "plugin:rdm", Ref: "notes.md", Chunk: "privateneedle"}}, false); err != nil { + t.Fatal(err) + } + if hits, err := st.SearchKnowledge("alice", core.RetrievalQuery{Text: "originalneedle", ProjectID: "domain"}); err != nil || len(hits) != 1 { + t.Fatalf("unscoped publication deleted scoped RDM data: %+v %v", hits, err) + } +} + +func TestUnregisteredPublishRetainsLegacyRefScope(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: "legacy", Owner: "alice", ProjectID: "old-project", Source: "manual", Ref: "same", Chunk: "legacyneedle"}); err != nil { + t.Fatal(err) + } + h := inprocHost{owner: "alice", pluginID: "other", caps: hostcaps{st: st}} + if err := h.KnowledgePublish(context.Background(), []core.KnowledgeDoc{{ID: "replacement", Source: "manual", Ref: "same", Chunk: "replacementneedle"}}, false); err != nil { + t.Fatal(err) + } + if hits, err := st.SearchKnowledge("alice", core.RetrievalQuery{Text: "legacyneedle"}); err != nil || len(hits) != 0 { + t.Fatalf("unregistered legacy scope changed: %+v %v", hits, err) + } + if hits, err := st.SearchKnowledge("alice", core.RetrievalQuery{Text: "replacementneedle"}); err != nil || len(hits) != 1 { + t.Fatalf("unregistered replacement missing: %+v %v", hits, err) + } +} diff --git a/internal/runmgr/knowledge_resource_test.go b/internal/runmgr/knowledge_resource_test.go index 70c2a59..2cbd96e 100644 --- a/internal/runmgr/knowledge_resource_test.go +++ b/internal/runmgr/knowledge_resource_test.go @@ -64,3 +64,80 @@ func TestRunKnowledgeResolvesTrustedProjectScope(t *testing.T) { t.Fatalf("model current scope: %+v %v", hits, err) } } + +func TestProjectWorkRevocationBlocksQueuedHITL(t *testing.T) { + c, st := newExecEnv(t) + c.run.ID = "revoked-run" + c.run.Status = core.RunWaitingHITL + if err := st.CreateRun(&c.run); err != nil { + t.Fatal(err) + } + h := &runHandle{hitlCh: make(chan hitlAnswer, 1)} + c.mgr.active = map[core.ID]*runHandle{c.run.ID: h} + card := core.HITLCard{ID: "revoked-card", RunID: c.run.ID, ConversationID: c.run.ConversationID} + if err := st.SaveHITLCard("alice", &card); err != nil { + t.Fatal(err) + } + c.mgr.SetProjectWorkAuthorization(func(_ context.Context, owner string, id core.ID) (bool, error) { + if owner != "alice" || id != c.run.ProjectID { + t.Fatalf("untrusted resolution identity owner=%s project=%s", owner, id) + } + return false, nil + }) + if _, _, err := c.mgr.ResolveHITL("alice", card.ID, core.HITLResponse{ChoiceID: "yes"}); !errors.Is(err, store.ErrNotFound) { + t.Fatalf("revoked confirmation should reject: %v", err) + } + persisted, err := st.GetHITLCard("alice", card.ID) + if err != nil || persisted.Resolved || len(h.hitlCh) != 0 { + t.Fatalf("revoked confirmation changed gate: card=%+v channel=%d error=%v", persisted, len(h.hitlCh), err) + } + run, err := st.GetRunByID(c.run.ID) + if err != nil || run.Status != core.RunWaitingHITL { + t.Fatalf("revoked confirmation resumed run: %+v %v", run, err) + } + payload, _ := json.Marshal(HITLCommandPayload{Owner: "alice", CardID: card.ID, Kind: "yes"}) + c.mgr.applyHITLCommand(ownedCommand{CommandID: "queued-approval", Payload: string(payload)}) + if len(h.hitlCh) != 0 { + t.Fatal("queued approval delivered a revoked answer") + } + run, err = st.GetRunByID(c.run.ID) + if err != nil || run.Status != core.RunWaitingHITL { + t.Fatalf("queued approval resumed run: %+v %v", run, err) + } +} + +func TestProjectWorkRevocationAfterToolConfirmation(t *testing.T) { + for _, choice := range []string{"yes", "session"} { + t.Run(choice, func(t *testing.T) { + c, st := newExecEnv(t) + c.h = &runHandle{hitlCh: make(chan hitlAnswer, 1)} + c.h.hitlCh <- hitlAnswer{resp: core.HITLResponse{ChoiceID: choice}} + c.prof.Tools.Confirm = []string{"write_artifact"} + ready := true + c.mgr.SetProjectWorkAuthorization(func(context.Context, string, core.ID) (bool, error) { ok := ready; ready = false; return ok, nil }) + res := c.ExecuteTool(core.ToolCallRequest{CallID: "revoked-call", Name: "write_artifact", Arguments: json.RawMessage(`{"name":"revoked.txt","content":"x"}`)}) + artifacts, err := st.ListArtifacts("alice", c.run.ProjectID) + if err != nil { + t.Fatal(err) + } + if res.Status != "error" || len(artifacts) != 0 { + t.Fatalf("tool executed after revoked confirmation: status=%s artifacts=%d", res.Status, len(artifacts)) + } + if c.mgr.sessionAllows(c.run.ConversationID, "write_artifact") { + t.Fatal("revoked confirmation granted session permission") + } + }) + } +} + +func TestProjectWorkRevocationBlocksDirectArtifactWrite(t *testing.T) { + c, st := newExecEnv(t) + c.mgr.SetProjectWorkAuthorization(func(context.Context, string, core.ID) (bool, error) { return false, nil }) + if _, err := c.WriteArtifact("revoked.txt", "data", "text/plain", []byte("private")); !errors.Is(err, store.ErrNotFound) { + t.Fatalf("direct artifact should reject: %v", err) + } + artifacts, err := st.ListArtifacts("alice", c.run.ProjectID) + if err != nil || len(artifacts) != 0 { + t.Fatalf("revoked artifact was written: %+v %v", artifacts, err) + } +} diff --git a/internal/runmgr/runmgr.go b/internal/runmgr/runmgr.go index b9d7ddb..e5fe6a3 100644 --- a/internal/runmgr/runmgr.go +++ b/internal/runmgr/runmgr.go @@ -630,7 +630,7 @@ func (c *runCtx) SetAssistantParts(parts []core.Part) error { return nil } -func (c *runCtx) AskHITL(card core.HITLCard) (core.HITLResponse, error) { +func (c *runCtx) AskHITL(card core.HITLCard) (answer core.HITLResponse, err error) { card.ID = store.NewID("hitl") card.RunID = c.run.ID card.ConversationID = c.run.ConversationID @@ -672,8 +672,13 @@ func (c *runCtx) AskHITL(card core.HITLCard) (core.HITLResponse, error) { // 排队,取消语境下重取失败由取消路径收口)。 c.mgr.adm.release(c.mgr.st, string(c.run.ID)) defer func() { - if err := c.mgr.adm.acquire(c.ctx, c.mgr.st, string(c.run.ID), c.run.Owner); err != nil { - log.Printf("[runmgr] admission reacquire after HITL %s: %v", c.run.ID, err) + if acquireErr := c.mgr.adm.acquire(c.ctx, c.mgr.st, string(c.run.ID), c.run.Owner); acquireErr != nil { + log.Printf("[runmgr] admission reacquire after HITL %s: %v", c.run.ID, acquireErr) + } + // Permission can change during both confirmation and admission waiting. + // Every HITL caller receives the live decision before it resumes work. + if err == nil { + err = c.mgr.authorizeProjectWork(c.ctx, c.run.Owner, c.run.ProjectID) } }() return waitHITL(c.ctx, c.h.hitlCh, timeout) @@ -876,6 +881,9 @@ func (c *runCtx) Artifact(id string) (core.Artifact, []byte, bool) { } func (c *runCtx) WriteArtifact(name, kind, mime string, content []byte) (core.Artifact, error) { + if err := c.mgr.authorizeProjectWork(c.ctx, c.run.Owner, c.run.ProjectID); err != nil { + return core.Artifact{}, err + } art := core.Artifact{ProjectID: c.run.ProjectID, Owner: c.run.Owner, Name: name, Kind: kind, MIME: mime, SourceRun: c.run.ID} if err := c.mgr.st.CreateArtifact(&art, content); err != nil { return art, err @@ -1008,12 +1016,18 @@ func (c *runCtx) ExecuteTool(call core.ToolCallRequest) core.ToolCallResult { if err != nil { return core.ToolCallResult{CallID: call.CallID, Status: "error", Output: "hitl aborted: " + err.Error()} } + if err := c.mgr.authorizeProjectWork(c.ctx, c.run.Owner, c.run.ProjectID); err != nil { + return core.ToolCallResult{CallID: call.CallID, Status: "error", Output: err.Error()} + } if resp.ChoiceID == "session" { c.mgr.rememberSessionAllow(c.run.ConversationID, call.Name) } else if resp.ChoiceID != "yes" { return core.ToolCallResult{CallID: call.CallID, Status: "error", Output: "用户取消了本次工具调用"} } } + if err := c.mgr.authorizeProjectWork(c.ctx, c.run.Owner, c.run.ProjectID); err != nil { + return core.ToolCallResult{CallID: call.CallID, Status: "error", Output: err.Error()} + } // EXT-14:命令即工具——命中命令投影时经命令总分发(Entrance=agent、 // Actor/Owner=会话 owner、带会话上下文),同审计信封与高危确认卡收口。 if c.mgr.commands != nil { @@ -1662,6 +1676,12 @@ func (m *Manager) ResolveHITL(owner, cardID string, resp core.HITLResponse) (piv if err != nil { return "", false, err } + if m.projectWorkAuthorization != nil && (run.Owner != owner || run.ConversationID != card.ConversationID) { + return "", false, store.ErrNotFound + } + if err := m.authorizeProjectWork(context.Background(), run.Owner, run.ProjectID); err != nil { + return "", false, err + } // U7 双进程验收实测缺陷修复:run 不在本进程执行时不得先落 resolve—— // 卡一旦标记 resolved,另一实例 server 层的命令 fallback(resolveHITL 的 // !card.Resolved 判定)即被跳过,执行实例的 runner 永远等不到答案, @@ -1706,6 +1726,9 @@ func (m *Manager) ResolveHITL(owner, cardID string, resp core.HITLResponse) (piv } } if pivot { + if err := m.authorizeProjectWork(context.Background(), run.Owner, run.ProjectID); err != nil { + return "", false, err + } _ = m.CancelRun(run.ID) m.st.ResolveHITLCard(owner, cardID, core.HITLResolvedPayload{CardID: core.ID(cardID), Kind: "cancelled", Answer: answer}) m.emit(run.ConversationID, run.ID, core.EvHITLResolved, core.HITLResolvedPayload{CardID: core.ID(cardID), Kind: "cancelled", Answer: answer}) @@ -1716,6 +1739,9 @@ func (m *Manager) ResolveHITL(owner, cardID string, resp core.HITLResponse) (piv return trimmed, true, nil } } + if err := m.authorizeProjectWork(context.Background(), run.Owner, run.ProjectID); err != nil { + return "", false, err + } resolution := core.HITLResolvedPayload{CardID: core.ID(cardID), Answer: answer, Kind: kind} if err := m.st.ResolveHITLCard(owner, cardID, resolution); err != nil { return "", false, err diff --git a/internal/store/content.go b/internal/store/content.go index f203824..c740f98 100644 --- a/internal/store/content.go +++ b/internal/store/content.go @@ -409,20 +409,36 @@ func (s *Store) SearchKnowledgeSharedAuthorized(requester string, q core.Retriev return out, nil } -// DeleteProjectKnowledgeByRef replaces one publication scope and its vectors in -// a single transaction. Empty project is the legacy unscoped publication domain. +// DeleteProjectKnowledgeByRef preserves the ordinary legacy replacement scope. +// Registered lifecycle publishers use DeletePluginProjectKnowledgeByRef instead. func (s *Store) DeleteProjectKnowledgeByRef(ctx context.Context, owner, projectID, ref string) error { - tx, err := s.db.BeginTx(ctx, nil) - if err != nil { - return err - } - defer tx.Rollback() args := []any{owner, ref} pred := "owner=? AND ref=?" if projectID != "" { pred += " AND project_id=?" args = append(args, projectID) } + return s.deleteKnowledgeAndVectors(ctx, pred, args...) +} + +// DeletePluginProjectKnowledgeByRef replaces only this actor/plugin publication +// scope. Empty project is an exact unscoped domain, never a wildcard. The source +// prefix is compared literally, including any percent/underscore in plugin IDs. +func (s *Store) DeletePluginProjectKnowledgeByRef(ctx context.Context, owner, projectID, ref, pluginID string) error { + if owner == "" || pluginID == "" { + return errors.New("store: publication owner and plugin required") + } + prefix := "plugin:" + pluginID + pred := `owner=? AND project_id=? AND ref=? AND (source=? OR substr(source,1,length(?))=?)` + return s.deleteKnowledgeAndVectors(ctx, pred, owner, projectID, ref, prefix, prefix+":", prefix+":") +} + +func (s *Store) deleteKnowledgeAndVectors(ctx context.Context, pred string, args ...any) error { + tx, err := s.db.BeginTx(ctx, nil) + if err != nil { + return err + } + defer tx.Rollback() if _, err := tx.ExecContext(ctx, s.q("DELETE FROM knowledge_vectors WHERE doc_id IN (SELECT id FROM knowledge WHERE "+pred+")"), args...); err != nil { return err } diff --git a/internal/store/knowledge_authorization_test.go b/internal/store/knowledge_authorization_test.go index 2ef80c1..b765e96 100644 --- a/internal/store/knowledge_authorization_test.go +++ b/internal/store/knowledge_authorization_test.go @@ -2,6 +2,7 @@ package store import ( core "agent-system-core" + "context" "testing" ) @@ -20,3 +21,50 @@ func TestAuthorizedKnowledgeFiltersBeforeTopK(t *testing.T) { t.Fatalf("denied rows consumed top-k: %+v %v", hits, err) } } + +func TestPluginProjectPublicationDeleteUsesExactScope(t *testing.T) { + st := openTestStore(t) + ctx := context.Background() + docs := []core.KnowledgeDoc{{ID: "unscoped", Owner: "alice", Source: "plugin:rdm", Ref: "same", Chunk: "unscoped"}, {ID: "scoped", Owner: "alice", ProjectID: "domain", Source: "plugin:rdm:published", Ref: "same", Chunk: "scoped"}, {ID: "foreign", Owner: "alice", Source: "plugin:other", Ref: "same", Chunk: "foreign"}, {ID: "other_owner", Owner: "bob", Source: "plugin:rdm", Ref: "same", Chunk: "other owner"}} + if err := st.AddKnowledge(docs...); err != nil { + t.Fatal(err) + } + for _, d := range docs { + if err := st.UpsertKnowledgeVector(ctx, d.Owner, d.ID, d.Ref, []float32{1, 0}); err != nil { + t.Fatal(err) + } + } + if err := st.DeletePluginProjectKnowledgeByRef(ctx, "alice", "", "same", "rdm"); err != nil { + t.Fatal(err) + } + for _, table := range []string{"knowledge", "knowledge_vectors"} { + var count int + if err := st.db.QueryRow("SELECT COUNT(*) FROM " + table).Scan(&count); err != nil || count != 3 { + t.Fatalf("exact empty-project namespace %s count=%d error=%v", table, count, err) + } + } + if err := st.DeletePluginProjectKnowledgeByRef(ctx, "alice", "domain", "same", "rdm"); err != nil { + t.Fatal(err) + } + for _, id := range []string{"foreign", "other_owner"} { + var count int + if err := st.db.QueryRow(st.q(`SELECT COUNT(*) FROM knowledge WHERE id=?`), id).Scan(&count); err != nil || count != 1 { + t.Fatalf("protected row %s count=%d error=%v", id, count, err) + } + } +} + +func TestPluginPublicationNamespaceIsLiteral(t *testing.T) { + st := openTestStore(t) + docs := []core.KnowledgeDoc{{ID: "owned", Owner: "alice", Source: "plugin:rdm_%", Ref: "same", Chunk: "owned"}, {ID: "owned_suffix", Owner: "alice", Source: "plugin:rdm_%:published", Ref: "same", Chunk: "owned suffix"}, {ID: "protected", Owner: "alice", Source: "plugin:rdmXYZ:published", Ref: "same", Chunk: "protected"}} + if err := st.AddKnowledge(docs...); err != nil { + t.Fatal(err) + } + if err := st.DeletePluginProjectKnowledgeByRef(context.Background(), "alice", "", "same", "rdm_%"); err != nil { + t.Fatal(err) + } + var id string + if err := st.db.QueryRow(`SELECT id FROM knowledge`).Scan(&id); err != nil || id != "protected" { + t.Fatalf("wildcards broadened namespace: id=%s error=%v", id, err) + } +} -- 2.54.0 From c4435d1415eda80e402c52b5ea865cf680be1997 Mon Sep 17 00:00:00 2001 From: weichen Date: Tue, 13 Oct 2026 09:46:20 +0000 Subject: [PATCH 4/6] Protect shared project metadata credentials and managed settings Why: Legacy project metadata can contain credentials outside the settings scope table. Sharing raw projection settings exposed them, and generic project PATCH bypassed the system-managed path guard already enforced by project settings endpoints. Evidence: SELF member Host regression failed before the fix. The fixed 4f72aaf server baseline overlay reproduced HTTP metadata leakage and managed PATCH returning 200. SELF shared Host-to-HTTP, immutable mask, dotted/schema/fallback, public mutation and atomic managed-write regressions passed; uncached full suite, isolated PG Store suite and static build passed. Independent delta replay remains a gate. Impact: Registered lifecycle metadata returns detached credential-masked views while preserving service/configuration keys and persisted data. Generic project PATCH shares the existing managed validator before rename or settings mutation; allowed metadata key shapes and raw owner-only management lookups remain available. Refs: Dev project-member-access plan r1 Task 1 and R5; approved hard secret boundary; 2026-10-13 shared project metadata review follow-up --- internal/app/project_lifecycle.go | 3 + internal/app/project_lifecycle_test.go | 86 ++++++++++++++++++++++ internal/server/project_lifecycle.go | 9 +++ internal/server/project_lifecycle_test.go | 82 +++++++++++++++++++++ internal/server/server.go | 14 +++- internal/settings/project_metadata.go | 86 ++++++++++++++++++++++ internal/settings/project_metadata_test.go | 53 +++++++++++++ internal/settings/settings.go | 6 +- pkg/extension/projects.go | 3 +- 9 files changed, 335 insertions(+), 7 deletions(-) create mode 100644 internal/settings/project_metadata.go create mode 100644 internal/settings/project_metadata_test.go diff --git a/internal/app/project_lifecycle.go b/internal/app/project_lifecycle.go index 883d5c2..78ccf95 100644 --- a/internal/app/project_lifecycle.go +++ b/internal/app/project_lifecycle.go @@ -98,6 +98,9 @@ func (h inprocHost) ProjectMetadata(id string) (core.Project, error) { if err != nil { return core.Project{}, err } + if h.caps.projects != nil && h.caps.projects.active() { + p.Settings = h.caps.set.MaskedProjectMetadata(p.Settings) + } if p.Owner == h.owner { return p, nil } diff --git a/internal/app/project_lifecycle_test.go b/internal/app/project_lifecycle_test.go index ad261c0..1650ff9 100644 --- a/internal/app/project_lifecycle_test.go +++ b/internal/app/project_lifecycle_test.go @@ -1,11 +1,16 @@ package app import ( + "agent-system-backend/internal/server" + "agent-system-backend/internal/settings" "agent-system-backend/internal/store" "agent-system-backend/pkg/extension" core "agent-system-core" + "bytes" "context" + "encoding/json" "errors" + "net/http/httptest" "testing" ) @@ -75,3 +80,84 @@ func TestProjectHostMetadataDoesNotGrantMutation(t *testing.T) { t.Fatalf("V2 denial ignored=%v", err) } } + +func TestProjectHostMetadataMasksSharedSecretsWithoutMutation(t *testing.T) { + st, err := store.OpenSQLite(t.TempDir() + "/t.db") + if err != nil { + t.Fatal(err) + } + defer st.Close() + p, err := st.CreateProject("alice", "Shared") + if err != nil { + t.Fatal(err) + } + raw := map[string]any{"models": map[string]any{"chat": map[string]any{"apiKey": "fixture-nested-secret"}}, "models.embedding.apiKey": "fixture-flat-secret", "legacy.token": "fixture-legacy-secret", "plugins.private": map[string]any{"credential": "fixture-schema-secret"}, "display": map[string]any{"caption": "Shared configuration"}, "rdm.projectId": "canonical", "rdm.state": "ready"} + if err := st.UpdateProjectSettings("alice", p.ID, raw); err != nil { + t.Fatal(err) + } + svc := settings.NewService(st) + svc.SetExtraSchema([]core.SettingDesc{{Key: "plugins.private.credential", Secret: true}}) + slot := &projectLifecycleSlot{} + auth := &projectAuthz{} + caps := hostcaps{st: st, set: svc, projects: slot, authz: auth} + provider := completeLifecycle() + provider.List = func(_ context.Context, h extension.Host) ([]core.Project, error) { + project, err := h.(extension.ProjectHost).ProjectMetadata(p.ID) + return []core.Project{project}, err + } + if err := (pluginRegistry{pluginID: "rdm", caps: caps}).RegisterProjectLifecycle(provider); err != nil { + t.Fatal(err) + } + auth.setV2("rdm", func(q extension.ResourceAuthzQuery) (bool, error) { + return q.Actor == "bob" && q.ResourceType == "project" && q.ResourceID == p.ID, nil + }) + for _, actor := range []string{"bob", "alice"} { + h := inprocHost{owner: actor, caps: caps} + view, err := h.ProjectMetadata(p.ID) + if err != nil { + t.Fatal(err) + } + encoded, _ := json.Marshal(view.Settings) + for _, value := range []string{"fixture-nested-secret", "fixture-flat-secret", "fixture-legacy-secret", "fixture-schema-secret"} { + if bytes.Contains(encoded, []byte(value)) { + t.Fatalf("%s metadata exposed a fixture credential", actor) + } + } + if view.Settings["rdm.projectId"] != "canonical" || view.Settings["rdm.state"] != "ready" || view.Settings["display"].(map[string]any)["caption"] != "Shared configuration" { + t.Fatal("allowed service/configuration metadata changed") + } + } + // Exercise the real registry -> actor-scoped Host -> public HTTP list chain. + srv := server.New(st, nil, nil, nil, svc, nil) + srv.Auth = server.NewAuthenticator(st, true) + slot.wire(srv, caps) + key, err := st.CreateAPIKey("bob", "test") + if err != nil { + t.Fatal(err) + } + request := httptest.NewRequest("GET", "/api/projects", nil) + request.Header.Set("Authorization", "Bearer "+key) + response := httptest.NewRecorder() + srv.Handler().ServeHTTP(response, request) + if response.Code != 200 { + t.Fatalf("member list=%d", response.Code) + } + for _, value := range []string{"fixture-nested-secret", "fixture-flat-secret", "fixture-legacy-secret", "fixture-schema-secret"} { + if bytes.Contains(response.Body.Bytes(), []byte(value)) { + t.Fatal("actor-scoped lifecycle list exposed a fixture credential") + } + } + if !bytes.Contains(response.Body.Bytes(), []byte(`"rdm.projectId":"canonical"`)) { + t.Fatal("actor-scoped list lost canonical metadata") + } + + owned, err := (inprocHost{owner: "alice", caps: caps}).OwnedProject(p.ID) + if err != nil { + t.Fatal(err) + } + before, _ := json.Marshal(raw) + after, _ := json.Marshal(owned.Settings) + if !bytes.Equal(before, after) { + t.Fatal("metadata read mutated persisted owner settings") + } +} diff --git a/internal/server/project_lifecycle.go b/internal/server/project_lifecycle.go index eb4d747..ed2b29c 100644 --- a/internal/server/project_lifecycle.go +++ b/internal/server/project_lifecycle.go @@ -71,3 +71,12 @@ func writeProjectJSON(w http.ResponseWriter, p any, err error) { } writeJSON(w, http.StatusOK, p) } + +// publicProjectMetadata also masks provider-returned metadata, since providers +// may obtain raw owner-management rows before producing a shared public view. +func (s *Server) publicProjectMetadata(p core.Project) core.Project { + if s.projectLifecycle != nil { + p.Settings = s.set.MaskedProjectMetadata(p.Settings) + } + return p +} diff --git a/internal/server/project_lifecycle_test.go b/internal/server/project_lifecycle_test.go index 61ea6dc..d4dc059 100644 --- a/internal/server/project_lifecycle_test.go +++ b/internal/server/project_lifecycle_test.go @@ -9,6 +9,7 @@ import ( "context" "encoding/json" "errors" + "fmt" "net/http" "net/http/httptest" "testing" @@ -197,3 +198,84 @@ func TestProjectLifecycleSettingsRetainServiceMetadata(t *testing.T) { t.Fatalf("settings lost service metadata: %d %s", w.Code, w.Body.String()) } } + +func TestProjectLifecyclePublicMetadataMasksSecrets(t *testing.T) { + s, st := newAuthTestServer(t) + alice, _ := st.CreateAPIKey("alice", "test") + bob, _ := st.CreateAPIKey("bob", "test") + p, _ := st.CreateProject("alice", "Shared") + raw := map[string]any{"models.chat.apiKey": "fixture-key", "custom": map[string]any{"token": "fixture-token"}, "custom.api_key": "fixture-flat-token", "plugin.private.credential": "fixture-schema-token", "display": map[string]any{"caption": "Shared"}, "rdm.projectId": "canonical", "rdm.state": "ready"} + if err := st.UpdateProjectSettings("alice", p.ID, raw); err != nil { + t.Fatal(err) + } + s.set.SetExtraSchema([]core.SettingDesc{{Key: "plugin.private.credential", Secret: true}}) + read := func() (core.Project, error) { return st.GetProject("alice", p.ID) } + s.SetProjectLifecycle(extension.ProjectLifecycle{List: func(context.Context, extension.Host) ([]core.Project, error) { + current, err := read() + return []core.Project{current}, err + }, Create: func(context.Context, extension.Host, string) (core.Project, error) { return read() }, Rename: func(context.Context, extension.Host, string, string) (core.Project, error) { return read() }}, nil) + for _, q := range []struct{ key, method, path, body string }{{bob, "GET", "/api/projects", ""}, {alice, "GET", "/api/projects", ""}, {alice, "POST", "/api/projects", `{"name":"New"}`}, {alice, "PATCH", "/api/projects/" + p.ID, `{"name":"Renamed"}`}, {alice, "PATCH", "/api/projects/" + p.ID, `{"settings":{"display":{"caption":"Updated"}}}`}} { + w := lifecycleRequest(t, s, q.key, q.method, q.path, q.body) + if w.Code != 200 { + t.Fatalf("public metadata %s=%d", q.method, w.Code) + } + for _, secret := range []string{"fixture-key", "fixture-token", "fixture-flat-token", "fixture-schema-token"} { + if bytes.Contains(w.Body.Bytes(), []byte(secret)) { + t.Fatalf("%s %s leaked a fixture credential", q.method, q.path) + } + } + if !bytes.Contains(w.Body.Bytes(), []byte(`"rdm.projectId":"canonical"`)) || !bytes.Contains(w.Body.Bytes(), []byte(`"rdm.state":"ready"`)) { + t.Fatal("service metadata lost") + } + } + owned, err := read() + if err != nil { + t.Fatal(err) + } + if owned.Settings["models.chat.apiKey"] != "fixture-key" || owned.Settings["custom"].(map[string]any)["token"] != "fixture-token" { + t.Fatal("public metadata masking changed stored credentials") + } +} + +func TestGenericProjectPatchRejectsManagedSettingsAtomically(t *testing.T) { + for _, registered := range []bool{false, true} { + t.Run(fmt.Sprint(registered), func(t *testing.T) { + s, st := newAuthTestServer(t) + key, _ := st.CreateAPIKey("alice", "test") + p, _ := st.CreateProject("alice", "Original") + s.set.SetExtraSchema([]core.SettingDesc{{Key: "plugins.private.managed", Scope: core.SettingScopeSystem}}) + if registered { + s.SetProjectLifecycle(extension.ProjectLifecycle{List: func(context.Context, extension.Host) ([]core.Project, error) { + current, err := st.GetProject("alice", p.ID) + return []core.Project{current}, err + }, Rename: func(_ context.Context, _ extension.Host, id, name string) (core.Project, error) { + if err := st.RenameProject("alice", id, name); err != nil { + return core.Project{}, err + } + return st.GetProject("alice", id) + }}, nil) + } + for _, body := range []string{`{"name":"Changed","settings":{"models":{"chat":{"apiKey":"fixture-key"}},"display":{"caption":"Forbidden partial write"}}}`, `{"settings":{"models.chat.apiKey":"fixture-key"}}`, `{"settings":{"models":"smash"}}`, `{"settings":{"plugins.private.managed":"override"}}`} { + w := lifecycleRequest(t, s, key, "PATCH", "/api/projects/"+p.ID, body) + if w.Code != 400 { + t.Fatalf("managed patch=%d", w.Code) + } + current, err := st.GetProject("alice", p.ID) + if err != nil { + t.Fatal(err) + } + if current.Name != "Original" || len(current.Settings) != 0 { + t.Fatal("rejected managed patch applied a partial mutation") + } + } + w := lifecycleRequest(t, s, key, "PATCH", "/api/projects/"+p.ID, `{"settings":{"custom.caption":"Allowed"}}`) + if w.Code != 200 { + t.Fatalf("allowed patch=%d", w.Code) + } + current, err := st.GetProject("alice", p.ID) + if err != nil || current.Settings["custom.caption"] != "Allowed" { + t.Fatal("allowed metadata shape changed") + } + }) + } +} diff --git a/internal/server/server.go b/internal/server/server.go index c36f113..f932783 100644 --- a/internal/server/server.go +++ b/internal/server/server.go @@ -627,7 +627,7 @@ func (s *Server) listProjects(w http.ResponseWriter, r *http.Request) { } out := make([]projectView, 0, len(ps)) for _, p := range ps { - out = append(out, projectView{Project: p, Shared: p.Owner != owner}) + out = append(out, projectView{Project: s.publicProjectMetadata(p), Shared: p.Owner != owner}) } writeJSON(w, http.StatusOK, out) return @@ -674,7 +674,7 @@ func (s *Server) createProject(w http.ResponseWriter, r *http.Request) { return } p, err := s.projectLifecycle.Create(r.Context(), s.lifecycleHost(s.owner(r)), strings.TrimSpace(req.Name)) - writeProjectJSON(w, p, err) + writeProjectJSON(w, s.publicProjectMetadata(p), err) return } p, err := s.st.CreateProject(s.owner(r), strings.TrimSpace(req.Name)) @@ -691,6 +691,14 @@ func (s *Server) patchProject(w http.ResponseWriter, r *http.Request) { http.Error(w, err.Error(), http.StatusBadRequest) return } + if req.Settings != nil { + values, err := s.set.PrepareProjectMetadataWrite(req.Settings) + if err != nil { + s.writeSettingsMerge(w, func() error { return err }) + return + } + req.Settings = values + } if s.projectLifecycle != nil { var p core.Project var err error @@ -732,7 +740,7 @@ func (s *Server) patchProject(w http.ResponseWriter, r *http.Request) { } } } - writeProjectJSON(w, p, err) + writeProjectJSON(w, s.publicProjectMetadata(p), err) return } if req.Name != nil { diff --git a/internal/settings/project_metadata.go b/internal/settings/project_metadata.go new file mode 100644 index 0000000..56359ab --- /dev/null +++ b/internal/settings/project_metadata.go @@ -0,0 +1,86 @@ +package settings + +import ( + core "agent-system-core" + "strings" +) + +// MaskedProjectMetadata returns a detached credential-safe view without changing +// stored values or dotted service/configuration key shapes. The builtin and +// plugin secret schema plus the existing secret-leaf fallback cover legacy data. +func (s *Service) MaskedProjectMetadata(values map[string]any) map[string]any { + if values == nil { + return map[string]any{} + } + schema := Schema() + if s != nil { + schema = append(schema, s.extraSchema...) + } + secrets := map[string]bool{} + for _, d := range schema { + if d.Type == core.SettingSecret || d.Secret { + secrets[d.Key] = true + } + } + return maskedProjectValue(values, "", secrets).(map[string]any) +} + +func maskedProjectValue(value any, path string, secrets map[string]bool) any { + leaf := path + if i := strings.LastIndexByte(leaf, '.'); i >= 0 { + leaf = leaf[i+1:] + } + if secrets[path] || isSecretKey(leaf) { + if value == nil { + return nil + } + if text, ok := value.(string); ok && text == "" { + return text + } + return MaskPlaceholder + } + switch v := value.(type) { + case map[string]any: + if v == nil { + return v + } + out := make(map[string]any, len(v)) + for key, item := range v { + child := key + if path != "" { + child = path + "." + key + } + out[key] = maskedProjectValue(item, child, secrets) + } + return out + case []any: + if v == nil { + return v + } + out := make([]any, len(v)) + for i, item := range v { + out[i] = maskedProjectValue(item, path, secrets) + } + return out + default: + return value + } +} + +// PrepareProjectMetadataWrite applies the same managed-path and mask-placeholder +// rules as project settings, while preserving the metadata store's key shapes. +// Project metadata lives in projects.settings, not the settings scope table. +func (s *Service) PrepareProjectMetadataWrite(values map[string]any) (map[string]any, error) { + incoming := dropMaskPlaceholders(values) + if err := s.checkManagedWrite(normalizeKeys(incoming)); err != nil { + return nil, err + } + return incoming, nil +} + +func (s *Service) checkManagedWrite(incoming map[string]any) error { + if hits := core.TouchesManagedSettings(incoming, s.ManagedSettingPaths()); len(hits) > 0 { + return &ErrManagedPath{Paths: hits} + } + return nil +} diff --git a/internal/settings/project_metadata_test.go b/internal/settings/project_metadata_test.go new file mode 100644 index 0000000..12fa9f3 --- /dev/null +++ b/internal/settings/project_metadata_test.go @@ -0,0 +1,53 @@ +package settings + +import ( + core "agent-system-core" + "bytes" + "encoding/json" + "testing" +) + +func TestMaskedProjectMetadataCopiesSecretViews(t *testing.T) { + s := NewService(nil) + s.SetExtraSchema([]core.SettingDesc{{Key: "plugins.private.credential", Secret: true}}) + raw := map[string]any{"models.chat.apiKey": "fixture-known", "legacy.token": "fixture-flat", "plugins.private": map[string]any{"credential": "fixture-schema"}, "nested": []any{map[string]any{"api_key": "fixture-array"}}, "opaque.secret": map[string]any{"value": "fixture-opaque"}, "custom.token": []any{"fixture-list"}, "custom.caption": "Allowed", "rdm.projectId": "canonical", "rdm.state": "ready"} + before, _ := json.Marshal(raw) + view := s.MaskedProjectMetadata(raw) + after, _ := json.Marshal(raw) + if string(before) != string(after) { + t.Fatal("read-only metadata view mutated its source") + } + encoded, _ := json.Marshal(view) + for _, secret := range []string{"fixture-known", "fixture-flat", "fixture-schema", "fixture-array", "fixture-opaque", "fixture-list"} { + if bytes.Contains(encoded, []byte(secret)) { + t.Fatal("metadata view exposed a fixture secret") + } + } + if view["custom.caption"] != "Allowed" || view["rdm.projectId"] != "canonical" || view["rdm.state"] != "ready" { + t.Fatal("nonsecret metadata shape/value changed") + } + if view["legacy.token"] != MaskPlaceholder || view["opaque.secret"] != MaskPlaceholder || view["custom.token"] != MaskPlaceholder { + t.Fatal("secret fallback did not mask complete dotted values") + } +} + +func TestPrepareProjectMetadataWriteUsesManagedGuard(t *testing.T) { + s := NewService(nil) + s.SetExtraSchema([]core.SettingDesc{{Key: "plugins.private.managed", Scope: core.SettingScopeSystem}}) + for _, raw := range []map[string]any{{"models.chat.apiKey": "fixture"}, {"models": map[string]any{"chat": map[string]any{"apiKey": "fixture"}}}, {"models": "smash"}, {"plugins.private.managed": "override"}} { + if _, err := s.PrepareProjectMetadataWrite(raw); err == nil { + t.Fatal("managed metadata write accepted") + } + } + raw := map[string]any{"custom.caption": "Allowed", "custom.token": MaskPlaceholder, "models.chat.apiKey": MaskPlaceholder} + view, err := s.PrepareProjectMetadataWrite(raw) + if err != nil { + t.Fatal(err) + } + if len(view) != 1 || view["custom.caption"] != "Allowed" { + t.Fatal("metadata writes must preserve allowed dotted keys and ignore mask placeholders") + } + if raw["custom.token"] != MaskPlaceholder { + t.Fatal("write preparation mutated its input") + } +} diff --git a/internal/settings/settings.go b/internal/settings/settings.go index 05dfd0a..c07074c 100644 --- a/internal/settings/settings.go +++ b/internal/settings/settings.go @@ -167,7 +167,7 @@ func (s *Service) SetExtraSchema(descs []core.SettingDesc) { s.extraSchema = des // ManagedSettingPaths 返回全部受管键路径(内置 schema + 插件声明)。 func (s *Service) ManagedSettingPaths() []string { paths := core.ManagedPaths(Schema()) - if len(s.extraSchema) > 0 { + if s != nil && len(s.extraSchema) > 0 { paths = append(paths, core.ManagedPaths(s.extraSchema)...) } return paths @@ -238,8 +238,8 @@ func (s *Service) MergeSystem(values map[string]any) error { func (s *Service) mergeScope(owner, scope string, values map[string]any) error { incoming := prepareWrite(values) if scope != ScopeSystem { - if hits := core.TouchesManagedSettings(incoming, s.ManagedSettingPaths()); len(hits) > 0 { - return &ErrManagedPath{Paths: hits} + if err := s.checkManagedWrite(incoming); err != nil { + return err } } if err := s.st.UpdateSettingsScope(owner, scope, func(cur map[string]any) (map[string]any, error) { diff --git a/pkg/extension/projects.go b/pkg/extension/projects.go index 259e4dd..d0f5042 100644 --- a/pkg/extension/projects.go +++ b/pkg/extension/projects.go @@ -6,7 +6,8 @@ import ( ) // ProjectHost is an optional actor-scoped host projection capability. Metadata -// can be shared by an authorizer; owned lookups and mutations remain owner-only. +// can be shared by an authorizer; registered-lifecycle metadata masks credentials. +// OwnedProject remains an owner-only management lookup; mutations remain owner-only. type ProjectHost interface { Owner() string OwnedProjects() ([]core.Project, error) -- 2.54.0 From d2f96e79bb4161e182d8216de4a402452f7c3a7c Mon Sep 17 00:00:00 2001 From: weichen Date: Tue, 13 Oct 2026 10:23:37 +0000 Subject: [PATCH 5/6] Admit private member uploads and preserve masked metadata edits Why: Nested unchanged credential masks left empty parents that looked like managed subtree overwrites. Shared members also hit an owner-only upload guard, while upload ingestion cards could disclose private filenames and parser metadata. Evidence: SELF independent nested-mask probe, HTTP nested/mixed writeback, member upload, project card privacy and authorization-change regressions failed before each fix and passed after it. Affected settings/server suite passed; exact final-object build and independent replay remain delivery gates owned by the task coordinator. Impact: Prune only parents emptied by ignored masks; explicit empty/real managed writes stay rejected. Registered uploads check live work and trusted project existence, keep originals owned by the actor, and recheck before persistence. Suppress only upload project cards when no private stream exists; explicit structure cards and unregistered upload behavior remain available. Refs: Dev project-member-access plan r1 Task 1/R5; independent nested mask P2; 2026-10-13 composed member-upload and private project-stream acceptance findings --- internal/server/project_lifecycle_test.go | 222 +++++++++++++++++++++ internal/server/server.go | 34 +++- internal/settings/project_metadata.go | 24 ++- internal/settings/project_metadata_test.go | 33 +++ 4 files changed, 306 insertions(+), 7 deletions(-) diff --git a/internal/server/project_lifecycle_test.go b/internal/server/project_lifecycle_test.go index d4dc059..47ea686 100644 --- a/internal/server/project_lifecycle_test.go +++ b/internal/server/project_lifecycle_test.go @@ -1,6 +1,7 @@ package server import ( + "agent-system-backend/internal/cardsvc" "agent-system-backend/internal/cmdbus" "agent-system-backend/internal/surfacesvc" "agent-system-backend/pkg/extension" @@ -10,6 +11,7 @@ import ( "encoding/json" "errors" "fmt" + "mime/multipart" "net/http" "net/http/httptest" "testing" @@ -279,3 +281,223 @@ func TestGenericProjectPatchRejectsManagedSettingsAtomically(t *testing.T) { }) } } + +func TestGenericProjectPatchAcceptsNestedMaskedWriteback(t *testing.T) { + for _, registered := range []bool{false, true} { + t.Run(fmt.Sprint(registered), func(t *testing.T) { + s, st := newAuthTestServer(t) + key, _ := st.CreateAPIKey("alice", "test") + p, _ := st.CreateProject("alice", "Project") + if err := st.UpdateProjectSettings("alice", p.ID, map[string]any{"models": map[string]any{"chat": map[string]any{"apiKey": "fixture-persisted-key"}}, "display": map[string]any{"caption": "Original"}}); err != nil { + t.Fatal(err) + } + if registered { + s.SetProjectLifecycle(extension.ProjectLifecycle{List: func(context.Context, extension.Host) ([]core.Project, error) { + current, err := st.GetProject("alice", p.ID) + return []core.Project{current}, err + }}, nil) + } + for _, tc := range []struct{ body, caption string }{ + {`{"settings":{"models":{"chat":{"apiKey":"••••••"}},"display":{"caption":"Nested updated"}}}`, "Nested updated"}, + {`{"settings":{"models.chat":{"apiKey":"••••••"},"display":{"caption":"Mixed updated"}}}`, "Mixed updated"}, + {`{"settings":{"models":{"chat.apiKey":"••••••"},"display":{"caption":"Mixed leaf updated"}}}`, "Mixed leaf updated"}, + } { + w := lifecycleRequest(t, s, key, "PATCH", "/api/projects/"+p.ID, tc.body) + if w.Code != 200 { + t.Fatalf("masked writeback rejected legal edit: %d", w.Code) + } + current, err := st.GetProject("alice", p.ID) + if err != nil { + t.Fatal(err) + } + if current.Settings["models"].(map[string]any)["chat"].(map[string]any)["apiKey"] != "fixture-persisted-key" || current.Settings["display"].(map[string]any)["caption"] != tc.caption { + t.Fatalf("masked writeback secret preserved=%v caption=%q expected=%q", current.Settings["models"].(map[string]any)["chat"].(map[string]any)["apiKey"] == "fixture-persisted-key", current.Settings["display"].(map[string]any)["caption"], tc.caption) + } + } + for _, body := range []string{`{"settings":{"models":{}}}`, `{"settings":{"models":{"chat":{}}}}`, `{"settings":{"models.chat":{}}}`, `{"settings":{"models":{"chat":{"apiKey":"••••••","model":"Overwrite"}}}}`} { + if w := lifecycleRequest(t, s, key, "PATCH", "/api/projects/"+p.ID, body); w.Code != 400 { + t.Fatalf("explicit empty/real managed write accepted: %d", w.Code) + } + } + }) + } +} + +func lifecycleUpload(t *testing.T, s *Server, key, projectID, name, content string) *httptest.ResponseRecorder { + t.Helper() + var body bytes.Buffer + writer := multipart.NewWriter(&body) + file, err := writer.CreateFormFile("file", name) + if err != nil { + t.Fatal(err) + } + if _, err := file.Write([]byte(content)); err != nil { + t.Fatal(err) + } + if err := writer.Close(); err != nil { + t.Fatal(err) + } + req := httptest.NewRequest("POST", "/api/projects/"+projectID+"/uploads", &body) + req.Header.Set("Authorization", "Bearer "+key) + req.Header.Set("Content-Type", writer.FormDataContentType()) + rec := httptest.NewRecorder() + s.Handler().ServeHTTP(rec, req) + return rec +} + +func TestProjectLifecycleMemberUploadStaysPrivate(t *testing.T) { + s, st := newAuthTestServer(t) + alice, _ := st.CreateAPIKey("alice", "test") + bob, _ := st.CreateAPIKey("bob", "test") + mallory, _ := st.CreateAPIKey("mallory", "test") + p, _ := st.CreateProject("alice", "Shared") + ready := true + s.SetProjectLifecycle(extension.ProjectLifecycle{Work: func(_ context.Context, actor string, id core.ID) (bool, error) { + return ready && id == p.ID && (actor == "alice" || actor == "bob"), nil + }}, nil) + s.SetResourceAuthorizerV2(func(q extension.ResourceAuthzQuery) (bool, error) { + if q.ResourceType == "artifact" { + return q.Actor == q.Owner, nil + } + return q.ResourceType == "project" && q.ProjectID == p.ID && (q.Actor == "alice" || q.Actor == "bob"), nil + }) + s.DisableUploadIndexing = true + w := lifecycleUpload(t, s, bob, p.ID, "member-private.txt", "QA_MEMBER_PRIVATE_ORIGINAL") + if w.Code != 200 { + t.Fatalf("member upload=%d", w.Code) + } + var info core.UploadInfo + if err := json.Unmarshal(w.Body.Bytes(), &info); err != nil { + t.Fatal(err) + } + artifact, err := st.GetArtifact("bob", info.ArtifactID) + if err != nil || artifact.ProjectID != p.ID || artifact.Owner != "bob" { + t.Fatalf("upload ownership/target incorrect: %+v %v", artifact, err) + } + if w := lifecycleRequest(t, s, bob, "GET", "/api/artifacts/"+info.ArtifactID+"/content", ""); w.Code != 200 || w.Body.String() != "QA_MEMBER_PRIVATE_ORIGINAL" { + t.Fatalf("owner original read=%d", w.Code) + } + for _, key := range []string{alice, mallory} { + for _, suffix := range []string{"", "/content"} { + if w := lifecycleRequest(t, s, key, "GET", "/api/artifacts/"+info.ArtifactID+suffix, ""); w.Code != 404 { + t.Fatalf("other actor read private original=%d", w.Code) + } + } + } + if w := lifecycleUpload(t, s, mallory, p.ID, "forbidden.txt", "DENIED"); w.Code != 404 { + t.Fatalf("nonmember upload=%d", w.Code) + } + ready = false + if w := lifecycleUpload(t, s, bob, p.ID, "revoked.txt", "DENIED"); w.Code != 404 { + t.Fatalf("revoked upload=%d", w.Code) + } + artifacts, err := st.ListProjectArtifactsAll(p.ID) + if err != nil || len(artifacts) != 1 { + t.Fatalf("denied upload wrote artifacts: count=%d error=%v", len(artifacts), err) + } +} + +func TestProjectLifecycleUploadSuppressesPrivateProjectCards(t *testing.T) { + s, st := newAuthTestServer(t) + alice, _ := st.CreateAPIKey("alice", "test") + bob, _ := st.CreateAPIKey("bob", "test") + p, _ := st.CreateProject("alice", "Shared") + s.SetProjectLifecycle(extension.ProjectLifecycle{Work: func(_ context.Context, actor string, id core.ID) (bool, error) { + return id == p.ID && (actor == "alice" || actor == "bob"), nil + }}, nil) + s.SetResourceAuthorizerV2(func(q extension.ResourceAuthzQuery) (bool, error) { + return q.ResourceType == "project" && q.ProjectID == p.ID && (q.Actor == "alice" || q.Actor == "bob"), nil + }) + s.Cards = cardsvc.New(st, s.mgr.EmitCardEvent) + s.DisableUploadIndexing = true + if _, err := s.Cards.Create("alice", core.ActivityCard{ProjectID: p.ID, Title: "Shared structure", Kind: core.CardKindProgress}, ""); err != nil { + t.Fatal(err) + } + // The owner path already admitted upload before the member authorization fix. + if w := lifecycleUpload(t, s, alice, p.ID, "owner-private-filename.txt", "PRIVATE ORIGINAL"); w.Code != 200 { + t.Fatalf("owner upload=%d", w.Code) + } + if w := lifecycleUpload(t, s, bob, p.ID, "member-private-filename.txt", "MEMBER PRIVATE ORIGINAL"); w.Code != 200 { + t.Fatalf("member upload=%d", w.Code) + } + for _, owner := range []string{"alice", "bob"} { + cards, err := st.ListProjectCards(owner, p.ID) + if err != nil { + t.Fatal(err) + } + expected := 0 + if owner == "alice" { + expected = 1 + } + if len(cards) != expected { + t.Fatal("private upload added project card history") + } + for _, card := range cards { + if card.Title != "Shared structure" { + t.Fatal("private filename/parser card entered project history") + } + } + } + events, err := st.EventsAfter(core.ProjectChannelKey(p.ID), 0, 100) + if err != nil { + t.Fatal(err) + } + if len(events) != 1 { + t.Fatal("private upload added project events") + } + for _, event := range events { + if bytes.Contains(event.Payload, []byte("owner-private-filename.txt")) || bytes.Contains(event.Payload, []byte("member-private-filename.txt")) { + t.Fatal("private upload metadata entered persisted project stream") + } + } + request := httptest.NewRequest("GET", "/api/events?scope=project&projectId="+p.ID, nil) + request.Header.Set("Authorization", "Bearer "+bob) + ctx, cancel := context.WithTimeout(request.Context(), 20*time.Millisecond) + defer cancel() + request = request.WithContext(ctx) + response := httptest.NewRecorder() + s.Handler().ServeHTTP(response, request) + if response.Code != 200 || !bytes.Contains(response.Body.Bytes(), []byte("Shared structure")) { + t.Fatalf("member structural stream did not replay: %d", response.Code) + } + if bytes.Contains(response.Body.Bytes(), []byte("owner-private-filename.txt")) || bytes.Contains(response.Body.Bytes(), []byte("member-private-filename.txt")) { + t.Fatal("another member's SSE exposed private upload metadata") + } +} + +func TestUnregisteredUploadKeepsLegacyCardsAndValidation(t *testing.T) { + s, st := newAuthTestServer(t) + key, _ := st.CreateAPIKey("alice", "test") + p, _ := st.CreateProject("alice", "Private") + s.Cards = cardsvc.New(st, s.mgr.EmitCardEvent) + s.DisableUploadIndexing = true + if w := lifecycleUpload(t, s, key, p.ID, "legacy-upload.txt", "Original"); w.Code != 200 { + t.Fatalf("unregistered owner upload=%d", w.Code) + } + cards, err := st.ListProjectCards("alice", p.ID) + if err != nil || len(cards) != 1 || cards[0].Title != "legacy-upload.txt" { + t.Fatalf("unregistered ingestion card changed: count=%d error=%v", len(cards), err) + } + if w := lifecycleRequest(t, s, key, "POST", "/api/projects/missing/uploads", "not multipart"); w.Code != 400 { + t.Fatalf("legacy missing-file validation changed: %d", w.Code) + } +} + +func TestProjectLifecycleUploadRechecksBeforePersistence(t *testing.T) { + s, st := newAuthTestServer(t) + key, _ := st.CreateAPIKey("bob", "test") + p, _ := st.CreateProject("alice", "Shared") + ready := true + s.SetProjectLifecycle(extension.ProjectLifecycle{Work: func(context.Context, string, core.ID) (bool, error) { + allowed := ready + ready = false + return allowed, nil + }}, nil) + if w := lifecycleUpload(t, s, key, p.ID, "revoked-during-upload.txt", "PRIVATE"); w.Code != 404 { + t.Fatalf("upload persisted after authorization changed: %d", w.Code) + } + artifacts, err := st.ListProjectArtifactsAll(p.ID) + if err != nil || len(artifacts) != 0 { + t.Fatal("revoked upload created an artifact") + } +} diff --git a/internal/server/server.go b/internal/server/server.go index f932783..426070e 100644 --- a/internal/server/server.go +++ b/internal/server/server.go @@ -1413,6 +1413,17 @@ func (s *Server) cardDismiss(w http.ResponseWriter, r *http.Request) { func (s *Server) upload(w http.ResponseWriter, r *http.Request) { projectID := r.PathValue("id") + owner := s.owner(r) + if s.projectLifecycle != nil { + if err := s.projectWork(r.Context(), owner, projectID); err != nil { + writeProjectErr(w, err) + return + } + if _, err := s.st.ProjectMetadata(projectID); err != nil { + writeStoreErr(w, err) + return + } + } r.ParseMultipartForm(64 << 20) file, header, err := r.FormFile("file") if err != nil { @@ -1425,10 +1436,18 @@ func (s *Server) upload(w http.ResponseWriter, r *http.Request) { http.Error(w, err.Error(), http.StatusBadRequest) return } - owner := s.owner(r) - if _, err := s.st.GetProject(owner, projectID); err != nil { - writeStoreErr(w, err) - return + if s.projectLifecycle == nil { + if _, err := s.st.GetProject(owner, projectID); err != nil { + writeStoreErr(w, err) + return + } + } + if s.projectLifecycle != nil { + // Request-body reading can outlast the membership/ready decision. + if err := s.projectWork(r.Context(), owner, projectID); err != nil { + writeProjectErr(w, err) + return + } } mime := header.Header.Get("Content-Type") art := core.Artifact{ProjectID: projectID, Owner: owner, Name: header.Filename, Kind: kindOf(header.Filename), MIME: mime} @@ -1443,11 +1462,14 @@ func (s *Server) upload(w http.ResponseWriter, r *http.Request) { Status: core.CardActive, Tone: "info", Body: []core.UINode{{Type: core.UIProgress, Progress: &core.UIProgressSpec{Label: "解析中", Value: 0.3}}}, } - if s.Cards != nil { + // This endpoint has no private conversation stream. Registered uploads keep + // original filename/parser metadata out of shared project cards and events. + showIngestCard := s.Cards != nil && s.projectLifecycle == nil + if showIngestCard { ingestCard, _ = s.Cards.Create(owner, ingestCard, "") } doc := ingest.Parse(art.Name, mime, content) - if s.Cards != nil { + if showIngestCard { ingestCard.Status = core.CardDone ingestCard.Tone = "ok" body := []core.UINode{ diff --git a/internal/settings/project_metadata.go b/internal/settings/project_metadata.go index 56359ab..2eb0035 100644 --- a/internal/settings/project_metadata.go +++ b/internal/settings/project_metadata.go @@ -71,13 +71,35 @@ func maskedProjectValue(value any, path string, secrets map[string]bool) any { // rules as project settings, while preserving the metadata store's key shapes. // Project metadata lives in projects.settings, not the settings scope table. func (s *Service) PrepareProjectMetadataWrite(values map[string]any) (map[string]any, error) { - incoming := dropMaskPlaceholders(values) + incoming := dropProjectMaskPlaceholders(values) if err := s.checkManagedWrite(normalizeKeys(incoming)); err != nil { return nil, err } return incoming, nil } +// dropProjectMaskPlaceholders prunes a parent only when a previously nonempty +// object became empty through mask removal. Explicit empty objects still express +// an overwrite and must reach the managed-path guard unchanged. +func dropProjectMaskPlaceholders(values map[string]any) map[string]any { + out := make(map[string]any, len(values)) + for key, value := range values { + if child, ok := value.(map[string]any); ok { + prepared := dropProjectMaskPlaceholders(child) + if len(child) > 0 && len(prepared) == 0 { + continue + } + out[key] = prepared + continue + } + if text, ok := value.(string); ok && text == MaskPlaceholder { + continue + } + out[key] = value + } + return out +} + func (s *Service) checkManagedWrite(incoming map[string]any) error { if hits := core.TouchesManagedSettings(incoming, s.ManagedSettingPaths()); len(hits) > 0 { return &ErrManagedPath{Paths: hits} diff --git a/internal/settings/project_metadata_test.go b/internal/settings/project_metadata_test.go index 12fa9f3..443eb83 100644 --- a/internal/settings/project_metadata_test.go +++ b/internal/settings/project_metadata_test.go @@ -51,3 +51,36 @@ func TestPrepareProjectMetadataWriteUsesManagedGuard(t *testing.T) { t.Fatal("write preparation mutated its input") } } + +func TestPrepareProjectMetadataWritePrunesOnlyMaskedParents(t *testing.T) { + s := NewService(nil) + for _, raw := range []map[string]any{ + {"models": map[string]any{"chat": map[string]any{"apiKey": MaskPlaceholder}}, "display": map[string]any{"caption": "Updated"}}, + {"models.chat": map[string]any{"apiKey": MaskPlaceholder}, "display.caption": "Updated"}, + {"models": map[string]any{"chat.apiKey": MaskPlaceholder}, "display.caption": "Updated"}, + } { + before, _ := json.Marshal(raw) + got, err := s.PrepareProjectMetadataWrite(raw) + if err != nil { + t.Fatalf("unchanged masked credential rejected legal metadata edit: %v", err) + } + if len(got) != 1 { + t.Fatal("mask-only parent remained in prepared metadata") + } + after, _ := json.Marshal(raw) + if !bytes.Equal(before, after) { + t.Fatal("mask preparation changed its input") + } + } + for _, raw := range []map[string]any{ + {"models": map[string]any{}}, + {"models": map[string]any{"chat": map[string]any{}}}, + {"models.chat": map[string]any{}}, + {"models": map[string]any{"chat": map[string]any{"apiKey": MaskPlaceholder, "model": "Real overwrite"}}}, + {"models.chat": map[string]any{"apiKey": MaskPlaceholder}, "models": map[string]any{"chat": map[string]any{}}}, + } { + if _, err := s.PrepareProjectMetadataWrite(raw); err == nil { + t.Fatal("explicit empty or real managed overwrite accepted") + } + } +} -- 2.54.0 From d26f3cf2e864a76ad0b868fd968da3257cc7bb44 Mon Sep 17 00:00:00 2001 From: weichen Date: Tue, 13 Oct 2026 11:12:47 +0000 Subject: [PATCH 6/6] Protect private project event replay and live delivery Why: Legacy project streams replayed upload filenames and parser metadata to newly authorized members even though the underlying cards remained owner-only. Evidence: SELF real HTTP red regressions exposed legacy replay, spoofed sharing claims, private-history pagination, and live delivery after membership removal. The affected packages, uncached full Backend suite, isolated PostgreSQL Store suite, and agent-system build pass after applying the event boundary. Impact: Registered lifecycle streams resolve trusted resource ownership and require an internal publication proof for cross-owner delivery. Event and proof commit in one transaction; v30 adds an additive SQLite/PostgreSQL sidecar without granting access to historical events. Seen and visible cursors remain separate, revoked members leave live streams, and unregistered base behavior remains compatible. The storage migration tool preserves the new proof rows. Refs: project-member-access-implementation-plan.md Task 1 / R5; authorized legacy SSE privacy follow-up; project-member-composed-legacy-project-history.json --- cmd/storage-migrate/main.go | 4 +- internal/app/app.go | 1 + internal/cardsvc/cardsvc.go | 41 +++++- internal/runmgr/events.go | 28 ++++ internal/runmgr/runmgr.go | 6 + internal/server/project_events.go | 30 ++++ internal/server/project_events_test.go | 163 ++++++++++++++++++++++ internal/server/project_lifecycle_test.go | 3 +- internal/server/server.go | 34 ++++- internal/store/messages.go | 11 ++ internal/store/migrate.go | 7 + internal/store/migrate_compat.go | 1 + internal/store/project_events.go | 147 +++++++++++++++++++ internal/store/project_events_test.go | 140 +++++++++++++++++++ 14 files changed, 605 insertions(+), 11 deletions(-) create mode 100644 internal/server/project_events.go create mode 100644 internal/server/project_events_test.go create mode 100644 internal/store/project_events.go create mode 100644 internal/store/project_events_test.go diff --git a/cmd/storage-migrate/main.go b/cmd/storage-migrate/main.go index bdb1561..7ee9cb2 100644 --- a/cmd/storage-migrate/main.go +++ b/cmd/storage-migrate/main.go @@ -284,7 +284,7 @@ var copyOrder = []string{ "projects", "users", "profiles", "settings", "plugins", "conversations", "runs", "messages", "events", "artifacts", "hitl_cards", "plans", "todos", "knowledge", "knowledge_vectors", - "activity_cards", "command_audit", "prompt_modules", + "activity_cards", "project_event_shares", "command_audit", "prompt_modules", "sessions", "api_keys", "plugin_grants", "plugin_schema", "plugin_kv", "background_tasks", "memory_entries", "audit_events", "pending_confirms", "llm_usage_daily", @@ -784,7 +784,7 @@ func advanceSequences(db *sql.DB, table string) error { // checkOwners:业务行引用的 owner 必须存在于 users(单租户默认 ”/'local' // 除外)。未知 owner 阻塞迁移(U4/R7:不静默带过未知归属)。 func checkOwners(db *sql.DB, src *sql.DB) error { - ownerTables := []string{"projects", "conversations", "runs", "artifacts", "knowledge", "hitl_cards", "profiles", "activity_cards", "command_audit"} + ownerTables := []string{"projects", "conversations", "runs", "artifacts", "knowledge", "hitl_cards", "profiles", "activity_cards", "project_event_shares", "command_audit"} for _, t := range ownerTables { if !sqliteHasTable(src, t) { continue diff --git a/internal/app/app.go b/internal/app/app.go index 67587f3..33749b7 100644 --- a/internal/app/app.go +++ b/internal/app/app.go @@ -528,6 +528,7 @@ func Run(opt Options) error { // 收件箱任务卡:事件走会话事件管道(持久化/seq/重放);关闭即 T0 基线。 cardSvc := cardsvc.New(st, mgr.EmitCardEvent) + cardSvc.SetProjectSharedEmitter(mgr.EmitProjectSharedCardEvent) surfaceSvc := surfacesvc.New(st, mgr.EmitCardEvent) go func() { // 启动恢复:重发「快照已落盘、事件未发出」的待恢复投影(KTD)。 if n, err := surfaceSvc.RecoverPendingEvents(context.Background()); err == nil && n > 0 { diff --git a/internal/cardsvc/cardsvc.go b/internal/cardsvc/cardsvc.go index a17b6dc..6b4d0ab 100644 --- a/internal/cardsvc/cardsvc.go +++ b/internal/cardsvc/cardsvc.go @@ -27,8 +27,9 @@ var ( const cardAppendWindow = 250 * time.Millisecond type Service struct { - st *store.Store - emit Emitter + st *store.Store + emit Emitter + projectSharedEmit ProjectSharedEmitter // MirrorConvCards EXT-12:会话卡是否镜像进项目公共流(缺省 true=现状全量 // 镜像;false 时会话卡只进本人会话流,项目级卡不受影响)。 MirrorConvCards bool @@ -41,6 +42,42 @@ func New(st *store.Store, emit Emitter) *Service { return &Service{st: st, emit: emit, now: time.Now, lastEmit: map[string]time.Time{}, MirrorConvCards: true} } +// ProjectSharedEmitter is an internal trusted publication adapter. It is never +// bound to model arguments or exposed as a mandatory extension capability. +type ProjectSharedEmitter func(owner, producer, channel, runID string, typ core.StreamEventType, payload any) error + +func (s *Service) SetProjectSharedEmitter(emit ProjectSharedEmitter) { s.projectSharedEmit = emit } + +// PublishProjectCard explicitly publishes a project-level structural snapshot. +// Ordinary Create/Update remain private unless this entry issues a new proof. +func (s *Service) PublishProjectCard(owner, producer string, card core.ActivityCard, ref string) (core.ActivityCard, error) { + if s.projectSharedEmit == nil || owner == "" || producer == "" || card.ProjectID == "" || card.ConversationID != "" { + return card, errors.New("card: trusted project publisher required") + } + if card.ID != "" { + current, err := s.st.GetCard(owner, card.ID) + if err != nil { + return card, err + } + if current.ProjectID != card.ProjectID || current.ConversationID != "" { + return card, store.ErrNotFound + } + } else { + card.ID = store.NewID("card") + } + now := s.now().UTC() + card.CreatedAt = now + card.UpdatedAt = now + if card.Status == "" { + card.Status = core.CardActive + } + if err := s.st.SaveCard(owner, &card, ref); err != nil { + return card, err + } + err := s.projectSharedEmit(owner, producer, string(core.ProjectChannelKey(card.ProjectID)), card.SourceRunID, core.EvCardCreated, core.CardPayload{Card: card}) + return card, err +} + // emitCard 按卡归属发布卡片事件(D50): // - 会话卡 → 会话流;同时镜像项目流(伪会话键 "project:")—— // 项目收件箱聚合视图(项目级卡 ∪ 各会话未决卡)的实时性依赖它;卡片事件 diff --git a/internal/runmgr/events.go b/internal/runmgr/events.go index 9e41162..971e0c2 100644 --- a/internal/runmgr/events.go +++ b/internal/runmgr/events.go @@ -116,6 +116,34 @@ func (eb *eventBuffer) emit(conversationID, runID string, typ core.StreamEventTy return 0, nil } +// emitProjectShared persists an explicit internal publication and its proof +// before fan-out. A client-visible payload cannot opt itself into this path. +func (eb *eventBuffer) emitProjectShared(owner, producer, channel, runID string, typ core.StreamEventType, payload any) error { + project, ok := core.ProjectFromChannel(channel) + if !ok { + return store.ErrNotFound + } + encoded, err := json.Marshal(payload) + if err != nil { + return err + } + event := core.StreamEvent{ConversationID: channel, ProjectID: project, RunID: runID, Type: typ, Payload: encoded, TS: time.Now().UTC().Format(time.RFC3339Nano), V: core.ProtocolVersion} + eb.flushConversation(channel) + eb.flushing.Lock() + defer eb.flushing.Unlock() + events := []core.StreamEvent{event} + if _, err := eb.store.AppendProjectSharedEvents(owner, producer, channel, events); err != nil { + return err + } + for _, event := range events { + eb.bus.Publish(event) + } + if eb.onPersisted != nil { + eb.onPersisted(channel, events) + } + return nil +} + // append 落盘并按 seq 顺序发布。 func (eb *eventBuffer) append(conversationID string, evs []core.StreamEvent) error { eb.flushing.Lock() diff --git a/internal/runmgr/runmgr.go b/internal/runmgr/runmgr.go index e5fe6a3..b3f3d99 100644 --- a/internal/runmgr/runmgr.go +++ b/internal/runmgr/runmgr.go @@ -179,6 +179,12 @@ type IntentGate interface { Match(ctx context.Context, input string) (branch, topID string) } +// EmitProjectSharedCardEvent is an internal producer-authorized structural +// publication entry, separate from the ordinary private card event emitter. +func (m *Manager) EmitProjectSharedCardEvent(owner, producer, channel, runID string, typ core.StreamEventType, payload any) error { + return m.events.emitProjectShared(owner, producer, channel, runID, typ, payload) +} + // SetGate 注入意图闸门(装配期一次;插件意图 overlay 经 pluginhost 回调重建)。 func (m *Manager) SetGate(g IntentGate) { m.gate = g } diff --git a/internal/server/project_events.go b/internal/server/project_events.go new file mode 100644 index 0000000..7058cec --- /dev/null +++ b/internal/server/project_events.go @@ -0,0 +1,30 @@ +package server + +import ( + core "agent-system-core" +) + +// projectStreamMembership consults the registered resource authorizer using the +// physical project's trusted owner, never the event publisher or payload claims. +func (s *Server) projectStreamMembership(actor, project string) bool { + p, err := s.st.ProjectMetadata(project) + if err != nil { + return false + } + return s.authorizeSharedResource(actor, "stream", "project", project, p.Owner, project) +} + +func (s *Server) projectEventVisible(actor string, event core.StreamEvent) bool { + if s.projectLifecycle == nil { + return true + } + access, err := s.st.ResolveProjectEventAccess(event) + if err != nil || access.Owner == "" { + return false + } + if access.Owner == actor { + return true + } + project, ok := core.ProjectFromChannel(event.ConversationID) + return ok && access.Shared && s.projectStreamMembership(actor, project) +} diff --git a/internal/server/project_events_test.go b/internal/server/project_events_test.go new file mode 100644 index 0000000..0360ded --- /dev/null +++ b/internal/server/project_events_test.go @@ -0,0 +1,163 @@ +package server + +import ( + "agent-system-backend/internal/cardsvc" + "agent-system-backend/pkg/extension" + core "agent-system-core" + "bufio" + "bytes" + "context" + "encoding/json" + "io" + "net/http" + "net/http/httptest" + "sync/atomic" + "testing" + "time" +) + +func projectStreamReplay(t *testing.T, s *Server, key, project string) *httptest.ResponseRecorder { + t.Helper() + r := httptest.NewRequest("GET", "/api/events?scope=project&projectId="+project+"&since=0", nil) + r.Header.Set("Authorization", "Bearer "+key) + ctx, cancel := context.WithTimeout(r.Context(), 200*time.Millisecond) + defer cancel() + w := httptest.NewRecorder() + s.Handler().ServeHTTP(w, r.WithContext(ctx)) + return w +} +func projectEventFixture(t *testing.T) (*Server, string, string, string, *atomic.Bool) { + t.Helper() + s, st := newAuthTestServer(t) + alice, _ := st.CreateAPIKey("alice", "test") + bob, _ := st.CreateAPIKey("bob", "test") + p, _ := st.CreateProject("alice", "Project") + s.Cards = cardsvc.New(st, s.mgr.EmitCardEvent) + s.Cards.SetProjectSharedEmitter(s.mgr.EmitProjectSharedCardEvent) + member := &atomic.Bool{} + member.Store(true) + s.SetResourceAuthorizerV2(func(q extension.ResourceAuthzQuery) (bool, error) { + return q.ResourceType == "project" && q.ProjectID == p.ID && (q.Actor == "alice" || (q.Actor == "bob" && member.Load())), nil + }) + return s, alice, bob, p.ID, member +} +func TestProjectEventsLegacyReplayIsPrivate(t *testing.T) { + s, alice, bob, p, _ := projectEventFixture(t) + s.DisableUploadIndexing = true + if w := lifecycleUpload(t, s, alice, p, "legacy-owner-private.txt", "PRIVATE"); w.Code != 200 { + t.Fatal(w.Code) + } + s.SetProjectLifecycle(extension.ProjectLifecycle{Work: func(context.Context, string, core.ID) (bool, error) { return true, nil }}, nil) + member := projectStreamReplay(t, s, bob, p) + if member.Code != 200 || bytes.Contains(member.Body.Bytes(), []byte("legacy-owner-private.txt")) || bytes.Contains(member.Body.Bytes(), []byte("builtin:txt")) { + t.Fatal("legacy private upload leaked to member") + } + owner := projectStreamReplay(t, s, alice, p) + if owner.Code != 200 || !bytes.Contains(owner.Body.Bytes(), []byte("legacy-owner-private.txt")) { + t.Fatal("owner lost private history") + } +} +func TestProjectEventsSharedProofAndPrivatePagination(t *testing.T) { + s, _, bob, p, _ := projectEventFixture(t) + s.SetProjectLifecycle(extension.ProjectLifecycle{}, nil) + card, err := s.Cards.Create("alice", core.ActivityCard{ProjectID: p, Title: "PRIVATE HISTORY"}, "") + if err != nil { + t.Fatal(err) + } + payload, _ := json.Marshal(map[string]any{"card": card, "owner": "bob", "shared": true, "producer": "plugin:rdm:structure"}) + private := make([]core.StreamEvent, 1001) + for i := range private { + private[i] = core.StreamEvent{Type: core.EvCardUpdated, Payload: payload, TS: time.Now().UTC().Format(time.RFC3339Nano), V: 1} + } + if _, err := s.st.AppendEvents(core.ProjectChannelKey(p), private); err != nil { + t.Fatal(err) + } + if _, err := s.Cards.PublishProjectCard("alice", "plugin:rdm:structure", core.ActivityCard{ProjectID: p, Title: "EXPLICIT SHARED"}, ""); err != nil { + t.Fatal(err) + } + w := projectStreamReplay(t, s, bob, p) + if w.Code != 200 || bytes.Contains(w.Body.Bytes(), []byte("PRIVATE HISTORY")) || !bytes.Contains(w.Body.Bytes(), []byte("EXPLICIT SHARED")) { + t.Fatal("private pagination/spoof proof failed") + } +} +func TestProjectEventsLivePrivacyAndRevocation(t *testing.T) { + s, _, bob, p, member := projectEventFixture(t) + s.SetProjectLifecycle(extension.ProjectLifecycle{}, nil) + httpServer := httptest.NewServer(s.Handler()) + defer httpServer.Close() + ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second) + defer cancel() + req, _ := http.NewRequestWithContext(ctx, "GET", httpServer.URL+"/api/events?scope=project&projectId="+p, nil) + req.Header.Set("Authorization", "Bearer "+bob) + response, err := httpServer.Client().Do(req) + if err != nil { + t.Fatal(err) + } + defer response.Body.Close() + if _, err := s.Cards.Create("alice", core.ActivityCard{ProjectID: p, Title: "LIVE PRIVATE"}, ""); err != nil { + t.Fatal(err) + } + if _, err := s.Cards.PublishProjectCard("alice", "plugin:rdm:structure", core.ActivityCard{ProjectID: p, Title: "LIVE SHARED"}, ""); err != nil { + t.Fatal(err) + } + reader := bufio.NewReader(response.Body) + var before bytes.Buffer + for { + line, err := reader.ReadString('\n') + if err != nil { + t.Fatalf("live shared event did not arrive: %v", err) + } + before.WriteString(line) + if bytes.Contains([]byte(line), []byte("LIVE SHARED")) { + break + } + } + if bytes.Contains(before.Bytes(), []byte("LIVE PRIVATE")) { + t.Fatal("live private frame leaked before revocation") + } + member.Store(false) + if _, err := s.Cards.PublishProjectCard("alice", "plugin:rdm:structure", core.ActivityCard{ProjectID: p, Title: "AFTER REMOVAL"}, ""); err != nil { + t.Fatal(err) + } + body, err := io.ReadAll(reader) + if err != nil { + t.Fatalf("revoked stream did not stop: %v", err) + } + if bytes.Contains(body, []byte("LIVE PRIVATE")) || bytes.Contains(body, []byte("AFTER REMOVAL")) { + t.Fatal("live private/revoked event leaked") + } +} +func TestUnregisteredProjectEventsRetainLegacyReads(t *testing.T) { + s, _, bob, p, _ := projectEventFixture(t) + if _, err := s.Cards.Create("alice", core.ActivityCard{ProjectID: p, Title: "LEGACY BASE"}, ""); err != nil { + t.Fatal(err) + } + w := projectStreamReplay(t, s, bob, p) + if w.Code != 200 || !bytes.Contains(w.Body.Bytes(), []byte("LEGACY BASE")) { + t.Fatal("unregistered project stream changed") + } +} + +func TestProjectEventsIdleStreamStopsAfterMembershipRemoval(t *testing.T) { + previous := sseAuthRecheck + sseAuthRecheck = 20 * time.Millisecond + defer func() { sseAuthRecheck = previous }() + s, _, bob, p, member := projectEventFixture(t) + s.SetProjectLifecycle(extension.ProjectLifecycle{}, nil) + httpServer := httptest.NewServer(s.Handler()) + defer httpServer.Close() + ctx, cancel := context.WithTimeout(context.Background(), time.Second) + defer cancel() + request, _ := http.NewRequestWithContext(ctx, "GET", httpServer.URL+"/api/events?scope=project&projectId="+p, nil) + request.Header.Set("Authorization", "Bearer "+bob) + response, err := httpServer.Client().Do(request) + if err != nil { + t.Fatal(err) + } + defer response.Body.Close() + member.Store(false) + body, err := io.ReadAll(response.Body) + if err != nil || !bytes.Contains(body, []byte("project-access-revoked")) { + t.Fatal("idle project stream kept revoked member connected") + } +} diff --git a/internal/server/project_lifecycle_test.go b/internal/server/project_lifecycle_test.go index 47ea686..a58553e 100644 --- a/internal/server/project_lifecycle_test.go +++ b/internal/server/project_lifecycle_test.go @@ -409,8 +409,9 @@ func TestProjectLifecycleUploadSuppressesPrivateProjectCards(t *testing.T) { return q.ResourceType == "project" && q.ProjectID == p.ID && (q.Actor == "alice" || q.Actor == "bob"), nil }) s.Cards = cardsvc.New(st, s.mgr.EmitCardEvent) + s.Cards.SetProjectSharedEmitter(s.mgr.EmitProjectSharedCardEvent) s.DisableUploadIndexing = true - if _, err := s.Cards.Create("alice", core.ActivityCard{ProjectID: p.ID, Title: "Shared structure", Kind: core.CardKindProgress}, ""); err != nil { + if _, err := s.Cards.PublishProjectCard("alice", "plugin:rdm:structure", core.ActivityCard{ProjectID: p.ID, Title: "Shared structure", Kind: core.CardKindProgress}, ""); err != nil { t.Fatal(err) } // The owner path already admitted upload before the member authorization fix. diff --git a/internal/server/server.go b/internal/server/server.go index 426070e..d90b3c3 100644 --- a/internal/server/server.go +++ b/internal/server/server.go @@ -431,6 +431,7 @@ var sseAuthRecheck = 15 * time.Second func (s *Server) events(w http.ResponseWriter, r *http.Request) { conversationID := r.URL.Query().Get("conversationId") isProject := false + streamProject := "" // 项目流(D50):scope=project&projectId= 订阅项目频道——伪会话键 // "project:" 复用会话流的持久化/seq/重放机制;租户边界按项目校验。 if r.URL.Query().Get("scope") == "project" { @@ -448,6 +449,7 @@ func (s *Server) events(w http.ResponseWriter, r *http.Request) { } conversationID = string(core.ProjectChannelKey(core.ID(pid))) isProject = true + streamProject = pid } else if conversationID == "" { http.Error(w, "conversationId required", http.StatusBadRequest) return @@ -509,28 +511,38 @@ func (s *Server) events(w http.ResponseWriter, r *http.Request) { live, stop := s.Subscribe(conversationID) defer stop() + lastVisible := since send := func(e core.StreamEvent) bool { + if e.Seq <= lastVisible { + return true + } + if isProject && !s.projectEventVisible(wantOwner, e) { + return r.Context().Err() == nil + } buf, _ := json.Marshal(e) fmt.Fprintf(w, "id: %d\nevent: %s\ndata: %s\n\n", e.Seq, e.Type, buf) flusher.Flush() + lastVisible = e.Seq return r.Context().Err() == nil } - lastSent := since + // Seen progresses even when a private frame is filtered. Visible only tracks + // frames delivered to this actor, preserving authoritative seq and resumption. + lastSeen := since for { - replay, err := s.st.EventsAfter(conversationID, lastSent, 1000) + replay, err := s.st.EventsAfter(conversationID, lastSeen, 1000) if err != nil || len(replay) == 0 { break } for _, e := range replay { - if e.Seq <= lastSent { + if e.Seq <= lastSeen { continue } if !send(e) { stop() return } - lastSent = e.Seq + lastSeen = e.Seq } if len(replay) < 1000 { break @@ -548,6 +560,11 @@ func (s *Server) events(w http.ResponseWriter, r *http.Request) { stop() return case <-recheck.C: + if isProject && s.projectLifecycle != nil && !s.projectStreamMembership(wantOwner, streamProject) { + fmt.Fprint(w, ": project-access-revoked\n\n") + flusher.Flush() + return + } if !stillAuthed() { fmt.Fprint(w, ": session-expired\n\n") flusher.Flush() @@ -562,14 +579,19 @@ func (s *Server) events(w http.ResponseWriter, r *http.Request) { stop() return } - if e.ConversationID != conversationID || e.Seq <= lastSent { + if e.ConversationID != conversationID || e.Seq <= lastSeen { continue } + if isProject && s.projectLifecycle != nil && !s.projectStreamMembership(wantOwner, streamProject) { + fmt.Fprint(w, ": project-access-revoked\n\n") + flusher.Flush() + return + } if !send(e) { stop() return } - lastSent = e.Seq + lastSeen = e.Seq } } } diff --git a/internal/store/messages.go b/internal/store/messages.go index 36c2042..58724f2 100644 --- a/internal/store/messages.go +++ b/internal/store/messages.go @@ -266,6 +266,10 @@ func (s *Store) ChildRuns(runID string) ([]core.Run, error) { // 成功后回填 evs[i].Seq。事务内锁定会话序列:PG 用事务级咨询锁(跨实例安全), // SQLite 单写连接天然串行。空批次为空操作。 func (s *Store) AppendEvents(conversationID string, evs []core.StreamEvent) (int64, error) { + return s.appendEvents(conversationID, evs, "", "") +} + +func (s *Store) appendEvents(conversationID string, evs []core.StreamEvent, owner, producer string) (int64, error) { if len(evs) == 0 { return 0, nil } @@ -298,6 +302,13 @@ func (s *Store) AppendEvents(conversationID string, evs []core.StreamEvent) (int if _, err := tx.Exec(s.q(sb.String()), args...); err != nil { return 0, err } + if producer != "" { + for i, e := range evs { + if err := s.saveProjectEventProof(tx, conversationID, base.Int64+int64(i)+1, e, owner, producer); err != nil { + return 0, err + } + } + } // KTD6:事件提交与 NOTIFY 同事务(outbox 语义的前半——通知不早于事件 // 可见;后半的命令 outbox 走 EnqueueCommandInTx 由批准路径使用)。 if s.dialect.name() == "postgres" { diff --git a/internal/store/migrate.go b/internal/store/migrate.go index 4428563..c31e0e8 100644 --- a/internal/store/migrate.go +++ b/internal/store/migrate.go @@ -841,6 +841,13 @@ func init() { `DROP INDEX IF EXISTS idx_redeem_attempts_ip_at`, `DROP TABLE IF EXISTS redeem_attempts`, }}}, + migration{version: 30, name: "project_event_publication_proofs", + up: map[string][]string{ + "sqlite": {`CREATE TABLE project_event_shares(channel TEXT NOT NULL,seq INTEGER NOT NULL,event_type TEXT NOT NULL,payload_sha256 TEXT NOT NULL,owner TEXT NOT NULL,resource_type TEXT NOT NULL,resource_id TEXT NOT NULL,producer TEXT NOT NULL,created_at TEXT NOT NULL,PRIMARY KEY(channel,seq),FOREIGN KEY(channel,seq) REFERENCES events(conversation_id,seq) ON DELETE CASCADE)`}, + "postgres": {`CREATE TABLE project_event_shares(channel TEXT NOT NULL,seq BIGINT NOT NULL,event_type TEXT NOT NULL,payload_sha256 TEXT NOT NULL,owner TEXT NOT NULL,resource_type TEXT NOT NULL,resource_id TEXT NOT NULL,producer TEXT NOT NULL,created_at TEXT NOT NULL,PRIMARY KEY(channel,seq),FOREIGN KEY(channel,seq) REFERENCES events(conversation_id,seq) ON DELETE CASCADE)`}, + }, + down: map[string][]string{"sqlite": {`DROP TABLE IF EXISTS project_event_shares`}, "postgres": {`DROP TABLE IF EXISTS project_event_shares`}}, + }, ) } diff --git a/internal/store/migrate_compat.go b/internal/store/migrate_compat.go index ac0347d..86c692c 100644 --- a/internal/store/migrate_compat.go +++ b/internal/store/migrate_compat.go @@ -34,6 +34,7 @@ var migrationBreaksOldReaders = map[int]bool{ 27: false, 28: false, // auth_version_account_events:仅加列与新表,旧读者不受影响 29: false, // redeem_attempts:新表,旧读者不受影响 + 30: false, // project_event_publication_proofs:新表,旧读者不受影响 } // SchemaTarget 是本二进制会迁移到的 schema 版本。 diff --git a/internal/store/project_events.go b/internal/store/project_events.go new file mode 100644 index 0000000..ea6e7c4 --- /dev/null +++ b/internal/store/project_events.go @@ -0,0 +1,147 @@ +package store + +import ( + core "agent-system-core" + "crypto/sha256" + "database/sql" + "encoding/hex" + "encoding/json" + "errors" + "fmt" + "strings" +) + +// ProjectEventAccess is trusted read metadata; payload claims never establish it. +type ProjectEventAccess struct { + Owner string + Shared bool + Producer string +} +type eventResource struct{ owner, project, conversation, kind, id string } +type eventQuery interface{ QueryRow(string, ...any) *sql.Row } + +func cardEventID(e core.StreamEvent) (string, error) { + switch e.Type { + case core.EvCardCreated, core.EvCardUpdated, core.EvCardStatus, core.EvCardResolved: + default: + return "", nil + } + var payload struct { + Card struct { + ID string `json:"id"` + } `json:"card"` + CardID string `json:"cardId"` + } + if err := json.Unmarshal(e.Payload, &payload); err != nil { + return "", err + } + switch e.Type { + case core.EvCardCreated, core.EvCardUpdated: + return payload.Card.ID, nil + case core.EvCardStatus, core.EvCardResolved: + return payload.CardID, nil + } + return "", nil +} +func eventPayloadHash(payload []byte) string { + hash := sha256.Sum256(payload) + return hex.EncodeToString(hash[:]) +} + +func (s *Store) projectEventResource(q eventQuery, e core.StreamEvent) (eventResource, error) { + var resource eventResource + id, err := cardEventID(e) + if err != nil { + return resource, err + } + switch e.Type { + case core.EvCardCreated, core.EvCardUpdated, core.EvCardStatus, core.EvCardResolved: + if id == "" { + return resource, ErrNotFound + } + } + if id != "" { + resource.kind, resource.id = "card", id + err = q.QueryRow(s.q(`SELECT owner,project_id,conversation_id FROM activity_cards WHERE id=?`), id).Scan(&resource.owner, &resource.project, &resource.conversation) + } else if e.Type == core.EvArtifactCreated { + var payload struct { + Artifact struct { + ID string `json:"id"` + } `json:"artifact"` + } + if err := json.Unmarshal(e.Payload, &payload); err != nil { + return resource, err + } + if payload.Artifact.ID == "" { + return resource, ErrNotFound + } + resource.kind, resource.id = "artifact", payload.Artifact.ID + err = q.QueryRow(s.q(`SELECT owner,project_id FROM artifacts WHERE id=?`), resource.id).Scan(&resource.owner, &resource.project) + } else if e.RunID != "" { + resource.kind, resource.id = "run", e.RunID + err = q.QueryRow(s.q(`SELECT owner,project_id,conversation_id FROM runs WHERE id=?`), e.RunID).Scan(&resource.owner, &resource.project, &resource.conversation) + } else { + return resource, ErrNotFound + } + if errors.Is(err, sql.ErrNoRows) { + return resource, ErrNotFound + } + if err != nil { + return resource, err + } + project, ok := core.ProjectFromChannel(e.ConversationID) + if !ok || resource.owner == "" || resource.project != project || (e.ProjectID != "" && e.ProjectID != project) { + return resource, ErrNotFound + } + return resource, nil +} + +// ResolveProjectEventAccess resolves immutable resource ownership and verifies an +// exact internal publication record. Old events retain their private default. +func (s *Store) ResolveProjectEventAccess(e core.StreamEvent) (ProjectEventAccess, error) { + resource, err := s.projectEventResource(s.db, e) + if err != nil { + return ProjectEventAccess{}, err + } + access := ProjectEventAccess{Owner: resource.owner} + var owner, kind, id, producer, typ, hash string + err = s.db.QueryRow(s.q(`SELECT owner,resource_type,resource_id,producer,event_type,payload_sha256 FROM project_event_shares WHERE channel=? AND seq=?`), e.ConversationID, e.Seq).Scan(&owner, &kind, &id, &producer, &typ, &hash) + if errors.Is(err, sql.ErrNoRows) { + return access, nil + } + if err != nil { + return ProjectEventAccess{}, err + } + if owner != resource.owner || kind != resource.kind || id != resource.id || strings.TrimSpace(producer) == "" || typ != string(e.Type) || hash != eventPayloadHash(e.Payload) || resource.conversation != "" { + return ProjectEventAccess{}, ErrNotFound + } + access.Shared, access.Producer = true, producer + return access, nil +} + +// AppendProjectSharedEvents is an internal publication boundary, not an API or +// model tool. Events and their exact sharing proofs commit together. Caller +// identity must own a project-level card; private conversation cards cannot be +// published through this structural-card path. +func (s *Store) AppendProjectSharedEvents(owner, producer, channel string, events []core.StreamEvent) (int64, error) { + if owner == "" || strings.TrimSpace(producer) == "" { + return 0, fmt.Errorf("store: shared event requires owner and producer") + } + if _, ok := core.ProjectFromChannel(channel); !ok { + return 0, ErrNotFound + } + return s.appendEvents(channel, events, owner, producer) +} + +func (s *Store) saveProjectEventProof(tx *sql.Tx, channel string, seq int64, e core.StreamEvent, owner, producer string) error { + e.ConversationID = channel + resource, err := s.projectEventResource(tx, e) + if err != nil { + return err + } + if resource.kind != "card" || resource.owner != owner || resource.conversation != "" { + return ErrNotFound + } + _, err = tx.Exec(s.q(`INSERT INTO project_event_shares(channel,seq,event_type,payload_sha256,owner,resource_type,resource_id,producer,created_at) VALUES(?,?,?,?,?,?,?,?,?)`), channel, seq, string(e.Type), eventPayloadHash(e.Payload), resource.owner, resource.kind, resource.id, producer, nowISO()) + return err +} diff --git a/internal/store/project_events_test.go b/internal/store/project_events_test.go new file mode 100644 index 0000000..2c69aab --- /dev/null +++ b/internal/store/project_events_test.go @@ -0,0 +1,140 @@ +package store + +import ( + core "agent-system-core" + "encoding/json" + "testing" + "time" +) + +func storedProjectCardEvent(t *testing.T, st *Store, owner, project, id string) core.StreamEvent { + t.Helper() + card := core.ActivityCard{ID: id, ProjectID: project, Title: "PRIVATE", CreatedAt: time.Now().UTC(), UpdatedAt: time.Now().UTC()} + if err := st.SaveCard(owner, &card, ""); err != nil { + t.Fatal(err) + } + payload, _ := json.Marshal(core.CardPayload{Card: card}) + return core.StreamEvent{ConversationID: core.ProjectChannelKey(project), ProjectID: project, Type: core.EvCardCreated, Payload: payload, TS: time.Now().UTC().Format(time.RFC3339Nano), V: 1} +} +func TestProjectEventSharedProofBindsExactEvent(t *testing.T) { + st := openTestStore(t) + p, _ := st.CreateProject("alice", "Project") + e := storedProjectCardEvent(t, st, "alice", p.ID, "card-a") + private := []core.StreamEvent{e} + if _, err := st.AppendEvents(e.ConversationID, private); err != nil { + t.Fatal(err) + } + access, err := st.ResolveProjectEventAccess(private[0]) + if err != nil || access.Owner != "alice" || access.Shared { + t.Fatalf("private access=%+v %v", access, err) + } + shared := []core.StreamEvent{e} + if _, err := st.AppendProjectSharedEvents("alice", "plugin:rdm:structure", e.ConversationID, shared); err != nil { + t.Fatal(err) + } + access, err = st.ResolveProjectEventAccess(shared[0]) + if err != nil || !access.Shared || access.Owner != "alice" { + t.Fatalf("shared proof=%+v %v", access, err) + } + access, err = st.ResolveProjectEventAccess(private[0]) + if err != nil || access.Shared { + t.Fatal("publication retroactively shared private history") + } + for _, mutate := range []func(*core.StreamEvent){func(e *core.StreamEvent) { e.Type = core.EvCardUpdated }, func(e *core.StreamEvent) { e.Payload = append(e.Payload, ' ') }, func(e *core.StreamEvent) { e.Seq += 100 }, func(e *core.StreamEvent) { e.ConversationID = "project:another" }, func(e *core.StreamEvent) { e.ProjectID = "another" }} { + changed := shared[0] + mutate(&changed) + access, err = st.ResolveProjectEventAccess(changed) + if err == nil && access.Shared { + t.Fatal("mutated event retained sharing") + } + } + bad := []core.StreamEvent{e} + before, _ := st.LastEventSeq(e.ConversationID) + if _, err := st.AppendProjectSharedEvents("bob", "plugin:rdm:structure", e.ConversationID, bad); err == nil { + t.Fatal("other owner issued publication") + } + after, _ := st.LastEventSeq(e.ConversationID) + if after != before { + t.Fatal("rejected publication appended event") + } +} +func TestProjectEventMigrationPreservesPrivateHistory(t *testing.T) { + st := openTestStore(t) + if err := st.MigrateDown(29); err != nil { + t.Fatal(err) + } + p, _ := st.CreateProject("alice", "Legacy") + e := storedProjectCardEvent(t, st, "alice", p.ID, "legacy-card") + events := []core.StreamEvent{e} + if _, err := st.AppendEvents(e.ConversationID, events); err != nil { + t.Fatal(err) + } + before, _ := st.EventsAfter(e.ConversationID, 0, 100) + if err := st.migrate(); err != nil { + t.Fatal(err) + } + after, _ := st.EventsAfter(e.ConversationID, 0, 100) + b, _ := json.Marshal(before) + a, _ := json.Marshal(after) + if string(a) != string(b) { + t.Fatal("upgrade changed private event history") + } + access, err := st.ResolveProjectEventAccess(after[0]) + if err != nil || access.Owner != "alice" || access.Shared { + t.Fatalf("legacy privacy=%+v %v", access, err) + } +} + +func TestProjectSharedEventBatchRollsBackProofAndEvents(t *testing.T) { + st := openTestStore(t) + p, _ := st.CreateProject("alice", "Project") + a := storedProjectCardEvent(t, st, "alice", p.ID, "card-a") + b := storedProjectCardEvent(t, st, "bob", p.ID, "card-b") + if _, err := st.AppendProjectSharedEvents("alice", "plugin:rdm:structure", a.ConversationID, []core.StreamEvent{a, b}); err == nil { + t.Fatal("mixed owner batch published") + } + var count int + for _, table := range []string{"events", "project_event_shares"} { + if err := st.db.QueryRow("SELECT COUNT(*) FROM " + table).Scan(&count); err != nil || count != 0 { + t.Fatalf("partial %s publication: count=%d error=%v", table, count, err) + } + } +} + +func TestProjectEventInvalidProofAndMissingOwnerFailClosed(t *testing.T) { + st := openTestStore(t) + p, _ := st.CreateProject("alice", "Project") + e := storedProjectCardEvent(t, st, "alice", p.ID, "card-a") + shared := []core.StreamEvent{e} + if _, err := st.AppendProjectSharedEvents("alice", "plugin:rdm:structure", e.ConversationID, shared); err != nil { + t.Fatal(err) + } + if _, err := st.db.Exec(`UPDATE project_event_shares SET producer=''`); err != nil { + t.Fatal(err) + } + if access, err := st.ResolveProjectEventAccess(shared[0]); err == nil || access.Shared { + t.Fatal("corrupted publication proof was accepted") + } + if _, err := st.db.Exec(`UPDATE activity_cards SET owner=''`); err != nil { + t.Fatal(err) + } + if _, err := st.ResolveProjectEventAccess(shared[0]); err == nil { + t.Fatal("missing resource owner was accepted") + } +} + +func TestProjectSharingRejectsPrivateConversationCards(t *testing.T) { + st := openTestStore(t) + p, _ := st.CreateProject("alice", "Project") + e := storedProjectCardEvent(t, st, "alice", p.ID, "card-a") + if _, err := st.db.Exec(`UPDATE activity_cards SET conversation_id='private-conversation'`); err != nil { + t.Fatal(err) + } + if _, err := st.AppendProjectSharedEvents("alice", "plugin:rdm:structure", e.ConversationID, []core.StreamEvent{e}); err == nil { + t.Fatal("private conversation card publicly marked") + } + events, err := st.EventsAfter(e.ConversationID, 0, 100) + if err != nil || len(events) != 0 { + t.Fatal("rejected conversation publication appended events") + } +} -- 2.54.0