Skip to main content

kimetsu_brain/
trust.rs

1//! v2.6: how much a memory's origin is worth.
2//!
3//! Every memory in the brain has been treated as equally believable regardless
4//! of where it came from — a lesson you typed yourself, one a model distilled
5//! from a transcript, and one that arrived in a pack downloaded from a URL all
6//! rank on relevance and usefulness alone.
7//!
8//! That is the shape of a real attack. Memory poisoning (OWASP ASI06) differs
9//! from prompt injection in exactly the way that matters here: prompt injection
10//! is session-scoped and resets, while a poisoned memory persists and
11//! influences every future session until someone notices. MINJA
12//! (arXiv 2601.05504) reports >95% injection success against memory-backed
13//! agents through ordinary, unprivileged interaction — no elevated access, just
14//! conversation that induces the agent to write something.
15//!
16//! Kimetsu's exposure is narrower than a hosted service's — the brain is a
17//! local file — but it is not zero, and it grows with exactly the features that
18//! make the product good: `brain import` from a URL, `brain sync` across
19//! machines, and Kimetsu Remote's shared org brain.
20//!
21//! ## What this module does
22//!
23//! It scores *origin*, and nothing else. A [`Provenance`] read off the memory's
24//! stored snapshot maps to a [`trust_multiplier`] the broker folds into the
25//! composite score, so a local lesson outranks an anonymous
26//! imported one at equal relevance.
27//!
28//! Two deliberate limits:
29//!
30//! * **It never blocks retrieval.** Trust is a weight, not a gate. A hard gate
31//!   on provenance would make a bad pack import silently delete a user's
32//!   working knowledge, which is a worse failure than the one it prevents.
33//! * **Reliance is not verification.** Citations and successful-run association
34//!   never remove an origin penalty. No explicit verification channel exists.
35//!
36//! ## Not done here
37//!
38//! Quarantining imports — routing pack memories into the proposal queue instead
39//! of accepting them outright — is the mechanism that actually stops a poisoned
40//! pack from influencing anything before a human looks at it, and it is
41//! deliberately not in this module: it changes what `brain import` *does*, which
42//! belongs in the import path rather than behind a scoring weight. It lives in
43//! [`crate::packs::quarantine_memories`], on by default for `http(s)://`
44//! sources. This module stays the answer for *history* — a pack imported before
45//! quarantine existed is discounted by origin rather than pulled back out of
46//! retrieval, because reaching into a working brain on an upgrade is a worse
47//! failure than the one quarantine prevents.
48
49use serde::{Deserialize, Serialize};
50
51/// Where a memory came from.
52///
53/// Ordered least to most trusted, so the enum's ordering *is* the trust
54/// ordering and the two cannot drift apart.
55#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)]
56#[serde(rename_all = "snake_case")]
57pub enum Provenance {
58    /// Arrived in a pack (`brain import`), possibly from a URL. The widest
59    /// attack surface Kimetsu has: content authored elsewhere, by someone
60    /// else, landing in the brain wholesale.
61    Pack,
62    /// Replicated from another machine or a shared org brain (`brain sync`,
63    /// Kimetsu Remote). Authored by someone with access, which is a real
64    /// constraint, but not by this user on this machine.
65    Remote,
66    /// Written by the model — the session-end distiller or reflection. The
67    /// content is derived from a real transcript, but no human read it before
68    /// it became durable, and a transcript can contain anything the agent was
69    /// shown.
70    Distilled,
71    /// Consolidation output: a staple or merge of memories already in the
72    /// brain. Inherits its members' standing and adds no new claims.
73    Derived,
74    /// Recorded by the user, or by an agent acting on the user's instruction,
75    /// on this machine.
76    Local,
77}
78
79impl Provenance {
80    pub fn as_str(self) -> &'static str {
81        match self {
82            Provenance::Pack => "pack",
83            Provenance::Remote => "remote",
84            Provenance::Distilled => "distilled",
85            Provenance::Derived => "derived",
86            Provenance::Local => "local",
87        }
88    }
89
90    /// Classify a memory's stored `provenance_snapshot_json`.
91    ///
92    /// Unrecognised or missing provenance reads as [`Provenance::Local`]: every
93    /// memory written before this module existed has a snapshot this does not
94    /// know, and treating an existing brain's entire contents as untrusted on
95    /// upgrade would be a far worse outcome than the attack being defended.
96    pub fn from_snapshot(snapshot_json: &str) -> Self {
97        let Ok(value) = serde_json::from_str::<serde_json::Value>(snapshot_json) else {
98            return Provenance::Local;
99        };
100        let source = value
101            .get("source")
102            .and_then(serde_json::Value::as_str)
103            .unwrap_or("");
104        match source {
105            "pack" => Provenance::Pack,
106            "remote" | "sync" | "org" => Provenance::Remote,
107            "distiller" | "distilled" | "reflection" => Provenance::Distilled,
108            "staple" | "merge" | "consolidation" => Provenance::Derived,
109            _ => Provenance::Local,
110        }
111    }
112}
113
114/// Multiplier applied to a candidate's composite score.
115///
116/// The second argument is retained for API compatibility and represents observed
117/// successful-run association. It does not verify the proposition.
118pub fn trust_multiplier(provenance: Provenance, _associated: bool) -> f32 {
119    match provenance {
120        Provenance::Local | Provenance::Derived => 1.0,
121        Provenance::Distilled => 0.95,
122        Provenance::Remote => 0.90,
123        Provenance::Pack => 0.85,
124    }
125}
126
127// ── Audit ───────────────────────────────────────────────────────────────────
128
129/// One provenance class in the audit report.
130#[derive(Debug, Clone, Serialize, Deserialize)]
131pub struct ProvenanceGroup {
132    pub provenance: String,
133    pub total: usize,
134    /// Observed successful-run association; not verification.
135    pub associated: usize,
136    /// From an external origin without explicit verification: the population a
137    /// poisoned memory would be hiding in.
138    pub unvetted: usize,
139}
140
141/// A suspicious burst of writes.
142///
143/// Memory poisoning through ordinary interaction tends to arrive as a cluster —
144/// MINJA's technique is to induce several related writes in a short window. A
145/// human recording lessons does not usually produce thirty memories in a
146/// minute; a pack import or a runaway loop does. This flags the shape without
147/// claiming to know intent.
148#[derive(Debug, Clone, Serialize, Deserialize)]
149pub struct WriteBurst {
150    /// RFC 3339 minute the burst falls in.
151    pub minute: String,
152    pub writes: usize,
153}
154
155/// The audit report.
156#[derive(Debug, Clone, Serialize, Deserialize)]
157pub struct AuditReport {
158    pub groups: Vec<ProvenanceGroup>,
159    pub bursts: Vec<WriteBurst>,
160    /// Total active memories considered.
161    pub total: usize,
162}
163
164/// Writes in one minute above which a cluster is worth a look.
165pub const BURST_THRESHOLD: usize = 20;
166
167/// Group the active corpus by provenance and flag write bursts.
168///
169/// Read-only and non-destructive by design: this reports, and a human decides.
170/// An automated purge keyed on a heuristic like "many writes in one minute"
171/// would delete a legitimate bulk import, which is a worse outcome than the
172/// attack it is guarding against.
173pub fn audit(conn: &rusqlite::Connection) -> kimetsu_core::KimetsuResult<AuditReport> {
174    use std::collections::BTreeMap;
175
176    let mut stmt = conn.prepare(
177        "SELECT provenance_snapshot_json, last_useful_at, created_at
178         FROM memories
179         WHERE invalidated_at IS NULL AND superseded_by IS NULL",
180    )?;
181    let rows = stmt
182        .query_map([], |row| {
183            Ok((
184                row.get::<_, Option<String>>(0)?,
185                row.get::<_, Option<String>>(1)?,
186                row.get::<_, String>(2)?,
187            ))
188        })?
189        .collect::<Result<Vec<_>, _>>()?;
190
191    let mut by_provenance: BTreeMap<Provenance, (usize, usize)> = BTreeMap::new();
192    let mut by_minute: BTreeMap<String, usize> = BTreeMap::new();
193    for (snapshot, last_useful_at, created_at) in &rows {
194        let provenance = Provenance::from_snapshot(snapshot.as_deref().unwrap_or("{}"));
195        let entry = by_provenance.entry(provenance).or_insert((0, 0));
196        entry.0 += 1;
197        if last_useful_at.is_some() {
198            entry.1 += 1;
199        }
200        // RFC 3339 truncated to the minute: "2026-07-24T20:31".
201        let minute: String = created_at.chars().take(16).collect();
202        *by_minute.entry(minute).or_insert(0) += 1;
203    }
204
205    let groups = by_provenance
206        .into_iter()
207        .map(|(provenance, (total, corroborated))| ProvenanceGroup {
208            provenance: provenance.as_str().to_string(),
209            total,
210            associated: corroborated,
211            unvetted: if provenance >= Provenance::Derived {
212                0 // local and derived memories have no external origin to vet
213            } else {
214                total
215            },
216        })
217        .collect();
218
219    let mut bursts: Vec<WriteBurst> = by_minute
220        .into_iter()
221        .filter(|(_, writes)| *writes >= BURST_THRESHOLD)
222        .map(|(minute, writes)| WriteBurst { minute, writes })
223        .collect();
224    bursts.sort_by_key(|b| std::cmp::Reverse(b.writes));
225
226    Ok(AuditReport {
227        groups,
228        bursts,
229        total: rows.len(),
230    })
231}
232
233#[cfg(test)]
234mod tests {
235    use super::*;
236
237    #[test]
238    fn the_enum_ordering_is_the_trust_ordering() {
239        assert!(Provenance::Pack < Provenance::Remote);
240        assert!(Provenance::Remote < Provenance::Distilled);
241        assert!(Provenance::Distilled < Provenance::Derived);
242        assert!(Provenance::Derived < Provenance::Local);
243
244        // …and the multipliers agree with it, so the two cannot drift apart.
245        let m = |p| trust_multiplier(p, false);
246        assert!(m(Provenance::Pack) < m(Provenance::Remote));
247        assert!(m(Provenance::Remote) < m(Provenance::Distilled));
248        assert!(m(Provenance::Distilled) <= m(Provenance::Derived));
249        assert_eq!(m(Provenance::Local), 1.0);
250    }
251
252    /// The upgrade-safety property: an existing brain full of memories written
253    /// before provenance was a concept must not become untrusted overnight.
254    #[test]
255    fn unknown_provenance_reads_as_local() {
256        assert_eq!(Provenance::from_snapshot("{}"), Provenance::Local);
257        assert_eq!(Provenance::from_snapshot("not json"), Provenance::Local);
258        assert_eq!(
259            Provenance::from_snapshot(r#"{"source":"manual_cli"}"#),
260            Provenance::Local
261        );
262        assert_eq!(
263            Provenance::from_snapshot(r#"{"source":"something-from-the-future"}"#),
264            Provenance::Local
265        );
266    }
267
268    #[test]
269    fn known_sources_classify() {
270        for (json, expected) in [
271            (r#"{"source":"pack"}"#, Provenance::Pack),
272            (r#"{"source":"sync"}"#, Provenance::Remote),
273            (r#"{"source":"org"}"#, Provenance::Remote),
274            (r#"{"source":"distiller"}"#, Provenance::Distilled),
275            (r#"{"source":"staple"}"#, Provenance::Derived),
276        ] {
277            assert_eq!(Provenance::from_snapshot(json), expected, "for {json}");
278        }
279    }
280
281    #[test]
282    fn hardening_citation_retains_origin_penalty() {
283        for provenance in [Provenance::Pack, Provenance::Remote, Provenance::Distilled] {
284            assert_eq!(
285                trust_multiplier(provenance, true),
286                trust_multiplier(provenance, false)
287            );
288            assert!(trust_multiplier(provenance, true) < 1.0);
289        }
290    }
291
292    /// Trust holds a memory back; it never promotes one. Two mechanisms that
293    /// both boost would make the composite score unreadable.
294    #[test]
295    fn trust_never_exceeds_one() {
296        for provenance in [
297            Provenance::Pack,
298            Provenance::Remote,
299            Provenance::Distilled,
300            Provenance::Derived,
301            Provenance::Local,
302        ] {
303            for corroborated in [false, true] {
304                let m = trust_multiplier(provenance, corroborated);
305                assert!(
306                    m > 0.0 && m <= 1.0,
307                    "{provenance:?} corroborated={corroborated} gave {m}"
308                );
309            }
310        }
311    }
312
313    // ── Audit ────────────────────────────────────────────────────────────
314
315    fn audit_conn() -> rusqlite::Connection {
316        let conn = rusqlite::Connection::open_in_memory().expect("open");
317        crate::schema::initialize(&conn).expect("schema");
318        conn
319    }
320
321    fn insert(conn: &rusqlite::Connection, id: &str, source: &str, corroborated: bool, at: &str) {
322        conn.execute(
323            "INSERT INTO memories
324             (memory_id, scope, kind, text, normalized_text, confidence,
325              provenance_snapshot_json, created_at, last_useful_at)
326             VALUES (?1, 'project', 'fact', ?1, ?1, 0.9, ?2, ?3, ?4)",
327            rusqlite::params![
328                id,
329                format!(r#"{{"source":"{source}"}}"#),
330                at,
331                corroborated.then(|| at.to_string()),
332            ],
333        )
334        .expect("insert");
335    }
336
337    /// External memories remain unvetted even after observed association. Local memories are not "unvetted" — there is no external
338    /// origin to vet.
339    #[test]
340    fn audit_counts_the_unvetted_external_population() {
341        let conn = audit_conn();
342        insert(
343            &conn,
344            "local-1",
345            "manual_cli",
346            false,
347            "2026-01-01T00:00:00Z",
348        );
349        insert(&conn, "pack-1", "pack", false, "2026-01-01T00:00:00Z");
350        insert(&conn, "pack-2", "pack", true, "2026-01-01T00:00:00Z");
351        insert(
352            &conn,
353            "distilled-1",
354            "distiller",
355            false,
356            "2026-01-01T00:00:00Z",
357        );
358
359        let report = audit(&conn).expect("audit");
360        assert_eq!(report.total, 4);
361
362        let group = |name: &str| {
363            report
364                .groups
365                .iter()
366                .find(|g| g.provenance == name)
367                .unwrap_or_else(|| panic!("missing group {name}"))
368                .clone()
369        };
370        assert_eq!(group("local").unvetted, 0, "nothing external to vet");
371        assert_eq!(group("pack").total, 2);
372        assert_eq!(group("pack").associated, 1);
373        assert_eq!(group("pack").unvetted, 2, "association is not verification");
374        assert_eq!(group("distilled").unvetted, 1);
375    }
376
377    #[test]
378    fn audit_flags_a_write_burst_and_ignores_ordinary_writing() {
379        let conn = audit_conn();
380        // A human recording lessons across the day.
381        for i in 0..10 {
382            insert(
383                &conn,
384                &format!("slow-{i}"),
385                "manual_cli",
386                false,
387                &format!("2026-01-01T{:02}:00:00Z", i),
388            );
389        }
390        assert!(
391            audit(&conn).expect("audit").bursts.is_empty(),
392            "ordinary writing is not a burst"
393        );
394
395        // …and a cluster that arrived faster than anyone types.
396        for i in 0..BURST_THRESHOLD {
397            insert(
398                &conn,
399                &format!("burst-{i}"),
400                "pack",
401                false,
402                "2026-02-02T03:04:00Z",
403            );
404        }
405        let bursts = audit(&conn).expect("audit").bursts;
406        assert_eq!(bursts.len(), 1, "got: {bursts:?}");
407        assert_eq!(bursts[0].minute, "2026-02-02T03:04");
408        assert_eq!(bursts[0].writes, BURST_THRESHOLD);
409    }
410
411    #[test]
412    fn audit_of_an_empty_brain_is_empty_not_an_error() {
413        let report = audit(&audit_conn()).expect("audit");
414        assert_eq!(report.total, 0);
415        assert!(report.groups.is_empty());
416        assert!(report.bursts.is_empty());
417    }
418}