From c2244cd894895c005f837e093757a71b1a43eeed Mon Sep 17 00:00:00 2001 From: ajianaz Date: Wed, 21 Oct 2026 14:44:23 +0700 Subject: [PATCH] feat(mcp,cli): report masked and rejected secrets (#1464, part 2b-2c) --- CHANGELOG.md | 1 + crates/uteke-cli/src/commands/maintenance.rs | 9 ++ crates/uteke-cli/src/commands/remember.rs | 11 +++ crates/uteke-core/src/operations.rs | 12 +++ crates/uteke-core/src/privacy_write_tests.rs | 17 ++++ crates/uteke-mcp/src/lib.rs | 94 +++++++++++++++++++- 6 files changed, 141 insertions(+), 3 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 2cc6c5d1..5b9df7c5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,7 @@ ### Added +- **Privacy filter reporting in the CLI and MCP (#1464, part 2b-2c)** - `uteke remember` prints which kinds of secret were masked and its `--json` output gains `redacted`; `uteke import` reports masked and rejected rows; `uteke_remember` over MCP says so in its result, and in strict mode `uteke_remember` and `uteke_update` fail with a tool error that starts with `secret_detected:` and names the kinds, never the value. - **`[privacy]` in `uteke.toml` (#1464, part 2b-2a)** - `mode` (`redact`, the default, `strict` or `off`) and `allowlist` (regexes) now configure the secret filter in the CLI, `uteke-serve` and `uteke-mcp`; `UTEKE_PRIVACY_MODE` overrides the mode. The section is read from the global file only (a project file could otherwise switch the filter off), and a bad value falls back to the default instead of disabling it. - **`redacted` on the HTTP write responses and `secret_detected` in strict mode (#1464, part 2b-2b)** - `POST /remember` and `POST /room/remember` return `redacted: true` when the privacy filter masked a secret; a strict-mode write is refused with `400 {"error": "secret_detected", "message", "kinds"}` (also on `PUT /memory`); `POST /import` reports `redacted` and `rejected_secret`. Additive; agreed with cloud in uteke-cloud#218. - **`author` on every write path (#1427, phase 2)** - a memory records who wrote it (the agent, user or tool name), with or without a room. `POST /remember`, MCP `uteke_remember` and `uteke remember --author` accept it (free text, trimmed, 1 to 128 characters; an invalid value is a 400, a tool error or a CLI error before anything is stored). A room write stamps the named writer on the memory as well as on the room link; the `anonymous` / `user` fallback of the room link is not an author. `author` is returned on the memory object by recall, search, list and get (omitted when unset), echoed by `POST /remember`, and kept by export and import. Stores migrate to schema v22 on first open (see Internal below): back up a production store first. Filtering by author is not part of this phase. Contract: uteke-cloud#219 (cloud already has the field). diff --git a/crates/uteke-cli/src/commands/maintenance.rs b/crates/uteke-cli/src/commands/maintenance.rs index 946d0bf5..4ddb5051 100644 --- a/crates/uteke-cli/src/commands/maintenance.rs +++ b/crates/uteke-cli/src/commands/maintenance.rs @@ -375,6 +375,15 @@ pub(crate) fn run_import( "\u{2713} Imported {} memories ({} skipped) as {detected_format}", result.imported, result.skipped ); + if result.redacted > 0 { + println!(" \u{26a0} Secrets masked in {} memories", result.redacted); + } + if result.rejected_secret > 0 { + println!( + " \u{26a0} {} entries skipped: they contain a secret (strict privacy mode)", + result.rejected_secret + ); + } } Ok(()) } diff --git a/crates/uteke-cli/src/commands/remember.rs b/crates/uteke-cli/src/commands/remember.rs index 1d860938..3cf5f8fb 100644 --- a/crates/uteke-cli/src/commands/remember.rs +++ b/crates/uteke-cli/src/commands/remember.rs @@ -47,6 +47,13 @@ fn parse_meta_pairs(pairs: &[String]) -> serde_json::Map bool { !self.redactions.is_empty() } + + /// What was masked, for a human: `github-token x1, jwt x2`. `None` when + /// nothing was. + pub fn redaction_summary(&self) -> Option { + self.redacted().then(|| { + self.redactions + .iter() + .map(|r| format!("{} x{}", r.kind, r.count)) + .collect::>() + .join(", ") + }) + } } impl crate::Uteke { diff --git a/crates/uteke-core/src/privacy_write_tests.rs b/crates/uteke-core/src/privacy_write_tests.rs index 26214282..e03127a8 100644 --- a/crates/uteke-core/src/privacy_write_tests.rs +++ b/crates/uteke-core/src/privacy_write_tests.rs @@ -575,3 +575,20 @@ fn an_update_with_content_and_metadata_flags_by_the_content() { assert_eq!(m.metadata["k"], "v"); assert_eq!(m.metadata["redacted"], true); } + +#[test] +fn the_redaction_summary_names_kinds_and_counts_not_values() { + let u = store(PrivacyMode::Redact); + let masked = u + .remember_detailed(&format!("a {} b {}", gh(), aws()), &[], None, None) + .unwrap(); + let summary = masked.redaction_summary().expect("two kinds masked"); + assert!(summary.contains("github-token x1"), "{summary}"); + assert!(summary.contains("aws-access-key x1"), "{summary}"); + assert!(!summary.contains(&gh())); + + let clean = u + .remember_detailed("nothing secret", &[], None, None) + .unwrap(); + assert_eq!(clean.redaction_summary(), None); +} diff --git a/crates/uteke-mcp/src/lib.rs b/crates/uteke-mcp/src/lib.rs index a31016bd..0c30644c 100644 --- a/crates/uteke-mcp/src/lib.rs +++ b/crates/uteke-mcp/src/lib.rs @@ -1225,6 +1225,16 @@ fn namespace_deprecation_suffix(namespace: Option<&str>) -> String { .unwrap_or_default() } +/// The error text of a failed write. Strict privacy mode (#1464) is +/// recognisable: it starts with `secret_detected`, the same code the HTTP API +/// uses. +fn write_error(e: uteke_core::Error) -> String { + match e { + uteke_core::Error::SecretDetected { .. } => format!("secret_detected: {e}"), + other => format!("Failed: {other}"), + } +} + fn exec_remember(uteke: &Uteke, args: &Value) -> Result { let req = decode_remember_args(args)?; let content = req.content.as_str(); @@ -1246,11 +1256,11 @@ fn exec_remember(uteke: &Uteke, args: &Value) -> Result { room_id, author, ) - .map_err(|e| format!("Failed: {e}"))? + .map_err(write_error)? } else { uteke .remember_typed_detailed(content, &tags, None, namespace, memory_type) - .map_err(|e| format!("Failed: {e}"))? + .map_err(write_error)? }; // The named writer is the memory's own author, with or without a room // (#1427 phase 2); already validated and trimmed when the arguments were @@ -1267,6 +1277,11 @@ fn exec_remember(uteke: &Uteke, args: &Value) -> Result { "\n⚠ Embedding NOT written: {warn}\nMemory is keyword-searchable only; run `uteke repair` to backfill." )); } + if let Some(summary) = outcome.redaction_summary() { + text.push_str(&format!( + "\n⚠ Secrets masked in the stored text (#1464): {summary}" + )); + } text.push_str(&namespace_deprecation_suffix(namespace)); Ok(ToolResult { @@ -1656,7 +1671,7 @@ fn exec_update(uteke: &Uteke, args: &Value) -> Result { pinned, memory_type, ) - .map_err(|e| format!("Failed: {e}"))?; + .map_err(write_error)?; if !updated { return Ok(ToolResult { @@ -4228,3 +4243,76 @@ mod author_tests { assert_eq!(u.count(None).unwrap(), 0, "nothing is stored"); } } + +/// The privacy filter over MCP (#1464): masked writes say so, strict mode is a +/// tool error that starts with `secret_detected` and names kinds, not values. +#[cfg(test)] +mod privacy_mcp_tests { + use super::*; + use uteke_core::Uteke; + use uteke_core::privacy::{PrivacyConfig, PrivacyMode}; + + fn store(mode: PrivacyMode) -> Uteke { + let mut u = Uteke::open_with_backend(":memory:", None).expect("open without embedder"); + u.set_privacy_config(PrivacyConfig { + mode, + allowlist: Vec::new(), + }); + u + } + + fn token() -> String { + format!("{}{}", "ghp_", "aB3dE5gH7jK9mN1pQ3rS5tU7vW9xY1zA3bC5") + } + + fn text(r: &ToolResult) -> String { + match &r.content[0] { + McpContent::Text { text, .. } => text.clone(), + } + } + + #[test] + fn remember_says_when_a_secret_was_masked() { + let u = store(PrivacyMode::Redact); + let r = exec_remember( + &u, + &serde_json::json!({"content": format!("key {}", token())}), + ) + .unwrap(); + let t = text(&r); + assert!( + t.contains("Secrets masked") && t.contains("github-token x1"), + "{t}" + ); + assert!(!t.contains("aB3dE5gH7jK9"), "{t}"); + + let clean = exec_remember(&u, &serde_json::json!({"content": "nothing secret"})).unwrap(); + assert!(!text(&clean).contains("Secrets masked")); + } + + #[test] + fn strict_mode_is_a_secret_detected_tool_error_for_remember_and_update() { + let u = store(PrivacyMode::Strict); + let err = exec_remember( + &u, + &serde_json::json!({"content": format!("key {}", token())}), + ) + .err() + .expect("strict rejects"); + assert!(err.starts_with("secret_detected:"), "{err}"); + assert!( + err.contains("github-token") && !err.contains("aB3dE5gH7jK9"), + "{err}" + ); + + let ok = exec_remember(&u, &serde_json::json!({"content": "plain"})).unwrap(); + let id = text(&ok).rsplit(' ').next().unwrap().trim().to_string(); + let err = exec_update( + &u, + &serde_json::json!({"id": id, "content": format!("now {}", token())}), + ) + .err() + .expect("strict rejects the update"); + assert!(err.starts_with("secret_detected:"), "{err}"); + } +}