Skip to content
Merged
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
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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).
Expand Down
9 changes: 9 additions & 0 deletions crates/uteke-cli/src/commands/maintenance.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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(())
}
Expand Down
11 changes: 11 additions & 0 deletions crates/uteke-cli/src/commands/remember.rs
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,13 @@ fn parse_meta_pairs(pairs: &[String]) -> serde_json::Map<String, serde_json::Val
map
}

/// Tell the user when the privacy filter changed what was stored (#1464).
fn print_redaction(outcome: &uteke_core::RememberOutcome) {
if let Some(summary) = outcome.redaction_summary() {
println!(" \u{26a0} Secrets masked in the stored text: {summary}");
}
}

#[allow(clippy::too_many_arguments)]
pub(crate) fn run(
cli: &Cli,
Expand Down Expand Up @@ -180,6 +187,7 @@ pub(crate) fn run(
"id": id,
"author_type": author_type.unwrap_or("agent"),
"embedding_written": embed_warning.is_none(),
"redacted": outcome.redacted(),
"warning": embed_warning,
"contradiction": {
"detected": contradiction.contradicted,
Expand All @@ -198,6 +206,7 @@ pub(crate) fn run(
println!("{obj}");
} else {
output::print_remember_human(&id);
print_redaction(&outcome);
if let Some(ref warn) = embed_warning {
println!(" \u{26a0} Embedding NOT written: {warn}");
println!(" Memory is keyword-searchable only; run `uteke repair` to backfill.");
Expand Down Expand Up @@ -257,6 +266,7 @@ pub(crate) fn run(
"id": id,
"author_type": author_type.unwrap_or("agent"),
"embedding_written": outcome.embedding_written,
"redacted": outcome.redacted(),
"warning": outcome.warning,
});
// Reconstruct metadata for JSON output (already consumed by remember)
Expand Down Expand Up @@ -287,6 +297,7 @@ pub(crate) fn run(
println!("{obj}");
} else {
output::print_remember_human(&id);
print_redaction(&outcome);
if let Some(ref warn) = outcome.warning {
println!(" \u{26a0} Embedding NOT written: {warn}");
println!(" Memory is keyword-searchable only; run `uteke repair` to backfill.");
Expand Down
12 changes: 12 additions & 0 deletions crates/uteke-core/src/operations.rs
Original file line number Diff line number Diff line change
Expand Up @@ -97,6 +97,18 @@ impl RememberOutcome {
pub fn redacted(&self) -> 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<String> {
self.redacted().then(|| {
self.redactions
.iter()
.map(|r| format!("{} x{}", r.kind, r.count))
.collect::<Vec<_>>()
.join(", ")
})
}
}

impl crate::Uteke {
Expand Down
17 changes: 17 additions & 0 deletions crates/uteke-core/src/privacy_write_tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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);
}
94 changes: 91 additions & 3 deletions crates/uteke-mcp/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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<ToolResult, String> {
let req = decode_remember_args(args)?;
let content = req.content.as_str();
Expand All @@ -1246,11 +1256,11 @@ fn exec_remember(uteke: &Uteke, args: &Value) -> Result<ToolResult, String> {
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
Expand All @@ -1267,6 +1277,11 @@ fn exec_remember(uteke: &Uteke, args: &Value) -> Result<ToolResult, String> {
"\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 {
Expand Down Expand Up @@ -1656,7 +1671,7 @@ fn exec_update(uteke: &Uteke, args: &Value) -> Result<ToolResult, String> {
pinned,
memory_type,
)
.map_err(|e| format!("Failed: {e}"))?;
.map_err(write_error)?;

if !updated {
return Ok(ToolResult {
Expand Down Expand Up @@ -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}");
}
}
Loading