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
+8 -3
View File
@@ -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
}
+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}$`)
// 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 {
+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 {
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))
}