diff --git a/.task/feature-rdm-two-trees-project-audit.md b/.task/feature-rdm-two-trees-project-audit.md new file mode 100644 index 0000000..06329d8 --- /dev/null +++ b/.task/feature-rdm-two-trees-project-audit.md @@ -0,0 +1,20 @@ +# W9 Backend project audit read slice + +- Scope: TT-A01/A02/A04/U04, approved stage 5 W9. Stage 6 local implementation; human review, formal acceptance, PR, CI, tag, and deployment are pending. +- Branch/worktree: `feature/rdm-two-trees-project-audit` at `/home/weichen/projects/Agent-System-Backend-wt-feature-rdm-two-trees-project-audit`. +- R0: Backend authoritative `origin/main` `5d5fc9891c0eb8994545ccb8b740535d9b99fb0e`; main was clean and equal to the fetched remote before this worktree was created. Core gitlink in this worktree is `5773e74086791731059730bd3726dc8c9cd1381f`. +- Startup receipt: `/tmp/backend-w9-precheck-20261014-01.json` (SELF, exit 2, `SYNC_REQUIRED` for the separate Dev checkout `feature/rdm-increment-acceptance-note`, six commits behind Dev `origin/main`). This Backend task uses the independent-development path in Backend `AGENTS.md`; it does not write that Dev checkout or its task card. The receipt is preserved as a failure, not relabeled READY. + +## Implemented local slice + +- Optional `ProjectAuditHost` and domain `audit.read` registration. A different plugin, absent registration, or unbound actor cannot read. +- Store write snapshots the project from the owner-matched conversation; query requires the authenticated actor's owner and that immutable project. This deliberately excludes other members' private conversations and unbound historical rows, and does not move old receipts when a conversation changes projects. +- Command audit stores the current verified account display name at write time; old rows expose `not-recorded`. Public receipts omit raw payload and parameter digest. Cursor uses project-and-owner scoped source cutoff. +- Coverage explicitly marks cross-member published operations, legacy project attribution, run relations, model/background receipts, and resource bodies unsupported. This is not the full W9 or complete project audit. + +## Verification and remaining work + +- The session saw RED for the missing store and host interfaces, unscoped cutoff, cross-member private disclosure, and conversation-project reassignment before the respective fixes. These RED outputs were not captured by `make evidence`, so this card does not use them as formal evidence. +- SELF receipt `/tmp/backend-w9-go-test-20261014-01.json`: `go test ./...`, exit 0, run on the pre-commit worktree based on R0. +- SELF receipt `/tmp/backend-w9-build-20261014-01.json`: `CGO_ENABLED=0 go build ./cmd/agent-system`, exit 0, run on that pre-commit tree. Generated local binary removed after the build. +- Required next X2 contract: persist trusted operation/run/resource attribution and publication or visibility at source, with a per-object metadata/body disclosure callback and cursor binding to actor, authorization epoch, project, filter, and cutoff. Then test two members reading published project operations while private conversations, revoked objects, and bodies without current rights remain hidden. Current `command_audit` has owner, conversation, raw envelope, and new project/name snapshots, but no proof that an operation was published or that its resource may be disclosed. Add real model/tool/background receipts, delayed receipt reconciliation, history/export checks, and PostgreSQL/combined consumer verification before claiming W9 complete. diff --git a/internal/app/app.go b/internal/app/app.go index 33749b7..8ec4808 100644 --- a/internal/app/app.go +++ b/internal/app/app.go @@ -597,7 +597,7 @@ func Run(opt Options) error { // hostcaps:hostapi 闭包与进程内插件句柄共用的 owner 作用域能力(EXT-1 批注 1)。 authzSlot := &projectAuthz{} projectSlot := &projectLifecycleSlot{} - caps := hostcaps{projects: projectSlot, st: st, pipe: pipe, set: setSvc, authz: authzSlot, surfaces: surfaceSvc} + caps := hostcaps{projects: projectSlot, projectAudit: &projectAuditSlot{}, 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 { diff --git a/internal/app/hostcaps.go b/internal/app/hostcaps.go index 866a4f4..caf3e7a 100644 --- a/internal/app/hostcaps.go +++ b/internal/app/hostcaps.go @@ -21,12 +21,13 @@ import ( // 同一实现同时服务 hostapi.Server 的 Search/Complete 闭包与进程内 // inprocHost(EXT-1 批注 1:复用现有 owner 作用域函数,不新造句柄)。 type hostcaps struct { - projects *projectLifecycleSlot - st *store.Store - pipe *rag.Pipeline - set *settings.Service - authz *projectAuthz // EXT-8 适配器槽(共享指针;首个注册者生效) - surfaces *surfacesvc.Service // EXT-17 Surface 能力槽(nil = 未装配,明确拒绝) + projects *projectLifecycleSlot + projectAudit *projectAuditSlot + st *store.Store + pipe *rag.Pipeline + set *settings.Service + authz *projectAuthz // EXT-8 适配器槽(共享指针;首个注册者生效) + surfaces *surfacesvc.Service // EXT-17 Surface 能力槽(nil = 未装配,明确拒绝) } // projectAuthz EXT-8 资源授权适配器槽:进程内扩展注册,组合根装配进 Server。 diff --git a/internal/app/project_audit.go b/internal/app/project_audit.go new file mode 100644 index 0000000..07562fe --- /dev/null +++ b/internal/app/project_audit.go @@ -0,0 +1,103 @@ +package app + +import ( + "context" + "errors" + "sync" + + "agent-system-backend/pkg/extension" + core "agent-system-core" +) + +// projectAuditSlot binds the audit.read policy to its registering domain plugin. +// A different plugin cannot borrow that policy through its own Host handle. +type projectAuditSlot struct { + mu sync.RWMutex + holder string + check extension.ProjectAuditAuthorizer +} + +func (s *projectAuditSlot) register(pluginID string, check extension.ProjectAuditAuthorizer) error { + if pluginID == "" || check == nil { + return extension.ErrProjectAuditUnsupported + } + s.mu.Lock() + defer s.mu.Unlock() + if s.check != nil { + return errors.New("extension: project audit authorizer already registered") + } + s.holder, s.check = pluginID, check + return nil +} + +func (r compiledRegistry) RegisterProjectAuditAuthorizer(extension.ProjectAuditAuthorizer) error { + return errors.New("extension: declarative plugins cannot register project audit authorizers") +} + +func (r pluginRegistry) RegisterProjectAuditAuthorizer(check extension.ProjectAuditAuthorizer) error { + if r.caps.projectAudit == nil { + return extension.ErrProjectAuditUnsupported + } + return r.caps.projectAudit.register(r.pluginID, check) +} + +func (r recordRegistry) RegisterProjectAuditAuthorizer(check extension.ProjectAuditAuthorizer) error { + reg, ok := r.Registry.(extension.ProjectAuditRegistry) + if !ok { + return extension.ErrProjectAuditUnsupported + } + return reg.RegisterProjectAuditAuthorizer(check) +} + +var _ extension.ProjectAuditHost = inprocHost{} + +func (h inprocHost) QueryProjectAudit(ctx context.Context, q extension.ProjectAuditQuery) (extension.ProjectAuditPage, error) { + if err := ctx.Err(); err != nil { + return extension.ProjectAuditPage{}, err + } + if h.actor == "" || h.actor != h.owner || q.ProjectID == "" { + return extension.ProjectAuditPage{}, extension.ErrProjectAuditDenied + } + if h.caps.projectAudit == nil || h.caps.st == nil { + return extension.ProjectAuditPage{}, extension.ErrProjectAuditUnsupported + } + slot := h.caps.projectAudit + slot.mu.RLock() + holder, check := slot.holder, slot.check + slot.mu.RUnlock() + if holder != h.pluginID || check == nil { + return extension.ProjectAuditPage{}, extension.ErrProjectAuditUnsupported + } + ok, err := check(h.actor, core.ID(q.ProjectID)) + if err != nil || !ok { + return extension.ProjectAuditPage{}, extension.ErrProjectAuditDenied + } + if err := ctx.Err(); err != nil { + return extension.ProjectAuditPage{}, err + } + rows, cutoff, next, err := h.caps.st.ListProjectCommandAudits(h.owner, string(q.ProjectID), q.BeforeOffset, q.CutoffOffset, q.Limit) + if err != nil { + return extension.ProjectAuditPage{}, err + } + page := extension.ProjectAuditPage{ + Receipts: make([]extension.HostAuditReceipt, 0, len(rows)), + NextOffset: next, CutoffOffset: cutoff, + Coverage: extension.ProjectAuditCoverage{ + CommandAudit: "actor-owned-project-bound-only", CrossMember: "unsupported", LegacyProjectBinding: "not-recorded", RunRelations: "unsupported", ModelReceipts: "unsupported", + BackgroundReceipts: "unsupported", ResourceBodies: "unsupported", + }, + } + for _, row := range rows { + nameStatus := "not-recorded" + if row.ActorName != "" { + nameStatus = "recorded" + } + page.Receipts = append(page.Receipts, extension.HostAuditReceipt{ + SourceOffset: row.Offset, Time: row.Envelope.Time, ActorID: row.Envelope.Actor, + ActorName: row.ActorName, ActorNameStatus: nameStatus, + CommandID: row.Envelope.CommandID, State: string(row.Envelope.State), + OperationID: row.Envelope.OperationID, ConversationID: row.Envelope.ConversationID, + }) + } + return page, nil +} diff --git a/internal/app/project_audit_test.go b/internal/app/project_audit_test.go new file mode 100644 index 0000000..4157fa9 --- /dev/null +++ b/internal/app/project_audit_test.go @@ -0,0 +1,101 @@ +package app + +import ( + "context" + "encoding/json" + "errors" + "path/filepath" + "strings" + "testing" + "time" + + agentcommand "agent-command" + "agent-system-backend/internal/store" + "agent-system-backend/pkg/extension" + core "agent-system-core" +) + +func TestProjectAuditHostChecksDomainReadAndProjectsStoredReceipts(t *testing.T) { + st, err := store.OpenSQLite(filepath.Join(t.TempDir(), "audit.db")) + if err != nil { + t.Fatal(err) + } + defer st.Close() + for _, owner := range []string{"alice", "bob"} { + if err := st.EnsureUser(owner, strings.ToUpper(owner)); err != nil { + t.Fatal(err) + } + } + a, err := st.CreateProject("alice", "A") + if err != nil { + t.Fatal(err) + } + b, err := st.CreateProject("alice", "B") + if err != nil { + t.Fatal(err) + } + ac, err := st.CreateConversation("alice", string(a.ID), "A", "") + if err != nil { + t.Fatal(err) + } + bc, err := st.CreateConversation("alice", string(b.ID), "B", "") + if err != nil { + t.Fatal(err) + } + shared, err := st.CreateConversationInProject("bob", string(a.ID), "A shared", "") + if err != nil { + t.Fatal(err) + } + for _, item := range []struct{ owner, conversation, command string }{ + {"alice", string(ac.ID), "a.command"}, + {"alice", string(bc.ID), "b.command"}, + {"bob", string(shared.ID), "shared.command"}, + } { + if err := st.AppendAudit(item.owner, agentcommand.AuditEnvelope{Time: time.Now(), Actor: item.owner, CommandID: item.command, ConversationID: item.conversation, State: agentcommand.StateCommitted, ParamsDigest: "sensitive-digest"}); err != nil { + t.Fatal(err) + } + } + slot := &projectAuditSlot{} + caps := hostcaps{st: st, projectAudit: slot} + reg := pluginRegistry{pluginID: "rdm", caps: caps} + allowed := true + if err := reg.RegisterProjectAuditAuthorizer(func(actor string, projectID core.ID) (bool, error) { + return allowed && actor == "bob" && projectID == a.ID, nil + }); err != nil { + t.Fatal(err) + } + h := inprocHost{caps: caps, owner: "bob", actor: "bob", pluginID: "rdm"} + page, err := h.QueryProjectAudit(context.Background(), extension.ProjectAuditQuery{ProjectID: a.ID, Limit: 10}) + if err != nil { + t.Fatal(err) + } + if len(page.Receipts) != 1 || page.Receipts[0].CommandID != "shared.command" { + t.Fatalf("A page: %+v", page) + } + if page.Coverage.CommandAudit != "actor-owned-project-bound-only" || page.Coverage.CrossMember != "unsupported" || page.Coverage.LegacyProjectBinding != "not-recorded" || page.Coverage.RunRelations != "unsupported" || page.Coverage.ModelReceipts != "unsupported" { + t.Fatalf("coverage: %+v", page.Coverage) + } + if page.Receipts[0].ActorName != "BOB" { + t.Fatalf("actor names: %+v", page.Receipts) + } + raw, err := json.Marshal(page) + if err != nil { + t.Fatal(err) + } + if strings.Contains(string(raw), "sensitive-digest") || strings.Contains(string(raw), "paramsDigest") || strings.Contains(string(raw), "payload") { + t.Fatalf("raw audit payload leaked: %s", raw) + } + if _, err := h.QueryProjectAudit(context.Background(), extension.ProjectAuditQuery{ProjectID: b.ID}); !errors.Is(err, extension.ErrProjectAuditDenied) { + t.Fatalf("project B: %v", err) + } + if _, err := (inprocHost{caps: caps, owner: "bob", actor: "bob", pluginID: "other"}).QueryProjectAudit(context.Background(), extension.ProjectAuditQuery{ProjectID: a.ID}); !errors.Is(err, extension.ErrProjectAuditUnsupported) { + t.Fatalf("other plugin: %v", err) + } + if _, err := (inprocHost{caps: caps, owner: "bob", pluginID: "rdm"}).QueryProjectAudit(context.Background(), extension.ProjectAuditQuery{ProjectID: a.ID}); !errors.Is(err, extension.ErrProjectAuditDenied) { + t.Fatalf("unbound actor: %v", err) + } + allowed = false + if _, err := h.QueryProjectAudit(context.Background(), extension.ProjectAuditQuery{ProjectID: a.ID, BeforeOffset: page.NextOffset, CutoffOffset: page.CutoffOffset}); !errors.Is(err, extension.ErrProjectAuditDenied) { + t.Fatalf("revoked audit.read: %v", err) + } +} diff --git a/internal/store/audit.go b/internal/store/audit.go index b5479a8..d77301a 100644 --- a/internal/store/audit.go +++ b/internal/store/audit.go @@ -3,6 +3,7 @@ package store import ( "database/sql" "encoding/json" + "errors" "time" agentcommand "agent-command" @@ -16,13 +17,69 @@ func (s *Store) AppendAudit(owner string, env agentcommand.AuditEnvelope) error if err != nil { return err } - _, err = s.db.Exec(s.q(`INSERT INTO command_audit (time, command_id, source, entrance, state, operation_id, conversation_id, owner, payload) - VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`), + _, err = s.db.Exec(s.q(`INSERT INTO command_audit (time, command_id, source, entrance, state, operation_id, conversation_id, owner, payload, actor_name, project_id) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, COALESCE((SELECT name FROM users WHERE owner=?), ''), + COALESCE((SELECT project_id FROM conversations WHERE id=? AND owner=?), ''))`), env.Time.UTC().Format(time.RFC3339Nano), env.CommandID, string(env.Source), env.Entrance, string(env.State), - env.OperationID, env.ConversationID, owner, string(raw)) + env.OperationID, env.ConversationID, owner, string(raw), env.Actor, env.ConversationID, owner) return err } +// ProjectCommandAuditRow is an internal command receipt. Callers must project +// only approved fields; the envelope contains a digest and is not a public DTO. +type ProjectCommandAuditRow struct { + Offset int64 + ActorName string // empty on old rows or when no trusted account name was recorded + Envelope agentcommand.AuditEnvelope +} + +// ListProjectCommandAudits reads the project resolved from a trusted conversation +// at append time. Reassigning or deleting that conversation cannot rewrite the +// original project. Legacy rows without this snapshot are excluded: current +// project membership cannot establish their historical project attribution. +// Only the authenticated owner can read this first private slice. +func (s *Store) ListProjectCommandAudits(actorOwner, projectID string, before, cutoff int64, limit int) ([]ProjectCommandAuditRow, int64, int64, error) { + if actorOwner == "" || projectID == "" || before < 0 || cutoff < 0 || (before != 0 && cutoff == 0) { + return nil, 0, 0, errors.New("project audit: invalid scope or cursor") + } + if limit <= 0 || limit > 200 { + limit = 50 + } + if cutoff == 0 { + if err := s.db.QueryRow(s.q(`SELECT COALESCE(MAX(id),0) FROM command_audit + WHERE project_id=? AND owner=?`), projectID, actorOwner).Scan(&cutoff); err != nil { + return nil, 0, 0, err + } + } + rows, err := s.db.Query(s.q(`SELECT id, actor_name, payload FROM command_audit + WHERE project_id=? AND owner=? AND id<=? AND (?=0 OR id