Integrate the shared document renderer and two-trees project work #209
@@ -0,0 +1,20 @@
|
||||
# W9 Backend project audit read slice
|
||||
|
||||
- Scope: TT-A01/A02/A04/U04, approved stage 5 W9. Stage 6 local implementation; human review, formal acceptance, PR, CI, tag, and deployment are pending.
|
||||
- Branch/worktree: `feature/rdm-two-trees-project-audit` at `/home/weichen/projects/Agent-System-Backend-wt-feature-rdm-two-trees-project-audit`.
|
||||
- R0: Backend authoritative `origin/main` `5d5fc9891c0eb8994545ccb8b740535d9b99fb0e`; main was clean and equal to the fetched remote before this worktree was created. Core gitlink in this worktree is `5773e74086791731059730bd3726dc8c9cd1381f`.
|
||||
- Startup receipt: `/tmp/backend-w9-precheck-20261014-01.json` (SELF, exit 2, `SYNC_REQUIRED` for the separate Dev checkout `feature/rdm-increment-acceptance-note`, six commits behind Dev `origin/main`). This Backend task uses the independent-development path in Backend `AGENTS.md`; it does not write that Dev checkout or its task card. The receipt is preserved as a failure, not relabeled READY.
|
||||
|
||||
## Implemented local slice
|
||||
|
||||
- Optional `ProjectAuditHost` and domain `audit.read` registration. A different plugin, absent registration, or unbound actor cannot read.
|
||||
- Store write snapshots the project from the owner-matched conversation; query requires the authenticated actor's owner and that immutable project. This deliberately excludes other members' private conversations and unbound historical rows, and does not move old receipts when a conversation changes projects.
|
||||
- Command audit stores the current verified account display name at write time; old rows expose `not-recorded`. Public receipts omit raw payload and parameter digest. Cursor uses project-and-owner scoped source cutoff.
|
||||
- Coverage explicitly marks cross-member published operations, legacy project attribution, run relations, model/background receipts, and resource bodies unsupported. This is not the full W9 or complete project audit.
|
||||
|
||||
## Verification and remaining work
|
||||
|
||||
- The session saw RED for the missing store and host interfaces, unscoped cutoff, cross-member private disclosure, and conversation-project reassignment before the respective fixes. These RED outputs were not captured by `make evidence`, so this card does not use them as formal evidence.
|
||||
- SELF receipt `/tmp/backend-w9-go-test-20261014-01.json`: `go test ./...`, exit 0, run on the pre-commit worktree based on R0.
|
||||
- SELF receipt `/tmp/backend-w9-build-20261014-01.json`: `CGO_ENABLED=0 go build ./cmd/agent-system`, exit 0, run on that pre-commit tree. Generated local binary removed after the build.
|
||||
- Required next X2 contract: persist trusted operation/run/resource attribution and publication or visibility at source, with a per-object metadata/body disclosure callback and cursor binding to actor, authorization epoch, project, filter, and cutoff. Then test two members reading published project operations while private conversations, revoked objects, and bodies without current rights remain hidden. Current `command_audit` has owner, conversation, raw envelope, and new project/name snapshots, but no proof that an operation was published or that its resource may be disclosed. Add real model/tool/background receipts, delayed receipt reconciliation, history/export checks, and PostgreSQL/combined consumer verification before claiming W9 complete.
|
||||
+1
-1
@@ -597,7 +597,7 @@ func Run(opt Options) error {
|
||||
// hostcaps:hostapi 闭包与进程内插件句柄共用的 owner 作用域能力(EXT-1 批注 1)。
|
||||
authzSlot := &projectAuthz{}
|
||||
projectSlot := &projectLifecycleSlot{}
|
||||
caps := hostcaps{projects: projectSlot, st: st, pipe: pipe, set: setSvc, authz: authzSlot, surfaces: surfaceSvc}
|
||||
caps := hostcaps{projects: projectSlot, projectAudit: &projectAuditSlot{}, st: st, pipe: pipe, set: setSvc, authz: authzSlot, surfaces: surfaceSvc}
|
||||
// EXT-13:回合内检索(knowledge_search / @knowledge 注入)与 HTTP/Host 检索面
|
||||
// 同一适配器授权;槽为共享指针,扩展在 applyCompiled 期间注册后即生效。
|
||||
mgr.SetSharedKnowledgeAllow(func(owner string, projectID core.ID) bool {
|
||||
|
||||
@@ -21,12 +21,13 @@ import (
|
||||
// 同一实现同时服务 hostapi.Server 的 Search/Complete 闭包与进程内
|
||||
// inprocHost(EXT-1 批注 1:复用现有 owner 作用域函数,不新造句柄)。
|
||||
type hostcaps struct {
|
||||
projects *projectLifecycleSlot
|
||||
st *store.Store
|
||||
pipe *rag.Pipeline
|
||||
set *settings.Service
|
||||
authz *projectAuthz // EXT-8 适配器槽(共享指针;首个注册者生效)
|
||||
surfaces *surfacesvc.Service // EXT-17 Surface 能力槽(nil = 未装配,明确拒绝)
|
||||
projects *projectLifecycleSlot
|
||||
projectAudit *projectAuditSlot
|
||||
st *store.Store
|
||||
pipe *rag.Pipeline
|
||||
set *settings.Service
|
||||
authz *projectAuthz // EXT-8 适配器槽(共享指针;首个注册者生效)
|
||||
surfaces *surfacesvc.Service // EXT-17 Surface 能力槽(nil = 未装配,明确拒绝)
|
||||
}
|
||||
|
||||
// projectAuthz EXT-8 资源授权适配器槽:进程内扩展注册,组合根装配进 Server。
|
||||
|
||||
@@ -0,0 +1,103 @@
|
||||
package app
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"sync"
|
||||
|
||||
"agent-system-backend/pkg/extension"
|
||||
core "agent-system-core"
|
||||
)
|
||||
|
||||
// projectAuditSlot binds the audit.read policy to its registering domain plugin.
|
||||
// A different plugin cannot borrow that policy through its own Host handle.
|
||||
type projectAuditSlot struct {
|
||||
mu sync.RWMutex
|
||||
holder string
|
||||
check extension.ProjectAuditAuthorizer
|
||||
}
|
||||
|
||||
func (s *projectAuditSlot) register(pluginID string, check extension.ProjectAuditAuthorizer) error {
|
||||
if pluginID == "" || check == nil {
|
||||
return extension.ErrProjectAuditUnsupported
|
||||
}
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
if s.check != nil {
|
||||
return errors.New("extension: project audit authorizer already registered")
|
||||
}
|
||||
s.holder, s.check = pluginID, check
|
||||
return nil
|
||||
}
|
||||
|
||||
func (r compiledRegistry) RegisterProjectAuditAuthorizer(extension.ProjectAuditAuthorizer) error {
|
||||
return errors.New("extension: declarative plugins cannot register project audit authorizers")
|
||||
}
|
||||
|
||||
func (r pluginRegistry) RegisterProjectAuditAuthorizer(check extension.ProjectAuditAuthorizer) error {
|
||||
if r.caps.projectAudit == nil {
|
||||
return extension.ErrProjectAuditUnsupported
|
||||
}
|
||||
return r.caps.projectAudit.register(r.pluginID, check)
|
||||
}
|
||||
|
||||
func (r recordRegistry) RegisterProjectAuditAuthorizer(check extension.ProjectAuditAuthorizer) error {
|
||||
reg, ok := r.Registry.(extension.ProjectAuditRegistry)
|
||||
if !ok {
|
||||
return extension.ErrProjectAuditUnsupported
|
||||
}
|
||||
return reg.RegisterProjectAuditAuthorizer(check)
|
||||
}
|
||||
|
||||
var _ extension.ProjectAuditHost = inprocHost{}
|
||||
|
||||
func (h inprocHost) QueryProjectAudit(ctx context.Context, q extension.ProjectAuditQuery) (extension.ProjectAuditPage, error) {
|
||||
if err := ctx.Err(); err != nil {
|
||||
return extension.ProjectAuditPage{}, err
|
||||
}
|
||||
if h.actor == "" || h.actor != h.owner || q.ProjectID == "" {
|
||||
return extension.ProjectAuditPage{}, extension.ErrProjectAuditDenied
|
||||
}
|
||||
if h.caps.projectAudit == nil || h.caps.st == nil {
|
||||
return extension.ProjectAuditPage{}, extension.ErrProjectAuditUnsupported
|
||||
}
|
||||
slot := h.caps.projectAudit
|
||||
slot.mu.RLock()
|
||||
holder, check := slot.holder, slot.check
|
||||
slot.mu.RUnlock()
|
||||
if holder != h.pluginID || check == nil {
|
||||
return extension.ProjectAuditPage{}, extension.ErrProjectAuditUnsupported
|
||||
}
|
||||
ok, err := check(h.actor, core.ID(q.ProjectID))
|
||||
if err != nil || !ok {
|
||||
return extension.ProjectAuditPage{}, extension.ErrProjectAuditDenied
|
||||
}
|
||||
if err := ctx.Err(); err != nil {
|
||||
return extension.ProjectAuditPage{}, err
|
||||
}
|
||||
rows, cutoff, next, err := h.caps.st.ListProjectCommandAudits(h.owner, string(q.ProjectID), q.BeforeOffset, q.CutoffOffset, q.Limit)
|
||||
if err != nil {
|
||||
return extension.ProjectAuditPage{}, err
|
||||
}
|
||||
page := extension.ProjectAuditPage{
|
||||
Receipts: make([]extension.HostAuditReceipt, 0, len(rows)),
|
||||
NextOffset: next, CutoffOffset: cutoff,
|
||||
Coverage: extension.ProjectAuditCoverage{
|
||||
CommandAudit: "actor-owned-project-bound-only", CrossMember: "unsupported", LegacyProjectBinding: "not-recorded", RunRelations: "unsupported", ModelReceipts: "unsupported",
|
||||
BackgroundReceipts: "unsupported", ResourceBodies: "unsupported",
|
||||
},
|
||||
}
|
||||
for _, row := range rows {
|
||||
nameStatus := "not-recorded"
|
||||
if row.ActorName != "" {
|
||||
nameStatus = "recorded"
|
||||
}
|
||||
page.Receipts = append(page.Receipts, extension.HostAuditReceipt{
|
||||
SourceOffset: row.Offset, Time: row.Envelope.Time, ActorID: row.Envelope.Actor,
|
||||
ActorName: row.ActorName, ActorNameStatus: nameStatus,
|
||||
CommandID: row.Envelope.CommandID, State: string(row.Envelope.State),
|
||||
OperationID: row.Envelope.OperationID, ConversationID: row.Envelope.ConversationID,
|
||||
})
|
||||
}
|
||||
return page, nil
|
||||
}
|
||||
@@ -0,0 +1,101 @@
|
||||
package app
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
agentcommand "agent-command"
|
||||
"agent-system-backend/internal/store"
|
||||
"agent-system-backend/pkg/extension"
|
||||
core "agent-system-core"
|
||||
)
|
||||
|
||||
func TestProjectAuditHostChecksDomainReadAndProjectsStoredReceipts(t *testing.T) {
|
||||
st, err := store.OpenSQLite(filepath.Join(t.TempDir(), "audit.db"))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer st.Close()
|
||||
for _, owner := range []string{"alice", "bob"} {
|
||||
if err := st.EnsureUser(owner, strings.ToUpper(owner)); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
a, err := st.CreateProject("alice", "A")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
b, err := st.CreateProject("alice", "B")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
ac, err := st.CreateConversation("alice", string(a.ID), "A", "")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
bc, err := st.CreateConversation("alice", string(b.ID), "B", "")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
shared, err := st.CreateConversationInProject("bob", string(a.ID), "A shared", "")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for _, item := range []struct{ owner, conversation, command string }{
|
||||
{"alice", string(ac.ID), "a.command"},
|
||||
{"alice", string(bc.ID), "b.command"},
|
||||
{"bob", string(shared.ID), "shared.command"},
|
||||
} {
|
||||
if err := st.AppendAudit(item.owner, agentcommand.AuditEnvelope{Time: time.Now(), Actor: item.owner, CommandID: item.command, ConversationID: item.conversation, State: agentcommand.StateCommitted, ParamsDigest: "sensitive-digest"}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
slot := &projectAuditSlot{}
|
||||
caps := hostcaps{st: st, projectAudit: slot}
|
||||
reg := pluginRegistry{pluginID: "rdm", caps: caps}
|
||||
allowed := true
|
||||
if err := reg.RegisterProjectAuditAuthorizer(func(actor string, projectID core.ID) (bool, error) {
|
||||
return allowed && actor == "bob" && projectID == a.ID, nil
|
||||
}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
h := inprocHost{caps: caps, owner: "bob", actor: "bob", pluginID: "rdm"}
|
||||
page, err := h.QueryProjectAudit(context.Background(), extension.ProjectAuditQuery{ProjectID: a.ID, Limit: 10})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(page.Receipts) != 1 || page.Receipts[0].CommandID != "shared.command" {
|
||||
t.Fatalf("A page: %+v", page)
|
||||
}
|
||||
if page.Coverage.CommandAudit != "actor-owned-project-bound-only" || page.Coverage.CrossMember != "unsupported" || page.Coverage.LegacyProjectBinding != "not-recorded" || page.Coverage.RunRelations != "unsupported" || page.Coverage.ModelReceipts != "unsupported" {
|
||||
t.Fatalf("coverage: %+v", page.Coverage)
|
||||
}
|
||||
if page.Receipts[0].ActorName != "BOB" {
|
||||
t.Fatalf("actor names: %+v", page.Receipts)
|
||||
}
|
||||
raw, err := json.Marshal(page)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if strings.Contains(string(raw), "sensitive-digest") || strings.Contains(string(raw), "paramsDigest") || strings.Contains(string(raw), "payload") {
|
||||
t.Fatalf("raw audit payload leaked: %s", raw)
|
||||
}
|
||||
if _, err := h.QueryProjectAudit(context.Background(), extension.ProjectAuditQuery{ProjectID: b.ID}); !errors.Is(err, extension.ErrProjectAuditDenied) {
|
||||
t.Fatalf("project B: %v", err)
|
||||
}
|
||||
if _, err := (inprocHost{caps: caps, owner: "bob", actor: "bob", pluginID: "other"}).QueryProjectAudit(context.Background(), extension.ProjectAuditQuery{ProjectID: a.ID}); !errors.Is(err, extension.ErrProjectAuditUnsupported) {
|
||||
t.Fatalf("other plugin: %v", err)
|
||||
}
|
||||
if _, err := (inprocHost{caps: caps, owner: "bob", pluginID: "rdm"}).QueryProjectAudit(context.Background(), extension.ProjectAuditQuery{ProjectID: a.ID}); !errors.Is(err, extension.ErrProjectAuditDenied) {
|
||||
t.Fatalf("unbound actor: %v", err)
|
||||
}
|
||||
allowed = false
|
||||
if _, err := h.QueryProjectAudit(context.Background(), extension.ProjectAuditQuery{ProjectID: a.ID, BeforeOffset: page.NextOffset, CutoffOffset: page.CutoffOffset}); !errors.Is(err, extension.ErrProjectAuditDenied) {
|
||||
t.Fatalf("revoked audit.read: %v", err)
|
||||
}
|
||||
}
|
||||
+60
-3
@@ -3,6 +3,7 @@ package store
|
||||
import (
|
||||
"database/sql"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"time"
|
||||
|
||||
agentcommand "agent-command"
|
||||
@@ -16,13 +17,69 @@ func (s *Store) AppendAudit(owner string, env agentcommand.AuditEnvelope) error
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
_, err = s.db.Exec(s.q(`INSERT INTO command_audit (time, command_id, source, entrance, state, operation_id, conversation_id, owner, payload)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`),
|
||||
_, err = s.db.Exec(s.q(`INSERT INTO command_audit (time, command_id, source, entrance, state, operation_id, conversation_id, owner, payload, actor_name, project_id)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, COALESCE((SELECT name FROM users WHERE owner=?), ''),
|
||||
COALESCE((SELECT project_id FROM conversations WHERE id=? AND owner=?), ''))`),
|
||||
env.Time.UTC().Format(time.RFC3339Nano), env.CommandID, string(env.Source), env.Entrance, string(env.State),
|
||||
env.OperationID, env.ConversationID, owner, string(raw))
|
||||
env.OperationID, env.ConversationID, owner, string(raw), env.Actor, env.ConversationID, owner)
|
||||
return err
|
||||
}
|
||||
|
||||
// ProjectCommandAuditRow is an internal command receipt. Callers must project
|
||||
// only approved fields; the envelope contains a digest and is not a public DTO.
|
||||
type ProjectCommandAuditRow struct {
|
||||
Offset int64
|
||||
ActorName string // empty on old rows or when no trusted account name was recorded
|
||||
Envelope agentcommand.AuditEnvelope
|
||||
}
|
||||
|
||||
// ListProjectCommandAudits reads the project resolved from a trusted conversation
|
||||
// at append time. Reassigning or deleting that conversation cannot rewrite the
|
||||
// original project. Legacy rows without this snapshot are excluded: current
|
||||
// project membership cannot establish their historical project attribution.
|
||||
// Only the authenticated owner can read this first private slice.
|
||||
func (s *Store) ListProjectCommandAudits(actorOwner, projectID string, before, cutoff int64, limit int) ([]ProjectCommandAuditRow, int64, int64, error) {
|
||||
if actorOwner == "" || projectID == "" || before < 0 || cutoff < 0 || (before != 0 && cutoff == 0) {
|
||||
return nil, 0, 0, errors.New("project audit: invalid scope or cursor")
|
||||
}
|
||||
if limit <= 0 || limit > 200 {
|
||||
limit = 50
|
||||
}
|
||||
if cutoff == 0 {
|
||||
if err := s.db.QueryRow(s.q(`SELECT COALESCE(MAX(id),0) FROM command_audit
|
||||
WHERE project_id=? AND owner=?`), projectID, actorOwner).Scan(&cutoff); err != nil {
|
||||
return nil, 0, 0, err
|
||||
}
|
||||
}
|
||||
rows, err := s.db.Query(s.q(`SELECT id, actor_name, payload FROM command_audit
|
||||
WHERE project_id=? AND owner=? AND id<=? AND (?=0 OR id<?)
|
||||
ORDER BY id DESC LIMIT ?`), projectID, actorOwner, cutoff, before, before, limit+1)
|
||||
if err != nil {
|
||||
return nil, 0, 0, err
|
||||
}
|
||||
defer rows.Close()
|
||||
out := make([]ProjectCommandAuditRow, 0, limit+1)
|
||||
for rows.Next() {
|
||||
var row ProjectCommandAuditRow
|
||||
var raw string
|
||||
if err := rows.Scan(&row.Offset, &row.ActorName, &raw); err != nil {
|
||||
return nil, 0, 0, err
|
||||
}
|
||||
if err := json.Unmarshal([]byte(raw), &row.Envelope); err != nil {
|
||||
return nil, 0, 0, err
|
||||
}
|
||||
out = append(out, row)
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
return nil, 0, 0, err
|
||||
}
|
||||
if len(out) <= limit {
|
||||
return out, cutoff, 0, nil
|
||||
}
|
||||
out = out[:limit]
|
||||
return out, cutoff, out[len(out)-1].Offset, nil
|
||||
}
|
||||
|
||||
// ListAudits 审计查询(owner 作用域,时间倒序;conversationID 空 = 该租户全局)。
|
||||
// owner 为空视为无作用域:不返回任何行(防调用方漏传租户时把未归属存量行
|
||||
// 全部泄出——服务端认证层恒注入非空 owner,这里是纵深防御)。
|
||||
|
||||
@@ -848,6 +848,15 @@ func init() {
|
||||
},
|
||||
down: map[string][]string{"sqlite": {`DROP TABLE IF EXISTS project_event_shares`}, "postgres": {`DROP TABLE IF EXISTS project_event_shares`}},
|
||||
},
|
||||
migration{version: 31, name: "command_audit_attribution_snapshot",
|
||||
up: map[string][]string{
|
||||
"sqlite": {`ALTER TABLE command_audit ADD COLUMN actor_name TEXT NOT NULL DEFAULT ''`, `ALTER TABLE command_audit ADD COLUMN project_id TEXT NOT NULL DEFAULT ''`, `CREATE INDEX idx_command_audit_project_owner_offset ON command_audit(project_id,owner,id)`},
|
||||
"postgres": {`ALTER TABLE command_audit ADD COLUMN actor_name TEXT NOT NULL DEFAULT ''`, `ALTER TABLE command_audit ADD COLUMN project_id TEXT NOT NULL DEFAULT ''`, `CREATE INDEX idx_command_audit_project_owner_offset ON command_audit(project_id,owner,id)`},
|
||||
},
|
||||
down: map[string][]string{
|
||||
"sqlite": {`DROP INDEX IF EXISTS idx_command_audit_project_owner_offset`, `ALTER TABLE command_audit DROP COLUMN project_id`, `ALTER TABLE command_audit DROP COLUMN actor_name`},
|
||||
"postgres": {`DROP INDEX IF EXISTS idx_command_audit_project_owner_offset`, `ALTER TABLE command_audit DROP COLUMN project_id`, `ALTER TABLE command_audit DROP COLUMN actor_name`},
|
||||
}},
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -35,6 +35,7 @@ var migrationBreaksOldReaders = map[int]bool{
|
||||
28: false, // auth_version_account_events:仅加列与新表,旧读者不受影响
|
||||
29: false, // redeem_attempts:新表,旧读者不受影响
|
||||
30: false, // project_event_publication_proofs:新表,旧读者不受影响
|
||||
31: false, // command_audit_attribution_snapshot:仅加列/索引,旧读者不受影响
|
||||
}
|
||||
|
||||
// SchemaTarget 是本二进制会迁移到的 schema 版本。
|
||||
|
||||
@@ -0,0 +1,174 @@
|
||||
package store
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
agentcommand "agent-command"
|
||||
)
|
||||
|
||||
func TestProjectCommandAuditsUseStoredConversationProjectAndFixedCutoff(t *testing.T) {
|
||||
st, err := OpenSQLite(filepath.Join(t.TempDir(), "audit.db"))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer st.Close()
|
||||
if err := st.EnsureUser("alice", "Alice at action time"); 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)
|
||||
}
|
||||
convA, err := st.CreateConversation("alice", string(projectA.ID), "A work", "")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
convB, err := st.CreateConversation("alice", string(projectB.ID), "B work", "")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
appendCommand := func(owner, conversation, command string) {
|
||||
t.Helper()
|
||||
if err := st.AppendAudit(owner, agentcommand.AuditEnvelope{Time: time.Now(), Actor: "alice", CommandID: command, ConversationID: conversation, State: agentcommand.StateCommitted, ParamsDigest: "private-digest"}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
appendCommand("alice", string(convA.ID), "a.one")
|
||||
appendCommand("alice", string(convB.ID), "b.one")
|
||||
appendCommand("alice", "client-supplied-only", "unbound")
|
||||
appendCommand("bob", string(convA.ID), "wrong-owner")
|
||||
appendCommand("alice", string(convA.ID), "a.two")
|
||||
var expectedCutoff int64
|
||||
if err := st.db.QueryRow(st.q(`SELECT id FROM command_audit WHERE command_id=?`), "a.two").Scan(&expectedCutoff); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
appendCommand("alice", string(convB.ID), "b.latest")
|
||||
|
||||
first, cutoff, next, err := st.ListProjectCommandAudits("alice", string(projectA.ID), 0, 0, 1)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(first) != 1 || first[0].Envelope.CommandID != "a.two" || first[0].ActorName != "Alice at action time" || next == 0 || cutoff != expectedCutoff {
|
||||
t.Fatalf("first page: rows=%+v cutoff=%d next=%d", first, cutoff, next)
|
||||
}
|
||||
appendCommand("alice", string(convA.ID), "a.late")
|
||||
second, fixed, next, err := st.ListProjectCommandAudits("alice", string(projectA.ID), next, cutoff, 1)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if fixed != cutoff || len(second) != 1 || second[0].Envelope.CommandID != "a.one" || next != 0 {
|
||||
t.Fatalf("second page: rows=%+v cutoff=%d next=%d", second, fixed, next)
|
||||
}
|
||||
other, _, _, err := st.ListProjectCommandAudits("alice", string(projectB.ID), 0, 0, 10)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(other) != 2 || other[0].Envelope.CommandID != "b.latest" || other[1].Envelope.CommandID != "b.one" {
|
||||
t.Fatalf("B rows: %+v", other)
|
||||
}
|
||||
unknown, _, _, err := st.ListProjectCommandAudits("alice", "untrusted-project", 0, 0, 10)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(unknown) != 0 {
|
||||
t.Fatalf("untrusted project rows: %+v", unknown)
|
||||
}
|
||||
}
|
||||
|
||||
func TestProjectCommandAuditKeepsActorNameSnapshot(t *testing.T) {
|
||||
st, err := OpenSQLite(filepath.Join(t.TempDir(), "audit.db"))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer st.Close()
|
||||
if err := st.EnsureUser("alice", "Former name"); 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), "A work", "")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := st.AppendAudit("alice", agentcommand.AuditEnvelope{Time: time.Now(), Actor: "alice", CommandID: "old", ConversationID: string(c.ID)}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := st.db.Exec(st.q(`UPDATE users SET name=? WHERE owner=?`), "Current name", "alice"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := st.AppendAudit("alice", agentcommand.AuditEnvelope{Time: time.Now(), Actor: "alice", CommandID: "new", ConversationID: string(c.ID)}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
legacy, err := json.Marshal(agentcommand.AuditEnvelope{Time: time.Now(), Actor: "alice", CommandID: "legacy", ConversationID: string(c.ID)})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := st.db.Exec(st.q(`INSERT INTO command_audit (time, command_id, source, entrance, state, operation_id, conversation_id, owner, payload) VALUES (?, ?, '', '', '', '', ?, ?, ?)`), time.Now().UTC().Format(time.RFC3339Nano), "legacy", string(c.ID), "alice", string(legacy)); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
rows, _, _, err := st.ListProjectCommandAudits("alice", string(p.ID), 0, 0, 10)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(rows) != 2 || rows[0].ActorName != "Current name" || rows[1].ActorName != "Former name" {
|
||||
t.Fatalf("names: %+v", rows)
|
||||
}
|
||||
var legacyProject string
|
||||
if err := st.db.QueryRow(st.q(`SELECT project_id FROM command_audit WHERE command_id=?`), "legacy").Scan(&legacyProject); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if legacyProject != "" {
|
||||
t.Fatalf("legacy attribution was fabricated: %q", legacyProject)
|
||||
}
|
||||
}
|
||||
|
||||
func TestProjectCommandAuditDoesNotMoveWithConversation(t *testing.T) {
|
||||
st, err := OpenSQLite(filepath.Join(t.TempDir(), "audit.db"))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer st.Close()
|
||||
a, err := st.CreateProject("alice", "A")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
b, err := st.CreateProject("alice", "B")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
c, err := st.CreateConversation("alice", string(a.ID), "work", "")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
appendCommand := func(id string) {
|
||||
t.Helper()
|
||||
if err := st.AppendAudit("alice", agentcommand.AuditEnvelope{Time: time.Now(), Actor: "alice", CommandID: id, ConversationID: string(c.ID)}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
appendCommand("before-move")
|
||||
if _, err := st.db.Exec(st.q(`UPDATE conversations SET project_id=? WHERE id=?`), string(b.ID), string(c.ID)); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
appendCommand("after-move")
|
||||
oldProject, _, _, err := st.ListProjectCommandAudits("alice", string(a.ID), 0, 0, 10)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
newProject, _, _, err := st.ListProjectCommandAudits("alice", string(b.ID), 0, 0, 10)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(oldProject) != 1 || oldProject[0].Envelope.CommandID != "before-move" || len(newProject) != 1 || newProject[0].Envelope.CommandID != "after-move" {
|
||||
t.Fatalf("project attribution changed: A=%+v B=%+v", oldProject, newProject)
|
||||
}
|
||||
}
|
||||
@@ -34,7 +34,9 @@ import (
|
||||
// 分区组件自身的命令面 fail-closed)。
|
||||
// 2.12.0-experimental: optional ProjectHost/ProjectLifecycleRegistry and trusted
|
||||
// ResourceAuthzQuery.Source for per-document publication authorization.
|
||||
const SurfaceVersion = "2.12.0-experimental"
|
||||
// 2.13.0-experimental: optional ProjectAuditHost and ProjectAuditRegistry (X2
|
||||
// project-bound command-audit read slice and domain audit.read callback).
|
||||
const SurfaceVersion = "2.13.0-experimental"
|
||||
|
||||
// ErrNotLinked 表示进程没有空白导入 link 包,Main 无法启动服务。
|
||||
var ErrNotLinked = errors.New("extension: runner not linked; blank-import agent-system-backend/pkg/extension/link")
|
||||
|
||||
@@ -0,0 +1,75 @@
|
||||
package extension
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"time"
|
||||
|
||||
core "agent-system-core"
|
||||
)
|
||||
|
||||
// ProjectAuditAuthorizer answers the domain's audit.read decision for the
|
||||
// authenticated actor and a project identified by trusted host storage.
|
||||
// A project ID supplied by a caller is a locator, never an authorization.
|
||||
type ProjectAuditAuthorizer func(actor string, projectID core.ID) (bool, error)
|
||||
|
||||
// ProjectAuditRegistry is optional so older extension registries remain valid.
|
||||
// Only an in-process domain plugin can register this callback.
|
||||
type ProjectAuditRegistry interface {
|
||||
RegisterProjectAuditAuthorizer(ProjectAuditAuthorizer) error
|
||||
}
|
||||
|
||||
var (
|
||||
ErrProjectAuditDenied = errors.New("extension: project audit denied")
|
||||
ErrProjectAuditUnsupported = errors.New("extension: project audit unsupported")
|
||||
)
|
||||
|
||||
// ProjectAuditQuery reads one project-bound command-audit slice. Offset is a
|
||||
// host source offset, not a cross-domain event sequence. A nonzero BeforeOffset
|
||||
// must be paired with the CutoffOffset returned on the first page.
|
||||
type ProjectAuditQuery struct {
|
||||
ProjectID core.ID `json:"projectId"`
|
||||
BeforeOffset int64 `json:"beforeOffset,omitempty"`
|
||||
CutoffOffset int64 `json:"cutoffOffset,omitempty"`
|
||||
Limit int `json:"limit,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.
|
||||
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"`
|
||||
}
|
||||
|
||||
// ProjectAuditCoverage states the exact scope of this first X2 slice. Missing
|
||||
// host sources and old unbound rows cannot be inferred from owner-wide audit.
|
||||
type ProjectAuditCoverage struct {
|
||||
CommandAudit string `json:"commandAudit"`
|
||||
CrossMember string `json:"crossMember"`
|
||||
LegacyProjectBinding string `json:"legacyProjectBinding"`
|
||||
RunRelations string `json:"runRelations"`
|
||||
ModelReceipts string `json:"modelReceipts"`
|
||||
BackgroundReceipts string `json:"backgroundReceipts"`
|
||||
ResourceBodies string `json:"resourceBodies"`
|
||||
}
|
||||
|
||||
type ProjectAuditPage struct {
|
||||
Receipts []HostAuditReceipt `json:"receipts"`
|
||||
NextOffset int64 `json:"nextOffset,omitempty"`
|
||||
CutoffOffset int64 `json:"cutoffOffset"`
|
||||
Coverage ProjectAuditCoverage `json:"coverage"`
|
||||
}
|
||||
|
||||
// ProjectAuditHost is an optional, actor-bound read capability. A consumer must
|
||||
// treat a failed type assertion as unsupported; an authorized page still reports
|
||||
// partial coverage explicitly.
|
||||
type ProjectAuditHost interface {
|
||||
QueryProjectAudit(context.Context, ProjectAuditQuery) (ProjectAuditPage, error)
|
||||
}
|
||||
@@ -8,6 +8,8 @@ AdminProvider.IsAdmin
|
||||
AdminZoneSpec
|
||||
Compiled
|
||||
ErrNotLinked
|
||||
ErrProjectAuditDenied
|
||||
ErrProjectAuditUnsupported
|
||||
Handler
|
||||
Host
|
||||
Host.ArtifactContent
|
||||
@@ -25,6 +27,7 @@ Host.ParseDocument
|
||||
Host.PluginID
|
||||
Host.TaskCreate
|
||||
Host.TaskUpdate
|
||||
HostAuditReceipt
|
||||
HostClient
|
||||
InProc
|
||||
IntentSpec
|
||||
@@ -32,6 +35,14 @@ Main
|
||||
NewHostClient
|
||||
NewScopedHost
|
||||
Options
|
||||
ProjectAuditAuthorizer
|
||||
ProjectAuditCoverage
|
||||
ProjectAuditHost
|
||||
ProjectAuditHost.QueryProjectAudit
|
||||
ProjectAuditPage
|
||||
ProjectAuditQuery
|
||||
ProjectAuditRegistry
|
||||
ProjectAuditRegistry.RegisterProjectAuditAuthorizer
|
||||
ProjectHost
|
||||
ProjectHost.EnsureProject
|
||||
ProjectHost.OwnedProject
|
||||
|
||||
Reference in New Issue
Block a user