Compare commits

...
Author SHA1 Message Date
agent-exec-transport 97f60c1df6 agent-exec transport f461d74086dd6a80db72f83748f34b04/128fee1609ad
governance / governance (pull_request) Successful in 15s
ci / vet-and-test (pull_request) Successful in 1m20s
2026-10-22 03:41:15 +00:00
weichen 275b1e234d Merge pull request 'Close the W6 review MINOR findings' (#242) from fix/w6-review-minors into main
governance / governance (push) Successful in 17s
ci / vet-and-test (push) Successful in 1m25s
2026-10-22 03:07:56 +00:00
agent-exec-transport 4c2db4c89b agent-exec transport baad876f1320016310be2522c373a71b/9d3ed5593bfb
governance / governance (pull_request) Successful in 17s
ci / vet-and-test (pull_request) Successful in 1m27s
2026-10-22 03:03:31 +00:00
15 changed files with 1273 additions and 21 deletions
+62 -8
View File
@@ -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
}
+702
View File
@@ -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)
}
}
+13 -2
View File
@@ -119,8 +119,19 @@ func (s *projectContextSlot) readEvidence(ctx context.Context, req extension.Pro
}
func (s *projectContextSlot) recheck(ctx context.Context, req extension.ProjectContextSourceRecheckRequest) (extension.ProjectContextSourceRecheckResult, error) {
if s == nil || req.Scope.Actor == "" || req.Scope.ProjectID == "" || req.Scope.Goal != "" {
return extension.ProjectContextSourceRecheckResult{}, errors.New("extension: trusted source recheck scope missing")
// Each unusable scope is reported on its own so host-side logs name the
// actual defect; every branch still rejects (fail-closed).
if s == nil {
return extension.ProjectContextSourceRecheckResult{}, errors.New("extension: project context source recheck slot missing")
}
if req.Scope.Actor == "" {
return extension.ProjectContextSourceRecheckResult{}, errors.New("extension: trusted source recheck scope has no actor")
}
if req.Scope.ProjectID == "" {
return extension.ProjectContextSourceRecheckResult{}, errors.New("extension: trusted source recheck scope has no project")
}
if req.Scope.Goal != "" {
return extension.ProjectContextSourceRecheckResult{}, errors.New("extension: source recheck scope carries a generation goal")
}
s.mu.RLock()
p, holder := s.provider, s.holder
@@ -27,6 +27,45 @@ func registeredRecheckSlot(t *testing.T, recheck func(context.Context, extension
return slot
}
func TestProjectSourceRecheckScopeErrorsAreDistinguishable(t *testing.T) {
var nilSlot *projectContextSlot
slot := registeredRecheckSlot(t, func(context.Context, extension.ProjectContextSourceRecheckRequest) (extension.ProjectContextSourceRecheckResult, error) {
t.Error("unusable recheck scope reached the provider")
return extension.ProjectContextSourceRecheckResult{}, nil
})
cases := map[string]func() (extension.ProjectContextSourceRecheckResult, error){
"slot missing": func() (extension.ProjectContextSourceRecheckResult, error) {
return nilSlot.recheck(context.Background(), extension.ProjectContextSourceRecheckRequest{Scope: extension.ProjectContextRequest{Actor: "alice", ProjectID: "p1"}})
},
"no actor": func() (extension.ProjectContextSourceRecheckResult, error) {
return slot.recheck(context.Background(), extension.ProjectContextSourceRecheckRequest{Scope: extension.ProjectContextRequest{ProjectID: "p1"}})
},
"no project": func() (extension.ProjectContextSourceRecheckResult, error) {
return slot.recheck(context.Background(), extension.ProjectContextSourceRecheckRequest{Scope: extension.ProjectContextRequest{Actor: "alice"}})
},
"carries goal": func() (extension.ProjectContextSourceRecheckResult, error) {
return slot.recheck(context.Background(), extension.ProjectContextSourceRecheckRequest{Scope: extension.ProjectContextRequest{Actor: "alice", ProjectID: "p1", Goal: "secret"}})
},
}
seen := map[string]string{}
for name, call := range cases {
result, err := call()
if err == nil {
t.Fatalf("%s scope accepted", name)
}
if result.Bundle.Status != "" || result.Bundle.ProjectID != "" {
t.Fatalf("%s scope returned a non-empty bundle: %+v", name, result.Bundle)
}
if other, dup := seen[err.Error()]; dup {
t.Fatalf("%s and %s share one error message: %q", other, name, err)
}
seen[err.Error()] = name
}
if len(seen) != len(cases) {
t.Fatalf("expected %d distinct errors, got %d: %v", len(cases), len(seen), seen)
}
}
func TestProjectSourceRecheckFailsClosedAtThreeSecondCeiling(t *testing.T) {
var remaining time.Duration
slot := registeredRecheckSlot(t, func(ctx context.Context, req extension.ProjectContextSourceRecheckRequest) (extension.ProjectContextSourceRecheckResult, error) {
+22 -1
View File
@@ -127,7 +127,10 @@ func (c *runCtx) recheckManifest(ctx context.Context, m extension.ProjectContext
}
expectedRefs[ref.ID] = ref
}
if len(current.Items) != len(m.Included) || len(current.Evidence) != len(expectedRefs) || !slices.Equal(current.Missing, m.Missing) {
// Set-wise, not provider-order-wise: a benign reordering of the same
// recorded identities is tolerated, while any added, dropped or replaced
// entry still fails closed. Order carries no authorization meaning.
if len(current.Items) != len(m.Included) || len(current.Evidence) != len(expectedRefs) || !sameMissingSet(current.Missing, m.Missing) {
return errors.New("project source recheck returned an incomplete source set")
}
// Each returned item must account for exactly one recorded input; the hash
@@ -180,9 +183,27 @@ func (c *runCtx) recheckManifest(ctx context.Context, m extension.ProjectContext
return errors.New("project evidence permission or content changed")
}
}
// Load-bearing fail-closed close: a context cancelled or expired after the
// last structural pass above must still reject. Do not relax this to
// `return nil`; a late cancellation would otherwise admit machine context
// that was never rechecked against live authorization.
return ctx.Err()
}
// sameMissingSet compares the rechecked and recorded missing-source lists as
// sets. Neither input is mutated; any difference other than order is a
// rejection, because a withdrawn or newly missing source changes what the
// original model request actually saw.
func sameMissingSet(current, recorded []string) bool {
if len(current) != len(recorded) {
return false
}
left, right := slices.Clone(current), slices.Clone(recorded)
slices.Sort(left)
slices.Sort(right)
return slices.Equal(left, right)
}
func (c *runCtx) contextRequest() extension.ProjectContextRequest {
c.projectContextMu.Lock()
defer c.projectContextMu.Unlock()
@@ -314,6 +314,103 @@ func TestProjectStepOutputRejectsUnsettledAttemptInSameStep(t *testing.T) {
}
}
func TestProjectSourceRecheckComparesMissingSourcesAsASet(t *testing.T) {
f := newRecheckFixture(t)
receipt := f.receipt
receipt.Missing = []string{"source-b", "source-a"}
// Order carries no authorization meaning, so a reordered list of the same
// unresolved sources stays a pass.
reordered := f.current
reordered.Missing = []string{"source-a", "source-b"}
f.answer(reordered)
if err := f.rc.recheckManifest(context.Background(), receipt, "draft"); err != nil {
t.Fatalf("reordered missing sources treated as a changed source set: %v", err)
}
if strings.Join(receipt.Missing, ",") != "source-b,source-a" || strings.Join(reordered.Missing, ",") != "source-a,source-b" {
t.Fatalf("missing-source comparison mutated a caller slice: recorded=%q current=%q", receipt.Missing, reordered.Missing)
}
// Any difference other than order still fails closed.
for name, missing := range map[string][]string{
"dropped": {"source-a"},
"added": {"source-a", "source-b", "source-c"},
"replaced": {"source-a", "source-z"},
"masked-by-dup": {"source-a", "source-a"},
"withdrawn": nil,
} {
changed := f.current
changed.Missing = missing
f.answer(changed)
if err := f.rc.recheckManifest(context.Background(), receipt, "draft"); err == nil {
t.Fatalf("changed missing-source set %q was accepted", name)
}
}
f.answer(reordered)
if err := f.rc.recheckManifest(context.Background(), receipt, "draft"); err != nil {
t.Fatalf("unrelated failure left the authorized baseline broken: %v", err)
}
}
func TestProjectStepOutputRejectsEvidenceRefWithChangedHash(t *testing.T) {
st := openRecoveryStore(t)
m := newRecoveryManager(t, st)
conv := recoveryConversation(t, st)
run := recoveryRun(t, st, conv, core.RunPending)
m.SetProjectWorkAuthorization(func(context.Context, string, core.ID) (bool, error) { return true, nil })
ref := extension.ProjectEvidenceRef{ID: "ref-a", SourceID: "doc-a", Revision: "r1", Hash: "hd"}
evidenceHash := ref.Hash
bundle := func() extension.ProjectContextBundle {
return extension.ProjectContextBundle{Status: extension.ProjectContextReady, ProjectID: run.ProjectID, Version: "v1", Watermark: "w1",
AuthorizationEpoch: "duty-1", PublicationEpoch: "pub-1",
Items: []extension.ProjectContextItem{{SourceID: "source-a", Revision: "r1", Hash: "ha", Text: "AUTHORIZED_A", Required: true}},
Evidence: []extension.ProjectEvidenceRef{{ID: ref.ID, SourceID: ref.SourceID, Revision: ref.Revision, Hash: evidenceHash}}}
}
m.SetProjectContextResolver(func(context.Context, extension.ProjectContextRequest) (extension.ProjectContextBundle, error) {
return bundle(), nil
})
m.SetProjectContextSourceRechecker(func(context.Context, extension.ProjectContextSourceRecheckRequest) (extension.ProjectContextSourceRecheckResult, error) {
return extension.ProjectContextSourceRecheckResult{Bundle: bundle()}, nil
})
m.SetProjectEvidenceReader(func(_ context.Context, req extension.ProjectEvidenceRequest) (extension.ProjectEvidence, error) {
return extension.ProjectEvidence{Reference: req.Reference, Text: "DETAIL_A"}, nil
})
rc := &runCtx{mgr: m, run: run, ctx: context.Background()}
current, _, err := rc.ProjectContext(context.Background(), "draft", 900)
if err != nil {
t.Fatal(err)
}
_, receipt, err := projectcontext.Assemble(current, 900)
if err != nil {
t.Fatal(err)
}
receipt.InputSHA256, receipt.AttemptID, receipt.WorkflowStepAttemptID = "input-1", "model-1", "engine-run/draft/1"
receipt.ReadEvidence = []extension.ProjectContextManifestEntry{{ReferenceID: ref.ID, SourceID: ref.SourceID, Revision: ref.Revision, SourceHash: ref.Hash,
InputSHA256: fmt.Sprintf("%x", sha256.Sum256([]byte("DETAIL_A")))}}
scoped := projectcontext.WithResolveScope(context.Background(), "draft", "")
for _, delivery := range []string{"attempted", "responded"} {
receipt.Delivery = delivery
if err := rc.SaveContextManifest(scoped, receipt); err != nil {
t.Fatal(err)
}
}
output := []byte("AUTHORIZED_A report")
if err := rc.BindProjectStepOutput(context.Background(), "draft", "engine-run/draft/1", "prompt", time.Now().Add(-time.Second), output); err != nil {
t.Fatalf("unchanged evidence source rejected at binding: %v", err)
}
if err := rc.VerifyProjectStepOutput(context.Background(), "draft", "engine-run/draft/1", "prompt", output); err != nil {
t.Fatalf("unchanged evidence source rejected at the critic boundary: %v", err)
}
// Same ReferenceID and revision, different source hash: a re-published or
// substituted document must not inherit the recorded grant.
evidenceHash = "hd2"
if err := rc.VerifyProjectStepOutput(context.Background(), "draft", "engine-run/draft/1", "prompt", output); err == nil {
t.Fatal("same reference id with a changed source hash passed the critic boundary")
}
evidenceHash = ref.Hash
if err := rc.VerifyProjectStepOutput(context.Background(), "draft", "engine-run/draft/1", "prompt", output); err != nil {
t.Fatalf("restoring the recorded hash did not restore the verdict: %v", err)
}
}
func TestSaveContextManifestRejectsUnrecheckableModelInput(t *testing.T) {
st := openRecoveryStore(t)
m := newRecoveryManager(t, st)
+61
View File
@@ -40,6 +40,67 @@ func TestContextManifestTerminalRequiresMatchingAttempt(t *testing.T) {
}
}
func TestContextManifestAttemptIsNotReusableAcrossProjectOrOwner(t *testing.T) {
st, err := OpenSQLite(filepath.Join(t.TempDir(), "attempt-identity.db"))
if err != nil {
t.Fatal(err)
}
defer st.Close()
base := extension.ProjectContextManifest{ProjectID: "p1", AttemptID: "attempt-1", InputSHA256: "input-hash", Delivery: "attempted"}
if err := st.AppendContextManifest("alice", "run-1", base); err != nil {
t.Fatal(err)
}
base.Delivery = "responded"
if err := st.AppendContextManifest("alice", "run-1", base); err != nil {
t.Fatal(err)
}
// A terminal receipt is matched against the whole persisted identity
// (attempt, owner, target, project, input hash), so the same attempt id
// cannot claim a trusted response under another project or owner.
crossProject := base
crossProject.ProjectID = "p2"
if err := st.AppendContextManifest("alice", "run-1", crossProject); err == nil {
t.Fatal("attempt id reused across project ids to claim a trusted response")
}
crossOwner := base
if err := st.AppendContextManifest("bob", "run-1", crossOwner); err == nil {
t.Fatal("attempt id reused across owners to claim a trusted response")
}
// Planting the send under a foreign scope is refused too: attempt identity
// is global, so one trusted send has exactly one receipt pair.
if err := st.AppendContextManifest("alice", "run-1", base); err == nil {
t.Fatal("attempt id reused across projects to open a second send")
}
if err := st.AppendContextManifest("bob", "run-1", base); err == nil {
t.Fatal("attempt id reused across owners to open a second send")
}
kept, err := st.ListContextManifests("alice", "run-1")
if err != nil || len(kept) != 2 {
t.Fatalf("refused cross-scope receipts changed the trusted set: %+v err=%v", kept, err)
}
for _, m := range kept {
if m.ProjectID != "p1" || m.AttemptID != "attempt-1" {
t.Fatalf("foreign scope leaked into the trusted receipts: %+v", m)
}
}
if foreign, err := st.ListContextManifests("bob", "run-1"); err != nil || len(foreign) != 0 {
t.Fatalf("cross-owner attempt became visible: %+v err=%v", foreign, err)
}
// A genuinely different attempt in another owner/project still works, so
// the refusals above are identity-based rather than scope-based.
other := extension.ProjectContextManifest{ProjectID: "p2", AttemptID: "attempt-2", InputSHA256: "other-input", Delivery: "attempted"}
if err := st.AppendContextManifest("bob", "run-2", other); err != nil {
t.Fatalf("a distinct attempt in another project/owner was refused: %v", err)
}
other.Delivery = "responded"
if err := st.AppendContextManifest("bob", "run-2", other); err != nil {
t.Fatalf("a distinct attempt could not record its own response: %v", err)
}
if mine, err := st.ListContextManifests("alice", "run-2"); err != nil || len(mine) != 0 {
t.Fatalf("another owner's response became visible: %+v err=%v", mine, err)
}
}
func TestContextManifestDoesNotPersistWorkflowOutputInResolveScope(t *testing.T) {
st, err := OpenSQLite(filepath.Join(t.TempDir(), "scope-privacy.db"))
if err != nil {
+11
View File
@@ -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
}
@@ -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)
}
}
+7
View File
@@ -558,6 +558,13 @@ func (r *hostRunner) readStepOutput(workDir, stepID string) string {
// renderRefs 运行期解析残留的 {{steps.X.output}} 占位符(宿主步实参/正文;
// workDir 为本步子工作区,经其上两级回到运行根再定位目标步产出)。
// 信任不变式(本函数不校验任何东西,只依赖调用顺序):直接调用方只有 doTool
// 与 doArtifact,二者所在的宿主步在 Run 里以 strict=true 先跑
// verifyPriorWorkflowOutputs,逐个 prompt 步调用 VerifyProjectStepOutput
// 重校验来源之后才会走到这里;strict 覆盖 wf.tool/wf.artifact/wf.script/
// wf.critic,即所有把上文送模型或落盘的目的步。因此 renderRefs 不会读到
// 未重校验的机器产出。新增不以 strict 运行的调用方(如 wf.card/wf.hitl
// 直读他步产出)会打破该不变式,必须在新增调用点自行校验,不得依赖本函数。
func (r *hostRunner) renderRefs(s, workDir string) string {
if !strings.Contains(s, "{{steps.") {
return s
+7 -1
View File
@@ -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")
+58 -9
View File
@@ -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 {
+99
View File
@@ -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)
}
}
}
+11
View File
@@ -100,6 +100,17 @@ type ProjectContextSourceRecheckResult struct{ Bundle ProjectContextBundle }
// ProjectContextSourceRechecker is an optional method of the same registered
// ProjectContextProvider. Project workflow reuse fails closed without it.
//
// Fail-closed comparison contract: Backend compares the returned Bundle with
// the recorded receipt as a set of stable identities, never as provider-ordered
// output and never by the provider's own echo. Every recorded Included entry
// must be answered by exactly one item with the same SourceID, Revision and
// Hash whose text still hashes to the recorded InputSHA256; every recorded
// OfferedReference and ReadEvidence must be answered by the same
// (ID, SourceID, Revision, Hash); Missing must be equal as a set, so a benign
// reordering is accepted while an added, dropped or replaced entry is refused.
// Anything unproven, extra or absent rejects; reordering never widens
// authorization.
type ProjectContextSourceRechecker interface {
Recheck(context.Context, ProjectContextSourceRecheckRequest) (ProjectContextSourceRecheckResult, error)
}
+1
View File
@@ -110,6 +110,7 @@ ProjectAuditPublisher.PublishProjectAuditLink
ProjectAuditPublisherFactory
ProjectAuditPublisherFactory.ForOwner
ProjectAuditQuery
ProjectAuditReceiptResource
ProjectAuditRegistry
ProjectAuditRegistry.RegisterProjectAuditAuthorizer
ProjectContextBundle