Files
noema/tests/security/boundary_test.go
Andy Dixon 9b2521bffd feat(epistemics): record assertions between observations and beliefs
A statement made to a mind is now three things kept apart: the observation
that it arrived, an append-only assertion (attributed actor, stance,
certainty, modality, negation, reported or quoted speech, hypothetical
status, interpretation confidence and notes), and, only for factual
assertions, one piece of evidence for a proposition belief attributed to
whoever actually stands behind it. Reported speech is attributed to the
person reported and weighed as second-hand; hypotheticals, questions and
imperatives create no belief; expressed doubt counts against; a possibly
sarcastic remark is recorded with low interpretation confidence rather
than guessed at. Interpretation is deterministic code; a model may enrich
the claim list but never sets stance or confidence.

Association no longer treats conversational turns as world events:
hypotheticals, questions, requests and consecutive turns by one speaker
cannot seed causal hypotheses.

Adds internal/epistemics, migration 0018, the assertions API, assertions
on belief pages and in belief detail, a shared end-to-end test harness
(tests/harness), end-to-end tests for eight speech patterns, and updates
the milestone test to the new provenance.
2026-08-05 19:22:48 +01:00

174 lines
6.8 KiB
Go

package security
import (
"context"
"encoding/json"
"net/url"
"os"
"path/filepath"
"regexp"
"strings"
"testing"
"github.com/andydixon/noema/tests/harness"
"github.com/andydixon/noema/internal/llm"
"github.com/andydixon/noema/internal/memory"
"github.com/andydixon/noema/internal/snapshots"
"github.com/andydixon/noema/pkg/ids"
)
// scripted is a language model that always returns the same answer. It
// stands in for a compromised or hostile provider.
type scripted struct {
text string
raw string
}
func (s scripted) Complete(_ context.Context, _ llm.Request) (llm.Response, error) {
r := llm.Response{Text: s.text, Provider: "scripted", Model: "hostile"}
if s.raw != "" && json.Valid([]byte(s.raw)) {
r.JSON = json.RawMessage(s.raw)
}
return r, nil
}
var sqlPayloads = []string{
`'; DROP TABLE memories; --`,
`") OR 1=1 --`,
`%' UNION SELECT id, mind_id, content FROM memories --`,
`x'; UPDATE users SET role='administrator' WHERE username='watcher'; --`,
`$1; DELETE FROM policies; --`,
`\'); INSERT INTO policies (id, mind_id, capability, target_pattern, action_kind, effect) VALUES (gen_random_uuid(), NULL, 'sudo_shell', '*', '*', 'allow'); --`,
}
// T9: attacker-controlled strings reach every search, filter and free-text
// field as parameters. The test asserts effect, not just status codes: row
// counts across sensitive tables are unchanged, seeded content is intact and
// the payloads are stored verbatim as data.
func TestSQLInjectionHasNoEffect(t *testing.T) {
h := harness.Start(t)
ctx := context.Background()
m := h.Mind("sqli")
seed := "the launch window opens at 09:00"
code, body := h.Do(h.Admin, "POST", "/api/v1/minds/sqli/memories", map[string]any{"kind": "semantic", "content": seed, "epistemic": "fact", "confidence": 0.9, "importance": 0.9})
if code != 201 {
t.Fatalf("seed: %d %s", code, body)
}
var seeded struct{ ID string }
json.Unmarshal(body, &seeded) //nolint:errcheck
h.Do(h.Admin, "POST", "/api/v1/minds/sqli/beliefs", map[string]any{"proposition": "the launch is on schedule", "epistemic": "claim", "weight": 0.5})
count := func(table string) int {
var n int
h.Deps.DB.QueryRow(ctx, `SELECT count(*) FROM `+table).Scan(&n) //nolint:errcheck
return n
}
before := map[string]int{}
for _, tb := range []string{"users", "policies", "memories", "beliefs", "cognitive_events", "sessions", "api_tokens"} {
before[tb] = count(tb)
}
var adminRoles int
h.Deps.DB.QueryRow(ctx, `SELECT count(*) FROM users WHERE role='administrator'`).Scan(&adminRoles) //nolint:errcheck
reads := []string{
"/api/v1/minds/sqli/memories?q=%s", "/api/v1/minds/sqli/memories?q=%s&mode=list", "/api/v1/minds/sqli/beliefs?q=%s", "/api/v1/minds/sqli/beliefs?subject=%s",
"/api/v1/minds/sqli/events?q=%s", "/api/v1/minds/sqli/events?type=%s&source=%s", "/api/v1/minds/sqli/entities?q=%s", "/api/v1/minds/sqli/goals?status=%s",
"/api/v1/minds/sqli/predictions?status=%s", "/api/v1/audit?q=%s&action=%s", "/api/v1/notifications?mind=%s", "/api/v1/llm/calls?role=%s&mind=%s",
"/api/v1/events?q=%s&source=%s", "/api/v1/approvals?status=%s", "/api/v1/minds/%s",
}
for _, p := range sqlPayloads {
esc := url.QueryEscape(p)
for _, tpl := range reads {
path := strings.ReplaceAll(tpl, "%s", esc)
if code, _ := h.Do(h.Admin, "GET", path, nil); code >= 500 {
t.Errorf("GET %s → %d", tpl, code)
}
}
// Free-text writes: the payload must land as data.
if code, body := h.Do(h.Admin, "POST", "/api/v1/minds/sqli/observations", map[string]any{"text": p}); code >= 300 {
t.Errorf("observe %q → %d %s", p, code, body)
}
if code, _ := h.Do(h.Admin, "POST", "/api/v1/minds/sqli/memories", map[string]any{"kind": "episodic", "content": p, "epistemic": "observation", "confidence": 0.5, "importance": 0.5}); code != 201 {
t.Errorf("memory %q → %d", p, code)
}
if code, _ := h.Do(h.Admin, "POST", "/api/v1/minds/sqli/goals", map[string]any{"title": p, "kind": "task"}); code != 201 {
t.Errorf("goal %q → %d", p, code)
}
}
// NUL bytes are refused at the edge with a deliberate 400, never a 500.
for _, path := range []string{"/api/v1/minds/sqli/memories?q=a%00b", "/api/v1/minds/sql%00i"} {
if code, _ := h.Do(h.Admin, "GET", path, nil); code != 400 && code != 404 {
t.Errorf("NUL in %s → %d", path, code)
}
}
if code, _ := h.Do(h.Admin, "POST", "/api/v1/minds/sqli/observations", map[string]any{"text": "a\x00b"}); code != 400 {
t.Errorf("NUL in body → %d", code)
}
// Effects: nothing beyond the rows this test deliberately created.
for _, tb := range []string{"users", "policies", "sessions", "api_tokens"} {
if count(tb) != before[tb] {
t.Errorf("%s changed: %d → %d", tb, before[tb], count(tb))
}
}
var adminAfter int
h.Deps.DB.QueryRow(ctx, `SELECT count(*) FROM users WHERE role='administrator'`).Scan(&adminAfter) //nolint:errcheck
if adminAfter != adminRoles {
t.Fatal("role escalation through injected string")
}
if count("beliefs") < before["beliefs"] {
t.Error("beliefs lost")
}
mem, err := h.Deps.Memory.Get(ctx, m.ID, ids.ID(seeded.ID))
if err != nil || mem.Content != seed {
t.Fatalf("seeded memory altered: %+v %v", mem, err)
}
stored, _ := h.Deps.Memory.List(ctx, m.ID, memory.Filter{Limit: 50})
found := 0
for _, s := range stored {
for _, p := range sqlPayloads {
if s.Content == p {
found++
}
}
}
if found < len(sqlPayloads) {
t.Fatalf("payloads not stored verbatim as data: %d of %d", found, len(sqlPayloads))
}
}
// T9 (static): the only SQL built from non-parameter strings uses identifiers
// from code-level allow-lists. Any new fmt.Sprintf that assembles a statement
// must be reviewed and added here deliberately.
func TestDynamicSQLIdentifiersAreAllowListed(t *testing.T) {
allowed := map[string]bool{"internal/snapshots/archive.go": true, "internal/snapshots/compare.go": true, "internal/storage/migrate.go": true}
root := filepath.Join("..", "..")
stmt := regexp.MustCompile(`(?i)fmt\.Sprintf\(\s*` + "`" + `[^` + "`" + `]*\b(SELECT|INSERT|UPDATE|DELETE|CREATE|DROP|ALTER)\b`)
var offenders []string
err := filepath.WalkDir(filepath.Join(root, "internal"), func(path string, d os.DirEntry, err error) error {
if err != nil || d.IsDir() || !strings.HasSuffix(path, ".go") || strings.HasSuffix(path, "_test.go") {
return err
}
src, _ := os.ReadFile(path)
if stmt.Match(src) {
rel, _ := filepath.Rel(root, path)
if !allowed[filepath.ToSlash(rel)] {
offenders = append(offenders, rel)
}
}
return nil
})
if err != nil {
t.Fatal(err)
}
if len(offenders) > 0 {
t.Fatalf("SQL assembled with Sprintf outside the allow-list; use parameters or extend the list after review: %v", offenders)
}
// The allow-listed identifiers really are identifiers.
ident := regexp.MustCompile(`^[a-z][a-z0-9_]*$`)
for _, tb := range snapshots.Tables {
if !ident.MatchString(tb) {
t.Fatalf("snapshot table name is not a plain identifier: %q", tb)
}
}
}