memoryd: refuse and flag memory writes through bxcore::sanitize

Co-Authored-By: ornith-1.5-35b-a3b <agent@wntrmute.dev>
This commit is contained in:
2026-09-27 19:08:42 -07:00
co-authored by ornith-1.5-35b-a3b
parent b3e9af7586
commit ca8b50cd69
15 changed files with 1001 additions and 249 deletions
+103 -17
View File
@@ -1,12 +1,16 @@
//! Applying a `memory` call (M5a spec, section 5). `note` records experience in any session;
//! the identity actions change `memory.md` or `user.md`, and only a session that has not read
//! untrusted content may use them. Every refusal is a fixed sentence: it reaches the model.
//!
//! Every text a write would store goes through the sanitizer (M5b spec, section 7): a text that
//! breaks a refuse rule is refused; one that matches a flag rule is kept, one tier lower, as a
//! note marked untrusted (section 6.3).
use std::collections::HashSet;
use bxcore::sanitize::{Refusal, flag, refuse};
use bxcore::{DataClass, MemoryArgs, MemoryFile, Timestamp};
use crate::check::check;
use crate::entries;
use crate::files::{Store, grouped};
use crate::notes::{Kind, Note, minute};
@@ -37,6 +41,8 @@ pub struct Outcome {
pub action: &'static str,
pub answer: String,
pub change: Change,
/// The flag rule a text of the write matched, which made it an untrusted note.
pub flag: Option<&'static str>,
}
/// The sentence for a write over a file's limit.
@@ -80,9 +86,42 @@ fn stale(file: MemoryFile) -> String {
)
}
fn checked(text: &str) -> Result<String, String> {
check(text).map_err(|refused| refused.sentence().to_string())?;
Ok(text.trim().to_string())
/// The answer when an identity write matched a flag rule and became notes (M5b spec, section 6.3).
pub fn flagged_as_note(file: MemoryFile, rule: &str) -> String {
format!(
"not added to {}: it matches the rule {rule}, so it was saved in notes.md as an untrusted note",
file.name()
)
}
/// The answer to a `note` that matched a flag rule, from a session that has not read untrusted
/// content.
pub fn noted_flagged(rule: &str) -> String {
format!("saved in notes.md, marked untrusted: it matches the rule {rule}")
}
/// The texts `args` would store: the `text` of an add, a replace or a note, and every `text` of a
/// consolidate (its rewrites and new entries). An `old` is not here: it names what is stored.
fn texts(args: &MemoryArgs) -> Vec<&str> {
match args {
MemoryArgs::Add { text, .. }
| MemoryArgs::Replace { text, .. }
| MemoryArgs::Note { text } => vec![text.as_str()],
MemoryArgs::Remove { .. } => Vec::new(),
MemoryArgs::Consolidate { entries, .. } => {
entries.iter().filter_map(|e| e.text.as_deref()).collect()
}
}
}
/// The refusal of the first text `args` would store that breaks a refuse rule.
pub fn refused(args: &MemoryArgs) -> Option<Refusal> {
texts(args).into_iter().find_map(refuse)
}
/// The rule of the first text `args` would store that matches a flag rule.
pub fn flagged(args: &MemoryArgs) -> Option<&'static str> {
texts(args).into_iter().find_map(flag).map(|f| f.rule)
}
fn note(ctx: &Context, kind: Kind, text: &str) -> Note {
@@ -98,6 +137,10 @@ fn note(ctx: &Context, kind: Kind, text: &str) -> Note {
pub fn apply(store: &Store, args: &MemoryArgs, ctx: &Context) -> Result<Outcome, String> {
// 1. `args.check()?`.
args.check()?;
// 1a. A text that breaks a refuse rule: Err(refusal.sentence), before anything is written.
if let Some(refusal) = refused(args) {
return Err(refusal.sentence.to_string());
}
// 2. A Note: return `noted(store, ctx, text, "note")`. Otherwise take the file.
let file = match args {
MemoryArgs::Note { text } => {
@@ -126,6 +169,11 @@ pub fn apply(store: &Store, args: &MemoryArgs, ctx: &Context) -> Result<Outcome,
// &before, args, ctx)?;`.
let before = store.entries(file)?;
let (action, after, moved) = edited(file, &before, args, ctx)?;
// 4a. A text that matches a flag rule: `as_notes` saves every text of the write as an
// untrusted note instead, and the file is not changed.
if let Some(rule) = flagged(args) {
return as_notes(store, ctx, file, action, &texts(args), rule);
}
// 5. When `after` holds the same entry twice (collect into a HashSet and compare lengths):
// Err(format!("{} would hold the same entry twice", file.name())).
let unique: HashSet<&String> = after.iter().collect();
@@ -160,6 +208,7 @@ pub fn apply(store: &Store, args: &MemoryArgs, ctx: &Context) -> Result<Outcome,
after,
notes: moved,
},
flag: None,
})
}
@@ -171,7 +220,7 @@ fn edited(
) -> Result<(&'static str, Vec<String>, Vec<Note>), String> {
match args {
MemoryArgs::Add { text, .. } => {
let text = checked(text)?;
let text = text.trim().to_string();
if before.contains(&text) {
return Err(format!("{} already has this entry", file.name()));
}
@@ -181,7 +230,7 @@ fn edited(
}
MemoryArgs::Replace { old, text, .. } => {
let pos = position(before, old).ok_or_else(|| stale(file))?;
let text = checked(text)?;
let text = text.trim().to_string();
if before
.iter()
.enumerate()
@@ -221,30 +270,67 @@ fn noted(
text: &str,
action: &'static str,
) -> Result<Outcome, String> {
let text = checked(text)?;
let rule = flag(text).map(|f| f.rule);
let entry = note(
ctx,
Kind::Noted {
class: ctx.taint,
untrusted: ctx.untrusted,
untrusted: ctx.untrusted || rule.is_some(),
},
&text,
text.trim(),
);
store.append("notes.md", &entry.render())?;
let answer = match (ctx.untrusted, rule) {
(true, _) => NOTED_UNTRUSTED.to_string(),
(false, Some(rule)) => noted_flagged(rule),
(false, None) => NOTED.to_string(),
};
Ok(Outcome {
action,
answer: if ctx.untrusted {
NOTED_UNTRUSTED
} else {
NOTED
}
.to_string(),
answer,
change: Change {
file: None,
before: Vec::new(),
after: Vec::new(),
notes: vec![entry],
},
flag: rule,
})
}
/// An identity write that matched the flag rule `rule`: each of `texts`, trimmed, is appended to
/// `notes.md` as a note marked untrusted, in order, and the identity file is not changed.
fn as_notes(
store: &Store,
ctx: &Context,
file: MemoryFile,
action: &'static str,
texts: &[&str],
rule: &'static str,
) -> Result<Outcome, String> {
let mut notes = Vec::new();
for text in texts {
let entry = note(
ctx,
Kind::Noted {
class: ctx.taint,
untrusted: true,
},
text.trim(),
);
store.append("notes.md", &entry.render())?;
notes.push(entry);
}
Ok(Outcome {
action,
answer: flagged_as_note(file, rule),
change: Change {
file: None,
before: Vec::new(),
after: Vec::new(),
notes,
},
flag: Some(rule),
})
}
@@ -285,7 +371,7 @@ fn consolidate(
}
bxcore::EntryAction::Rewrite { old, text } => {
claim(file, before, &mut named, &old)?;
after.push(checked(&text)?);
after.push(text.trim().to_string());
}
bxcore::EntryAction::Move(old) => {
claim(file, before, &mut named, &old)?;
@@ -296,7 +382,7 @@ fn consolidate(
out.push(note(ctx, Kind::Dropped(file), old.trim()));
}
bxcore::EntryAction::New(text) => {
after.push(checked(&text)?);
after.push(text.trim().to_string());
}
}
}