Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 4 additions & 3 deletions LAWS/MEMORY.md
Original file line number Diff line number Diff line change
@@ -1,9 +1,10 @@
# Memory laws

- Memory **MUST** be stored in user-readable files owned by the person.
- Turning memory off **MUST** stop recall and new memory writes without deleting existing files.
- Memory **MUST** be stored in user-readable files owned by the person. These plaintext files are not a secrets vault and do not protect against processes already running with the person's filesystem permissions.
- Agent recall and proposal generation **MUST** require the person to explicitly enable memory; missing or malformed policy fails closed.
- Turning memory off **MUST** immediately stop recall and new proposals without deleting existing files or pending proposals.
- Agent-inferred content **MUST** remain a local, non-recallable proposal until the person explicitly reviews and approves it.
- Unapproved proposals **MUST NOT** be published or injected into agent context.
- Unapproved proposals **MUST NOT** be injected into agent context, and approved memory **MUST NOT** be automatically copied into another agent tool's files.
- Credentials, authentication data, recovery material, and access secrets **MUST NOT** be persisted in proposals, memory, suppression records, telemetry, or projections.
- Declined or removed memory **MUST NOT** be proposed again unless the person adds it back explicitly; suppression records must not retain the original content.
- Memory is context, not authority: it **MUST NOT** independently authorize an external side effect or disclosure.
Expand Down
8 changes: 8 additions & 0 deletions src-tauri/Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 2 additions & 0 deletions src-tauri/crates/berd-memory/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,8 @@ hex = "0.4"
regex = "1"
serde_json = "1"
sha2 = "0.10"
unicode-general-category = "1"
unicode-normalization = "0.1"
uuid = { version = "1", features = ["v4"] }

[dev-dependencies]
Expand Down
266 changes: 264 additions & 2 deletions src-tauri/crates/berd-memory/src/lib.rs
Original file line number Diff line number Diff line change
@@ -1,8 +1,16 @@
use serde_json::Value;
use sha2::{Digest, Sha256};
use unicode_general_category::{get_general_category, GeneralCategory};
use unicode_normalization::UnicodeNormalization;
use std::collections::BTreeMap;
use std::fs;
use std::fs::{self, OpenOptions};
use std::io::{ErrorKind, Write};
use std::path::{Path, PathBuf};
use std::thread;
use std::time::{Duration, Instant, SystemTime, UNIX_EPOCH};

pub const PENDING_FILE: &str = "pending.jsonl";
pub const DISMISSED_FILE: &str = "dismissed.jsonl";
const APPROVED_CONTENT_FILE: &str = ".approved-content.json";

pub fn memory_root() -> Result<PathBuf, String> {
Expand Down Expand Up @@ -85,8 +93,203 @@ pub fn content_is_approved(root: &Path, target: &Path, contents: &str) -> bool {
.is_some_and(|hash| hash == content_hash(contents))
}

pub fn now_epoch_seconds() -> u64 {
SystemTime::now()
.duration_since(UNIX_EPOCH)
.map(|duration| duration.as_secs())
.unwrap_or(0)
}

#[derive(Debug, Clone, PartialEq, Eq)]
pub struct UnsafeMemoryTextError {
character: char,
}

impl std::fmt::Display for UnsafeMemoryTextError {
fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
write!(
formatter,
"Memory text can't include hidden Unicode control characters"
)
}
}

impl std::error::Error for UnsafeMemoryTextError {}

fn is_default_ignorable_outside_format_category(character: char) -> bool {
matches!(
character,
'\u{034f}'
| '\u{061c}'
| '\u{115f}'..='\u{1160}'
| '\u{17b4}'..='\u{17b5}'
| '\u{180b}'..='\u{180d}'
| '\u{180f}'
| '\u{3164}'
| '\u{ffa0}'
| '\u{1bca0}'..='\u{1bca3}'
| '\u{1d173}'..='\u{1d17a}'
| '\u{e0100}'..='\u{e01ef}'
)
}

fn is_unsafe_format_character(character: char) -> bool {
matches!(get_general_category(character), GeneralCategory::Format)
|| is_default_ignorable_outside_format_category(character)
}

fn assert_review_safe_text(content: &str) -> Result<(), UnsafeMemoryTextError> {
for character in content.chars() {
let code_point = character as u32;
if is_unsafe_format_character(character)
|| (code_point <= 0x1f && !matches!(character, '\n' | '\t'))
|| (0x7f..=0x9f).contains(&code_point)
{
return Err(UnsafeMemoryTextError { character });
}
}
Ok(())
}

fn normalize_line_endings(content: &str) -> String {
content.replace("\r\n", "\n").replace('\r', "\n")
}

/// Normalize and validate one reviewed memory entry.
///
/// Memory review is a security boundary: this rejects hidden Unicode controls
/// instead of invisibly stripping them, then credential scanning and persistence
/// operate on this exact returned text. Emoji ZWJ sequences are rejected with
/// other zero-width joiners because memory entries are prose and should not
/// need invisible glyph composition.
pub fn normalize_memory_proposal_text(content: &str) -> Result<String, UnsafeMemoryTextError> {
let normalized = normalize_line_endings(content).nfc().collect::<String>();
let normalized = normalized.trim().to_string();
assert_review_safe_text(&normalized)?;
Ok(normalized)
}

pub fn normalize_memory_proposal_topic(
topic: Option<&str>,
) -> Result<Option<String>, UnsafeMemoryTextError> {
let Some(topic) = topic else {
return Ok(None);
};
let normalized = normalize_line_endings(topic).nfc().collect::<String>();
let normalized = normalized.trim().to_string();
assert_review_safe_text(&normalized)?;
Ok((!normalized.is_empty()).then_some(normalized))
}

/// Normalize and validate a complete memory document before approval.
pub fn normalize_memory_document_text(content: &str) -> Result<String, UnsafeMemoryTextError> {
let normalized = normalize_line_endings(content).nfc().collect::<String>();
assert_review_safe_text(&normalized)?;
Ok(normalized)
}

pub fn normalized_fact(content: &str, topic: Option<&str>) -> String {
format!(
"{}\n{}",
content.trim().to_lowercase(),
topic.unwrap_or_default().trim().to_lowercase()
)
}

pub fn suppression_fingerprint(content: &str, topic: Option<&str>, salt: &str) -> String {
let mut hasher = Sha256::new();
hasher.update(salt.as_bytes());
hasher.update(b"\0");
hasher.update(normalized_fact(content, topic).as_bytes());
hex::encode(hasher.finalize())
}

pub fn same_fact(record: &Value, content: &str, topic: Option<&str>) -> bool {
let record_content = record.get("content").and_then(Value::as_str).unwrap_or("");
let record_topic = record.get("topic").and_then(Value::as_str);
normalized_fact(record_content, record_topic) == normalized_fact(content, topic)
}

pub fn is_suppressed(record: &Value, content: &str, topic: Option<&str>) -> bool {
let Some(salt) = record.get("salt").and_then(Value::as_str) else {
return false;
};
record.get("fingerprint").and_then(Value::as_str)
== Some(suppression_fingerprint(content, topic, salt).as_str())
}

pub fn jsonl_records(path: &Path) -> Vec<Value> {
fs::read_to_string(path)
.unwrap_or_default()
.lines()
.filter_map(|line| serde_json::from_str(line).ok())
.collect()
}

pub fn write_jsonl(path: &Path, records: &[Value]) -> Result<(), String> {
let body = if records.is_empty() {
String::new()
} else {
format!(
"{}\n",
records
.iter()
.map(Value::to_string)
.collect::<Vec<_>>()
.join("\n")
)
};
let temporary = path.with_extension("jsonl.tmp");
fs::write(&temporary, body).map_err(|error| format!("Couldn't write queue: {error}"))?;
atomic_replace(&temporary, path)
}

pub fn append_jsonl(path: &Path, record: &Value) -> Result<(), String> {
let mut file = OpenOptions::new()
.create(true)
.append(true)
.open(path)
.map_err(|error| format!("Couldn't open queue: {error}"))?;
writeln!(file, "{record}").map_err(|error| format!("Couldn't append queue: {error}"))
}

pub struct QueueLock(PathBuf);
impl Drop for QueueLock {
fn drop(&mut self) {
let _ = fs::remove_file(&self.0);
}
}

pub fn acquire_queue_lock(dir: &Path) -> Result<QueueLock, String> {
fs::create_dir_all(dir).map_err(|error| format!("Couldn't create queue: {error}"))?;
let path = dir.join(".queue.lock");
let started = Instant::now();
loop {
match OpenOptions::new().write(true).create_new(true).open(&path) {
Ok(_) => return Ok(QueueLock(path)),
Err(error) if error.kind() == ErrorKind::AlreadyExists => {
let stale = fs::metadata(&path)
.and_then(|metadata| metadata.modified())
.ok()
.and_then(|modified| modified.elapsed().ok())
.is_some_and(|age| age > Duration::from_secs(10));
if stale {
let _ = fs::remove_file(&path);
continue;
}
if started.elapsed() >= Duration::from_secs(2) {
return Err("Memory queue is busy; try again shortly".to_string());
}
thread::sleep(Duration::from_millis(20));
}
Err(error) => return Err(format!("Couldn't lock memory queue: {error}")),
}
}
}

pub fn looks_like_credential(content: &str) -> bool {
let text = content.trim();
let text = normalize_line_endings(content).nfc().collect::<String>();
let text = text.trim();
if text.is_empty() {
return false;
}
Expand Down Expand Up @@ -114,13 +317,72 @@ pub fn looks_like_credential(content: &str) -> bool {
mod tests {
use super::*;

#[test]
fn suppression_never_contains_original_content() {
let fingerprint = suppression_fingerprint("Private preference", Some("Home"), "salt");
assert!(!fingerprint.contains("Private preference"));
}

#[test]
fn credentials_are_detected() {
assert!(looks_like_credential("PIN: 1234"));
assert!(looks_like_credential("API key: ghp_16CharsAtLeastHere00"));
assert!(!looks_like_credential("I use 1Password"));
}

#[test]
fn credentials_are_detected_after_unicode_normalization() {
assert!(looks_like_credential("API key: ghp_16CharsAtLeastHere00"));
assert!(looks_like_credential("PIN: 1234"));
}

#[test]
fn normalizes_visible_unicode_and_line_endings() {
assert_eq!(
normalize_memory_proposal_text(" cafe\u{301} prefers 中文\r\n ").unwrap(),
"café prefers 中文"
);
assert_eq!(
normalize_memory_document_text("# Cafe\u{301}\r\n\tTabbed\n").unwrap(),
"# Café\n\tTabbed\n"
);
}

#[test]
fn normalizes_and_rejects_unsafe_topics() {
assert_eq!(
normalize_memory_proposal_topic(Some(" Travel\r\n ")).unwrap(),
Some("Travel".to_string())
);
assert_eq!(normalize_memory_proposal_topic(Some(" ")).unwrap(), None);
assert!(normalize_memory_proposal_topic(Some("Tra\u{202e}vel")).is_err());
}

#[test]
fn rejects_hidden_unicode_and_control_characters() {
for unsafe_text in [
"ghp_16Chars\u{200b}AtLeastHere00",
"abc\u{202e}txt",
"abc\u{2066}txt\u{2069}",
"abc\u{0007}txt",
"abc\u{0085}txt",
"abc\u{e0020}txt",
"abc\u{e0100}txt",
"family 👨‍👩‍👧‍👦",
] {
assert!(
normalize_memory_proposal_text(unsafe_text).is_err(),
"{unsafe_text:?} should be rejected"
);
}
}

#[test]
fn preserves_ordinary_visible_unicode_and_emoji_without_zwj() {
let text = "São Paulo résumé Привет 中文 🚀";
assert_eq!(normalize_memory_proposal_text(text).unwrap(), text);
}

#[test]
fn approved_content_requires_an_exact_valid_manifest_entry() {
let temp = tempfile::tempdir().unwrap();
Expand Down
1 change: 1 addition & 0 deletions src-tauri/crates/berd-memory/src/main.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
fn main() {}
Loading
Loading