diff --git a/docs/advanced.md b/docs/advanced.md index 42685b9..b683c76 100644 --- a/docs/advanced.md +++ b/docs/advanced.md @@ -601,6 +601,11 @@ Additional hosted invariants: remote tombstone always applies over local content, a local tombstone is never resurrected by a pull, and a file deleted locally after a sync is not silently re-created. +- `coven-code memory` exposes operator controls for hosted memory lifecycle + administration: list entries by directory or hosted tenant/repo/domain, + expire entries, redact/delete by id or path, delete hosted scopes, and export + a tombstone-only audit ledger. The ledger includes timestamps and reason + stubs but not original redacted or deleted content. - A key with an unresolved team-memory pull conflict is blocked from further sync until the persisted conflict record under `.conflicts/` is resolved. - `/review` injects the ids and trust labels of every loaded memory entry into diff --git a/docs/commands.md b/docs/commands.md index c3caf71..366a583 100644 --- a/docs/commands.md +++ b/docs/commands.md @@ -1240,6 +1240,7 @@ are CLI-only. | `pr-comments` | Get comments from a GitHub PR. | | `ultraplan` | Launch the Ultraplan agentic code planner with extended thinking. | | `stats` | Aggregate token / cost / tool stats across saved sessions (in the TUI, `/usage stats` opens the stats dialog). | +| `memory` | Operator controls for hosted/local memory lifecycle: list, expire, redact, delete, and export the tombstone audit ledger. | --- diff --git a/docs/configuration.md b/docs/configuration.md index 87cc383..bd885f4 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -384,6 +384,17 @@ Frontmatter fields: | `deleted_at` | Marks memory as deleted. Hosted review excludes deleted entries from prompt loading. | | `created_at`, `created_by`, `session_id`, `transcript_ref`, `confidence` | Optional provenance fields for audit and review artifacts. | +Retention defaults apply only when `expires_at` is absent. Hosted review treats +the effective expiry as `created_at + default window`; malformed dates fail open +the same way as explicit expiry parsing, so operators should use `YYYY-MM-DD`. + +| Retention class | Default window | Operator behavior | +|-----------------|----------------|-------------------| +| `standard` | No automatic expiry | Active until explicitly expired, redacted, or deleted. | +| `short_lived` | 30 days from `created_at` | Automatically excluded from hosted loads after the default window. | +| `security` | 90 days from `created_at` | Used for redaction stubs and security-sensitive audit records. | +| `legal_hold` | No automatic expiry | Never auto-expires; `memory expire` and `memory delete` require `--force`. | + Local mode tolerates missing metadata for backward compatibility. Hosted review mode treats missing trust as `unknown`, ignores expired memory, and excludes memory below the configured trust threshold. Tagged hosted memory is injected @@ -402,6 +413,20 @@ conflict record is written for operator review instead of overwriting local memory. Hosted team-memory sync also sends tenant, installation, repo, and domain scope metadata so the server can authorize the full tuple. +### Memory lifecycle operator controls + +Use `coven-code memory` to inspect and administer local and hosted memory +without exposing redacted or deleted content to the model. + +| Command | Purpose | +|---------|---------| +| `coven-code memory list [--dir ] [--tenant ] [--repo ] [--domain ] [--json]` | List memory id, path, retention class, trust, created time, effective expiry, and status. With no `--dir`, scans the project auto-memory directory plus hosted scopes under the Coven Code config directory. | +| `coven-code memory expire [--at YYYY-MM-DD] [--force]` | Set `expires_at` in frontmatter. Defaults to today and refuses `legal_hold` entries unless `--force` is present. | +| `coven-code memory redact --reason ` | Replace the file with a redaction tombstone stub via `redact_memory_file`; the original body is removed. | +| `coven-code memory delete --reason [--force]` | Replace the file with a deletion tombstone stub. `legal_hold` entries require `--force`. | +| `coven-code memory delete --scope tenant=,install=,repo=[,domain=] --reason [--force]` | Remove the hosted memory directory for a scoped tenant/installation/repo/domain. Scope deletion refuses legal-hold files unless forced. | +| `coven-code memory ledger [--dir ] [--json]` | Export tombstoned entries only: id, path, redacted/deleted timestamp, retention class, tombstone reason line, and provenance source. The ledger reads tombstone stubs and never includes original memory body content. | + ### @include directives AGENTS.md files support `@include` to pull in content from other files: diff --git a/docs/src/content/configuration.js b/docs/src/content/configuration.js index f8b86d0..8ae4049 100644 --- a/docs/src/content/configuration.js +++ b/docs/src/content/configuration.js @@ -92,6 +92,10 @@ export function render() { } } +

Memory Retention

+

AGENTS.md frontmatter supports lifecycle fields for hosted review memory. expires_at uses YYYY-MM-DD and always wins over retention defaults. retention_class can be standard (no automatic expiry), short_lived (30 days from created_at), security (90 days), or legal_hold (no automatic expiry and requires --force for operator expiry/deletion).

+

Operators can run coven-code memory list, expire, redact, delete, and ledger --json. Redaction and deletion write tombstone stubs; the audit ledger exports ids, timestamps, reasons, and provenance without original removed content.

+

Environment Variables

diff --git a/src-rust/crates/cli/src/main.rs b/src-rust/crates/cli/src/main.rs index 06c55c7..b9f7c27 100644 --- a/src-rust/crates/cli/src/main.rs +++ b/src-rust/crates/cli/src/main.rs @@ -10,6 +10,7 @@ mod codex_oauth_flow; mod headless; +mod memory_admin; mod oauth_flow; mod stream_mode; mod upgrade; @@ -444,6 +445,11 @@ async fn main() -> anyhow::Result<()> { return run_models_command(&raw_args[2..]).await; } + // Fast-path: `coven-code memory ...` — inspect and administer memory lifecycle controls. + if raw_args.get(1).map(|s| s.as_str()) == Some("memory") { + return memory_admin::handle_memory_command(&raw_args[2..]).await; + } + // Fast-path: named commands (`claude agents`, `claude ide`, `claude branch`, …) // Check before Cli::parse() so these names don't conflict with positional prompt arg. if let Some(cmd_name) = raw_args.get(1).map(|s| s.as_str()) { diff --git a/src-rust/crates/cli/src/memory_admin.rs b/src-rust/crates/cli/src/memory_admin.rs new file mode 100644 index 0000000..8e2ddef --- /dev/null +++ b/src-rust/crates/cli/src/memory_admin.rs @@ -0,0 +1,763 @@ +use anyhow::{bail, Context}; +use claurst_core::claudemd::{ + effective_memory_expires_at, memory_id, parse_frontmatter, MemoryFileInfo, MemoryScope, + RetentionClass, +}; +use claurst_core::hosted_review::{HostedReviewScope, MemoryDomain, MemorySourceTrust}; +use claurst_core::memdir::{ + auto_memory_path, delete_hosted_memory_for_scope, delete_memory_file_with_force, + expire_memory_file, memory_file_has_legal_hold, redact_memory_file, MEMORY_ENTRYPOINT, +}; +use serde::Serialize; +use std::path::{Path, PathBuf}; + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)] +#[serde(rename_all = "snake_case")] +pub(crate) enum MemoryStatus { + Active, + Expired, + Redacted, + Deleted, +} + +#[derive(Debug, Clone, Serialize)] +pub(crate) struct MemoryEntry { + pub id: String, + pub path: PathBuf, + pub retention_class: Option, + pub trust: Option, + pub created_at: Option, + pub expires_at: Option, + pub status: MemoryStatus, + pub redacted_at: Option, + pub deleted_at: Option, + pub source: Option, + #[serde(skip_serializing)] + reason: Option, +} + +#[derive(Debug, Clone, Serialize)] +pub(crate) struct MemoryLedgerEntry { + pub id: String, + pub path: PathBuf, + pub redacted_at: Option, + pub deleted_at: Option, + pub retention_class: Option, + pub reason: Option, + pub source: Option, +} + +pub(crate) async fn handle_memory_command(args: &[String]) -> anyhow::Result<()> { + match args.first().map(|arg| arg.as_str()) { + Some("list") => handle_list(&args[1..]), + Some("expire") => handle_expire(&args[1..]), + Some("redact") => handle_redact(&args[1..]), + Some("delete") => handle_delete(&args[1..]), + Some("ledger") => handle_ledger(&args[1..]), + Some("-h") | Some("--help") | None => { + print_usage(); + Ok(()) + } + Some(command) => { + bail!("unknown memory subcommand '{command}'"); + } + } +} + +fn handle_list(args: &[String]) -> anyhow::Result<()> { + let options = parse_common_options(args)?; + let mut entries = collect_entries_from_dirs(&options.dirs)?; + apply_filters(&mut entries, &options); + if options.json { + println!("{}", serde_json::to_string_pretty(&entries)?); + } else { + print_entries_table(&entries); + } + Ok(()) +} + +fn handle_expire(args: &[String]) -> anyhow::Result<()> { + let mut target = None; + let mut at = chrono::Local::now() + .date_naive() + .format("%Y-%m-%d") + .to_string(); + let mut force = false; + let mut dirs = Vec::new(); + let mut index = 0; + while index < args.len() { + match args[index].as_str() { + "--at" => { + index += 1; + at = required_arg(args, index, "--at")?.to_string(); + } + value if value.starts_with("--at=") => { + at = value.trim_start_matches("--at=").to_string(); + } + "--force" => force = true, + "--dir" => { + index += 1; + dirs.push(PathBuf::from(required_arg(args, index, "--dir")?)); + } + flag if flag.starts_with("--") => bail!("unknown memory expire flag: {flag}"), + value => set_single_target(&mut target, value)?, + } + index += 1; + } + let target = target + .context("usage: coven-code memory expire [--at YYYY-MM-DD] [--force]")?; + let entry = resolve_for_operation(&target, dirs)?; + expire_memory_file(&entry.path, &at, force) + .with_context(|| format!("failed to expire {}", entry.path.display()))?; + println!("expired {} at {}", entry.id, at); + Ok(()) +} + +fn handle_redact(args: &[String]) -> anyhow::Result<()> { + let (target, reason, _force, dirs) = parse_target_reason_args( + args, + "usage: coven-code memory redact --reason ", + )?; + let entry = resolve_for_operation(&target, dirs)?; + redact_memory_file(&entry.path, &reason) + .with_context(|| format!("failed to redact {}", entry.path.display()))?; + println!("redacted {}", entry.id); + Ok(()) +} + +fn handle_delete(args: &[String]) -> anyhow::Result<()> { + let mut scope = None; + let mut passthrough = Vec::new(); + let mut index = 0; + while index < args.len() { + match args[index].as_str() { + "--scope" => { + index += 1; + scope = Some(required_arg(args, index, "--scope")?.to_string()); + } + value if value.starts_with("--scope=") => { + scope = Some(value.trim_start_matches("--scope=").to_string()); + } + value => passthrough.push(value.to_string()), + } + index += 1; + } + + if let Some(scope) = scope { + let mut reason = None; + let mut force = false; + for (flag, value) in parse_flags_with_values(&passthrough)? { + match (flag.as_str(), value) { + ("--reason", Some(text)) => reason = Some(text), + ("--force", None) => force = true, + (unknown, _) => bail!("unknown memory delete flag: {unknown}"), + } + } + let _reason = reason + .context("usage: coven-code memory delete --scope --reason [--force]")?; + let scope = parse_scope(&scope)?; + if !force && hosted_scope_has_legal_hold(&scope)? { + bail!("refusing to delete legal_hold memory scope without --force"); + } + let path = claurst_core::memdir::hosted_memory_path_for_scope(&scope); + delete_hosted_memory_for_scope(&scope) + .with_context(|| format!("failed to delete hosted scope {}", path.display()))?; + println!("deleted hosted memory scope {}", path.display()); + return Ok(()); + } + + let (target, reason, force, dirs) = parse_target_reason_args( + &passthrough, + "usage: coven-code memory delete --reason [--force]", + )?; + let entry = resolve_for_operation(&target, dirs)?; + delete_memory_file_with_force(&entry.path, &reason, force) + .with_context(|| format!("failed to delete {}", entry.path.display()))?; + println!("deleted {}", entry.id); + Ok(()) +} + +fn handle_ledger(args: &[String]) -> anyhow::Result<()> { + let options = parse_common_options(args)?; + let mut entries = collect_entries_from_dirs(&options.dirs)?; + apply_filters(&mut entries, &options); + let ledger = export_ledger(&entries); + if options.json { + println!("{}", serde_json::to_string_pretty(&ledger)?); + } else { + print_ledger_table(&ledger); + } + Ok(()) +} + +#[derive(Default)] +struct CommonOptions { + dirs: Vec, + json: bool, + tenant: Option, + repo: Option, + domain: Option, +} + +fn parse_common_options(args: &[String]) -> anyhow::Result { + let mut options = CommonOptions::default(); + let mut index = 0; + while index < args.len() { + match args[index].as_str() { + "--json" => options.json = true, + "--dir" => { + index += 1; + options + .dirs + .push(PathBuf::from(required_arg(args, index, "--dir")?)); + } + "--tenant" => { + index += 1; + options.tenant = Some(required_arg(args, index, "--tenant")?.to_string()); + } + "--repo" => { + index += 1; + options.repo = Some(required_arg(args, index, "--repo")?.to_string()); + } + "--domain" => { + index += 1; + options.domain = Some(required_arg(args, index, "--domain")?.to_string()); + } + value if value.starts_with("--dir=") => { + options + .dirs + .push(PathBuf::from(value.trim_start_matches("--dir="))); + } + value if value.starts_with("--tenant=") => { + options.tenant = Some(value.trim_start_matches("--tenant=").to_string()); + } + value if value.starts_with("--repo=") => { + options.repo = Some(value.trim_start_matches("--repo=").to_string()); + } + value if value.starts_with("--domain=") => { + options.domain = Some(value.trim_start_matches("--domain=").to_string()); + } + flag if flag.starts_with("--") => bail!("unknown memory flag: {flag}"), + value => bail!("unexpected memory argument: {value}"), + } + index += 1; + } + + if options.dirs.is_empty() { + options.dirs = default_memory_dirs()?; + } + Ok(options) +} + +pub(crate) fn collect_entries_from_dirs(dirs: &[PathBuf]) -> anyhow::Result> { + let mut entries = Vec::new(); + for dir in dirs { + let mut paths = Vec::new(); + collect_admin_memory_paths(dir, &mut paths); + for path in paths { + entries.push(load_entry_from_path(&path)?); + } + } + entries.sort_by(|left, right| left.path.cmp(&right.path)); + Ok(entries) +} + +fn collect_admin_memory_paths(dir: &Path, out: &mut Vec) { + let Ok(entries) = std::fs::read_dir(dir) else { + return; + }; + for entry in entries.flatten() { + let path = entry.path(); + if path.is_dir() { + collect_admin_memory_paths(&path, out); + continue; + } + if path.extension().and_then(|ext| ext.to_str()) != Some("md") { + continue; + } + if path.file_name().and_then(|name| name.to_str()) == Some(MEMORY_ENTRYPOINT) { + continue; + } + out.push(path); + } +} + +fn load_entry_from_path(path: &Path) -> anyhow::Result { + let raw = std::fs::read_to_string(path) + .with_context(|| format!("failed to read memory file {}", path.display()))?; + let (frontmatter, body) = parse_frontmatter(&raw); + let file = MemoryFileInfo { + path: path.to_path_buf(), + scope: MemoryScope::Managed, + content: body.to_string(), + frontmatter, + mtime: None, + }; + let effective_expires_at = effective_memory_expires_at(&file.frontmatter) + .map(|date| date.format("%Y-%m-%d").to_string()); + let status = if file.frontmatter.deleted_at.is_some() { + MemoryStatus::Deleted + } else if file.frontmatter.redacted_at.is_some() { + MemoryStatus::Redacted + } else if effective_memory_expires_at(&file.frontmatter) + .map(|date| date < chrono::Local::now().date_naive()) + .unwrap_or(false) + { + MemoryStatus::Expired + } else { + MemoryStatus::Active + }; + let reason = if matches!(status, MemoryStatus::Redacted | MemoryStatus::Deleted) { + tombstone_reason(body) + } else { + None + }; + + Ok(MemoryEntry { + id: memory_id(&file), + path: path.to_path_buf(), + retention_class: file.frontmatter.retention_class, + trust: file.frontmatter.trust.map(trust_label).map(String::from), + created_at: file.frontmatter.created_at, + expires_at: effective_expires_at, + status, + redacted_at: file.frontmatter.redacted_at, + deleted_at: file.frontmatter.deleted_at, + source: file.frontmatter.source, + reason, + }) +} + +pub(crate) fn resolve_entry_target<'a>( + entries: &'a [MemoryEntry], + target: &str, +) -> anyhow::Result<&'a MemoryEntry> { + let target_path = Path::new(target); + let mut matches = Vec::new(); + for entry in entries { + if entry.id == target || entry.path == target_path || paths_match(&entry.path, target_path) + { + matches.push(entry); + } + } + + match matches.len() { + 0 => bail!("no memory entry matched '{target}'"), + 1 => Ok(matches[0]), + _ => bail!("memory target '{target}' matched multiple entries"), + } +} + +pub(crate) fn export_ledger(entries: &[MemoryEntry]) -> Vec { + entries + .iter() + .filter(|entry| matches!(entry.status, MemoryStatus::Redacted | MemoryStatus::Deleted)) + .map(|entry| MemoryLedgerEntry { + id: entry.id.clone(), + path: entry.path.clone(), + redacted_at: entry.redacted_at.clone(), + deleted_at: entry.deleted_at.clone(), + retention_class: entry.retention_class, + reason: entry.reason.clone(), + source: entry.source.clone(), + }) + .collect() +} + +fn resolve_for_operation(target: &str, dirs: Vec) -> anyhow::Result { + let target_path = PathBuf::from(target); + if target_path.exists() { + return load_entry_from_path(&target_path); + } + let dirs = if dirs.is_empty() { + default_memory_dirs()? + } else { + dirs + }; + let entries = collect_entries_from_dirs(&dirs)?; + resolve_entry_target(&entries, target).cloned() +} + +fn default_memory_dirs() -> anyhow::Result> { + let cwd = std::env::current_dir().context("failed to resolve current directory")?; + let mut dirs = vec![auto_memory_path(&cwd)]; + let hosted_root = claurst_core::config::Settings::config_dir().join("hosted-review"); + collect_hosted_memory_dirs(&hosted_root, &mut dirs); + Ok(dirs) +} + +fn collect_hosted_memory_dirs(dir: &Path, out: &mut Vec) { + let Ok(entries) = std::fs::read_dir(dir) else { + return; + }; + for entry in entries.flatten() { + let path = entry.path(); + if path.is_dir() { + if path.file_name().and_then(|name| name.to_str()) == Some("memory") { + out.push(path); + } else { + collect_hosted_memory_dirs(&path, out); + } + } + } +} + +fn apply_filters(entries: &mut Vec, options: &CommonOptions) { + entries.retain(|entry| { + path_contains_filter(&entry.path, options.tenant.as_deref()) + && path_contains_filter(&entry.path, options.repo.as_deref()) + && path_contains_filter(&entry.path, options.domain.as_deref()) + }); +} + +fn path_contains_filter(path: &Path, filter: Option<&str>) -> bool { + filter + .map(|value| path.to_string_lossy().contains(value)) + .unwrap_or(true) +} + +fn hosted_scope_has_legal_hold(scope: &HostedReviewScope) -> anyhow::Result { + let dir = claurst_core::memdir::hosted_memory_path_for_scope(scope); + let mut files = Vec::new(); + collect_markdown_files(&dir, &mut files); + for file in files { + if memory_file_has_legal_hold(&file)? { + return Ok(true); + } + } + Ok(false) +} + +fn collect_markdown_files(dir: &Path, out: &mut Vec) { + let Ok(entries) = std::fs::read_dir(dir) else { + return; + }; + for entry in entries.flatten() { + let path = entry.path(); + if path.is_dir() { + collect_markdown_files(&path, out); + } else if path.extension().and_then(|ext| ext.to_str()) == Some("md") { + out.push(path); + } + } +} + +fn parse_target_reason_args( + args: &[String], + usage: &str, +) -> anyhow::Result<(String, String, bool, Vec)> { + let mut target = None; + let mut reason = None; + let mut force = false; + let mut dirs = Vec::new(); + let mut index = 0; + while index < args.len() { + match args[index].as_str() { + "--reason" => { + index += 1; + reason = Some(required_arg(args, index, "--reason")?.to_string()); + } + value if value.starts_with("--reason=") => { + reason = Some(value.trim_start_matches("--reason=").to_string()); + } + "--force" => force = true, + "--dir" => { + index += 1; + dirs.push(PathBuf::from(required_arg(args, index, "--dir")?)); + } + flag if flag.starts_with("--") => bail!("unknown memory flag: {flag}"), + value => set_single_target(&mut target, value)?, + } + index += 1; + } + let target = target.with_context(|| usage.to_string())?; + let reason = reason.with_context(|| usage.to_string())?; + Ok((target, reason, force, dirs)) +} + +fn parse_flags_with_values(args: &[String]) -> anyhow::Result)>> { + let mut flags = Vec::new(); + let mut index = 0; + while index < args.len() { + match args[index].as_str() { + "--force" => flags.push(("--force".to_string(), None)), + "--reason" => { + index += 1; + flags.push(( + "--reason".to_string(), + Some(required_arg(args, index, "--reason")?.to_string()), + )); + } + value if value.starts_with("--reason=") => flags.push(( + "--reason".to_string(), + Some(value.trim_start_matches("--reason=").to_string()), + )), + flag if flag.starts_with("--") => flags.push((flag.to_string(), None)), + value => bail!("unexpected memory delete argument: {value}"), + } + index += 1; + } + Ok(flags) +} + +fn parse_scope(raw: &str) -> anyhow::Result { + let mut tenant = None; + let mut installation = None; + let mut repo = None; + let mut repo_full_name = None; + let mut domain = None; + for part in raw.split(',') { + let (key, value) = part + .split_once('=') + .with_context(|| format!("invalid scope component '{part}'"))?; + match key.trim() { + "tenant" | "tenant_id" => tenant = Some(value.trim().to_string()), + "install" | "installation" | "installation_id" => { + installation = Some(value.trim().to_string()); + } + "repo" | "repo_id" => repo = Some(value.trim().to_string()), + "repo_full_name" | "full_name" => repo_full_name = Some(value.trim().to_string()), + "domain" => domain = Some(value.trim().to_string()), + unknown => bail!("unknown scope component '{unknown}'"), + } + } + let repo = repo.context("scope requires repo=")?; + let mut scope = HostedReviewScope::new( + tenant.context("scope requires tenant=")?, + installation.context("scope requires install=")?, + repo.clone(), + repo_full_name.unwrap_or(repo), + ); + if let Some(domain) = domain { + scope = scope.with_domain(parse_domain(&domain)?); + } + scope.validate().map_err(anyhow::Error::msg)?; + Ok(scope) +} + +fn parse_domain(value: &str) -> anyhow::Result { + match value { + "default" | "default-branch" => Ok(MemoryDomain::DefaultBranch), + "security" | "security-private" => Ok(MemoryDomain::SecurityPrivate), + _ => { + if let Some(branch) = value.strip_prefix("branch:") { + return Ok(MemoryDomain::Branch(branch.to_string())); + } + if let Some(release) = value.strip_prefix("release:") { + return Ok(MemoryDomain::Release(release.to_string())); + } + if let Some(pr) = value.strip_prefix("pr:") { + let number = pr.parse().context("invalid pull request domain number")?; + return Ok(MemoryDomain::PullRequest(number)); + } + bail!("unknown memory domain '{value}'") + } + } +} + +fn set_single_target(target: &mut Option, value: &str) -> anyhow::Result<()> { + if target.is_some() { + bail!("only one memory id or path may be supplied"); + } + *target = Some(value.to_string()); + Ok(()) +} + +fn required_arg<'a>(args: &'a [String], index: usize, flag: &str) -> anyhow::Result<&'a str> { + args.get(index) + .map(|value| value.as_str()) + .with_context(|| format!("{flag} requires a value")) +} + +fn paths_match(entry_path: &Path, target_path: &Path) -> bool { + let Ok(entry) = entry_path.canonicalize() else { + return false; + }; + let Ok(target) = target_path.canonicalize() else { + return false; + }; + entry == target +} + +fn tombstone_reason(body: &str) -> Option { + let line = body.lines().map(str::trim).find(|line| !line.is_empty())?; + let canonical_redaction = line.starts_with("[REDACTED:") && line.ends_with(']'); + let canonical_deletion = line.starts_with("[DELETED:") && line.ends_with(']'); + if canonical_redaction || canonical_deletion { + Some(line.to_string()) + } else { + None + } +} + +fn trust_label(trust: MemorySourceTrust) -> &'static str { + match trust { + MemorySourceTrust::SystemPolicy => "system_policy", + MemorySourceTrust::MaintainerApproved => "maintainer_approved", + MemorySourceTrust::DefaultBranchCode => "default_branch_code", + MemorySourceTrust::ContributorInput => "contributor_input", + MemorySourceTrust::ForkInput => "fork_input", + MemorySourceTrust::ModelInferred => "model_inferred", + MemorySourceTrust::Unknown => "unknown", + } +} + +fn print_entries_table(entries: &[MemoryEntry]) { + println!("ID\tSTATUS\tRETENTION\tTRUST\tCREATED_AT\tEXPIRES_AT\tPATH"); + for entry in entries { + println!( + "{}\t{:?}\t{}\t{}\t{}\t{}\t{}", + entry.id, + entry.status, + entry + .retention_class + .map(RetentionClass::as_str) + .unwrap_or("standard"), + entry.trust.as_deref().unwrap_or("unknown"), + entry.created_at.as_deref().unwrap_or("-"), + entry.expires_at.as_deref().unwrap_or("-"), + entry.path.display() + ); + } +} + +fn print_ledger_table(entries: &[MemoryLedgerEntry]) { + println!("ID\tREDACTED_AT\tDELETED_AT\tRETENTION\tSOURCE\tREASON\tPATH"); + for entry in entries { + println!( + "{}\t{}\t{}\t{}\t{}\t{}\t{}", + entry.id, + entry.redacted_at.as_deref().unwrap_or("-"), + entry.deleted_at.as_deref().unwrap_or("-"), + entry + .retention_class + .map(RetentionClass::as_str) + .unwrap_or("standard"), + entry.source.as_deref().unwrap_or("-"), + entry.reason.as_deref().unwrap_or("-"), + entry.path.display() + ); + } +} + +fn print_usage() { + eprintln!("Usage: coven-code memory "); + eprintln!(" list [--dir ] [--tenant ] [--repo ] [--domain ] [--json]"); + eprintln!(" expire [--at YYYY-MM-DD] [--dir ] [--force]"); + eprintln!(" redact --reason [--dir ]"); + eprintln!(" delete --reason [--dir ] [--force]"); + eprintln!( + " delete --scope tenant=,install=,repo=[,domain=] --reason [--force]" + ); + eprintln!(" ledger [--dir ] [--json]"); +} + +#[cfg(test)] +mod tests { + use super::*; + + fn write_file(dir: &std::path::Path, name: &str, content: &str) -> std::path::PathBuf { + let path = dir.join(name); + std::fs::write(&path, content).expect("write memory file"); + path + } + + #[test] + fn resolve_entry_target_matches_memory_id_or_path() { + let dir = tempfile::tempdir().expect("tempdir"); + let path = write_file( + dir.path(), + "auth.md", + "---\nid: mem_auth\nsource: operator\n---\nauth memory", + ); + let entries = collect_entries_from_dirs(&[dir.path().to_path_buf()]).expect("entries"); + + let by_id = resolve_entry_target(&entries, "mem_auth").expect("resolve id"); + let by_path = + resolve_entry_target(&entries, &path.to_string_lossy()).expect("resolve path"); + + assert_eq!(by_id.path, path); + assert_eq!(by_path.path, path); + } + + #[test] + fn ledger_export_only_includes_tombstone_stubs() { + let dir = tempfile::tempdir().expect("tempdir"); + write_file( + dir.path(), + "deleted.md", + "---\nid: mem_deleted\ndeleted_at: 2026-07-07T00:00:00Z\nsource: deletion\n---\n\n[DELETED: user request]\n", + ); + write_file( + dir.path(), + "active.md", + "---\nid: mem_active\nsource: operator\n---\nactive sensitive body", + ); + let entries = collect_entries_from_dirs(&[dir.path().to_path_buf()]).expect("entries"); + + let ledger = export_ledger(&entries); + + assert_eq!(ledger.len(), 1); + assert_eq!(ledger[0].id, "mem_deleted"); + assert_eq!(ledger[0].reason.as_deref(), Some("[DELETED: user request]")); + assert!(!format!("{ledger:?}").contains("active sensitive body")); + } + + #[test] + fn ledger_export_omits_noncanonical_tombstone_body() { + let dir = tempfile::tempdir().expect("tempdir"); + write_file( + dir.path(), + "deleted.md", + "---\nid: mem_deleted\ndeleted_at: 2026-07-07T00:00:00Z\nsource: deletion\n---\n\noriginal sensitive body\n", + ); + let entries = collect_entries_from_dirs(&[dir.path().to_path_buf()]).expect("entries"); + + let ledger = export_ledger(&entries); + + assert_eq!(ledger.len(), 1); + assert_eq!(ledger[0].reason, None); + assert!(!format!("{ledger:?}").contains("original sensitive body")); + } + + #[test] + fn collect_entries_from_dirs_is_not_limited_to_prompt_scan_cap() { + let dir = tempfile::tempdir().expect("tempdir"); + for index in 0..=200 { + write_file( + dir.path(), + &format!("memory-{index:03}.md"), + &format!("---\nid: mem_{index:03}\nsource: operator\n---\nbody {index}"), + ); + } + + let entries = collect_entries_from_dirs(&[dir.path().to_path_buf()]).expect("entries"); + + assert_eq!(entries.len(), 201); + assert!(entries.iter().any(|entry| entry.id == "mem_000")); + assert!(entries.iter().any(|entry| entry.id == "mem_200")); + } + + #[test] + fn memory_status_marks_redacted_and_deleted_entries() { + let dir = tempfile::tempdir().expect("tempdir"); + write_file( + dir.path(), + "redacted.md", + "---\nid: mem_redacted\nredacted_at: 2026-07-07T00:00:00Z\nsource: redaction\n---\n\n[REDACTED: operator request]\n", + ); + write_file( + dir.path(), + "deleted.md", + "---\nid: mem_deleted\ndeleted_at: 2026-07-07T00:00:00Z\nsource: deletion\n---\n\n[DELETED: operator request]\n", + ); + let entries = collect_entries_from_dirs(&[dir.path().to_path_buf()]).expect("entries"); + + assert!(entries + .iter() + .any(|entry| entry.id == "mem_redacted" && entry.status == MemoryStatus::Redacted)); + assert!(entries + .iter() + .any(|entry| entry.id == "mem_deleted" && entry.status == MemoryStatus::Deleted)); + } +} diff --git a/src-rust/crates/core/src/claudemd.rs b/src-rust/crates/core/src/claudemd.rs index 14c6e87..87924a7 100644 --- a/src-rust/crates/core/src/claudemd.rs +++ b/src-rust/crates/core/src/claudemd.rs @@ -58,7 +58,7 @@ pub struct MemoryFrontmatter { #[serde(default)] pub expires_at: Option, #[serde(default)] - pub retention_class: Option, + pub retention_class: Option, #[serde(default)] pub redacted_at: Option, #[serde(default)] @@ -75,6 +75,44 @@ pub struct MemoryFrontmatter { pub confidence: Option, } +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum RetentionClass { + Standard, + ShortLived, + Security, + LegalHold, +} + +impl RetentionClass { + pub fn parse(value: &str) -> Option { + match normalized_frontmatter_value(value).as_str() { + "standard" => Some(Self::Standard), + "short_lived" => Some(Self::ShortLived), + "security" => Some(Self::Security), + "legal_hold" => Some(Self::LegalHold), + _ => None, + } + } + + pub fn as_str(self) -> &'static str { + match self { + Self::Standard => "standard", + Self::ShortLived => "short_lived", + Self::Security => "security", + Self::LegalHold => "legal_hold", + } + } + + pub fn default_retention_days(self) -> Option { + match self { + Self::ShortLived => Some(30), + Self::Security => Some(90), + Self::Standard | Self::LegalHold => None, + } + } +} + #[derive(Debug, Clone, PartialEq, Serialize, Deserialize, Default)] #[serde(rename_all = "camelCase")] pub struct MemoryProvenance { @@ -439,9 +477,7 @@ pub fn parse_frontmatter(content: &str) -> (MemoryFrontmatter, &str) { fm.source_actor = Some(strip_frontmatter_value(&val).to_string()) } "expires_at" => fm.expires_at = Some(strip_frontmatter_value(&val).to_string()), - "retention_class" => { - fm.retention_class = Some(strip_frontmatter_value(&val).to_string()) - } + "retention_class" => fm.retention_class = RetentionClass::parse(&val), "redacted_at" => { fm.redacted_at = Some(strip_frontmatter_value(&val).to_string()) } @@ -473,6 +509,46 @@ fn normalized_frontmatter_value(value: &str) -> String { .replace('-', "_") } +pub fn upsert_frontmatter_key(content: &str, key: &str, value: &str) -> String { + if !content.starts_with("---") { + return format!("---\n{key}: {value}\n---\n{content}"); + } + + let after_first = &content[3..]; + let Some(end) = after_first.find("\n---") else { + return format!("---\n{key}: {value}\n---\n{content}"); + }; + + let yaml = after_first[..end] + .strip_prefix('\n') + .unwrap_or(&after_first[..end]); + let mut updated = Vec::new(); + let mut replaced = false; + for line in yaml.lines() { + if line + .split_once(':') + .map(|(existing_key, _)| existing_key.trim() == key) + .unwrap_or(false) + { + updated.push(format!("{key}: {value}")); + replaced = true; + } else { + updated.push(line.to_string()); + } + } + + if !replaced { + updated.push(format!("{key}: {value}")); + } + + let mut result = String::from("---\n"); + result.push_str(&updated.join("\n")); + result.push('\n'); + result.push_str("---"); + result.push_str(&after_first[end + 4..]); + result +} + fn parse_memory_trust(value: &str) -> Option { match normalized_frontmatter_value(value).as_str() { "system_policy" => Some(MemorySourceTrust::SystemPolicy), @@ -607,7 +683,7 @@ pub fn memory_file_allowed_for_options(file: &MemoryFileInfo, options: &MemoryLo return true; } - if memory_is_expired(file.frontmatter.expires_at.as_deref()) { + if memory_is_expired(&file.frontmatter) { return false; } @@ -662,14 +738,39 @@ fn memory_has_provenance(frontmatter: &MemoryFrontmatter) -> bool { .is_some_and(|source| !source.trim().is_empty()) } -fn memory_is_expired(expires_at: Option<&str>) -> bool { - let Some(expires_at) = expires_at else { - return false; - }; - let Ok(expires) = chrono::NaiveDate::parse_from_str(expires_at.trim(), "%Y-%m-%d") else { +pub fn effective_memory_expires_at(frontmatter: &MemoryFrontmatter) -> Option { + if let Some(expires_at) = frontmatter.expires_at.as_deref() { + return parse_memory_date(expires_at); + } + + let retention_class = frontmatter.retention_class?; + if retention_class == RetentionClass::LegalHold { + return None; + } + let days = retention_class.default_retention_days()?; + let created_at = parse_memory_date(frontmatter.created_at.as_deref()?)?; + created_at.checked_add_signed(chrono::Duration::days(days)) +} + +fn parse_memory_date(value: &str) -> Option { + let trimmed = value.trim(); + if let Ok(date) = chrono::NaiveDate::parse_from_str(trimmed, "%Y-%m-%d") { + return Some(date); + } + if let Ok(timestamp) = chrono::DateTime::parse_from_rfc3339(trimmed) { + return Some(timestamp.date_naive()); + } + if let Some((date, _)) = trimmed.split_once('T') { + return chrono::NaiveDate::parse_from_str(date, "%Y-%m-%d").ok(); + } + None +} + +fn memory_is_expired(frontmatter: &MemoryFrontmatter) -> bool { + let Some(expires) = effective_memory_expires_at(frontmatter) else { return false; }; - expires < chrono::Local::now().date_naive() + expires <= chrono::Local::now().date_naive() } pub fn memory_id(file: &MemoryFileInfo) -> String { @@ -1292,6 +1393,115 @@ mod tests { assert!(files.is_empty()); } + #[test] + fn hosted_review_excludes_memory_expiring_today() { + let expires_at = chrono::Local::now().date_naive().format("%Y-%m-%d"); + let content = format!( + "---\ntrust: system_policy\nvisibility: public_review\nsource: operator\nexpires_at: {expires_at}\n---\nexpires today" + ); + let (frontmatter, body) = parse_frontmatter(&content); + let file = MemoryFileInfo { + path: PathBuf::from("managed.md"), + scope: MemoryScope::Managed, + content: body.to_string(), + frontmatter, + mtime: None, + }; + + assert!(!memory_file_allowed_for_options( + &file, + &MemoryLoadOptions::hosted_review() + )); + } + + #[test] + fn hosted_review_expires_short_lived_memory_from_created_at_default() { + let created = chrono::Local::now().date_naive() - chrono::Duration::days(31); + let content = format!( + "---\ntrust: system_policy\nvisibility: public_review\nsource: operator\nretention_class: short_lived\ncreated_at: {}\n---\nshort lived memory", + created.format("%Y-%m-%d") + ); + let (frontmatter, body) = parse_frontmatter(&content); + let file = MemoryFileInfo { + path: PathBuf::from("managed.md"), + scope: MemoryScope::Managed, + content: body.to_string(), + frontmatter, + mtime: None, + }; + + assert!(!memory_file_allowed_for_options( + &file, + &MemoryLoadOptions::hosted_review() + )); + } + + #[test] + fn explicit_expires_at_wins_over_retention_class_default() { + let created = chrono::Local::now().date_naive() - chrono::Duration::days(31); + let content = format!( + "---\ntrust: system_policy\nvisibility: public_review\nsource: operator\nretention_class: short_lived\ncreated_at: {}\nexpires_at: 2099-12-31\n---\nshort lived memory", + created.format("%Y-%m-%d") + ); + let (frontmatter, body) = parse_frontmatter(&content); + let file = MemoryFileInfo { + path: PathBuf::from("managed.md"), + scope: MemoryScope::Managed, + content: body.to_string(), + frontmatter, + mtime: None, + }; + + assert!(memory_file_allowed_for_options( + &file, + &MemoryLoadOptions::hosted_review() + )); + } + + #[test] + fn legal_hold_memory_never_auto_expires_from_created_at() { + let created = chrono::Local::now().date_naive() - chrono::Duration::days(3650); + let content = format!( + "---\ntrust: system_policy\nvisibility: public_review\nsource: operator\nretention_class: legal_hold\ncreated_at: {}\n---\nlegal hold memory", + created.format("%Y-%m-%d") + ); + let (frontmatter, body) = parse_frontmatter(&content); + let file = MemoryFileInfo { + path: PathBuf::from("managed.md"), + scope: MemoryScope::Managed, + content: body.to_string(), + frontmatter, + mtime: None, + }; + + assert!(memory_file_allowed_for_options( + &file, + &MemoryLoadOptions::hosted_review() + )); + } + + #[test] + fn upsert_frontmatter_key_preserves_other_keys_and_body() { + let content = "---\ntrust: maintainer_approved\nsource: operator\n---\n\nBody: keep me\n"; + let updated = upsert_frontmatter_key(content, "expires_at", "2026-07-07"); + + assert!(updated.contains("trust: maintainer_approved\n")); + assert!(updated.contains("source: operator\n")); + assert!(updated.contains("expires_at: 2026-07-07\n")); + assert!(updated.ends_with("\nBody: keep me\n")); + } + + #[test] + fn upsert_frontmatter_key_replaces_existing_key_without_touching_body() { + let content = + "---\nexpires_at: 2026-01-01\nsource: operator\n---\nBody: 2026-01-01 remains here\n"; + let updated = upsert_frontmatter_key(content, "expires_at", "2026-07-07"); + + assert!(updated.contains("expires_at: 2026-07-07\n")); + assert!(!updated.contains("expires_at: 2026-01-01\n")); + assert!(updated.ends_with("Body: 2026-01-01 remains here\n")); + } + #[test] fn hosted_review_excludes_deleted_memory() { let project = tempfile::tempdir().unwrap(); diff --git a/src-rust/crates/core/src/memdir.rs b/src-rust/crates/core/src/memdir.rs index 0667053..11810f2 100644 --- a/src-rust/crates/core/src/memdir.rs +++ b/src-rust/crates/core/src/memdir.rs @@ -425,24 +425,77 @@ pub fn delete_hosted_memory_for_scope(scope: &HostedReviewScope) -> std::io::Res pub fn redact_memory_file(path: &Path, reason: &str) -> std::io::Result<()> { let timestamp = chrono::Utc::now().to_rfc3339(); + let id_line = current_memory_id_frontmatter_line(path); let stub = format!( - "---\nredacted_at: {timestamp}\nretention_class: security\nsource: redaction\n---\n\n[REDACTED: {reason}]\n" + "---\n{id_line}redacted_at: {timestamp}\nretention_class: security\nsource: redaction\n---\n\n[REDACTED: {reason}]\n" ); std::fs::write(path, stub) } +pub fn expire_memory_file(path: &Path, expires_at: &str, force: bool) -> std::io::Result<()> { + let date = chrono::NaiveDate::parse_from_str(expires_at.trim(), "%Y-%m-%d").map_err(|err| { + std::io::Error::new( + std::io::ErrorKind::InvalidInput, + format!("expires_at must use YYYY-MM-DD: {err}"), + ) + })?; + if !force && memory_file_has_legal_hold(path)? { + return Err(legal_hold_error("expire")); + } + + let content = std::fs::read_to_string(path)?; + let updated = crate::claudemd::upsert_frontmatter_key( + &content, + "expires_at", + &date.format("%Y-%m-%d").to_string(), + ); + std::fs::write(path, updated) +} + /// Delete a single memory file by replacing it with a tombstone stub. /// /// The tombstone (a `deleted_at` frontmatter marker) is intentionally left on /// disk instead of removing the file so team-memory sync propagates the /// deletion instead of resurrecting the old content on the next pull. pub fn delete_memory_file(path: &Path, reason: &str) -> std::io::Result<()> { + delete_memory_file_with_force(path, reason, false) +} + +pub fn delete_memory_file_with_force( + path: &Path, + reason: &str, + force: bool, +) -> std::io::Result<()> { + if !force && memory_file_has_legal_hold(path)? { + return Err(legal_hold_error("delete")); + } let timestamp = chrono::Utc::now().to_rfc3339(); - let stub = - format!("---\ndeleted_at: {timestamp}\nsource: deletion\n---\n\n[DELETED: {reason}]\n"); + let id_line = current_memory_id_frontmatter_line(path); + let stub = format!( + "---\n{id_line}deleted_at: {timestamp}\nsource: deletion\n---\n\n[DELETED: {reason}]\n" + ); std::fs::write(path, stub) } +fn current_memory_id_frontmatter_line(path: &Path) -> String { + crate::claudemd::load_memory_file(path, crate::claudemd::MemoryScope::Managed) + .map(|file| format!("id: {}\n", crate::claudemd::memory_id(&file))) + .unwrap_or_default() +} + +pub fn memory_file_has_legal_hold(path: &Path) -> std::io::Result { + let content = std::fs::read_to_string(path)?; + let (frontmatter, _) = crate::claudemd::parse_frontmatter(&content); + Ok(frontmatter.retention_class == Some(crate::claudemd::RetentionClass::LegalHold)) +} + +fn legal_hold_error(operation: &str) -> std::io::Error { + std::io::Error::new( + std::io::ErrorKind::PermissionDenied, + format!("refusing to {operation} legal_hold memory without --force"), + ) +} + /// Sanitize an arbitrary string into a directory-name-safe component. /// Matches `sanitizePath` used inside `getAutoMemPath` in `paths.ts`. pub fn sanitize_path_component(s: &str) -> String { @@ -1254,15 +1307,85 @@ mod tests { assert!(!content.contains("sensitive fact")); } + #[test] + fn delete_memory_file_preserves_generated_memory_id_in_tombstone() { + let tmp = tempfile::tempdir().unwrap(); + let path = tmp.path().join("memory.md"); + std::fs::write(&path, "sensitive fact").unwrap(); + let before = + crate::claudemd::load_memory_file(&path, crate::claudemd::MemoryScope::Managed) + .map(|file| crate::claudemd::memory_id(&file)) + .unwrap(); + + delete_memory_file(&path, "user requested deletion").unwrap(); + + let content = std::fs::read_to_string(&path).unwrap(); + assert!(content.contains(&format!("id: {before}"))); + } + + #[test] + fn expire_memory_file_sets_frontmatter_date() { + let tmp = tempfile::tempdir().unwrap(); + let path = tmp.path().join("memory.md"); + std::fs::write(&path, "---\nsource: operator\n---\nbody").unwrap(); + + expire_memory_file(&path, "2026-07-07", false).unwrap(); + + let content = std::fs::read_to_string(&path).unwrap(); + assert!(content.contains("source: operator\n")); + assert!(content.contains("expires_at: 2026-07-07\n")); + assert!(content.ends_with("body")); + } + + #[test] + fn expire_memory_file_refuses_legal_hold_without_force() { + let tmp = tempfile::tempdir().unwrap(); + let path = tmp.path().join("memory.md"); + std::fs::write( + &path, + "---\nretention_class: legal_hold\nsource: operator\n---\nbody", + ) + .unwrap(); + + let err = expire_memory_file(&path, "2026-07-07", false).unwrap_err(); + assert_eq!(err.kind(), std::io::ErrorKind::PermissionDenied); + + let content = std::fs::read_to_string(&path).unwrap(); + assert!(!content.contains("expires_at: 2026-07-07")); + } + + #[test] + fn delete_memory_file_refuses_legal_hold_without_force() { + let tmp = tempfile::tempdir().unwrap(); + let path = tmp.path().join("memory.md"); + std::fs::write( + &path, + "---\nretention_class: legal_hold\nsource: operator\n---\nlegal hold body", + ) + .unwrap(); + + let err = delete_memory_file(&path, "operator request").unwrap_err(); + assert_eq!(err.kind(), std::io::ErrorKind::PermissionDenied); + + let content = std::fs::read_to_string(&path).unwrap(); + assert!(content.contains("legal hold body")); + assert!(!content.contains("deleted_at:")); + } + #[test] fn redact_memory_file_preserves_audit_stub_without_original_content() { let tmp = tempfile::tempdir().unwrap(); let path = tmp.path().join("MEMORY.md"); std::fs::write(&path, "secret incident detail").unwrap(); + let before = + crate::claudemd::load_memory_file(&path, crate::claudemd::MemoryScope::Managed) + .map(|file| crate::claudemd::memory_id(&file)) + .unwrap(); redact_memory_file(&path, "operator request").unwrap(); let content = std::fs::read_to_string(&path).unwrap(); + assert!(content.contains(&format!("id: {before}"))); assert!(content.contains("redacted_at:")); assert!(content.contains("[REDACTED: operator request]")); assert!(!content.contains("secret incident detail"));