Skip to main content

wm_memory/
redact.rs

1//! Store-wide credential redaction retrofit.
2//!
3//! `wm ingest --redact` redacts at ingest time, but content written before
4//! that flag existed (or by other write paths) can carry credential-shaped
5//! spans. A ledger entry makes the source file "unchanged", so a re-ingest
6//! cannot re-scrub it: the stored rows themselves must be rewritten.
7//!
8//! This pass mirrors [`crate::reindex::repair_content`]: matching rows are
9//! rewritten under the SAME id (content + recomputed content_hash) through
10//! `MemoryStore::put`, chained into the revision history as a content change,
11//! and delete-then-add reindexed. `apply = false` reports without writing.
12//!
13//! The caller must hold the writer lock (no writable serve on the store);
14//! a fresh `wm backup` before applying is the operator's responsibility.
15//!
16//! Note (2026-09-21 reviewer finding): rewrites scrub the live records in
17//! every lane — galaxy rows and the episodic raw records that mirror explicit
18//! memories. LMDB is copy-on-write, so a *freed* page can retain pre-rewrite
19//! bytes until the allocator reuses it; reads and search never consult freed
20//! pages, so nothing is retrievable once this pass reports clean. For
21//! byte-level absence on disk, back the store up and restore into a fresh
22//! store.
23
24// The writer must stay alive for the whole pass (one commit at the end);
25// tightening its drop scope mid-scan is exactly what the lint suggests and
26// exactly what would release the index lock early.
27#![allow(clippy::significant_drop_tightening)]
28
29use std::collections::BTreeMap;
30
31use serde::{Deserialize, Serialize};
32
33use crate::credentials::redact_credential_content;
34use crate::{MemoryStore, SearchEngine};
35use wm_core::{Galaxy, Result};
36
37/// Per-galaxy redaction outcome.
38#[derive(Debug, Default, Clone, Serialize, Deserialize)]
39pub struct GalaxyRedactStats {
40    pub galaxy: String,
41    pub scanned: usize,
42    pub redacted: usize,
43    pub already_clean: usize,
44    pub filtered_out: usize,
45}
46
47/// Aggregate redaction outcome.
48#[derive(Debug, Default, Clone, Serialize, Deserialize)]
49pub struct RedactReport {
50    pub scanned: usize,
51    pub redacted: usize,
52    pub already_clean: usize,
53    pub filtered_out: usize,
54    /// Credential kind → number of memories in which it fired.
55    pub kinds: BTreeMap<String, usize>,
56    pub galaxies: Vec<GalaxyRedactStats>,
57}
58
59/// Redact credential-shaped spans across `galaxies`.
60///
61/// When `tag_filter` is set, only memories carrying that exact tag are
62/// considered (all others count as `filtered_out`). When `apply` is false,
63/// the pass classifies and reports without writing or reindexing anything.
64///
65/// # Errors
66/// Propagates store/index errors; LMDB rows commit per-put and the index
67/// commits once at the end (take a backup before applying).
68pub fn redact_store_content(
69    store: &MemoryStore,
70    search: &SearchEngine,
71    galaxies: &[Galaxy],
72    tag_filter: Option<&str>,
73    apply: bool,
74) -> Result<RedactReport> {
75    let mut report = RedactReport::default();
76    let mut writer = if apply { Some(search.writer()?) } else { None };
77
78    for galaxy in galaxies {
79        let mut stats = GalaxyRedactStats {
80            galaxy: galaxy.db_name().to_string(),
81            ..Default::default()
82        };
83
84        for mut mem in store.scan_all(*galaxy)? {
85            stats.scanned += 1;
86
87            if let Some(tag) = tag_filter {
88                if !mem.metadata.tags.iter().any(|t| t == tag) {
89                    stats.filtered_out += 1;
90                    continue;
91                }
92            }
93
94            let (scrubbed, kinds) = redact_credential_content(&mem.content);
95            if kinds.is_empty() {
96                stats.already_clean += 1;
97                continue;
98            }
99
100            stats.redacted += 1;
101            for kind in kinds {
102                *report.kinds.entry(kind.to_string()).or_default() += 1;
103            }
104
105            let Some(writer) = writer.as_mut() else {
106                continue;
107            };
108
109            let old_hash = mem.metadata.content_hash.clone();
110            mem.content = scrubbed;
111            mem.metadata.content_hash = crate::content_hash(&mem.content);
112            mem.metadata.revision_count = mem.metadata.revision_count.saturating_add(1);
113            store.put(*galaxy, &mem)?;
114            store.record_revision(
115                *galaxy,
116                mem.metadata.id,
117                &old_hash,
118                &mem.metadata.content_hash,
119                crate::revision::RevisionActor {
120                    session: None,
121                    user: Some("wm-redact-content".to_string()),
122                    compartment: None,
123                },
124            )?;
125            // Explicit memories are mirrored into the episodic raw lane; the
126            // galaxy row alone is not the whole store. Without this the
127            // original bytes survived in data.mdb even though the galaxy row
128            // read clean (2026-09-21 reviewer finding).
129            store
130                .episodic()
131                .replace_content(mem.metadata.id, &mem.content)?;
132            let id_str = mem.metadata.id.to_string();
133            search.delete_document(writer, &id_str)?;
134            search.add_document(
135                writer,
136                &id_str,
137                galaxy.db_name(),
138                &mem.content,
139                &mem.metadata.tags,
140                mem.metadata.created_at.timestamp(),
141            )?;
142        }
143
144        report.scanned += stats.scanned;
145        report.redacted += stats.redacted;
146        report.already_clean += stats.already_clean;
147        report.filtered_out += stats.filtered_out;
148        report.galaxies.push(stats);
149    }
150
151    if let Some(mut writer) = writer {
152        search.commit(&mut writer)?;
153    }
154
155    Ok(report)
156}
157
158#[cfg(test)]
159mod tests {
160    use super::*;
161    use crate::Memory;
162    use tempfile::tempdir;
163
164    fn setup() -> (tempfile::TempDir, MemoryStore, SearchEngine) {
165        let tmp = tempdir().unwrap();
166        let store = MemoryStore::open_default(tmp.path()).unwrap();
167        let tantivy_dir = tmp.path().join("tantivy");
168        std::fs::create_dir_all(&tantivy_dir).unwrap();
169        let search = SearchEngine::open(&tantivy_dir).unwrap();
170        (tmp, store, search)
171    }
172
173    fn put_tagged(
174        store: &MemoryStore,
175        search: &SearchEngine,
176        galaxy: Galaxy,
177        content: &str,
178        tags: &[&str],
179    ) -> uuid::Uuid {
180        let mut mem = Memory::new(galaxy, content.to_string());
181        mem.metadata.tags = tags.iter().map(std::string::ToString::to_string).collect();
182        mem.metadata.content_hash = crate::content_hash(content);
183        let id = mem.metadata.id;
184        store.put(galaxy, &mem).unwrap();
185        let mut writer = search.writer().unwrap();
186        search
187            .add_document(
188                &mut writer,
189                &id.to_string(),
190                galaxy.db_name(),
191                content,
192                &mem.metadata.tags,
193                mem.metadata.created_at.timestamp(),
194            )
195            .unwrap();
196        search.commit(&mut writer).unwrap();
197        id
198    }
199
200    #[test]
201    fn dry_run_reports_without_writing() {
202        let (_tmp, store, search) = setup();
203        let secret = "db password=correct-horse-battery-staple";
204        let id = put_tagged(&store, &search, Galaxy::Research, secret, &["source:test"]);
205
206        let report =
207            redact_store_content(&store, &search, &[Galaxy::Research], None, false).unwrap();
208        assert_eq!(report.redacted, 1);
209        assert_eq!(report.already_clean, 0);
210        assert!(report.kinds.contains_key("credential_assignment"));
211
212        let mem = store.get(Galaxy::Research, id).unwrap().unwrap();
213        assert_eq!(mem.content, secret, "dry run must not write");
214        assert_eq!(mem.metadata.revision_count, 0);
215    }
216
217    #[test]
218    fn apply_redacts_in_place_reindexes_and_is_idempotent() {
219        let (_tmp, store, search) = setup();
220        put_tagged(
221            &store,
222            &search,
223            Galaxy::Research,
224            "db password=correct-horse-battery-staple",
225            &["source:test"],
226        );
227        put_tagged(
228            &store,
229            &search,
230            Galaxy::Research,
231            "harmless notes about password rotation policy",
232            &["source:test"],
233        );
234
235        let report =
236            redact_store_content(&store, &search, &[Galaxy::Research], None, true).unwrap();
237        assert_eq!(report.redacted, 1);
238        assert_eq!(report.already_clean, 1);
239
240        let mems = store.scan_all(Galaxy::Research).unwrap();
241        let redacted = mems
242            .iter()
243            .find(|m| m.content.contains("[REDACTED:credential_assignment]"))
244            .expect("redacted memory must exist");
245        assert!(!redacted.content.contains("correct-horse-battery-staple"));
246        assert_eq!(redacted.metadata.revision_count, 1);
247        let clean = mems
248            .iter()
249            .find(|m| m.content.contains("harmless notes"))
250            .expect("clean memory must survive untouched");
251        assert_eq!(clean.metadata.revision_count, 0);
252
253        let hits = search.search("REDACTED", 5).unwrap();
254        assert!(!hits.is_empty(), "reindex must expose the redacted marker");
255
256        // Second pass is a no-op.
257        let second =
258            redact_store_content(&store, &search, &[Galaxy::Research], None, true).unwrap();
259        assert_eq!(second.redacted, 0);
260        assert_eq!(second.already_clean, 2);
261    }
262
263    /// 2026-09-21 reviewer P0 store-level regression: after an apply pass the
264    /// fixture secret bytes must occur nowhere in LMDB and must not be
265    /// searchable; the redaction markers are.
266    #[test]
267    fn reviewer_fixtures_are_absent_from_lmdb_and_index_after_apply() {
268        let (_tmp, store, search) = setup();
269        let token_secret = "zq8x5v3p1k9r2m4n6w7a2s4d8";
270        let db_password = "m4n6p8q2r9t3v5w7y9b3c5f7";
271        let content = format!(
272            "secrets.txt\nTOKEN={token_secret}\n\
273             DATABASE_URL=postgres://alice:{db_password}@db.example.com/prod"
274        );
275        let id = put_tagged(
276            &store,
277            &search,
278            Galaxy::Research,
279            &content,
280            &["source:secrets"],
281        );
282        // Mirror the explicit-memory capture path: an episodic raw record
283        // with the same id carries the same bytes, and the retrofit must
284        // scrub that lane too (2026-09-21 review: the galaxy row read clean
285        // while data.mdb still held the secret in the raw record).
286        let episodic = wm_core::EpisodicRecord::new(
287            None,
288            0,
289            wm_core::EpisodicKind::Observation,
290            content,
291            wm_core::Provenance::new(wm_core::ProvenanceSource::Agent),
292        )
293        .with_id(id);
294        store.episodic().append(&episodic).unwrap();
295
296        let report =
297            redact_store_content(&store, &search, &[Galaxy::Research], None, true).unwrap();
298        assert_eq!(report.redacted, 1);
299        assert!(report.kinds.contains_key("credential_assignment"));
300        assert!(report.kinds.contains_key("credential_uri"));
301
302        for mem in store.scan_all(Galaxy::Research).unwrap() {
303            assert!(
304                !mem.content.contains(token_secret),
305                "LMDB still holds the token"
306            );
307            assert!(
308                !mem.content.contains(db_password),
309                "LMDB still holds the DB password"
310            );
311            assert!(
312                crate::credential_shaped_content(&mem.content).is_empty(),
313                "stored content must read clean: {}",
314                mem.content
315            );
316        }
317        let raw = store.episodic().get(id).unwrap().unwrap();
318        assert!(
319            !raw.content.contains(token_secret) && !raw.content.contains(db_password),
320            "episodic raw lane still holds the secret: {}",
321            raw.content
322        );
323        assert!(crate::credential_shaped_content(&raw.content).is_empty());
324        assert!(
325            store
326                .episodic()
327                .search(token_secret, 5, false)
328                .unwrap()
329                .is_empty(),
330            "episodic search must not match the old secret"
331        );
332        assert!(
333            search.search(token_secret, 5).unwrap().is_empty(),
334            "token must not be searchable after redaction"
335        );
336        assert!(
337            search.search(db_password, 5).unwrap().is_empty(),
338            "password must not be searchable after redaction"
339        );
340        assert!(
341            !search.search("REDACTED", 5).unwrap().is_empty(),
342            "redaction markers must be searchable"
343        );
344
345        // The store pass is idempotent: a second apply finds nothing.
346        let second =
347            redact_store_content(&store, &search, &[Galaxy::Research], None, true).unwrap();
348        assert_eq!(second.redacted, 0);
349        assert_eq!(second.already_clean, 1);
350    }
351
352    #[test]
353    fn tag_filter_scopes_the_retrofit() {
354        let (_tmp, store, search) = setup();
355        put_tagged(
356            &store,
357            &search,
358            Galaxy::Sessions,
359            "token sk-proj0123456789abcdefghijklmnopqrstuv",
360            &["source:harvest-a"],
361        );
362        put_tagged(
363            &store,
364            &search,
365            Galaxy::Sessions,
366            "token sk-projabcdefghijklmnopqrstuv0123456789",
367            &["source:harvest-b"],
368        );
369
370        let report = redact_store_content(
371            &store,
372            &search,
373            &[Galaxy::Sessions],
374            Some("source:harvest-a"),
375            true,
376        )
377        .unwrap();
378        assert_eq!(report.scanned, 2);
379        assert_eq!(report.filtered_out, 1);
380        assert_eq!(report.redacted, 1);
381
382        let mems = store.scan_all(Galaxy::Sessions).unwrap();
383        let untouched = mems
384            .iter()
385            .find(|m| m.metadata.tags.iter().any(|t| t == "source:harvest-b"))
386            .expect("untagged memory must survive");
387        assert!(
388            untouched
389                .content
390                .contains("sk-projabcdefghijklmnopqrstuv0123456789")
391        );
392    }
393}