Why: Project audit cannot use owner-wide logs or current conversation project state to establish trusted attribution. Evidence: RED cases were observed locally; go test ./pkg/extension ./internal/store ./internal/app -count=1 passed on this tree (SELF). Impact: Add optional X2 read and audit.read callback, immutable project/name snapshots, safe owner-only projection, and explicit unsupported coverage. Cross-member publication remains pending. Refs: Stage 5 W9; Stage 6 X2 public extension request
102 lines
3.8 KiB
Go
102 lines
3.8 KiB
Go
package app
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"errors"
|
|
"path/filepath"
|
|
"strings"
|
|
"testing"
|
|
"time"
|
|
|
|
agentcommand "agent-command"
|
|
"agent-system-backend/internal/store"
|
|
"agent-system-backend/pkg/extension"
|
|
core "agent-system-core"
|
|
)
|
|
|
|
func TestProjectAuditHostChecksDomainReadAndProjectsStoredReceipts(t *testing.T) {
|
|
st, err := store.OpenSQLite(filepath.Join(t.TempDir(), "audit.db"))
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
defer st.Close()
|
|
for _, owner := range []string{"alice", "bob"} {
|
|
if err := st.EnsureUser(owner, strings.ToUpper(owner)); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
}
|
|
a, err := st.CreateProject("alice", "A")
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
b, err := st.CreateProject("alice", "B")
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
ac, err := st.CreateConversation("alice", string(a.ID), "A", "")
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
bc, err := st.CreateConversation("alice", string(b.ID), "B", "")
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
shared, err := st.CreateConversationInProject("bob", string(a.ID), "A shared", "")
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
for _, item := range []struct{ owner, conversation, command string }{
|
|
{"alice", string(ac.ID), "a.command"},
|
|
{"alice", string(bc.ID), "b.command"},
|
|
{"bob", string(shared.ID), "shared.command"},
|
|
} {
|
|
if err := st.AppendAudit(item.owner, agentcommand.AuditEnvelope{Time: time.Now(), Actor: item.owner, CommandID: item.command, ConversationID: item.conversation, State: agentcommand.StateCommitted, ParamsDigest: "sensitive-digest"}); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
}
|
|
slot := &projectAuditSlot{}
|
|
caps := hostcaps{st: st, projectAudit: slot}
|
|
reg := pluginRegistry{pluginID: "rdm", caps: caps}
|
|
allowed := true
|
|
if err := reg.RegisterProjectAuditAuthorizer(func(actor string, projectID core.ID) (bool, error) {
|
|
return allowed && actor == "bob" && projectID == a.ID, nil
|
|
}); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
h := inprocHost{caps: caps, owner: "bob", actor: "bob", pluginID: "rdm"}
|
|
page, err := h.QueryProjectAudit(context.Background(), extension.ProjectAuditQuery{ProjectID: a.ID, Limit: 10})
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if len(page.Receipts) != 1 || page.Receipts[0].CommandID != "shared.command" {
|
|
t.Fatalf("A page: %+v", page)
|
|
}
|
|
if page.Coverage.CommandAudit != "actor-owned-project-bound-only" || page.Coverage.CrossMember != "unsupported" || page.Coverage.LegacyProjectBinding != "not-recorded" || page.Coverage.RunRelations != "unsupported" || page.Coverage.ModelReceipts != "unsupported" {
|
|
t.Fatalf("coverage: %+v", page.Coverage)
|
|
}
|
|
if page.Receipts[0].ActorName != "BOB" {
|
|
t.Fatalf("actor names: %+v", page.Receipts)
|
|
}
|
|
raw, err := json.Marshal(page)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if strings.Contains(string(raw), "sensitive-digest") || strings.Contains(string(raw), "paramsDigest") || strings.Contains(string(raw), "payload") {
|
|
t.Fatalf("raw audit payload leaked: %s", raw)
|
|
}
|
|
if _, err := h.QueryProjectAudit(context.Background(), extension.ProjectAuditQuery{ProjectID: b.ID}); !errors.Is(err, extension.ErrProjectAuditDenied) {
|
|
t.Fatalf("project B: %v", err)
|
|
}
|
|
if _, err := (inprocHost{caps: caps, owner: "bob", actor: "bob", pluginID: "other"}).QueryProjectAudit(context.Background(), extension.ProjectAuditQuery{ProjectID: a.ID}); !errors.Is(err, extension.ErrProjectAuditUnsupported) {
|
|
t.Fatalf("other plugin: %v", err)
|
|
}
|
|
if _, err := (inprocHost{caps: caps, owner: "bob", pluginID: "rdm"}).QueryProjectAudit(context.Background(), extension.ProjectAuditQuery{ProjectID: a.ID}); !errors.Is(err, extension.ErrProjectAuditDenied) {
|
|
t.Fatalf("unbound actor: %v", err)
|
|
}
|
|
allowed = false
|
|
if _, err := h.QueryProjectAudit(context.Background(), extension.ProjectAuditQuery{ProjectID: a.ID, BeforeOffset: page.NextOffset, CutoffOffset: page.CutoffOffset}); !errors.Is(err, extension.ErrProjectAuditDenied) {
|
|
t.Fatalf("revoked audit.read: %v", err)
|
|
}
|
|
}
|