From f715d7500bec1cfc2b3e0a06a018863d612470c8 Mon Sep 17 00:00:00 2001 From: Andy Dixon Date: Thu, 16 Jul 2026 20:23:18 +0100 Subject: [PATCH] security(import): never import policy rules from archives An archive is data about a mind, not a grant of permissions on this instance. Import now skips the policies table (forks on the same instance keep theirs), reports the omitted rows in the audit log and the mind's history, and the snapshot test proves both paths. --- docs/snapshots-replay.md | 2 +- internal/api/study.go | 11 ++++-- internal/snapshots/archive.go | 58 ++++++++++++++++++++++------ internal/snapshots/snapshots_test.go | 12 +++++- 4 files changed, 66 insertions(+), 17 deletions(-) diff --git a/docs/snapshots-replay.md b/docs/snapshots-replay.md index e58de1f..371c667 100644 --- a/docs/snapshots-replay.md +++ b/docs/snapshots-replay.md @@ -5,7 +5,7 @@ A **snapshot** is a JSON archive of a mind's state at a point in its history: identity, personality, configuration, workspace and working memory, drives and affect, goals, beliefs (with evidence, versions, alternatives, links), memories -(with relationships), predictions, entities, corrections, policies and +(with relationships), predictions, entities, corrections, policy rules (kept by forks, never imported from archives) and conversations. It records the event sequence number at capture and a SHA-256 checksum. Cognitive events are **not** copied. diff --git a/internal/api/study.go b/internal/api/study.go index 2462d57..02cd6f9 100644 --- a/internal/api/study.go +++ b/internal/api/study.go @@ -4,6 +4,7 @@ import ( "context" "encoding/json" "errors" + "fmt" "net/http" "strconv" @@ -190,15 +191,19 @@ func (s *Server) ImportArchive(ctx context.Context, raw []byte, slug, display, b if err := dec.Decode(&a); err != nil { return minds.Mind{}, ValidationError("invalid archive: " + err.Error()) } - m, err := s.deps.Snapshots.Import(ctx, a, snapshots.ForkOptions{Slug: slug, DisplayName: display, By: by, Notes: "imported archive of " + a.Source.Slug}) + m, omitted, err := s.deps.Snapshots.ImportReport(ctx, a, snapshots.ForkOptions{Slug: slug, DisplayName: display, By: by, Notes: "imported archive of " + a.Source.Slug}) if err != nil { if errors.Is(err, snapshots.ErrInvalid) { return m, ValidationError(err.Error()) } return m, mindErr(err) } - s.deps.Audit.Log(ctx, "mind.import", "mind", m.ID.String(), map[string]any{"source": a.Source.Slug, "checksum": a.Checksum, "counts": a.Counts()}) - s.deps.Events.OperatorAction(ctx, m.ID, by, "mind.import", "Mind imported from archive of "+a.Source.Slug+" ("+a.Checksum+")", map[string]any{"counts": a.Counts()}) + s.deps.Audit.Log(ctx, "mind.import", "mind", m.ID.String(), map[string]any{"source": a.Source.Slug, "checksum": a.Checksum, "counts": a.Counts(), "omitted": omitted}) + summary := "Mind imported from archive of " + a.Source.Slug + " (" + a.Checksum + ")" + if n := omitted["policies"]; n > 0 { + summary += fmt.Sprintf("; %d policy rule(s) in the archive were not imported: policies are set by this instance's operators", n) + } + s.deps.Events.OperatorAction(ctx, m.ID, by, "mind.import", summary, map[string]any{"counts": a.Counts(), "omitted": omitted}) s.deps.Brain.Reconcile(ctx) //nolint:errcheck return m, nil } diff --git a/internal/snapshots/archive.go b/internal/snapshots/archive.go index bf62c75..2a33590 100644 --- a/internal/snapshots/archive.go +++ b/internal/snapshots/archive.go @@ -248,13 +248,21 @@ func contains(xs []string, x string) bool { var uuidRe = regexp.MustCompile(`^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$`) +// AuthorityTables hold rows that grant or shape authority rather than +// describe cognitive state. They are copied by Fork (same operator, same +// instance) but never by Import: an archive from elsewhere must not be able +// to arrive carrying ALLOW rules for a mind (THREAT_MODEL T16). +var AuthorityTables = map[string]bool{"policies": true} + // Restore writes an archive's rows into mind `into`. Every row id found in // the archive gets a fresh id and all references to it (any uuid-shaped // string anywhere in the rows, including arrays and JSON detail) follow. // References to ids not in the archive (events, templates) are kept: that // is how history stays shared by reference. Must run in the caller's -// transaction after the mind row exists. -func Restore(ctx context.Context, tx pgx.Tx, cols map[string][]string, a Archive, into ids.ID) error { +// transaction after the mind row exists. Tables named in skip are not +// written; the returned map reports how many rows were omitted per table. +func Restore(ctx context.Context, tx pgx.Tx, cols map[string][]string, a Archive, into ids.ID, skip map[string]bool) (map[string]int, error) { + omitted := map[string]int{} remap := map[string]string{a.Source.MindID.String(): into.String()} for _, t := range Tables { for _, r := range a.Tables[t] { @@ -270,9 +278,13 @@ func Restore(ctx context.Context, tx pgx.Tx, cols map[string][]string, a Archive if len(rows) == 0 { continue } + if skip[t] { + omitted[t] = len(rows) + continue + } cs := cols[t] if len(cs) == 0 { - return fmt.Errorf("%w: table %s not present", ErrInvalid, t) + return omitted, fmt.Errorf("%w: table %s not present", ErrInvalid, t) } conflict := "" if key, ok := keyedByMind[t]; ok { @@ -291,11 +303,11 @@ func Restore(ctx context.Context, tx pgx.Tx, cols map[string][]string, a Archive row["mind_id"] = into.String() b, _ := json.Marshal(row) if _, err := tx.Exec(ctx, sql, b); err != nil { - return fmt.Errorf("restore %s: %w", t, err) + return omitted, fmt.Errorf("restore %s: %w", t, err) } } } - return nil + return omitted, nil } func remapValue(v any, remap map[string]string) any { @@ -420,6 +432,10 @@ type ForkOptions struct { Config *minds.CognitiveConfig Personality *minds.Personality By string + // OmitAuthority skips AuthorityTables (policies). Import sets it; Fork does not. + OmitAuthority bool + // Omitted is filled by the restore with the rows skipped per table. + Omitted map[string]int } // Fork creates a new mind from a snapshot. The new mind's state equals the @@ -430,7 +446,7 @@ func (s *Store) Fork(ctx context.Context, snapshotID ids.ID, o ForkOptions) (min if err != nil { return minds.Mind{}, err } - m, err := s.restoreNew(ctx, a, o) + m, err := s.restoreNew(ctx, a, &o) if err != nil { return m, err } @@ -441,14 +457,26 @@ func (s *Store) Fork(ctx context.Context, snapshotID ids.ID, o ForkOptions) (min } // Import validates a hostile archive and restores it into a new mind. +// Authority-bearing tables are never imported: an archive is data about a +// mind, not a grant of permissions on this instance. The number of skipped +// rows is reported in o.Omitted when the caller passes a pointer-free copy; +// use ImportReport to receive it. func (s *Store) Import(ctx context.Context, a Archive, o ForkOptions) (minds.Mind, error) { - if err := Validate(a); err != nil { - return minds.Mind{}, err - } - return s.restoreNew(ctx, a, o) + m, _, err := s.ImportReport(ctx, a, o) + return m, err } -func (s *Store) restoreNew(ctx context.Context, a Archive, o ForkOptions) (minds.Mind, error) { +// ImportReport is Import that also returns the rows omitted per table. +func (s *Store) ImportReport(ctx context.Context, a Archive, o ForkOptions) (minds.Mind, map[string]int, error) { + if err := Validate(a); err != nil { + return minds.Mind{}, nil, err + } + o.OmitAuthority = true + m, err := s.restoreNew(ctx, a, &o) + return m, o.Omitted, err +} + +func (s *Store) restoreNew(ctx context.Context, a Archive, o *ForkOptions) (minds.Mind, error) { cfg := a.Mind.Config if o.Config != nil { cfg = *o.Config @@ -477,8 +505,14 @@ func (s *Store) restoreNew(ctx context.Context, a Archive, o ForkOptions) (minds if err != nil { return m, err } + var skip map[string]bool + if o.OmitAuthority { + skip = AuthorityTables + } err = storage.Tx(ctx, s.DB, func(tx pgx.Tx) error { - if err := Restore(ctx, tx, cols, a, m.ID); err != nil { + omitted, err := Restore(ctx, tx, cols, a, m.ID, skip) + o.Omitted = omitted + if err != nil { return err } if o.Status != "" && o.Status != minds.StatusActive { diff --git a/internal/snapshots/snapshots_test.go b/internal/snapshots/snapshots_test.go index 5ef56e0..c2f26c0 100644 --- a/internal/snapshots/snapshots_test.go +++ b/internal/snapshots/snapshots_test.go @@ -43,6 +43,10 @@ func TestSnapshotForkImportCompare(t *testing.T) { Evidence: []beliefs.EvidenceInput{{Direction: "for", Kind: "memory", RefID: string(m1.ID), Source: "user", Weight: 0.5, Credibility: 0.9}}}); err != nil { t.Fatal(err) } + // An ALLOW rule travels with a fork (same operator) but never with an import. + if _, err := pool.Exec(ctx, `INSERT INTO policies (id, mind_id, capability, target_pattern, action_kind, effect, reason, created_by) VALUES ($1,$2,'http.fetch','*','*','allow','test','andy')`, ids.New(), src.ID); err != nil { + t.Fatal(err) + } st := &Store{DB: pool, Events: es, Minds: ms} sn, err := st.Create(ctx, src, "before", "test snapshot", "andy") if err != nil { @@ -110,10 +114,16 @@ func TestSnapshotForkImportCompare(t *testing.T) { if _, err := st.Import(ctx, hostile, ForkOptions{Slug: "bad"}); err == nil { t.Fatal("unknown table accepted") } - imp, err := st.Import(ctx, a, ForkOptions{Slug: "imported", By: "andy"}) + imp, omitted, err := st.ImportReport(ctx, a, ForkOptions{Slug: "imported", By: "andy"}) if err != nil { t.Fatal(err) } + var forkPolicies, importPolicies int + pool.QueryRow(ctx, `SELECT count(*) FROM policies WHERE mind_id=$1`, fork.ID).Scan(&forkPolicies) //nolint:errcheck + pool.QueryRow(ctx, `SELECT count(*) FROM policies WHERE mind_id=$1`, imp.ID).Scan(&importPolicies) //nolint:errcheck + if forkPolicies != 1 || importPolicies != 0 || omitted["policies"] != 1 { + t.Fatalf("policy handling: fork=%d import=%d omitted=%v", forkPolicies, importPolicies, omitted) + } if im, _ := mem.List(ctx, imp.ID, memory.Filter{Limit: 10}); len(im) != 2 { t.Fatalf("import memories: %d", len(im)) }