From 97f60c1df676482417b4da9e7101748273584a35 Mon Sep 17 00:00:00 2001 From: agent-exec-transport Date: Thu, 22 Oct 2026 03:41:15 +0000 Subject: [PATCH] agent-exec transport f461d74086dd6a80db72f83748f34b04/128fee1609ad --- internal/app/project_audit.go | 70 +- internal/app/project_audit_matrix_test.go | 702 +++++++++++++++++++++ internal/store/project_audit_links.go | 11 + internal/store/project_audit_links_test.go | 83 +++ pkg/extension/extension.go | 8 +- pkg/extension/project_audit.go | 67 +- pkg/extension/project_audit_test.go | 99 +++ pkg/extension/surface.txt | 1 + 8 files changed, 1023 insertions(+), 18 deletions(-) create mode 100644 internal/app/project_audit_matrix_test.go create mode 100644 pkg/extension/project_audit_test.go diff --git a/internal/app/project_audit.go b/internal/app/project_audit.go index 43a7183..640bd12 100644 --- a/internal/app/project_audit.go +++ b/internal/app/project_audit.go @@ -247,6 +247,12 @@ func (h inprocHost) QueryProjectAudit(ctx context.Context, q extension.ProjectAu if h.actor == "" || h.actor != h.owner || q.ProjectID == "" { return extension.ProjectAuditPage{}, extension.ErrProjectAuditDenied } + // An export is not a page. This slice has no whole-history export capability, + // so the request is refused outright rather than answered with a first page a + // consumer could mistake for the complete record. + if q.Export { + return extension.ProjectAuditPage{}, extension.ErrProjectAuditUnsupported + } if h.caps.projectAudit == nil || h.caps.st == nil { return extension.ProjectAuditPage{}, extension.ErrProjectAuditUnsupported } @@ -267,9 +273,16 @@ func (h inprocHost) QueryProjectAudit(ctx context.Context, q extension.ProjectAu if err := ctx.Err(); err != nil { return extension.ProjectAuditPage{}, err } - if q.LinkedSourceKind != "" || q.LinkedSourceID != "" || q.LinkedHostOffset != 0 { + if q.LinkedSourceKind != "" || q.LinkedSourceID != "" || q.LinkedHostOffset != 0 || + q.LinkedResourceKind != "" || q.LinkedResourceID != "" || q.LinkedRunID != "" { return h.queryLinkedProjectAudit(ctx, q, slot) } + // The actor-owned page is not bound to one verified object, so no per-object + // body right can be decided for it. Refusing keeps Coverage.ResourceBodies + // honest instead of answering a page trimmed by an unasked decision. + if q.IncludeBody { + return extension.ProjectAuditPage{}, extension.ErrProjectAuditUnsupported + } 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 @@ -279,7 +292,7 @@ func (h inprocHost) QueryProjectAudit(ctx context.Context, q extension.ProjectAu NextOffset: next, CutoffOffset: cutoff, Coverage: extension.ProjectAuditCoverage{ CommandAudit: "actor-owned-project-bound-only", CutoffConsistency: "committed-prefix", CrossMember: "unsupported", LegacyProjectBinding: "not-recorded", RunRelations: "unsupported", ModelReceipts: "unsupported", - BackgroundReceipts: "unsupported", ResourceBodies: "unsupported", + BackgroundReceipts: "unsupported", ResourceBodies: "unsupported", Export: "unsupported", }, } for _, row := range rows { @@ -298,7 +311,7 @@ func (h inprocHost) QueryProjectAudit(ctx context.Context, q extension.ProjectAu } func (h inprocHost) queryLinkedProjectAudit(ctx context.Context, q extension.ProjectAuditQuery, slot *projectAuditSlot) (extension.ProjectAuditPage, error) { - if q.LinkedSourceKind != "domain-commit" || q.LinkedSourceID == "" || q.LinkedHostOffset <= 0 || q.BeforeOffset != 0 || q.CutoffOffset != 0 { + if q.LinkedSourceKind != "domain-commit" || q.LinkedSourceID == "" || q.LinkedHostOffset <= 0 || q.BeforeOffset != 0 || q.CutoffOffset != 0 || q.Export { return extension.ProjectAuditPage{}, extension.ErrProjectAuditUnsupported } slot.mu.RLock() @@ -311,9 +324,32 @@ func (h inprocHost) queryLinkedProjectAudit(ctx context.Context, q extension.Pro if errors.Is(err, store.ErrNotFound) { return extension.ProjectAuditPage{}, extension.ErrProjectAuditDenied } + if errors.Is(err, store.ErrConflict) { + // The host row was edited after publication: the receipt no longer matches + // the binding the domain verified. Fail closed instead of re-projecting it. + return extension.ProjectAuditPage{}, extension.ErrProjectAuditDenied + } if err != nil { return extension.ProjectAuditPage{}, err } + // Caller locators only narrow the exact binding. Disagreement with stored host + // state denies; it never selects a different object. + if (q.LinkedResourceKind != "" && q.LinkedResourceKind != link.ResourceKind) || + (q.LinkedResourceID != "" && q.LinkedResourceID != link.ResourceID) || + (q.LinkedRunID != "" && q.LinkedRunID != link.RunID) { + return extension.ProjectAuditPage{}, extension.ErrProjectAuditDenied + } + // A run relation was verified at publication; re-check it against current + // storage so a deleted or moved run stops serving its receipt. + if link.RunID != "" { + bound, err := h.caps.st.RunBoundToAudit(link.Owner, link.ProjectID, row.Envelope.ConversationID, link.RunID) + if err != nil { + return extension.ProjectAuditPage{}, err + } + if !bound { + return extension.ProjectAuditPage{}, extension.ErrProjectAuditDenied + } + } resource := extension.ProjectAuditLinkedResource{ProjectID: q.ProjectID, Owner: link.Owner, SourceKind: link.SourceKind, SourceID: link.SourceID, OperationID: link.OperationID, ResourceKind: link.ResourceKind, ResourceID: link.ResourceID, RunID: link.RunID, DisclosureClass: link.DisclosureClass} allowed, err := disclose(ctx, h.actor, resource) @@ -323,14 +359,32 @@ func (h inprocHost) queryLinkedProjectAudit(ctx context.Context, q extension.Pro if !allowed.Metadata { return extension.ProjectAuditPage{}, extension.ErrProjectAuditDenied } + // audit.read and the per-object body right are separate authorities: a body + // request without that right is refused, never trimmed into a partial page. + if q.IncludeBody && !allowed.Body { + return extension.ProjectAuditPage{}, extension.ErrProjectAuditDenied + } + if err := ctx.Err(); err != nil { + return extension.ProjectAuditPage{}, err + } nameStatus := "not-recorded" if row.ActorName != "" { nameStatus = "recorded" } - return extension.ProjectAuditPage{Receipts: []extension.HostAuditReceipt{{SourceOffset: row.Offset, Time: row.Envelope.Time, + receipt := 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}}, - Coverage: extension.ProjectAuditCoverage{CommandAudit: "verified-exact-link-only", CrossMember: "verified-exact-link-only", - CutoffConsistency: "exact-link", LegacyProjectBinding: "not-recorded", RunRelations: "unsupported", - ModelReceipts: "unsupported", BackgroundReceipts: "unsupported", ResourceBodies: "unsupported"}}, nil + State: string(row.Envelope.State), OperationID: row.Envelope.OperationID} + coverage := extension.ProjectAuditCoverage{CommandAudit: "verified-exact-link-only", CrossMember: "verified-exact-link-only", + CutoffConsistency: "exact-link", LegacyProjectBinding: "not-recorded", RunRelations: "unsupported", + ModelReceipts: "unsupported", BackgroundReceipts: "unsupported", ResourceBodies: "unsupported", Export: "unsupported"} + if link.RunID != "" { + coverage.RunRelations = "verified-exact-link-run" + } + if q.IncludeBody { + // Body-permitted projection of the verified binding only: the object identity + // the domain attributed this command to, never resource content. + receipt.Resource = &extension.ProjectAuditReceiptResource{Kind: link.ResourceKind, ID: link.ResourceID, RunID: link.RunID} + coverage.ResourceBodies = "verified-exact-link-body" + } + return extension.ProjectAuditPage{Receipts: []extension.HostAuditReceipt{receipt}, Coverage: coverage}, nil } diff --git a/internal/app/project_audit_matrix_test.go b/internal/app/project_audit_matrix_test.go new file mode 100644 index 0000000..e2ccc3e --- /dev/null +++ b/internal/app/project_audit_matrix_test.go @@ -0,0 +1,702 @@ +package app + +// W9 X2 出口矩阵:可选 ProjectAuditHost 的读取面按【受信项目、actor、resource、 +// run、source offset 与截止】读取,并结合 RDM audit.read 回调与每对象正文权做 +// 服务端裁剪。本文件覆盖批准需求第(4)条列出的九个出口场景,以及第(3)条 +// “缺基座能力返回 unsupported 覆盖范围”的语义。 +// +// RDM 消费端(audit.read / doc.read 回调的领域实现)不在本仓:这里只验证宿主 +// 面必须向注册方询问、按对象裁剪、缺能力即 unsupported,不在宿主侧复制策略。 + +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" +) + +// auditMatrix 是出口矩阵的共用基座:alice 拥有 A/B 两个项目并在其中各有一份 +// 会话,bob 是 A 的第二成员(会话建在 A 下),carol 只存在于账号目录、不参与 +// 任何审计行。alice 的 op-a 是一条已发布链接(绑定 document/doc-1 与一次 run)。 +type auditMatrix struct { + t *testing.T + st *store.Store + caps hostcaps + projectA core.ID + projectB core.ID + foreign core.ID + aliceConv core.ID + bobConv core.ID + runID string + link extension.ProjectAuditLinkReceipt + + // 策略旋钮:每个用例按出口场景改写,回调只读它们。 + auditAll bool + auditRead map[core.ID]bool + metadata map[string]bool + body map[string]bool + auditCalls []string + discloseCall int +} + +func newAuditMatrix(t *testing.T) *auditMatrix { + t.Helper() + st, err := store.OpenSQLite(filepath.Join(t.TempDir(), "audit.db")) + if err != nil { + t.Fatal(err) + } + t.Cleanup(func() { st.Close() }) + m := &auditMatrix{t: t, st: st, auditRead: map[core.ID]bool{}, metadata: map[string]bool{}, body: map[string]bool{}} + for _, account := range []struct{ owner, name string }{ + {"alice", "Alice at commit"}, {"bob", "Bob"}, {"carol", "Carol directory only"}, + } { + if err := st.EnsureUser(account.owner, account.name); err != nil { + t.Fatal(err) + } + } + projectA, err := st.CreateProject("alice", "A") + if err != nil { + t.Fatal(err) + } + projectB, err := st.CreateProject("alice", "B") + if err != nil { + t.Fatal(err) + } + foreign, err := st.CreateProject("carol", "C") + if err != nil { + t.Fatal(err) + } + m.projectA, m.projectB, m.foreign = projectA.ID, projectB.ID, foreign.ID + aliceConv, err := st.CreateConversation("alice", string(projectA.ID), "A work", "") + if err != nil { + t.Fatal(err) + } + otherConv, err := st.CreateConversation("alice", string(projectB.ID), "B work", "") + if err != nil { + t.Fatal(err) + } + bobConv, err := st.CreateConversationInProject("bob", string(projectA.ID), "A shared", "") + if err != nil { + t.Fatal(err) + } + m.aliceConv, m.bobConv = aliceConv.ID, bobConv.ID + run := core.Run{ConversationID: aliceConv.ID, ProjectID: projectA.ID, Owner: "alice", + Kind: core.RunConversation, Status: core.RunRunning, Phase: core.PhaseThinking} + if err := st.CreateRun(&run); err != nil { + t.Fatal(err) + } + m.runID = string(run.ID) + m.append("alice", string(aliceConv.ID), "alice.submit", "op-a", m.runID) + m.append("alice", string(otherConv.ID), "alice.other", "op-b", "") + m.append("bob", string(bobConv.ID), "bob.submit", "op-bob", "") + + m.caps = hostcaps{st: st, projectAudit: &projectAuditSlot{}} + reg := pluginRegistry{pluginID: "rdm", caps: m.caps} + if err := reg.RegisterProjectAuditAuthorizer(func(actor string, projectID core.ID) (bool, error) { + m.auditCalls = append(m.auditCalls, actor+"@"+string(projectID)) + return m.auditAll || m.auditRead[projectID], nil + }); err != nil { + t.Fatal(err) + } + factory, err := (recordRegistry{Registry: reg}).RegisterProjectAuditLinkVerifier(func(_ context.Context, proof extension.ProjectAuditLinkProof) (bool, error) { + return proof.Owner == "alice" && proof.ProjectID == m.projectA && proof.SourceKind == "domain-commit" && + proof.SourceID == "commit-a" && proof.OperationID == "op-a" && proof.ResourceKind == "document" && + proof.ResourceID == "doc-1" && proof.RunID == m.runID, nil + }) + if err != nil { + t.Fatal(err) + } + if err := reg.RegisterProjectAuditDisclosureAuthorizer(func(_ context.Context, actor string, resource extension.ProjectAuditLinkedResource) (extension.ProjectAuditDisclosure, error) { + m.discloseCall++ + return extension.ProjectAuditDisclosure{Metadata: actor == "bob" && m.metadata[resource.ResourceID], + Body: actor == "bob" && m.body[resource.ResourceID]}, nil + }); err != nil { + t.Fatal(err) + } + publisher, err := factory.ForOwner("alice") + if err != nil { + t.Fatal(err) + } + receipt, err := publisher.PublishProjectAuditLink(context.Background(), extension.ProjectAuditLinkRequest{ + ProjectID: m.projectA, SourceKind: "domain-commit", SourceID: "commit-a", OperationID: "op-a", + ResourceKind: "document", ResourceID: "doc-1", RunID: m.runID, DisclosureClass: "metadata", IdempotencyKey: "outbox-a", + }) + if err != nil { + t.Fatal(err) + } + m.link = receipt + return m +} + +func (m *auditMatrix) append(owner, conversationID, commandID, operationID, runID string) { + m.t.Helper() + if err := m.st.AppendAudit(owner, agentcommand.AuditEnvelope{Time: time.Now().UTC(), Actor: owner, CommandID: commandID, + ConversationID: conversationID, State: agentcommand.StateCommitted, OperationID: operationID, RunID: runID, + ParamsDigest: "sensitive-digest"}); err != nil { + m.t.Fatal(err) + } +} + +func (m *auditMatrix) host(owner string) inprocHost { + return inprocHost{caps: m.caps, owner: owner, actor: owner, pluginID: "rdm"} +} + +func (m *auditMatrix) linkedQuery() extension.ProjectAuditQuery { + return extension.ProjectAuditQuery{ProjectID: m.projectA, LinkedSourceKind: "domain-commit", + LinkedSourceID: "commit-a", LinkedHostOffset: m.link.HostAuditOffset} +} + +func (m *auditMatrix) openBody() { + m.metadata["doc-1"], m.body["doc-1"] = true, true +} + +// 同 owner 跨项目/两成员:受信项目归属与成员身份都必须在服务端判定。 +func TestProjectAuditExitMatrixSameOwnerCrossProjectAndTwoMembers(t *testing.T) { + m := newAuditMatrix(t) + m.auditRead[m.projectA] = true + m.auditRead[m.projectB] = true + m.metadata["doc-1"] = true + + bobPage, err := m.host("bob").QueryProjectAudit(context.Background(), extension.ProjectAuditQuery{ProjectID: m.projectA}) + if err != nil { + t.Fatal(err) + } + if len(bobPage.Receipts) != 1 || bobPage.Receipts[0].CommandID != "bob.submit" || bobPage.Receipts[0].ActorName != "Bob" { + t.Fatalf("second member sees another member's row: %+v", bobPage.Receipts) + } + alicePage, err := m.host("alice").QueryProjectAudit(context.Background(), extension.ProjectAuditQuery{ProjectID: m.projectA}) + if err != nil { + t.Fatal(err) + } + if len(alicePage.Receipts) != 1 || alicePage.Receipts[0].CommandID != "alice.submit" || alicePage.Receipts[0].ActorName != "Alice at commit" { + t.Fatalf("owner page left its project scope: %+v", alicePage.Receipts) + } + otherProject, err := m.host("alice").QueryProjectAudit(context.Background(), extension.ProjectAuditQuery{ProjectID: m.projectB}) + if err != nil { + t.Fatal(err) + } + if len(otherProject.Receipts) != 1 || otherProject.Receipts[0].CommandID != "alice.other" { + t.Fatalf("project B page: %+v", otherProject.Receipts) + } + m.auditRead[m.projectB] = false + if _, err := m.host("bob").QueryProjectAudit(context.Background(), extension.ProjectAuditQuery{ProjectID: m.projectB}); !errors.Is(err, extension.ErrProjectAuditDenied) { + t.Fatalf("non-member read another project: %v", err) + } + if got := m.auditCalls[len(m.auditCalls)-1]; got != "bob@"+string(m.projectB) { + t.Fatalf("host did not ask the domain about the caller's project: %s", got) + } + if _, err := m.host("bob").QueryProjectAudit(context.Background(), extension.ProjectAuditQuery{}); !errors.Is(err, extension.ErrProjectAuditDenied) { + t.Fatalf("empty project id: %v", err) + } +} + +// 恶意 projectId:调用方自报的项目标识只是定位符,既不能换出别的项目,也不能 +// 让“看起来通过”的未知项目变成受信项目流。 +func TestProjectAuditExitMatrixMaliciousProjectID(t *testing.T) { + m := newAuditMatrix(t) + // 领域回调对所有项目一律放行(配置失误):宿主仍只能按受信存储归集。 + m.auditAll = true + for _, projectID := range []core.ID{m.foreign, "no-such-project"} { + page, err := m.host("alice").QueryProjectAudit(context.Background(), extension.ProjectAuditQuery{ProjectID: projectID}) + if err != nil { + t.Fatalf("project %q: %v", projectID, err) + } + if len(page.Receipts) != 0 { + t.Fatalf("project %q returned rows: %+v", projectID, page.Receipts) + } + if page.Coverage.LegacyProjectBinding != "not-recorded" || page.Coverage.Export != "unsupported" { + t.Fatalf("project %q coverage: %+v", projectID, page.Coverage) + } + } + // 领域误放行也不能换出受信任存储之外的对象:每个主体只看得到自己的行。 + for _, c := range []struct { + owner string + projectID core.ID + want string + }{ + {"alice", m.projectA, "alice.submit"}, {"bob", m.projectA, "bob.submit"}, + {"alice", m.projectB, "alice.other"}, {"bob", m.projectB, ""}, + } { + page, err := m.host(c.owner).QueryProjectAudit(context.Background(), extension.ProjectAuditQuery{ProjectID: c.projectID}) + if err != nil { + t.Fatalf("%s@%s: %v", c.owner, c.projectID, err) + } + got := []string{} + for _, receipt := range page.Receipts { + got = append(got, receipt.CommandID) + } + if strings.Join(got, ",") != c.want { + t.Fatalf("%s@%s rows = %v, want [%s]", c.owner, c.projectID, got, c.want) + } + } + // 他人项目下的链接按 projectId 定位失败:不得跨项目取回。 + crossProject := m.linkedQuery() + crossProject.ProjectID = m.foreign + if _, err := m.host("bob").QueryProjectAudit(context.Background(), crossProject); !errors.Is(err, extension.ErrProjectAuditDenied) { + t.Fatalf("cross-project link locator: %v", err) + } + // 调用方自报的 resource/run 定位符必须与受信绑定一致,否则拒绝。 + // 先打开逐对象元数据权,使拒绝只能来自定位符校验本身。 + m.metadata["doc-1"] = true + for _, mutate := range []func(*extension.ProjectAuditQuery){ + func(q *extension.ProjectAuditQuery) { q.LinkedResourceID = "doc-2" }, + func(q *extension.ProjectAuditQuery) { q.LinkedResourceKind = "run" }, + func(q *extension.ProjectAuditQuery) { q.LinkedRunID = "run-other" }, + } { + q := m.linkedQuery() + mutate(&q) + if _, err := m.host("bob").QueryProjectAudit(context.Background(), q); !errors.Is(err, extension.ErrProjectAuditDenied) { + t.Fatalf("forged locator was accepted: %+v err=%v", q, err) + } + } + q := m.linkedQuery() + q.LinkedResourceKind, q.LinkedResourceID, q.LinkedRunID = "document", "doc-1", m.runID + if _, err := m.host("bob").QueryProjectAudit(context.Background(), q); err != nil { + t.Fatalf("matching locators were refused: %v", err) + } +} + +// audit.read 无正文:元数据权成立、正文权不成立时只出元数据;请求正文则拒绝, +// 不得返回一份“被悄悄裁剪”的页冒充有正文覆盖。 +func TestProjectAuditExitMatrixAuditReadWithoutBody(t *testing.T) { + m := newAuditMatrix(t) + m.auditRead[m.projectA] = true + m.metadata["doc-1"] = true + page, err := m.host("bob").QueryProjectAudit(context.Background(), m.linkedQuery()) + if err != nil { + t.Fatal(err) + } + if len(page.Receipts) != 1 || page.Receipts[0].Resource != nil || page.Receipts[0].ActorName != "Alice at commit" { + t.Fatalf("metadata-only page: %+v", page.Receipts) + } + if page.Coverage.ResourceBodies != "unsupported" || page.Coverage.CommandAudit != "verified-exact-link-only" { + t.Fatalf("coverage overstated body coverage: %+v", page.Coverage) + } + body := m.linkedQuery() + body.IncludeBody = true + if _, err := m.host("bob").QueryProjectAudit(context.Background(), body); !errors.Is(err, extension.ErrProjectAuditDenied) { + t.Fatalf("body requested without the body right: %v", err) + } + // 无受信对象绑定的 actor 自有页没有每对象正文权可言:显式 unsupported。 + owned := extension.ProjectAuditQuery{ProjectID: m.projectA, IncludeBody: true} + if _, err := m.host("bob").QueryProjectAudit(context.Background(), owned); !errors.Is(err, extension.ErrProjectAuditUnsupported) { + t.Fatalf("actor-owned body read: %v", err) + } + // 正文权成立时按对象裁剪下发,且不下发正文载荷。 + m.openBody() + // 有权但未请求:不主动多给对象身份。 + undeclared, err := m.host("bob").QueryProjectAudit(context.Background(), m.linkedQuery()) + if err != nil { + t.Fatal(err) + } + if len(undeclared.Receipts) != 1 || undeclared.Receipts[0].Resource != nil || undeclared.Coverage.ResourceBodies != "unsupported" { + t.Fatalf("body right was exercised without a body request: %+v coverage=%+v", undeclared.Receipts, undeclared.Coverage) + } + bodyPage, err := m.host("bob").QueryProjectAudit(context.Background(), body) + if err != nil { + t.Fatal(err) + } + if len(bodyPage.Receipts) != 1 || bodyPage.Receipts[0].Resource == nil || + bodyPage.Receipts[0].Resource.Kind != "document" || bodyPage.Receipts[0].Resource.ID != "doc-1" || + bodyPage.Receipts[0].Resource.RunID != m.runID || bodyPage.Coverage.ResourceBodies != "verified-exact-link-body" || + bodyPage.Coverage.RunRelations != "verified-exact-link-run" { + t.Fatalf("body-permitted page: %+v coverage=%+v", bodyPage.Receipts, bodyPage.Coverage) + } + raw, err := json.Marshal(bodyPage) + if err != nil { + t.Fatal(err) + } + if strings.Contains(string(raw), "sensitive-digest") || strings.Contains(string(raw), "paramsDigest") || + strings.Contains(string(raw), string(m.aliceConv)) { + t.Fatalf("body-permitted page leaked a payload or private locator: %s", raw) + } +} + +// 正文无 audit.read:对象权先成立也必须先被项目 audit.read 挡住,且不得因为 +// 逐对象判定而向无 audit.read 的主体泄露“这个对象存在”。 +func TestProjectAuditExitMatrixBodyWithoutAuditRead(t *testing.T) { + m := newAuditMatrix(t) + m.auditRead[m.projectA] = true + m.openBody() + body := m.linkedQuery() + body.IncludeBody = true + if _, err := m.host("bob").QueryProjectAudit(context.Background(), body); err != nil { + t.Fatalf("baseline body read: %v", err) + } + m.auditRead[m.projectA] = false + before := m.discloseCall + if _, err := m.host("bob").QueryProjectAudit(context.Background(), m.linkedQuery()); !errors.Is(err, extension.ErrProjectAuditDenied) { + t.Fatalf("body right survived revoked audit.read: %v", err) + } + if _, err := m.host("bob").QueryProjectAudit(context.Background(), body); !errors.Is(err, extension.ErrProjectAuditDenied) { + t.Fatalf("body read without audit.read: %v", err) + } + if m.discloseCall != before { + t.Fatalf("per-object decision was consulted before audit.read: %d -> %d", before, m.discloseCall) + } +} + +// 对象已撤权/删改:撤权走逐对象判定;删除与改动让存储侧无法再把该链接解析成 +// 受信任回执;已删除的 run 关系也不再服务回执。 +func TestProjectAuditExitMatrixObjectRevokedDeletedOrEdited(t *testing.T) { + m := newAuditMatrix(t) + m.auditRead[m.projectA] = true + m.openBody() + if _, err := m.host("bob").QueryProjectAudit(context.Background(), m.linkedQuery()); err != nil { + t.Fatalf("baseline link: %v", err) + } + m.metadata["doc-1"], m.body["doc-1"] = false, false + if _, err := m.host("bob").QueryProjectAudit(context.Background(), m.linkedQuery()); !errors.Is(err, extension.ErrProjectAuditDenied) { + t.Fatalf("revoked object: %v", err) + } + m.openBody() + + // run 关系被改(run 移出本项目):链接仍在,但受约束的 run 不再匹配。 + if _, err := m.st.DB().Exec(`UPDATE runs SET project_id=? WHERE id=?`, string(m.projectB), m.runID); err != nil { + t.Fatal(err) + } + if _, err := m.host("bob").QueryProjectAudit(context.Background(), m.linkedQuery()); !errors.Is(err, extension.ErrProjectAuditDenied) { + t.Fatalf("moved run kept serving its receipt: %v", err) + } + if _, err := m.st.DB().Exec(`UPDATE runs SET project_id=? WHERE id=?`, string(m.projectA), m.runID); err != nil { + t.Fatal(err) + } + if _, err := m.host("bob").QueryProjectAudit(context.Background(), m.linkedQuery()); err != nil { + t.Fatalf("restored run: %v", err) + } + + // 宿主回执被改动(载荷里的 operationId 漂移):不再是领域验证过的那一条。 + var stored string + if err := m.st.DB().QueryRow(`SELECT payload FROM command_audit WHERE id=?`, m.link.HostAuditOffset).Scan(&stored); err != nil { + t.Fatal(err) + } + forged, err := json.Marshal(agentcommand.AuditEnvelope{Time: time.Now().UTC(), Actor: "alice", + CommandID: "alice.submit", ConversationID: string(m.aliceConv), State: agentcommand.StateCommitted, OperationID: "op-forged"}) + if err != nil { + t.Fatal(err) + } + if _, err := m.st.DB().Exec(`UPDATE command_audit SET payload=? WHERE id=?`, string(forged), m.link.HostAuditOffset); err != nil { + t.Fatal(err) + } + if _, err := m.host("bob").QueryProjectAudit(context.Background(), m.linkedQuery()); !errors.Is(err, extension.ErrProjectAuditDenied) { + t.Fatalf("edited receipt still resolved: %v", err) + } + if _, err := m.st.DB().Exec(`UPDATE command_audit SET payload=? WHERE id=?`, stored, m.link.HostAuditOffset); err != nil { + t.Fatal(err) + } + if _, err := m.host("bob").QueryProjectAudit(context.Background(), m.linkedQuery()); err != nil { + t.Fatalf("restored receipt: %v", err) + } + // 链接被删:关联不再存在,回执不再属于本项目审计。 + if _, err := m.st.DB().Exec(`DELETE FROM project_audit_links WHERE id=?`, m.link.LinkID); err != nil { + t.Fatal(err) + } + if _, err := m.host("bob").QueryProjectAudit(context.Background(), m.linkedQuery()); !errors.Is(err, extension.ErrProjectAuditDenied) { + t.Fatalf("deleted link still resolved: %v", err) + } + // 宿主回执的项目归属被改:外键仍在,但它已不是这一条被验证的绑定。 + if _, err := m.st.DB().Exec(`INSERT INTO project_audit_links(id,host_audit_offset,project_id,owner,source_kind,source_id,operation_id,resource_kind,resource_id,run_id,disclosure_class,idempotency_key,created_at) + VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?)`, m.link.LinkID, m.link.HostAuditOffset, string(m.projectA), "alice", "domain-commit", + "commit-a", "op-a", "document", "doc-1", m.runID, "metadata", "outbox-a", time.Now().UTC().Format(time.RFC3339Nano)); err != nil { + t.Fatal(err) + } + if _, err := m.st.DB().Exec(`UPDATE command_audit SET project_id=? WHERE id=?`, string(m.projectB), m.link.HostAuditOffset); err != nil { + t.Fatal(err) + } + if _, err := m.host("bob").QueryProjectAudit(context.Background(), m.linkedQuery()); !errors.Is(err, extension.ErrProjectAuditDenied) { + t.Fatalf("re-attributed receipt still resolved: %v", err) + } +} + +// 历史 cutoff:第一页固定的上限在其后写入的行不得进入本次分页视图。 +func TestProjectAuditExitMatrixHistoricalCutoff(t *testing.T) { + m := newAuditMatrix(t) + m.auditRead[m.projectA] = true + for i := 0; i < 4; i++ { + m.append("bob", string(m.bobConv), "bob.bulk-"+string(rune('a'+i)), "op-bulk", "") + } + first, err := m.host("bob").QueryProjectAudit(context.Background(), extension.ProjectAuditQuery{ProjectID: m.projectA, Limit: 2}) + if err != nil { + t.Fatal(err) + } + if len(first.Receipts) != 2 || first.CutoffOffset == 0 || first.NextOffset == 0 { + t.Fatalf("first page: %+v", first) + } + m.append("bob", string(m.bobConv), "bob.after-cutoff", "op-after", "") + seen := map[string]bool{} + for _, receipt := range first.Receipts { + seen[receipt.CommandID] = true + } + before, cutoff := first.NextOffset, first.CutoffOffset + for page := first; page.NextOffset != 0; { + if before >= cutoff { + t.Fatalf("cursor did not move: before=%d cutoff=%d", before, cutoff) + } + page, err = m.host("bob").QueryProjectAudit(context.Background(), + extension.ProjectAuditQuery{ProjectID: m.projectA, BeforeOffset: before, CutoffOffset: cutoff, Limit: 2}) + if err != nil { + t.Fatal(err) + } + if page.CutoffOffset != cutoff { + t.Fatalf("cutoff moved mid-read: %d -> %d", cutoff, page.CutoffOffset) + } + before = page.NextOffset + for _, receipt := range page.Receipts { + if seen[receipt.CommandID] { + t.Fatalf("duplicate receipt across pages: %s", receipt.CommandID) + } + seen[receipt.CommandID] = true + } + } + if len(seen) != 5 || seen["bob.after-cutoff"] { + t.Fatalf("historical cutoff read drifted: %v", seen) + } + // 不带 cutoff 的新读取才能看到其后写入的行。 + fresh, err := m.host("bob").QueryProjectAudit(context.Background(), extension.ProjectAuditQuery{ProjectID: m.projectA, Limit: 10}) + if err != nil { + t.Fatal(err) + } + if len(fresh.Receipts) != 6 || fresh.CutoffOffset <= cutoff { + t.Fatalf("fresh read did not advance: n=%d cutoff=%d", len(fresh.Receipts), fresh.CutoffOffset) + } +} + +// 导出:审计授权不是导出授权。本切片不提供整段导出,只能分页,且覆盖显式声明。 +func TestProjectAuditExitMatrixExportIsRefusedNotDegraded(t *testing.T) { + m := newAuditMatrix(t) + m.auditRead[m.projectA] = true + m.openBody() + for _, q := range []extension.ProjectAuditQuery{ + {ProjectID: m.projectA, Export: true}, + {ProjectID: m.projectA, Export: true, Limit: 100000}, + } { + page, err := m.host("bob").QueryProjectAudit(context.Background(), q) + if !errors.Is(err, extension.ErrProjectAuditUnsupported) || len(page.Receipts) != 0 { + t.Fatalf("export was not refused: page=%+v err=%v", page, err) + } + } + exportLink := m.linkedQuery() + exportLink.Export = true + if _, err := m.host("bob").QueryProjectAudit(context.Background(), exportLink); !errors.Is(err, extension.ErrProjectAuditUnsupported) { + t.Fatalf("export through the linked path: %v", err) + } + linked, err := m.host("bob").QueryProjectAudit(context.Background(), m.linkedQuery()) + if err != nil { + t.Fatal(err) + } + if linked.Coverage.Export != "unsupported" { + t.Fatalf("linked page export coverage: %+v", linked.Coverage) + } + for i := 0; i < 205; i++ { + m.append("bob", string(m.bobConv), "bob.flood", "op-flood", "") + } + huge, err := m.host("bob").QueryProjectAudit(context.Background(), extension.ProjectAuditQuery{ProjectID: m.projectA, Limit: 100000}) + if err != nil { + t.Fatal(err) + } + if len(huge.Receipts) > 200 || huge.NextOffset == 0 || huge.Coverage.Export != "unsupported" { + t.Fatalf("unbounded read was not capped and reported: n=%d next=%d coverage=%+v", len(huge.Receipts), huge.NextOffset, huge.Coverage) + } +} + +// 长分页:多次翻页必须完整、不重不漏,且游标单调收敛。 +func TestProjectAuditExitMatrixLongPagination(t *testing.T) { + m := newAuditMatrix(t) + m.auditRead[m.projectA] = true + const total = 25 + for i := 0; i < total; i++ { + m.append("bob", string(m.bobConv), "bob.page", "op-page", "") + } + seen := map[int64]bool{} + before, cutoff := int64(0), int64(0) + for pages := 0; ; pages++ { + if pages > 10 { + t.Fatalf("pagination did not terminate: %d rows", len(seen)) + } + page, err := m.host("bob").QueryProjectAudit(context.Background(), + extension.ProjectAuditQuery{ProjectID: m.projectA, BeforeOffset: before, CutoffOffset: cutoff, Limit: 7}) + if err != nil { + t.Fatal(err) + } + switch { + case cutoff == 0: + cutoff = page.CutoffOffset + if cutoff == 0 { + t.Fatal("first page must establish a cutoff") + } + case page.CutoffOffset != cutoff: + t.Fatalf("cutoff drifted on page %d: %d != %d", pages, page.CutoffOffset, cutoff) + } + if len(page.Receipts) > 7 { + t.Fatalf("page %d exceeded the requested limit: %d", pages, len(page.Receipts)) + } + for i, receipt := range page.Receipts { + if seen[receipt.SourceOffset] { + t.Fatalf("offset %d repeated on page %d", receipt.SourceOffset, pages) + } + if i > 0 && receipt.SourceOffset >= page.Receipts[i-1].SourceOffset { + t.Fatalf("page %d is not strictly descending: %+v", pages, page.Receipts) + } + seen[receipt.SourceOffset] = true + } + if page.NextOffset == 0 { + break + } + if before != 0 && page.NextOffset >= before { + t.Fatalf("cursor did not advance: %d -> %d", before, page.NextOffset) + } + before = page.NextOffset + } + if len(seen) != total+1 { + t.Fatalf("long pagination lost rows: got %d want %d", len(seen), total+1) + } +} + +// 旧记录不补造字段:显示名是动作当时记录的最小快照,旧行保持 not-recorded; +// 账号目录也不会随审计页下发。 +func TestProjectAuditExitMatrixLegacyRowsAreNotBackfilled(t *testing.T) { + m := newAuditMatrix(t) + m.auditRead[m.projectA] = true + legacy, err := json.Marshal(agentcommand.AuditEnvelope{Time: time.Now().UTC(), Actor: "alice", + CommandID: "alice.legacy", ConversationID: string(m.aliceConv), State: agentcommand.StateCommitted}) + if err != nil { + t.Fatal(err) + } + if _, err := m.st.DB().Exec(`INSERT INTO command_audit(time,command_id,source,entrance,state,operation_id,conversation_id,owner,payload,actor_name,project_id) + VALUES (?,?,'legacy','','committed','',?,?,?,'',?)`, time.Now().UTC().Format(time.RFC3339Nano), "alice.legacy", + string(m.aliceConv), "alice", string(legacy), string(m.projectA)); err != nil { + t.Fatal(err) + } + page, err := m.host("alice").QueryProjectAudit(context.Background(), extension.ProjectAuditQuery{ProjectID: m.projectA}) + if err != nil { + t.Fatal(err) + } + var legacyReceipt *extension.HostAuditReceipt + for i := range page.Receipts { + if page.Receipts[i].CommandID == "alice.legacy" { + legacyReceipt = &page.Receipts[i] + } + } + if legacyReceipt == nil || legacyReceipt.ActorName != "" || legacyReceipt.ActorNameStatus != "not-recorded" { + t.Fatalf("legacy row fabricated a display name: %+v", page.Receipts) + } + if _, err := m.st.DB().Exec(`UPDATE users SET name=? WHERE owner=?`, "Alice renamed", "alice"); err != nil { + t.Fatal(err) + } + after, err := m.host("alice").QueryProjectAudit(context.Background(), extension.ProjectAuditQuery{ProjectID: m.projectA}) + if err != nil { + t.Fatal(err) + } + seen := 0 + for _, receipt := range after.Receipts { + seen++ + switch receipt.CommandID { + case "alice.legacy": + if receipt.ActorName != "" || receipt.ActorNameStatus != "not-recorded" { + t.Fatalf("rename backfilled an old row: %+v", receipt) + } + case "alice.submit": + if receipt.ActorName != "Alice at commit" || receipt.ActorNameStatus != "recorded" { + t.Fatalf("recorded name was not preserved: %+v", receipt) + } + } + } + if seen != 2 { + t.Fatalf("unexpected row count after rename: %d", seen) + } + m.append("alice", string(m.aliceConv), "alice.after-rename", "op-rename", "") + fresh, err := m.host("alice").QueryProjectAudit(context.Background(), extension.ProjectAuditQuery{ProjectID: m.projectA, Limit: 1}) + if err != nil { + t.Fatal(err) + } + if len(fresh.Receipts) != 1 || fresh.Receipts[0].CommandID != "alice.after-rename" || fresh.Receipts[0].ActorName != "Alice renamed" { + t.Fatalf("new row did not record the current name: %+v", fresh.Receipts) + } + raw, err := json.Marshal(page) + if err != nil { + t.Fatal(err) + } + if strings.Contains(string(raw), "Carol directory only") || strings.Contains(string(raw), "Bob") { + t.Fatalf("account directory leaked through the audit page: %s", raw) + } +} + +// owner 全量审计不是项目流:租户级审计与项目审计互不越界;缺基座能力一律 +// unsupported(而非静默空页或降级读取)。 +func TestProjectAuditOwnerWideAuditIsNotAProjectFlowAndUnsupportedIsExplicit(t *testing.T) { + m := newAuditMatrix(t) + m.auditRead[m.projectA] = true + m.metadata["doc-1"] = true + // 租户级审计(含无项目归属的旧行)不得成为项目流的取数来源。 + if _, err := m.st.DB().Exec(`INSERT INTO command_audit(time,command_id,source,entrance,state,operation_id,conversation_id,owner,payload) + VALUES (?,'unbound','','','committed','','','alice','{}')`, time.Now().UTC().Format(time.RFC3339Nano)); err != nil { + t.Fatal(err) + } + ownerWide, err := m.st.ListAudits("alice", "", 100) + if err != nil { + t.Fatal(err) + } + page, err := m.host("alice").QueryProjectAudit(context.Background(), extension.ProjectAuditQuery{ProjectID: m.projectA, Limit: 100}) + if err != nil { + t.Fatal(err) + } + if len(ownerWide) <= len(page.Receipts) { + t.Fatalf("owner-wide audit unexpectedly matched the project page: %d vs %d", len(ownerWide), len(page.Receipts)) + } + for _, receipt := range page.Receipts { + if receipt.CommandID == "unbound" || receipt.CommandID == "bob.submit" { + t.Fatalf("owner-wide or cross-member row reached the project page: %+v", receipt) + } + } + if page.Coverage.CrossMember != "unsupported" || page.Coverage.LegacyProjectBinding != "not-recorded" { + t.Fatalf("coverage must state the project-flow boundary: %+v", page.Coverage) + } + // 缺基座能力:没有槽位、没有注册方、不是注册插件的宿主句柄。 + if _, err := (inprocHost{caps: hostcaps{st: m.st}, owner: "bob", actor: "bob", pluginID: "rdm"}). + QueryProjectAudit(context.Background(), extension.ProjectAuditQuery{ProjectID: m.projectA}); !errors.Is(err, extension.ErrProjectAuditUnsupported) { + t.Fatalf("missing audit slot: %v", err) + } + if _, err := (inprocHost{caps: hostcaps{st: m.st, projectAudit: &projectAuditSlot{}}, owner: "bob", actor: "bob", pluginID: "rdm"}). + QueryProjectAudit(context.Background(), extension.ProjectAuditQuery{ProjectID: m.projectA}); !errors.Is(err, extension.ErrProjectAuditUnsupported) { + t.Fatalf("unregistered authorizer: %v", err) + } + if _, err := (inprocHost{caps: m.caps, owner: "bob", actor: "bob", pluginID: "other"}). + QueryProjectAudit(context.Background(), extension.ProjectAuditQuery{ProjectID: m.projectA}); !errors.Is(err, extension.ErrProjectAuditUnsupported) { + t.Fatalf("another plugin borrowed the policy: %v", err) + } + // 逐对象判定缺失时,精确链接读取也必须 unsupported。 + noDisclosure := hostcaps{st: m.st, projectAudit: &projectAuditSlot{}} + if err := (pluginRegistry{pluginID: "rdm", caps: noDisclosure}).RegisterProjectAuditAuthorizer( + func(string, core.ID) (bool, error) { return true, nil }); err != nil { + t.Fatal(err) + } + if _, err := (inprocHost{caps: noDisclosure, owner: "bob", actor: "bob", pluginID: "rdm"}). + QueryProjectAudit(context.Background(), m.linkedQuery()); !errors.Is(err, extension.ErrProjectAuditUnsupported) { + t.Fatalf("missing disclosure authorizer: %v", err) + } + // 声明式扩展与缺少该接口的注册表都明确拒绝注册,不静默忽略。 + if err := (compiledRegistry{}).RegisterProjectAuditAuthorizer(func(string, core.ID) (bool, error) { return true, nil }); err == nil { + t.Fatal("declarative plugin registered an audit authorizer") + } + if err := (recordRegistry{Registry: pluginRegistry{pluginID: "rdm", caps: hostcaps{}}}).RegisterProjectAuditAuthorizer( + func(string, core.ID) (bool, error) { return true, nil }); !errors.Is(err, extension.ErrProjectAuditUnsupported) { + t.Fatalf("registry without audit capability: %v", err) + } + // 导出与无对象正文权同样是显式 unsupported。 + if _, err := m.host("bob").QueryProjectAudit(context.Background(), + extension.ProjectAuditQuery{ProjectID: m.projectA, Export: true}); !errors.Is(err, extension.ErrProjectAuditUnsupported) { + t.Fatalf("export on the actor-owned page: %v", err) + } + if _, err := m.host("bob").QueryProjectAudit(context.Background(), + extension.ProjectAuditQuery{ProjectID: m.projectA, IncludeBody: true}); !errors.Is(err, extension.ErrProjectAuditUnsupported) { + t.Fatalf("body on the actor-owned page: %v", err) + } +} diff --git a/internal/store/project_audit_links.go b/internal/store/project_audit_links.go index 6d5c96f..c8edbcf 100644 --- a/internal/store/project_audit_links.go +++ b/internal/store/project_audit_links.go @@ -148,6 +148,10 @@ func (s *Store) SaveProjectAuditLink(link ProjectAuditLink) (ProjectAuditLink, e // ProjectAuditLinkedReceipt reads one exact association and the underlying // host row. It does not enumerate private links or decide field disclosure. +// The published binding is immutable: a row that was deleted, or edited after +// publication (state, operation, owner, or the stored envelope), no longer +// resolves as this link. A read must never project something the domain +// verifier never vouched for, so drift is refused instead of re-projected. func (s *Store) ProjectAuditLinkedReceipt(projectID, sourceKind, sourceID string, hostOffset int64) (ProjectAuditLink, ProjectCommandAuditRow, error) { var row ProjectCommandAuditRow var link ProjectAuditLink @@ -157,6 +161,7 @@ func (s *Store) ProjectAuditLinkedReceipt(projectID, sourceKind, sourceID string } err := s.db.QueryRow(s.q(`SELECT `+projectAuditLinkAliasedColumns+`,a.actor_name,a.payload FROM project_audit_links l JOIN command_audit a ON a.id=l.host_audit_offset AND a.owner=l.owner AND a.project_id=l.project_id + AND a.state='committed' AND a.operation_id=l.operation_id WHERE l.project_id=? AND l.source_kind=? AND l.source_id=? AND l.host_audit_offset=?`), projectID, sourceKind, sourceID, hostOffset).Scan(&link.ID, &link.HostAuditOffset, &link.ProjectID, &link.Owner, &link.SourceKind, &link.SourceID, &link.OperationID, &link.ResourceKind, &link.ResourceID, &link.RunID, @@ -171,5 +176,11 @@ func (s *Store) ProjectAuditLinkedReceipt(projectID, sourceKind, sourceID string if err := json.Unmarshal([]byte(raw), &row.Envelope); err != nil { return link, row, err } + // The link was published only against a committed row owned by link.Owner whose + // envelope carried this operation. Re-check the envelope itself: a payload that + // no longer matches the verified binding is drift, not a new fact. + if row.Envelope.OperationID != link.OperationID || row.Envelope.Actor != link.Owner || string(row.Envelope.State) != "committed" { + return link, row, ErrConflict + } return link, row, nil } diff --git a/internal/store/project_audit_links_test.go b/internal/store/project_audit_links_test.go index 64b5b07..914c6ba 100644 --- a/internal/store/project_audit_links_test.go +++ b/internal/store/project_audit_links_test.go @@ -1,7 +1,9 @@ package store import ( + "encoding/json" "errors" + "path/filepath" "testing" "time" @@ -95,3 +97,84 @@ func TestProjectAuditLinkStoreIdempotenceAndExactScope(t *testing.T) { t.Fatalf("ambiguous host operation must not choose one: %v", err) } } + +// 已发布链接的绑定不可变:宿主回执在发布后被删或被改(状态、operation、载荷) +// 时,读取侧不得再把它当作领域验证过的那一条返回。SQLite 是本测试的落点—— +// PostgreSQL 由 all-writer 触发器在写侧直接拒绝改动。 +func TestProjectAuditLinkedReceiptRefusesEditedOrDeletedHostRows(t *testing.T) { + st, err := OpenSQLite(filepath.Join(t.TempDir(), "links.db")) + if err != nil { + t.Fatal(err) + } + defer st.Close() + if err := st.EnsureUser("alice", "Alice"); err != nil { + t.Fatal(err) + } + p, err := st.CreateProject("alice", "A") + if err != nil { + t.Fatal(err) + } + c, err := st.CreateConversation("alice", string(p.ID), "work", "") + if err != nil { + t.Fatal(err) + } + if err := st.AppendAudit("alice", agentcommand.AuditEnvelope{Time: time.Now(), Actor: "alice", CommandID: "rdm.confirm", + OperationID: "op-1", ConversationID: string(c.ID), State: agentcommand.StateCommitted}); err != nil { + t.Fatal(err) + } + rows, _, _, err := st.ListProjectCommandAudits("alice", string(p.ID), 0, 0, 10) + if err != nil { + t.Fatal(err) + } + link := ProjectAuditLink{HostAuditOffset: rows[0].Offset, ProjectID: string(p.ID), Owner: "alice", SourceKind: "domain-commit", + SourceID: "commit-1", OperationID: "op-1", ResourceKind: "document", ResourceID: "doc-1", DisclosureClass: "metadata", IdempotencyKey: "outbox-1"} + if _, err := st.SaveProjectAuditLink(link); err != nil { + t.Fatal(err) + } + if got, audit, err := st.ProjectAuditLinkedReceipt(string(p.ID), "domain-commit", "commit-1", rows[0].Offset); err != nil || + got.OperationID != "op-1" || audit.Envelope.CommandID != "rdm.confirm" { + t.Fatalf("baseline link: %+v %+v %v", got, audit, err) + } + var stored string + if err := st.db.QueryRow(st.q(`SELECT payload FROM command_audit WHERE id=?`), rows[0].Offset).Scan(&stored); err != nil { + t.Fatal(err) + } + forged, err := json.Marshal(agentcommand.AuditEnvelope{Time: time.Now(), Actor: "alice", CommandID: "rdm.confirm", + OperationID: "op-forged", ConversationID: string(c.ID), State: agentcommand.StateCommitted}) + if err != nil { + t.Fatal(err) + } + if _, err := st.db.Exec(st.q(`UPDATE command_audit SET payload=? WHERE id=?`), string(forged), rows[0].Offset); err != nil { + t.Fatal(err) + } + if _, _, err := st.ProjectAuditLinkedReceipt(string(p.ID), "domain-commit", "commit-1", rows[0].Offset); !errors.Is(err, ErrConflict) { + t.Fatalf("edited envelope still resolved as the verified link: %v", err) + } + if _, err := st.db.Exec(st.q(`UPDATE command_audit SET payload=? WHERE id=?`), stored, rows[0].Offset); err != nil { + t.Fatal(err) + } + if _, err := st.db.Exec(st.q(`UPDATE command_audit SET operation_id=? WHERE id=?`), "op-other", rows[0].Offset); err != nil { + t.Fatal(err) + } + if _, _, err := st.ProjectAuditLinkedReceipt(string(p.ID), "domain-commit", "commit-1", rows[0].Offset); !errors.Is(err, ErrNotFound) { + t.Fatalf("edited operation column still resolved: %v", err) + } + if _, err := st.db.Exec(st.q(`UPDATE command_audit SET operation_id=? WHERE id=?`), "op-1", rows[0].Offset); err != nil { + t.Fatal(err) + } + if _, err := st.db.Exec(st.q(`UPDATE command_audit SET state=? WHERE id=?`), "accepted", rows[0].Offset); err != nil { + t.Fatal(err) + } + if _, _, err := st.ProjectAuditLinkedReceipt(string(p.ID), "domain-commit", "commit-1", rows[0].Offset); !errors.Is(err, ErrNotFound) { + t.Fatalf("uncommitted row still resolved: %v", err) + } + if _, err := st.db.Exec(st.q(`UPDATE command_audit SET state=? WHERE id=?`), "committed", rows[0].Offset); err != nil { + t.Fatal(err) + } + if _, err := st.db.Exec(st.q(`UPDATE command_audit SET project_id=? WHERE id=?`), "", rows[0].Offset); err != nil { + t.Fatal(err) + } + if _, _, err := st.ProjectAuditLinkedReceipt(string(p.ID), "domain-commit", "commit-1", rows[0].Offset); !errors.Is(err, ErrNotFound) { + t.Fatalf("re-attributed row still resolved: %v", err) + } +} diff --git a/pkg/extension/extension.go b/pkg/extension/extension.go index 6ab17f2..984b2df 100644 --- a/pkg/extension/extension.go +++ b/pkg/extension/extension.go @@ -75,7 +75,13 @@ import ( // directory: RoleOption roleId+name, no permissions or bindings) and the // AccountDirectory purpose project.member-roster, an explicit project member // enumeration inside the trusted RDM scope. -const SurfaceVersion = "2.28.0-experimental" +// 2.29.0-experimental: X2 project audit read completion. ProjectAuditQuery gains +// optional linked resource/run locators, IncludeBody, and Export; receipts may +// carry ProjectAuditReceiptResource only under the domain's per-object body +// right; ProjectAuditCoverage states Export scope. Every addition is an optional +// field or a new exported type: existing ProjectAuditHost/ProjectAuditRegistry +// providers and queries keep compiling and behaving unchanged. +const SurfaceVersion = "2.29.0-experimental" // ErrNotLinked 表示进程没有空白导入 link 包,Main 无法启动服务。 var ErrNotLinked = errors.New("extension: runner not linked; blank-import agent-system-backend/pkg/extension/link") diff --git a/pkg/extension/project_audit.go b/pkg/extension/project_audit.go index c05d6dc..0216d14 100644 --- a/pkg/extension/project_audit.go +++ b/pkg/extension/project_audit.go @@ -74,6 +74,19 @@ type ProjectAuditDisclosure struct { Body bool } +// ProjectAuditDisclosureAuthorizer answers one per-object decision for the +// authenticated actor on a stored, immutable link. Metadata and Body are +// separate authorities: the host never substitutes one for the other, and it +// never answers a body request from a metadata-only right. +// +// TODO(RDM consumer, out of this repository): the decision itself — the current +// project audit.read grant, the document read duty plus stage scope that carries +// 正文权, and any export authority — belongs to the domain plugin that registers +// this callback. The Backend only refuses, trims, and reports coverage; it must +// not grow a local account/project policy to answer in the domain's place. A +// later RDM unit implements the callbacks and can then widen Coverage +// (Export, ResourceBodies, RunRelations) from this base's current honest +// "unsupported" statements. type ProjectAuditDisclosureAuthorizer func(context.Context, string, ProjectAuditLinkedResource) (ProjectAuditDisclosure, error) // ProjectAuditPublisherFactory is issued only by successful verifier @@ -107,6 +120,11 @@ type ProjectAuditPublisher interface { // database guards prove a // committed prefix; otherwise the host rejects the read instead of claiming a // fixed snapshot. See Coverage.CutoffConsistency. +// +// A read is keyed by the trusted project, the authenticated actor, the verified +// resource and run, and the source offset plus cutoff. Every caller-supplied +// field is a locator, never an authorization: the host re-checks it against +// stored host facts and the domain callbacks before a receipt is projected. type ProjectAuditQuery struct { ProjectID core.ID `json:"projectId"` BeforeOffset int64 `json:"beforeOffset,omitempty"` @@ -117,20 +135,48 @@ type ProjectAuditQuery struct { LinkedSourceKind string `json:"linkedSourceKind,omitempty"` LinkedSourceID string `json:"linkedSourceId,omitempty"` LinkedHostOffset int64 `json:"linkedHostOffset,omitempty"` + // LinkedResourceKind/LinkedResourceID/LinkedRunID narrow the same exact + // locator to the verified binding. A value that disagrees with stored host + // state denies the read; it never widens it and never stands in for the + // domain's current decision on that object. + LinkedResourceKind string `json:"linkedResourceKind,omitempty"` + LinkedResourceID string `json:"linkedResourceId,omitempty"` + LinkedRunID string `json:"linkedRunId,omitempty"` + // IncludeBody asks for the per-object body projection of one verified link. + // The host asks the domain disclosure authorizer again for that object: a + // metadata-only right serves metadata only, and a request without a verified + // object binding is reported unsupported instead of a silently trimmed page. + IncludeBody bool `json:"includeBody,omitempty"` + // Export asks for a whole-history export. No base capability serves one, so + // the host refuses instead of degrading into a partial paginated read that + // would claim a coverage it cannot establish. See Coverage.Export. + Export bool `json:"export,omitempty"` +} + +// ProjectAuditReceiptResource names the object one verified link attributes a +// receipt to. The host copies it from the stored binding and only when the +// domain granted the per-object body right; it is never derived from caller +// input and never carries resource content. +type ProjectAuditReceiptResource struct { + Kind string `json:"kind"` + ID string `json:"id"` + RunID string `json:"runId,omitempty"` } // HostAuditReceipt is a server-projected receipt, never a raw audit payload. // ActorName is the name recorded with this command; old rows have no snapshot. +// Resource appears only for a body-permitted verified link. type HostAuditReceipt struct { - SourceOffset int64 `json:"sourceOffset"` - Time time.Time `json:"time"` - ActorID string `json:"actorId,omitempty"` - ActorName string `json:"actorName,omitempty"` - ActorNameStatus string `json:"actorNameStatus"` - CommandID string `json:"commandId"` - State string `json:"state"` - OperationID string `json:"operationId,omitempty"` - ConversationID string `json:"conversationId"` + SourceOffset int64 `json:"sourceOffset"` + Time time.Time `json:"time"` + ActorID string `json:"actorId,omitempty"` + ActorName string `json:"actorName,omitempty"` + ActorNameStatus string `json:"actorNameStatus"` + CommandID string `json:"commandId"` + State string `json:"state"` + OperationID string `json:"operationId,omitempty"` + ConversationID string `json:"conversationId"` + Resource *ProjectAuditReceiptResource `json:"resource,omitempty"` } // ProjectAuditCoverage states the exact scope of this first X2 slice. Missing @@ -144,6 +190,9 @@ type ProjectAuditCoverage struct { ModelReceipts string `json:"modelReceipts"` BackgroundReceipts string `json:"backgroundReceipts"` ResourceBodies string `json:"resourceBodies"` + // Export states whether a whole-history export is served. An audit grant is + // not an export grant: this slice only pages, and says so. + Export string `json:"export"` } type ProjectAuditPage struct { diff --git a/pkg/extension/project_audit_test.go b/pkg/extension/project_audit_test.go new file mode 100644 index 0000000..2403397 --- /dev/null +++ b/pkg/extension/project_audit_test.go @@ -0,0 +1,99 @@ +package extension + +// W9 X2 读取面补完(2.29.0-experimental)的公开面契约: +// 1. 新增项全部是可选字段/新导出类型:既有 ProjectAuditHost、ProjectAuditRegistry +// 提供者与既有查询字面量照旧编译、照旧语义(不破坏旧 provider); +// 2. 新字段序列化带 omitempty:未使用的读取请求与旧消费者的线格式不变; +// 3. 正文投影与导出声明是显式契约字段,未开启时不下发。 +import ( + "context" + "encoding/json" + "errors" + "strings" + "testing" + + core "agent-system-core" +) + +// legacyAuditHost 是 2.14.0 形态的消费者:只实现既有方法,不感知新增可选字段。 +type legacyAuditHost struct{} + +func (legacyAuditHost) QueryProjectAudit(context.Context, ProjectAuditQuery) (ProjectAuditPage, error) { + return ProjectAuditPage{}, nil +} + +// legacyAuditRegistry 是 2.14.0 形态的注册方实现。 +type legacyAuditRegistry struct{} + +func (legacyAuditRegistry) RegisterProjectAuditAuthorizer(ProjectAuditAuthorizer) error { return nil } + +var ( + _ ProjectAuditHost = legacyAuditHost{} + _ ProjectAuditRegistry = legacyAuditRegistry{} +) + +func TestProjectAuditReadContractIsAdditiveAndOptional(t *testing.T) { + if SurfaceVersion < "2.29.0" { + t.Fatalf("SurfaceVersion must record the audit read completion, got %s", SurfaceVersion) + } + // 既有查询字面量(只填项目与游标)仍然可用,新增字段全部零值。 + legacy := ProjectAuditQuery{ProjectID: core.ID("p1"), Limit: 10, BeforeOffset: 4, CutoffOffset: 9} + if legacy.IncludeBody || legacy.Export || legacy.LinkedResourceID != "" || legacy.LinkedRunID != "" { + t.Fatalf("optional fields defaulted to non-zero: %+v", legacy) + } + raw, err := json.Marshal(legacy) + if err != nil { + t.Fatal(err) + } + for _, unwanted := range []string{"includeBody", "export", "linkedResourceKind", "linkedResourceId", "linkedRunId"} { + if strings.Contains(string(raw), unwanted) { + t.Fatalf("omitted optional field %s reached the wire: %s", unwanted, raw) + } + } + for _, want := range []string{`"projectId":"p1"`, `"beforeOffset":4`, `"cutoffOffset":9`, `"limit":10`} { + if !strings.Contains(string(raw), want) { + t.Fatalf("existing field %s missing from the wire form: %s", want, raw) + } + } + // 显式请求时字段可见,消费端能区分“正文/导出请求”与普通分页。 + full, err := json.Marshal(ProjectAuditQuery{ProjectID: core.ID("p1"), IncludeBody: true, Export: true, + LinkedResourceKind: "document", LinkedResourceID: "doc-1", LinkedRunID: "run-1"}) + if err != nil { + t.Fatal(err) + } + for _, want := range []string{`"includeBody":true`, `"export":true`, `"linkedResourceKind":"document"`, + `"linkedResourceId":"doc-1"`, `"linkedRunId":"run-1"`} { + if !strings.Contains(string(full), want) { + t.Fatalf("requested field %s missing: %s", want, full) + } + } + // 未授予正文时回执不带 resource 字段;授予后才出现对象身份(不含正文载荷)。 + metadataOnly, err := json.Marshal(ProjectAuditPage{Receipts: []HostAuditReceipt{{SourceOffset: 3, CommandID: "rdm.confirm"}}}) + if err != nil { + t.Fatal(err) + } + if strings.Contains(string(metadataOnly), `"resource"`) { + t.Fatalf("metadata-only receipt carried a resource: %s", metadataOnly) + } + withBody, err := json.Marshal(ProjectAuditPage{Receipts: []HostAuditReceipt{{SourceOffset: 3, CommandID: "rdm.confirm", + Resource: &ProjectAuditReceiptResource{Kind: "document", ID: "doc-1", RunID: "run-1"}}}, + Coverage: ProjectAuditCoverage{ResourceBodies: "verified-exact-link-body", Export: "unsupported"}}) + if err != nil { + t.Fatal(err) + } + for _, want := range []string{`"resource":{"kind":"document","id":"doc-1","runId":"run-1"}`, `"export":"unsupported"`} { + if !strings.Contains(string(withBody), want) { + t.Fatalf("body-permitted projection missing %s: %s", want, withBody) + } + } + // 四个审计哨兵互不相等:unsupported(缺基座能力)与 denied(策略拒绝) + // 不得互相冒充。 + if !errors.Is(ErrProjectAuditUnsupported, ErrProjectAuditUnsupported) { + t.Fatal("unsupported sentinel must stay comparable with errors.Is") + } + for _, distinct := range []error{ErrProjectAuditDenied, ErrProjectAuditLinkConflict, ErrProjectAuditPending} { + if errors.Is(ErrProjectAuditUnsupported, distinct) || errors.Is(distinct, ErrProjectAuditUnsupported) { + t.Fatalf("audit sentinels must stay distinct: %v vs %v", distinct, ErrProjectAuditUnsupported) + } + } +} diff --git a/pkg/extension/surface.txt b/pkg/extension/surface.txt index 6c140a6..58a9eb6 100644 --- a/pkg/extension/surface.txt +++ b/pkg/extension/surface.txt @@ -110,6 +110,7 @@ ProjectAuditPublisher.PublishProjectAuditLink ProjectAuditPublisherFactory ProjectAuditPublisherFactory.ForOwner ProjectAuditQuery +ProjectAuditReceiptResource ProjectAuditRegistry ProjectAuditRegistry.RegisterProjectAuditAuthorizer ProjectContextBundle