Files
optimclaw/tests/layered_memory.rs
T
212d661e20 feat(workspace): layered memory with sensitivity-based privacy redirect (#1112)
* feat(workspace): layered memory with sensitivity-based privacy redirect

Introduce MemoryLayer type for named memory layers with sensitivity
levels and write permissions. Layers map to synthetic user_id values
in workspace tables, enabling shared/private memory isolation.

- Add MemoryLayer, LayerSensitivity types with default_for_user()
- Add layer-aware write methods (write_to_layer, append_to_layer)
- Add PatternPrivacyClassifier to guard shared layer writes
- Add optional 'layer' parameter to memory_write tool and HTTP API
- Add 'redirected' and 'actual_layer' fields to write response
- Add MEMORY_LAYERS env var (JSON) for layer configuration
- Workspace user_id now derived from GATEWAY_USER_ID (was hardcoded "default")
- 10 integration tests for layered memory operations

Addresses prerequisite for Issue #59 (multi-tenancy).

Co-Authored-By: Claude Opus 4.6 <[email protected]>

* fix: add explicit default to memory_write layer schema

Add "default": "private" to the layer parameter's JSON schema so
LLM tool consumers can see the default without reading code.

Co-Authored-By: Claude Opus 4.6 <[email protected]>

* refactor: extract resolve_layer_target to deduplicate layer writes

Consolidate shared layer-lookup, writable check, and privacy
classification logic from write_to_layer and append_to_layer into a
single resolve_layer_target helper.

Flagged on #349 review — the duplication originates in this PR.

Co-Authored-By: Claude Opus 4.6 <[email protected]>

* fix: address review feedback on layered memory PR

- Fix email regex pipe bug in TLD character class (privacy.rs)
- Add append support to web memory_write handler via `append` field
- Validate MemoryLayer name/scope: reject empty, check duplicates
- Remove hardcoded 'private' default from tool schema; omit layer
  fields from output when no layer specified
- Document scope isolation risk for multi-tenant (Issue #59)

Co-Authored-By: Claude Opus 4.6 <[email protected]>

* fix: address adversarial review findings

- CRITICAL: fix identity file protection bypass via trailing slash
  (normalize target path before protection checks)
- HIGH: check private layer is writable before privacy redirect
- HIGH: map LayerNotFound/ReadOnly to proper 4xx HTTP status codes
- HIGH: honor `append` field in non-layer HTTP write path
- MEDIUM: remove redundant DB fetch in append_to_layer (narrower
  TOCTOU window)
- MEDIUM: remove dead memory_write_handler from handlers/memory.rs

Co-Authored-By: Claude Opus 4.6 <[email protected]>

* feat: opt-in privacy classifier, force override, confidence scoring

Address review feedback from @zmanian:

- Privacy classifier is now opt-in via with_privacy_classifier() instead
  of always-on. Default hardcoded patterns (doctor, therapy, email, phone)
  had unacceptable false positive rates in household contexts. LLM chooses
  the correct layer via system prompt; regex can't improve on that.
- Add ConfigurablePrivacyClassifier for operator-supplied patterns.
- PatternPrivacyClassifier defaults narrowed to hard PII only (SSN,
  credit card, credentials).
- Add force param to write_to_layer/append_to_layer to skip classifier.
- PrivacyClassifier trait returns SensitivityResult { is_sensitive,
  confidence } instead of bool, ready for probabilistic classifiers.

Co-Authored-By: Claude Opus 4.6 <[email protected]>

* fix: remove redundant heartbeat match arm in memory_write

The heartbeat arm was identical to the catch-all — resolved_path
already points to paths::HEARTBEAT when target is "heartbeat".

Addresses review feedback from gemini-code-assist on #1112.

Co-Authored-By: Claude Opus 4.6 <[email protected]>

* fix: return Result from PatternPrivacyClassifier::new()

Replace .expect() with proper error propagation per project
no-panics policy. Remove Default impl (unused in production).

Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]>

* refactor: move memory_layers from GatewayConfig to WorkspaceConfig

Resolve merge conflicts between HEAD (transcription, search, env helpers)
and the workspace config branch. GatewayConfig no longer owns memory_layers;
WorkspaceConfig::resolve() handles parsing, validation (name length >64,
character set, empty scope, duplicates), and fallback defaults.

Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]>

* test: strengthen privacy classifier and layer isolation coverage

Add 8 privacy classifier edge case tests (format variants, keywords,
longer documents, empty/partial inputs) and 5 layer write isolation
integration tests (cross-scope invisibility, overwrite, empty path,
sensitive-to-private no-redirect).

Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]>

* fix: tautological test assertion and add WorkspaceConfig validation tests

Replace always-true `is_ok() || is_err()` in write_empty_path_to_layer
with actual behavior assertion (write succeeds with normalized empty path).

Add 8 unit tests for WorkspaceConfig::resolve() covering valid JSON parsing,
invalid JSON, empty/long/invalid-char layer names, empty scopes, duplicates,
and default fallback behavior.

Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]>

* style: cargo fmt after staging merge

Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]>

---------

Co-authored-by: Claude Opus 4.6 <[email protected]>
Co-authored-by: [email protected] <[email protected]>
2026-03-20 22:15:29 -07:00

361 lines
12 KiB
Rust

#![cfg(feature = "libsql")]
//! Integration tests for layered memory using file-backed libSQL.
use std::sync::Arc;
use ironclaw::db::Database;
use ironclaw::db::libsql::LibSqlBackend;
use ironclaw::workspace::Workspace;
use ironclaw::workspace::layer::{LayerSensitivity, MemoryLayer};
use ironclaw::workspace::privacy::PatternPrivacyClassifier;
async fn setup() -> (Arc<dyn Database>, tempfile::TempDir) {
let dir = tempfile::tempdir().expect("create temp dir");
let db_path = dir.path().join("test.db");
let backend = LibSqlBackend::new_local(&db_path).await.expect("create db");
backend.run_migrations().await.expect("run migrations");
let db: Arc<dyn Database> = Arc::new(backend);
(db, dir)
}
fn test_layers() -> Vec<MemoryLayer> {
vec![
MemoryLayer {
name: "private".into(),
scope: "alice".into(),
writable: true,
sensitivity: LayerSensitivity::Private,
},
MemoryLayer {
name: "shared".into(),
scope: "shared".into(),
writable: true,
sensitivity: LayerSensitivity::Shared,
},
MemoryLayer {
name: "reports".into(),
scope: "reports".into(),
writable: false,
sensitivity: LayerSensitivity::Shared,
},
]
}
#[tokio::test]
async fn write_to_private_layer() {
let (db, _dir) = setup().await;
let ws = Workspace::new_with_db("alice", db).with_memory_layers(test_layers());
let result = ws
.write_to_layer("private", "notes/test.md", "Private note", false)
.await
.expect("write should succeed");
assert_eq!(result.document.content, "Private note");
assert!(!result.redirected);
assert_eq!(result.actual_layer, "private");
}
#[tokio::test]
async fn write_to_shared_layer() {
let (db, _dir) = setup().await;
let ws = Workspace::new_with_db("alice", db).with_memory_layers(test_layers());
let result = ws
.write_to_layer("shared", "plans/dinner.md", "Dinner Saturday at 6", false)
.await
.expect("write should succeed");
assert_eq!(result.document.content, "Dinner Saturday at 6");
assert!(!result.redirected);
assert_eq!(result.actual_layer, "shared");
}
#[tokio::test]
async fn write_to_read_only_layer_fails() {
let (db, _dir) = setup().await;
let ws = Workspace::new_with_db("alice", db).with_memory_layers(test_layers());
let result = ws
.write_to_layer("reports", "notes/budget.md", "Some budget note", false)
.await;
assert!(result.is_err());
}
#[tokio::test]
async fn write_to_unknown_layer_fails() {
let (db, _dir) = setup().await;
let ws = Workspace::new_with_db("alice", db).with_memory_layers(test_layers());
let result = ws
.write_to_layer("nonexistent", "notes/test.md", "content", false)
.await;
assert!(result.is_err());
}
#[tokio::test]
async fn no_redirect_without_classifier() {
let (db, _dir) = setup().await;
let ws = Workspace::new_with_db("alice", db).with_memory_layers(test_layers());
// Without a classifier, PII goes exactly where requested
let result = ws
.write_to_layer("shared", "notes/pii.md", "My SSN is 123-45-6789", false)
.await
.expect("write should succeed");
assert!(!result.redirected);
assert_eq!(result.actual_layer, "shared");
}
#[tokio::test]
async fn sensitive_content_redirected_to_private() {
let (db, _dir) = setup().await;
let db_clone = db.clone();
let ws = Workspace::new_with_db("alice", db)
.with_memory_layers(test_layers())
.with_privacy_classifier(Arc::new(PatternPrivacyClassifier::new().unwrap()));
// Write content containing hard PII to shared layer -- should be redirected
let result = ws
.write_to_layer("shared", "notes/pii.md", "My SSN is 123-45-6789", false)
.await
.expect("write should succeed (redirected)");
// WriteResult should indicate redirect to private layer
assert!(result.redirected, "Should be redirected");
assert_eq!(result.actual_layer, "private");
assert_eq!(result.document.content, "My SSN is 123-45-6789");
// Content should be in the private scope (alice), not the shared scope
let private_doc = ws.read("notes/pii.md").await;
assert!(
private_doc.is_ok(),
"Should find content in private scope (alice)"
);
assert_eq!(private_doc.unwrap().content, "My SSN is 123-45-6789");
// Verify content is NOT in the shared scope (same DB, different user_id)
let ws_shared = Workspace::new_with_db("shared", db_clone);
let shared_doc = ws_shared.read("notes/pii.md").await;
assert!(
shared_doc.is_err(),
"Should NOT find content in shared scope"
);
}
#[tokio::test]
async fn default_write_still_works() {
let (db, _dir) = setup().await;
let ws = Workspace::new_with_db("alice", db).with_memory_layers(test_layers());
// Regular write (no layer) should still work
let doc = ws
.write("notes/test.md", "Regular note")
.await
.expect("write should succeed");
assert_eq!(doc.content, "Regular note");
}
#[tokio::test]
async fn append_to_layer_works() {
let (db, _dir) = setup().await;
let ws = Workspace::new_with_db("alice", db).with_memory_layers(test_layers());
// Write initial content to a layer
ws.write_to_layer("private", "notes/log.md", "Entry one", false)
.await
.expect("initial write should succeed");
// Append to the same layer path
let result = ws
.append_to_layer("private", "notes/log.md", "Entry two", false)
.await
.expect("append should succeed");
// Content should be concatenated with double newline
assert!(
result.document.content.contains("Entry one"),
"Should contain first entry"
);
assert!(
result.document.content.contains("Entry two"),
"Should contain second entry"
);
}
#[tokio::test]
async fn sensitive_content_fails_without_private_layer() {
let (db, _dir) = setup().await;
// Workspace with classifier but only shared layers (no private layer for redirect)
let shared_only_layers = vec![MemoryLayer {
name: "shared".into(),
scope: "shared".into(),
writable: true,
sensitivity: LayerSensitivity::Shared,
}];
let ws = Workspace::new_with_db("alice", db)
.with_memory_layers(shared_only_layers)
.with_privacy_classifier(Arc::new(PatternPrivacyClassifier::new().unwrap()));
// Writing PII content should fail (no private layer to redirect to)
let result = ws
.write_to_layer("shared", "notes/pii.md", "My SSN is 123-45-6789", false)
.await;
assert!(
result.is_err(),
"Should fail when no private layer available for redirect"
);
}
#[tokio::test]
async fn append_sensitive_to_shared_redirects() {
let (db, _dir) = setup().await;
let ws = Workspace::new_with_db("alice", db)
.with_memory_layers(test_layers())
.with_privacy_classifier(Arc::new(PatternPrivacyClassifier::new().unwrap()));
// Append PII content to shared layer -- should be redirected
let result = ws
.append_to_layer(
"shared",
"notes/pii.md",
"Card number is 4111 1111 1111 1111",
false,
)
.await
.expect("append should succeed (redirected)");
assert!(result.redirected, "Should be redirected");
assert_eq!(result.actual_layer, "private");
assert!(result.document.content.contains("4111"));
}
#[tokio::test]
async fn force_skips_privacy_redirect() {
let (db, _dir) = setup().await;
let ws = Workspace::new_with_db("alice", db)
.with_memory_layers(test_layers())
.with_privacy_classifier(Arc::new(PatternPrivacyClassifier::new().unwrap()));
// PII content with force=true should stay in shared layer
let result = ws
.write_to_layer("shared", "notes/pii.md", "My SSN is 123-45-6789", true)
.await
.expect("write should succeed without redirect");
assert!(
!result.redirected,
"Should NOT be redirected with force=true"
);
assert_eq!(result.actual_layer, "shared");
}
#[tokio::test]
async fn search_finds_private_layer_content() {
let (db, _dir) = setup().await;
let ws = Workspace::new_with_db("alice", db).with_memory_layers(test_layers());
// Write to the private layer (scope = "alice" = user_id)
ws.write_to_layer(
"private",
"notes/private.md",
"My private thought about waffles",
false,
)
.await
.unwrap();
// Search should find content in the primary scope
let results = ws.search("waffles", 10).await.unwrap();
assert!(
!results.is_empty(),
"Should find results in the private layer"
);
}
#[tokio::test]
async fn write_to_private_invisible_from_shared_scope() {
let (db, _dir) = setup().await;
let db_clone = db.clone();
let ws = Workspace::new_with_db("alice", db).with_memory_layers(test_layers());
ws.write_to_layer("private", "notes/secret.md", "Private data", false)
.await
.expect("write should succeed");
let ws_shared = Workspace::new_with_db("shared", db_clone);
let result = ws_shared.read("notes/secret.md").await;
assert!(
result.is_err(),
"Shared scope must not read private layer content"
);
}
#[tokio::test]
async fn write_to_shared_invisible_from_private_scope() {
let (db, _dir) = setup().await;
let db_clone = db.clone();
let ws = Workspace::new_with_db("alice", db).with_memory_layers(test_layers());
ws.write_to_layer("shared", "plans/visible.md", "Shared plan", false)
.await
.expect("write should succeed");
let ws_alice = Workspace::new_with_db("alice", db_clone);
let result = ws_alice.read("plans/visible.md").await;
assert!(
result.is_err(),
"Private scope must not read shared layer content without multi-scope"
);
}
#[tokio::test]
async fn write_empty_path_to_layer() {
let (db, _dir) = setup().await;
let ws = Workspace::new_with_db("alice", db).with_memory_layers(test_layers());
let result = ws.write_to_layer("private", "", "content", false).await;
// normalize_path("") returns "" — the write succeeds with an empty-string path
assert!(result.is_ok(), "write with empty path should succeed");
let write_result = result.unwrap();
assert_eq!(write_result.document.content, "content");
assert!(!write_result.redirected);
assert_eq!(write_result.actual_layer, "private");
}
#[tokio::test]
async fn overwrite_existing_content_in_layer() {
let (db, _dir) = setup().await;
let ws = Workspace::new_with_db("alice", db).with_memory_layers(test_layers());
ws.write_to_layer("private", "notes/evolving.md", "Version 1", false)
.await
.expect("first write");
let result = ws
.write_to_layer("private", "notes/evolving.md", "Version 2", false)
.await
.expect("overwrite should succeed");
assert_eq!(result.document.content, "Version 2");
assert!(!result.redirected);
}
#[tokio::test]
async fn sensitive_write_to_private_layer_not_redirected() {
let (db, _dir) = setup().await;
let ws = Workspace::new_with_db("alice", db)
.with_memory_layers(test_layers())
.with_privacy_classifier(Arc::new(PatternPrivacyClassifier::new().unwrap()));
let result = ws
.write_to_layer("private", "notes/pii.md", "My SSN is 123-45-6789", false)
.await
.expect("write to private should succeed");
assert!(
!result.redirected,
"Private layer writes should not redirect"
);
assert_eq!(result.actual_layer, "private");
}