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.
This commit is contained in:
Andy Dixon
2026-07-16 20:23:18 +01:00
parent a91b7ccea2
commit f715d7500b
4 changed files with 66 additions and 17 deletions
+1 -1
View File
@@ -5,7 +5,7 @@
A **snapshot** is a JSON archive of a mind's state at a point in its history: 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 identity, personality, configuration, workspace and working memory, drives and
affect, goals, beliefs (with evidence, versions, alternatives, links), memories 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 conversations. It records the event sequence number at capture and a SHA-256
checksum. Cognitive events are **not** copied. checksum. Cognitive events are **not** copied.
+8 -3
View File
@@ -4,6 +4,7 @@ import (
"context" "context"
"encoding/json" "encoding/json"
"errors" "errors"
"fmt"
"net/http" "net/http"
"strconv" "strconv"
@@ -190,15 +191,19 @@ func (s *Server) ImportArchive(ctx context.Context, raw []byte, slug, display, b
if err := dec.Decode(&a); err != nil { if err := dec.Decode(&a); err != nil {
return minds.Mind{}, ValidationError("invalid archive: " + err.Error()) 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 err != nil {
if errors.Is(err, snapshots.ErrInvalid) { if errors.Is(err, snapshots.ErrInvalid) {
return m, ValidationError(err.Error()) return m, ValidationError(err.Error())
} }
return m, mindErr(err) 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.Audit.Log(ctx, "mind.import", "mind", m.ID.String(), map[string]any{"source": a.Source.Slug, "checksum": a.Checksum, "counts": a.Counts(), "omitted": omitted})
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()}) 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 s.deps.Brain.Reconcile(ctx) //nolint:errcheck
return m, nil return m, nil
} }
+46 -12
View File
@@ -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}$`) 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 // 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 // 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. // string anywhere in the rows, including arrays and JSON detail) follow.
// References to ids not in the archive (events, templates) are kept: that // 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 // is how history stays shared by reference. Must run in the caller's
// transaction after the mind row exists. // transaction after the mind row exists. Tables named in skip are not
func Restore(ctx context.Context, tx pgx.Tx, cols map[string][]string, a Archive, into ids.ID) error { // 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()} remap := map[string]string{a.Source.MindID.String(): into.String()}
for _, t := range Tables { for _, t := range Tables {
for _, r := range a.Tables[t] { 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 { if len(rows) == 0 {
continue continue
} }
if skip[t] {
omitted[t] = len(rows)
continue
}
cs := cols[t] cs := cols[t]
if len(cs) == 0 { 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 := "" conflict := ""
if key, ok := keyedByMind[t]; ok { 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() row["mind_id"] = into.String()
b, _ := json.Marshal(row) b, _ := json.Marshal(row)
if _, err := tx.Exec(ctx, sql, b); err != nil { 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 { func remapValue(v any, remap map[string]string) any {
@@ -420,6 +432,10 @@ type ForkOptions struct {
Config *minds.CognitiveConfig Config *minds.CognitiveConfig
Personality *minds.Personality Personality *minds.Personality
By string 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 // 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 { if err != nil {
return minds.Mind{}, err return minds.Mind{}, err
} }
m, err := s.restoreNew(ctx, a, o) m, err := s.restoreNew(ctx, a, &o)
if err != nil { if err != nil {
return m, err 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. // 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) { func (s *Store) Import(ctx context.Context, a Archive, o ForkOptions) (minds.Mind, error) {
if err := Validate(a); err != nil { m, _, err := s.ImportReport(ctx, a, o)
return minds.Mind{}, err return m, err
}
return s.restoreNew(ctx, a, o)
} }
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 cfg := a.Mind.Config
if o.Config != nil { if o.Config != nil {
cfg = *o.Config cfg = *o.Config
@@ -477,8 +505,14 @@ func (s *Store) restoreNew(ctx context.Context, a Archive, o ForkOptions) (minds
if err != nil { if err != nil {
return m, err return m, err
} }
var skip map[string]bool
if o.OmitAuthority {
skip = AuthorityTables
}
err = storage.Tx(ctx, s.DB, func(tx pgx.Tx) error { 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 return err
} }
if o.Status != "" && o.Status != minds.StatusActive { if o.Status != "" && o.Status != minds.StatusActive {
+11 -1
View File
@@ -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 { Evidence: []beliefs.EvidenceInput{{Direction: "for", Kind: "memory", RefID: string(m1.ID), Source: "user", Weight: 0.5, Credibility: 0.9}}}); err != nil {
t.Fatal(err) 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} st := &Store{DB: pool, Events: es, Minds: ms}
sn, err := st.Create(ctx, src, "before", "test snapshot", "andy") sn, err := st.Create(ctx, src, "before", "test snapshot", "andy")
if err != nil { if err != nil {
@@ -110,10 +114,16 @@ func TestSnapshotForkImportCompare(t *testing.T) {
if _, err := st.Import(ctx, hostile, ForkOptions{Slug: "bad"}); err == nil { if _, err := st.Import(ctx, hostile, ForkOptions{Slug: "bad"}); err == nil {
t.Fatal("unknown table accepted") 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 { if err != nil {
t.Fatal(err) 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 { if im, _ := mem.List(ctx, imp.ID, memory.Filter{Limit: 10}); len(im) != 2 {
t.Fatalf("import memories: %d", len(im)) t.Fatalf("import memories: %d", len(im))
} }