From 0e8cd040cc75c329eb8950676acc7a9a08487ad2 Mon Sep 17 00:00:00 2001 From: weichen Date: Wed, 14 Oct 2026 04:29:38 +0000 Subject: [PATCH] Bind audit publishers and randomize operation IDs Why: Clock-derived IDs could collide and string-scoped hosts could impersonate a registered audit publisher. Evidence: Fixed-clock concurrent, ambiguous private offset, forged scoped-host, and registered publisher RED-to-GREEN tests; go test ./... -count=1 and targeted race test passed locally as SELF. Impact: Generate 192-bit host IDs, reject ambiguous explicit offsets, and issue a publication-only capability at verifier registration. Refs: Stage 5 W9; approved Stage 6 audit-link design adjustment r1; review of a8fa2fe --- .task/feature-rdm-two-trees-project-audit.md | 2 + internal/app/project_audit.go | 103 +++++++++++++------ internal/app/project_audit_link_test.go | 48 ++++++++- internal/app/surface_audit_actor_test.go | 10 +- internal/cmdbus/cmdbus.go | 11 +- internal/cmdbus/cmdbus_test.go | 32 ++++++ pkg/extension/extension.go | 4 +- pkg/extension/project_audit.go | 12 ++- pkg/extension/surface.txt | 2 + 9 files changed, 180 insertions(+), 44 deletions(-) diff --git a/.task/feature-rdm-two-trees-project-audit.md b/.task/feature-rdm-two-trees-project-audit.md index 6fb6a96..f72e33b 100644 --- a/.task/feature-rdm-two-trees-project-audit.md +++ b/.task/feature-rdm-two-trees-project-audit.md @@ -13,6 +13,8 @@ - Command audit stores the current verified account display name at write time; old rows expose `not-recorded`. Public receipts omit raw payload and parameter digest. Cursor uses project-and-owner scoped source cutoff. - Initial commit `c8dd169a1fdc1451fab3b475c14f9636a7f5bf6c` exposed only the actor-owned project slice. The next local increment fixes button actor propagation and adds a trusted domain-commit link publisher, verifier callback, exact linked metadata read, current `audit.read` and per-object metadata checks, and durable idempotency. Unlinked personal conversations remain private. - Trusted command dispatch now creates an operation ID before calling the compiled handler and passes that ID through `extension.Request`. The handler can commit the same ID with its domain event/outbox; the final Backend audit/receipt uses it. Client `params.operationId` is ignored. An outbox link request with `HostAuditOffset=0` resolves a unique committed host row by owner, fixed project, and operation ID; a missing row returns retryable `ErrProjectAuditPending`, while duplicate rows fail closed. The separate idempotency key still controls retry equivalence. +- Independent review of `a8fa2fe` found two P2 security defects: clock-based operation IDs could collide; and `NewScopedHost(owner,"rdm")` let another compiled plugin self-report the publisher's plugin ID. The new local increment uses 192 bits from `crypto/rand` for each host-generated operation ID. Both zero-offset and explicit-offset publication require a unique committed row for owner/project/operation; a historical duplicate rejects publication, including attempts to link another private command. Verifier registration returns a private `ProjectAuditPublisherFactory` for the registering plugin's background outbox; a generic scoped Host has no publisher method. Fixed-clock concurrent IDs, forged params, ambiguous private offset, forged scoped Host, and legitimate registered publisher each have focused tests. +- The minted publisher exposes only `ProjectAuditPublisher`, not the full owner-scoped `Host`; tests reject attempts to use `NewScopedHost` with a self-reported `rdm` string as publisher authority. A publisher is a registration-time capability, while `IdempotencyKey` remains the separate retry identity. - Exact-source linked reads do not enumerate hidden links or reveal raw payload, private conversation ID, or content. Body disclosure and a broad project link list are not implemented; the public coverage reports the narrower capability. ## Verification and remaining work diff --git a/internal/app/project_audit.go b/internal/app/project_audit.go index 4b5101b..8a005cc 100644 --- a/internal/app/project_audit.go +++ b/internal/app/project_audit.go @@ -13,11 +13,41 @@ import ( // projectAuditSlot binds the audit.read policy to its registering domain plugin. // A different plugin cannot borrow that policy through its own Host handle. type projectAuditSlot struct { - mu sync.RWMutex - holder string - check extension.ProjectAuditAuthorizer - verifier extension.ProjectAuditLinkVerifier - disclose extension.ProjectAuditDisclosureAuthorizer + mu sync.RWMutex + holder string + check extension.ProjectAuditAuthorizer + verifier extension.ProjectAuditLinkVerifier + disclose extension.ProjectAuditDisclosureAuthorizer + publisherToken *auditPublisherToken +} + +// This identity is available only to the publisher factory returned at +// registration. A caller-provided pluginID cannot manufacture the pointer. +type auditPublisherToken struct{ marker byte } + +type projectAuditPublisherFactory struct { + caps hostcaps + pluginID string + token *auditPublisherToken +} + +func (f projectAuditPublisherFactory) ForOwner(owner string) (extension.ProjectAuditPublisher, error) { + if owner == "" || f.token == nil { + return nil, extension.ErrProjectAuditDenied + } + return projectAuditPublisher{caps: f.caps, owner: owner, pluginID: f.pluginID, token: f.token}, nil +} + +type projectAuditPublisher struct { + caps hostcaps + owner string + pluginID string + token *auditPublisherToken +} + +func (p projectAuditPublisher) PublishProjectAuditLink(ctx context.Context, req extension.ProjectAuditLinkRequest) (extension.ProjectAuditLinkReceipt, error) { + h := inprocHost{caps: p.caps, owner: p.owner, pluginID: p.pluginID} + return h.publishProjectAuditLink(ctx, req, p.token) } func (s *projectAuditSlot) register(pluginID string, check extension.ProjectAuditAuthorizer) error { @@ -33,17 +63,18 @@ func (s *projectAuditSlot) register(pluginID string, check extension.ProjectAudi return nil } -func (s *projectAuditSlot) registerVerifier(pluginID string, verify extension.ProjectAuditLinkVerifier) error { +func (s *projectAuditSlot) registerVerifier(pluginID string, verify extension.ProjectAuditLinkVerifier) (*auditPublisherToken, error) { if pluginID == "" || verify == nil { - return extension.ErrProjectAuditUnsupported + return nil, extension.ErrProjectAuditUnsupported } s.mu.Lock() defer s.mu.Unlock() if s.verifier != nil || (s.holder != "" && s.holder != pluginID) { - return errors.New("extension: project audit verifier already registered") + return nil, errors.New("extension: project audit verifier already registered") } s.holder, s.verifier = pluginID, verify - return nil + s.publisherToken = &auditPublisherToken{marker: 1} + return s.publisherToken, nil } func (s *projectAuditSlot) registerDisclosure(pluginID string, disclose extension.ProjectAuditDisclosureAuthorizer) error { @@ -78,17 +109,21 @@ func (r recordRegistry) RegisterProjectAuditAuthorizer(check extension.ProjectAu return reg.RegisterProjectAuditAuthorizer(check) } -func (r compiledRegistry) RegisterProjectAuditLinkVerifier(extension.ProjectAuditLinkVerifier) error { - return errors.New("extension: declarative plugins cannot register project audit link verifiers") +func (r compiledRegistry) RegisterProjectAuditLinkVerifier(extension.ProjectAuditLinkVerifier) (extension.ProjectAuditPublisherFactory, error) { + return nil, errors.New("extension: declarative plugins cannot register project audit link verifiers") } func (r compiledRegistry) RegisterProjectAuditDisclosureAuthorizer(extension.ProjectAuditDisclosureAuthorizer) error { return errors.New("extension: declarative plugins cannot register project audit disclosure authorizers") } -func (r pluginRegistry) RegisterProjectAuditLinkVerifier(verify extension.ProjectAuditLinkVerifier) error { +func (r pluginRegistry) RegisterProjectAuditLinkVerifier(verify extension.ProjectAuditLinkVerifier) (extension.ProjectAuditPublisherFactory, error) { if r.caps.projectAudit == nil { - return extension.ErrProjectAuditUnsupported + return nil, extension.ErrProjectAuditUnsupported } - return r.caps.projectAudit.registerVerifier(r.pluginID, verify) + token, err := r.caps.projectAudit.registerVerifier(r.pluginID, verify) + if err != nil { + return nil, err + } + return projectAuditPublisherFactory{caps: r.caps, pluginID: r.pluginID, token: token}, nil } func (r pluginRegistry) RegisterProjectAuditDisclosureAuthorizer(disclose extension.ProjectAuditDisclosureAuthorizer) error { if r.caps.projectAudit == nil { @@ -96,10 +131,10 @@ func (r pluginRegistry) RegisterProjectAuditDisclosureAuthorizer(disclose extens } return r.caps.projectAudit.registerDisclosure(r.pluginID, disclose) } -func (r recordRegistry) RegisterProjectAuditLinkVerifier(verify extension.ProjectAuditLinkVerifier) error { +func (r recordRegistry) RegisterProjectAuditLinkVerifier(verify extension.ProjectAuditLinkVerifier) (extension.ProjectAuditPublisherFactory, error) { reg, ok := r.Registry.(extension.ProjectAuditLinkRegistry) if !ok { - return extension.ErrProjectAuditUnsupported + return nil, extension.ErrProjectAuditUnsupported } return reg.RegisterProjectAuditLinkVerifier(verify) } @@ -112,9 +147,10 @@ func (r recordRegistry) RegisterProjectAuditDisclosureAuthorizer(disclose extens } var _ extension.ProjectAuditHost = inprocHost{} -var _ extension.ProjectAuditPublisher = inprocHost{} +var _ extension.ProjectAuditPublisher = projectAuditPublisher{} +var _ extension.ProjectAuditPublisherFactory = projectAuditPublisherFactory{} -func (h inprocHost) PublishProjectAuditLink(ctx context.Context, req extension.ProjectAuditLinkRequest) (extension.ProjectAuditLinkReceipt, error) { +func (h inprocHost) publishProjectAuditLink(ctx context.Context, req extension.ProjectAuditLinkRequest, capability *auditPublisherToken) (extension.ProjectAuditLinkReceipt, error) { if err := ctx.Err(); err != nil { return extension.ProjectAuditLinkReceipt{}, err } @@ -123,27 +159,34 @@ func (h inprocHost) PublishProjectAuditLink(ctx context.Context, req extension.P } slot := h.caps.projectAudit slot.mu.RLock() - holder, verify := slot.holder, slot.verifier + holder, verify, registeredToken := slot.holder, slot.verifier, slot.publisherToken slot.mu.RUnlock() - if holder != h.pluginID || verify == nil { + if holder != h.pluginID || verify == nil || registeredToken == nil || capability != registeredToken { return extension.ProjectAuditLinkReceipt{}, extension.ErrProjectAuditUnsupported } if h.owner == "" || (h.actor != "" && h.actor != h.owner) || req.HostAuditOffset < 0 || req.ProjectID == "" || req.SourceKind != "domain-commit" || req.SourceID == "" || req.OperationID == "" || req.ResourceKind == "" || req.ResourceID == "" || req.DisclosureClass != "metadata" || req.IdempotencyKey == "" { return extension.ProjectAuditLinkReceipt{}, extension.ErrProjectAuditDenied } - if req.HostAuditOffset == 0 { - resolved, err := h.caps.st.CommittedAuditOffset(h.owner, string(req.ProjectID), req.OperationID) - if errors.Is(err, store.ErrNotFound) { + // An explicit offset is only a locator. A historical collision or a forged + // offset must not turn a private command with the same operation ID into a + // published project receipt. + resolved, err := h.caps.st.CommittedAuditOffset(h.owner, string(req.ProjectID), req.OperationID) + if errors.Is(err, store.ErrNotFound) { + if req.HostAuditOffset == 0 { return extension.ProjectAuditLinkReceipt{}, extension.ErrProjectAuditPending } - if errors.Is(err, store.ErrConflict) { - return extension.ProjectAuditLinkReceipt{}, extension.ErrProjectAuditLinkConflict - } - if err != nil { - return extension.ProjectAuditLinkReceipt{}, err - } - req.HostAuditOffset = resolved + return extension.ProjectAuditLinkReceipt{}, extension.ErrProjectAuditDenied } + if errors.Is(err, store.ErrConflict) { + return extension.ProjectAuditLinkReceipt{}, extension.ErrProjectAuditLinkConflict + } + if err != nil { + return extension.ProjectAuditLinkReceipt{}, err + } + if req.HostAuditOffset != 0 && req.HostAuditOffset != resolved { + return extension.ProjectAuditLinkReceipt{}, extension.ErrProjectAuditLinkConflict + } + req.HostAuditOffset = resolved row, projectID, err := h.caps.st.CommandAuditForLink(h.owner, req.HostAuditOffset) if errors.Is(err, store.ErrNotFound) { return extension.ProjectAuditLinkReceipt{}, extension.ErrProjectAuditDenied diff --git a/internal/app/project_audit_link_test.go b/internal/app/project_audit_link_test.go index 8c805ca..8668fed 100644 --- a/internal/app/project_audit_link_test.go +++ b/internal/app/project_audit_link_test.go @@ -56,9 +56,10 @@ func TestProjectAuditLinkSharesOnlyVerifiedCommittedMetadata(t *testing.T) { }); err != nil { t.Fatal(err) } - if err := reg.RegisterProjectAuditLinkVerifier(func(_ context.Context, proof extension.ProjectAuditLinkProof) (bool, error) { + publisherFactory, err := (recordRegistry{Registry: reg}).RegisterProjectAuditLinkVerifier(func(_ context.Context, proof extension.ProjectAuditLinkProof) (bool, error) { return proof.Owner == "alice" && proof.ProjectID == p.ID && proof.SourceKind == "domain-commit" && proof.SourceID == "commit-1" && proof.OperationID == "op-1" && proof.ResourceKind == "document" && proof.ResourceID == "doc-1", nil - }); err != nil { + }) + if err != nil { t.Fatal(err) } metadataAllowed := true @@ -72,8 +73,27 @@ func TestProjectAuditLinkSharesOnlyVerifiedCommittedMetadata(t *testing.T) { if _, err := bob.QueryProjectAudit(context.Background(), q); !errors.Is(err, extension.ErrProjectAuditDenied) { t.Fatalf("unlinked private audit: %v", err) } - alicePublisher := inprocHost{caps: caps, pluginID: "rdm", owner: "alice"} // trusted outbox-scoped host + alicePublisher, err := publisherFactory.ForOwner("alice") // capability retained by trusted RDM outbox + if err != nil { + t.Fatal(err) + } + if _, ok := alicePublisher.(extension.Host); ok { + t.Fatal("publisher factory exposed a full owner-scoped Host") + } linkReq := extension.ProjectAuditLinkRequest{ProjectID: p.ID, SourceKind: "domain-commit", SourceID: "commit-1", OperationID: "op-1", ResourceKind: "document", ResourceID: "doc-1", DisclosureClass: "metadata", IdempotencyKey: "outbox-1"} + // A different compiled plugin can call the public scoped-host factory with + // "rdm" as text. That must not mint RDM's audit publication authority. + wireScopedHostFactory(caps) + t.Cleanup(func() { extension.SetScopedHostFactory(nil) }) + forged, err := extension.NewScopedHost("alice", "rdm") + if err != nil { + t.Fatal(err) + } + if publisher, ok := forged.(extension.ProjectAuditPublisher); ok { + if _, err := publisher.PublishProjectAuditLink(context.Background(), linkReq); !errors.Is(err, extension.ErrProjectAuditUnsupported) { + t.Fatalf("self-reported plugin ID minted audit publisher: %v", err) + } + } if rows, err := bob.QueryProjectAudit(context.Background(), extension.ProjectAuditQuery{ProjectID: p.ID}); err != nil || len(rows.Receipts) != 0 { t.Fatalf("private owner row visible without exact link: %+v %v", rows, err) } @@ -88,8 +108,8 @@ func TestProjectAuditLinkSharesOnlyVerifiedCommittedMetadata(t *testing.T) { if _, err := alicePublisher.PublishProjectAuditLink(context.Background(), uncommitted); !errors.Is(err, extension.ErrProjectAuditDenied) { t.Fatalf("uncommitted source: %v", err) } - if _, err := (inprocHost{caps: caps, pluginID: "other", owner: "alice"}).PublishProjectAuditLink(context.Background(), linkReq); !errors.Is(err, extension.ErrProjectAuditUnsupported) { - t.Fatalf("other plugin: %v", err) + if _, err := (pluginRegistry{pluginID: "other", caps: caps}).RegisterProjectAuditLinkVerifier(func(context.Context, extension.ProjectAuditLinkProof) (bool, error) { return true, nil }); err == nil { + t.Fatal("other plugin replaced the registered verifier") } pending := linkReq pending.OperationID, pending.IdempotencyKey = "op-not-yet-recorded", "outbox-pending" @@ -121,6 +141,24 @@ func TestProjectAuditLinkSharesOnlyVerifiedCommittedMetadata(t *testing.T) { if strings.Contains(string(raw), "secret-digest") || strings.Contains(string(raw), string(c.ID)) { t.Fatalf("private receipt fields leaked: %s", raw) } + if err := st.AppendAudit("alice", agentcommand.AuditEnvelope{Time: time.Now(), Actor: "alice", CommandID: "private.note", OperationID: "op-1", ConversationID: string(c.ID), State: agentcommand.StateCommitted}); err != nil { + t.Fatal(err) + } + duplicates, _, _, err := st.ListProjectCommandAudits("alice", string(p.ID), 0, 0, 10) + if err != nil { + t.Fatal(err) + } + privateOffset := duplicates[0].Offset + mislink := linkReq + mislink.HostAuditOffset, mislink.IdempotencyKey = privateOffset, "outbox-private" + if _, err := alicePublisher.PublishProjectAuditLink(context.Background(), mislink); !errors.Is(err, extension.ErrProjectAuditLinkConflict) { + t.Fatalf("ambiguous operation linked a private command: %v", err) + } + privateQuery := q + privateQuery.LinkedHostOffset = privateOffset + if _, err := bob.QueryProjectAudit(context.Background(), privateQuery); !errors.Is(err, extension.ErrProjectAuditDenied) { + t.Fatalf("private command disclosed through ambiguous operation: %v", err) + } metadataAllowed = false if _, err := bob.QueryProjectAudit(context.Background(), q); !errors.Is(err, extension.ErrProjectAuditDenied) { t.Fatalf("revoked object metadata: %v", err) diff --git a/internal/app/surface_audit_actor_test.go b/internal/app/surface_audit_actor_test.go index d57eae1..b4f3fb3 100644 --- a/internal/app/surface_audit_actor_test.go +++ b/internal/app/surface_audit_actor_test.go @@ -88,9 +88,10 @@ func TestCompiledHandlerReceivesHostOperationID(t *testing.T) { if err := reg.RegisterProjectAuditAuthorizer(func(actor string, project core.ID) (bool, error) { return actor == "bob" && project == p.ID, nil }); err != nil { t.Fatal(err) } - if err := reg.RegisterProjectAuditLinkVerifier(func(_ context.Context, proof extension.ProjectAuditLinkProof) (bool, error) { + publisherFactory, err := (recordRegistry{Registry: reg}).RegisterProjectAuditLinkVerifier(func(_ context.Context, proof extension.ProjectAuditLinkProof) (bool, error) { return proof.Owner == "alice" && proof.ProjectID == p.ID && proof.OperationID == seen && proof.SourceID == "domain-commit-1" && proof.ResourceID == "doc-1", nil - }); err != nil { + }) + if err != nil { t.Fatal(err) } if err := reg.RegisterProjectAuditDisclosureAuthorizer(func(_ context.Context, actor string, r extension.ProjectAuditLinkedResource) (extension.ProjectAuditDisclosure, error) { @@ -98,7 +99,10 @@ func TestCompiledHandlerReceivesHostOperationID(t *testing.T) { }); err != nil { t.Fatal(err) } - publisher := inprocHost{caps: caps, owner: "alice", pluginID: "rdm"} + publisher, err := publisherFactory.ForOwner("alice") + if err != nil { + t.Fatal(err) + } linked, err := publisher.PublishProjectAuditLink(context.Background(), extension.ProjectAuditLinkRequest{ProjectID: p.ID, OperationID: seen, SourceKind: "domain-commit", SourceID: "domain-commit-1", ResourceKind: "document", ResourceID: "doc-1", DisclosureClass: "metadata", IdempotencyKey: "outbox-1"}) if err != nil || linked.HostAuditOffset != rows[0].Offset { t.Fatalf("publish by trusted operation: %+v %v", linked, err) diff --git a/internal/cmdbus/cmdbus.go b/internal/cmdbus/cmdbus.go index 8507aa7..3fb4a14 100644 --- a/internal/cmdbus/cmdbus.go +++ b/internal/cmdbus/cmdbus.go @@ -5,6 +5,7 @@ package cmdbus import ( "context" + "crypto/rand" "crypto/sha256" "encoding/hex" "encoding/json" @@ -281,9 +282,13 @@ func (b *Bus) Dispatch(ctx context.Context, req Request) Response { return receipt(agentcommand.StateRejected, "", "no host handler bound for "+req.ID) } // The domain handler can persist this trusted ID with its transaction/outbox. - // Never accept an ID from Params or the incoming Request. A handler failure - // still writes only a rejected audit receipt with no committed operation ID. - req.OperationID = fmt.Sprintf("op_%s_%d", strings.ReplaceAll(req.ID, ".", "_"), b.now().UnixNano()) + // Entropy, not the clock or caller Params, distinguishes concurrent operations. + // A handler failure still writes only a rejected audit receipt without an ID. + var opBytes [24]byte + if _, err := rand.Read(opBytes[:]); err != nil { + return receipt(agentcommand.StateUnknown, "", "operation ID unavailable") + } + req.OperationID = "op_" + hex.EncodeToString(opBytes[:]) payload, err := h(ctx, req) if err != nil { return receipt(agentcommand.StateRejected, "", "rejected: "+err.Error()) diff --git a/internal/cmdbus/cmdbus_test.go b/internal/cmdbus/cmdbus_test.go index 4d64948..90d5c63 100644 --- a/internal/cmdbus/cmdbus_test.go +++ b/internal/cmdbus/cmdbus_test.go @@ -115,6 +115,38 @@ func TestHandlerReceivesCommittedOperationIDBeforeDomainWrite(t *testing.T) { } } +func TestOperationIDsRemainUniqueWithFixedClockAndConcurrentDispatch(t *testing.T) { + audit := &memAudit{} + bus := New(audit, nil, func() time.Time { return time.Unix(1700000000, 123).UTC() }) + if err := bus.RegisterBuiltin(agentcommand.CommandSpec{ID: "rdm.confirm", Title: "Confirm"}, "", func(_ context.Context, req Request) (any, error) { + return req.OperationID, nil + }); err != nil { + t.Fatal(err) + } + const count = 64 + var wg sync.WaitGroup + ids := make([]string, count) + for i := range ids { + wg.Add(1) + go func(i int) { + defer wg.Done() + resp := bus.Dispatch(context.Background(), Request{ID: "rdm.confirm", Actor: "alice", Owner: "alice"}) + ids[i] = resp.Receipt.OperationID + }(i) + } + wg.Wait() + seen := map[string]bool{} + for _, id := range ids { + if id == "" || seen[id] { + t.Fatalf("fixed clock produced a missing or duplicate trusted operation ID: %q", id) + } + seen[id] = true + } + if len(audit.logs) != count { + t.Fatalf("audit rows=%d want=%d", len(audit.logs), count) + } +} + func TestDispatchUnknownAndBadParams(t *testing.T) { a := &memAudit{} b := newTestBus(a, &memCards{}) diff --git a/pkg/extension/extension.go b/pkg/extension/extension.go index 793f074..1812671 100644 --- a/pkg/extension/extension.go +++ b/pkg/extension/extension.go @@ -40,7 +40,9 @@ import ( // verifier, disclosure callback, and exact linked receipt read. // 2.15.0-experimental: trusted Request.OperationID before handler execution and // retryable ProjectAuditLinkRequest.HostAuditOffset=0 resolution. -const SurfaceVersion = "2.15.0-experimental" +// 2.16.0-experimental: cryptographic operation IDs and registration-bound +// ProjectAuditPublisherFactory; scoped Host string IDs cannot publish links. +const SurfaceVersion = "2.16.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 0900efe..ddca97c 100644 --- a/pkg/extension/project_audit.go +++ b/pkg/extension/project_audit.go @@ -76,10 +76,18 @@ type ProjectAuditDisclosure struct { type ProjectAuditDisclosureAuthorizer func(context.Context, string, ProjectAuditLinkedResource) (ProjectAuditDisclosure, error) +// ProjectAuditPublisherFactory is issued only by successful verifier +// registration. A domain outbox may retain it and bind an owner for each job; +// owner and committed-source attribution are checked again when publishing. +// NewScopedHost(owner, pluginID) cannot mint this capability. +type ProjectAuditPublisherFactory interface { + ForOwner(owner string) (ProjectAuditPublisher, error) +} + // ProjectAuditLinkRegistry is optional. Only the registering compiled domain -// plugin may publish or read its links. +// plugin receives a publisher factory; string plugin IDs carry no authority. type ProjectAuditLinkRegistry interface { - RegisterProjectAuditLinkVerifier(ProjectAuditLinkVerifier) error + RegisterProjectAuditLinkVerifier(ProjectAuditLinkVerifier) (ProjectAuditPublisherFactory, error) RegisterProjectAuditDisclosureAuthorizer(ProjectAuditDisclosureAuthorizer) error } diff --git a/pkg/extension/surface.txt b/pkg/extension/surface.txt index 6f06b8f..7d5f851 100644 --- a/pkg/extension/surface.txt +++ b/pkg/extension/surface.txt @@ -54,6 +54,8 @@ ProjectAuditLinkedResource ProjectAuditPage ProjectAuditPublisher ProjectAuditPublisher.PublishProjectAuditLink +ProjectAuditPublisherFactory +ProjectAuditPublisherFactory.ForOwner ProjectAuditQuery ProjectAuditRegistry ProjectAuditRegistry.RegisterProjectAuditAuthorizer