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]>
This commit is contained in:
standardtoaster
2026-03-20 22:15:29 -07:00
committed by GitHub
co-authored by Claude Opus 4.6 [email protected] <[email protected]>
parent 0d1a5c210b
commit 212d661e20
13 changed files with 1363 additions and 78 deletions
+9 -1
View File
@@ -312,15 +312,23 @@ impl AppBuilder {
.create_provider(&self.config.llm.nearai.base_url, self.session.clone());
// Register memory tools if database is available
let workspace_user_id = self
.config
.channels
.gateway
.as_ref()
.map(|gw| gw.user_id.as_str())
.unwrap_or("default");
let workspace = if let Some(ref db) = self.db {
let emb_cache_config = EmbeddingCacheConfig {
max_entries: self.config.embeddings.cache_size,
};
let mut ws = Workspace::new_with_db(&self.config.owner_id, db.clone())
let mut ws = Workspace::new_with_db(workspace_user_id, db.clone())
.with_search_config(&self.config.search);
if let Some(ref emb) = embeddings {
ws = ws.with_embeddings_cached(emb.clone(), emb_cache_config);
}
ws = ws.with_memory_layers(self.config.workspace.memory_layers.clone());
let ws = Arc::new(ws);
tools.register_memory_tools(Arc::clone(&ws));
Some(ws)
+2 -19
View File
@@ -123,25 +123,8 @@ pub async fn memory_read_handler(
}))
}
pub async fn memory_write_handler(
State(state): State<Arc<GatewayState>>,
Json(req): Json<MemoryWriteRequest>,
) -> Result<Json<MemoryWriteResponse>, (StatusCode, String)> {
let workspace = state.workspace.as_ref().ok_or((
StatusCode::SERVICE_UNAVAILABLE,
"Workspace not available".to_string(),
))?;
workspace
.write(&req.path, &req.content)
.await
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
Ok(Json(MemoryWriteResponse {
path: req.path,
status: "written",
}))
}
// memory_write_handler lives in server.rs (layer-aware version with append,
// privacy redirect, and proper error status codes).
pub async fn memory_search_handler(
State(state): State<Arc<GatewayState>>,
+43 -4
View File
@@ -1822,14 +1822,53 @@ async fn memory_write_handler(
"Workspace not available".to_string(),
))?;
workspace
.write(&req.path, &req.content)
.await
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
// Route through layer-aware methods when a layer is specified
if let Some(ref layer_name) = req.layer {
let result = if req.append {
workspace
.append_to_layer(layer_name, &req.path, &req.content, req.force)
.await
} else {
workspace
.write_to_layer(layer_name, &req.path, &req.content, req.force)
.await
}
.map_err(|e| {
use crate::error::WorkspaceError;
let status = match &e {
WorkspaceError::LayerNotFound { .. } => StatusCode::BAD_REQUEST,
WorkspaceError::LayerReadOnly { .. } => StatusCode::FORBIDDEN,
WorkspaceError::PrivacyRedirectFailed => StatusCode::UNPROCESSABLE_ENTITY,
_ => StatusCode::INTERNAL_SERVER_ERROR,
};
(status, e.to_string())
})?;
return Ok(Json(MemoryWriteResponse {
path: req.path,
status: "written",
redirected: Some(result.redirected),
actual_layer: Some(result.actual_layer),
}));
}
// Non-layer path: honor the append field
if req.append {
workspace
.append(&req.path, &req.content)
.await
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
} else {
workspace
.write(&req.path, &req.content)
.await
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
}
Ok(Json(MemoryWriteResponse {
path: req.path,
status: "written",
redirected: None,
actual_layer: None,
}))
}
+18
View File
@@ -302,12 +302,30 @@ pub struct MemoryReadResponse {
pub struct MemoryWriteRequest {
pub path: String,
pub content: String,
/// Optional layer to write to. When present, uses `write_to_layer()`
/// which enables privacy classification and redirect.
pub layer: Option<String>,
/// When true and a layer is specified, appends to existing content
/// instead of replacing it.
#[serde(default)]
pub append: bool,
/// Skip privacy classification and write directly to the specified layer.
#[serde(default)]
pub force: bool,
}
#[derive(Debug, Serialize)]
pub struct MemoryWriteResponse {
pub path: String,
pub status: &'static str,
/// Whether the write was redirected to a different layer (e.g., sensitive
/// content redirected from shared to private).
#[serde(skip_serializing_if = "Option::is_none")]
pub redirected: Option<bool>,
/// The layer the content was actually written to (may differ from requested
/// layer if privacy redirect occurred).
#[serde(skip_serializing_if = "Option::is_none")]
pub actual_layer: Option<String>,
}
#[derive(Debug, Deserialize)]
+5 -1
View File
@@ -111,6 +111,10 @@ impl ChannelsConfig {
let gateway_enabled = parse_bool_env("GATEWAY_ENABLED", cs.gateway_enabled)?;
let gateway = if gateway_enabled {
let user_id = optional_env("GATEWAY_USER_ID")?
.or_else(|| cs.gateway_user_id.clone())
.unwrap_or_else(|| "default".to_string());
Some(GatewayConfig {
host: optional_env("GATEWAY_HOST")?
.or_else(|| cs.gateway_host.clone())
@@ -121,7 +125,7 @@ impl ChannelsConfig {
)?,
auth_token: optional_env("GATEWAY_AUTH_TOKEN")?
.or_else(|| cs.gateway_auth_token.clone()),
user_id: owner_id.to_string(),
user_id,
})
} else {
None
+17 -2
View File
@@ -24,6 +24,7 @@ mod skills;
mod transcription;
mod tunnel;
mod wasm;
mod workspace;
use std::collections::HashMap;
use std::sync::{LazyLock, Mutex, Once};
@@ -53,6 +54,7 @@ pub use self::skills::SkillsConfig;
pub use self::transcription::TranscriptionConfig;
pub use self::tunnel::TunnelConfig;
pub use self::wasm::WasmConfig;
pub use self::workspace::WorkspaceConfig;
pub use crate::llm::config::{
BedrockConfig, CacheRetention, LlmConfig, NearAiConfig, OAUTH_PLACEHOLDER, OpenAiCodexConfig,
RegistryProviderConfig,
@@ -98,6 +100,7 @@ pub struct Config {
pub skills: SkillsConfig,
pub transcription: TranscriptionConfig,
pub search: WorkspaceSearchConfig,
pub workspace: WorkspaceConfig,
pub observability: crate::observability::ObservabilityConfig,
/// Channel-relay integration (Slack via external relay service).
/// Present only when both `CHANNEL_RELAY_URL` and `CHANNEL_RELAY_API_KEY` are set.
@@ -175,6 +178,9 @@ impl Config {
},
transcription: TranscriptionConfig::default(),
search: WorkspaceSearchConfig::default(),
workspace: WorkspaceConfig {
memory_layers: vec![],
},
observability: crate::observability::ObservabilityConfig::default(),
relay: None,
}
@@ -305,13 +311,21 @@ impl Config {
async fn build(settings: &Settings) -> Result<Self, ConfigError> {
let owner_id = resolve_owner_id(settings)?;
let tunnel = TunnelConfig::resolve(settings)?;
let channels = ChannelsConfig::resolve(settings, &owner_id)?;
let workspace_user_id = channels
.gateway
.as_ref()
.map(|gw| gw.user_id.clone())
.unwrap_or_else(|| "default".to_string());
Ok(Self {
owner_id: owner_id.clone(),
database: DatabaseConfig::resolve()?,
llm: LlmConfig::resolve(settings)?,
embeddings: EmbeddingsConfig::resolve(settings)?,
tunnel: TunnelConfig::resolve(settings)?,
channels: ChannelsConfig::resolve(settings, &owner_id)?,
tunnel,
channels,
agent: AgentConfig::resolve(settings)?,
safety: resolve_safety_config(settings)?,
wasm: WasmConfig::resolve(settings)?,
@@ -325,6 +339,7 @@ impl Config {
skills: SkillsConfig::resolve()?,
transcription: TranscriptionConfig::resolve(settings)?,
search: WorkspaceSearchConfig::resolve()?,
workspace: WorkspaceConfig::resolve(&workspace_user_id)?,
observability: crate::observability::ObservabilityConfig {
backend: std::env::var("OBSERVABILITY_BACKEND").unwrap_or_else(|_| "none".into()),
},
+208
View File
@@ -0,0 +1,208 @@
use crate::config::helpers::optional_env;
use crate::error::ConfigError;
use crate::workspace::layer::MemoryLayer;
/// Workspace memory configuration.
///
/// Controls memory layer definitions for privacy-aware writes.
/// Layers are parsed from the `MEMORY_LAYERS` env var (JSON array)
/// or default to a single private layer scoped to the gateway user.
#[derive(Debug, Clone)]
pub struct WorkspaceConfig {
pub memory_layers: Vec<MemoryLayer>,
}
impl WorkspaceConfig {
pub(crate) fn resolve(user_id: &str) -> Result<Self, ConfigError> {
let memory_layers: Vec<MemoryLayer> = match optional_env("MEMORY_LAYERS")? {
Some(json_str) => {
serde_json::from_str(&json_str).map_err(|e| ConfigError::InvalidValue {
key: "MEMORY_LAYERS".to_string(),
message: format!("must be valid JSON array of layer objects: {e}"),
})?
}
None => MemoryLayer::default_for_user(user_id),
};
// Validate layer names and scopes
for layer in &memory_layers {
if layer.name.trim().is_empty() {
return Err(ConfigError::InvalidValue {
key: "MEMORY_LAYERS".to_string(),
message: "layer name must not be empty".to_string(),
});
}
if layer.name.len() > 64 {
return Err(ConfigError::InvalidValue {
key: "MEMORY_LAYERS".to_string(),
message: format!("layer name '{}' exceeds 64 characters", layer.name),
});
}
if !layer
.name
.chars()
.all(|c| c.is_alphanumeric() || c == '_' || c == '-')
{
return Err(ConfigError::InvalidValue {
key: "MEMORY_LAYERS".to_string(),
message: format!(
"layer name '{}' contains invalid characters (only alphanumeric, _, - allowed)",
layer.name
),
});
}
if layer.scope.trim().is_empty() {
return Err(ConfigError::InvalidValue {
key: "MEMORY_LAYERS".to_string(),
message: format!("layer '{}' has an empty scope", layer.name),
});
}
}
// Check for duplicate layer names
{
let mut seen = std::collections::HashSet::new();
for layer in &memory_layers {
if !seen.insert(&layer.name) {
return Err(ConfigError::InvalidValue {
key: "MEMORY_LAYERS".to_string(),
message: format!("duplicate layer name '{}'", layer.name),
});
}
}
}
Ok(Self { memory_layers })
}
}
#[cfg(test)]
mod tests {
use super::*;
use std::sync::Mutex;
// Serialize env-var-dependent tests to avoid races.
static ENV_LOCK: Mutex<()> = Mutex::new(());
fn with_env(key: &str, val: Option<&str>, f: impl FnOnce()) {
let _guard = ENV_LOCK.lock().unwrap();
let prev = std::env::var(key).ok();
match val {
Some(v) => unsafe { std::env::set_var(key, v) },
None => unsafe { std::env::remove_var(key) },
}
f();
match prev {
Some(v) => unsafe { std::env::set_var(key, v) },
None => unsafe { std::env::remove_var(key) },
}
}
#[test]
fn valid_json_parses_correctly() {
let json = r#"[{"name":"private","scope":"alice","writable":true,"sensitivity":"private"},{"name":"shared","scope":"shared","writable":true,"sensitivity":"shared"}]"#;
with_env("MEMORY_LAYERS", Some(json), || {
let config = WorkspaceConfig::resolve("alice").expect("should parse");
assert_eq!(config.memory_layers.len(), 2);
assert_eq!(config.memory_layers[0].name, "private");
assert_eq!(config.memory_layers[1].name, "shared");
});
}
#[test]
fn invalid_json_returns_error() {
with_env("MEMORY_LAYERS", Some("not json"), || {
let result = WorkspaceConfig::resolve("alice");
assert!(result.is_err(), "invalid JSON should fail");
let err = result.unwrap_err().to_string();
assert!(
err.contains("valid JSON"),
"error should mention JSON: {err}"
);
});
}
#[test]
fn empty_layer_name_returns_error() {
let json = r#"[{"name":"","scope":"alice"}]"#;
with_env("MEMORY_LAYERS", Some(json), || {
let result = WorkspaceConfig::resolve("alice");
assert!(result.is_err(), "empty layer name should fail");
let err = result.unwrap_err().to_string();
assert!(err.contains("empty"), "error should mention empty: {err}");
});
}
#[test]
fn layer_name_exceeding_64_chars_returns_error() {
let long_name = "a".repeat(65);
let json = format!(r#"[{{"name":"{long_name}","scope":"alice"}}]"#);
with_env("MEMORY_LAYERS", Some(&json), || {
let result = WorkspaceConfig::resolve("alice");
assert!(result.is_err(), "long layer name should fail");
let err = result.unwrap_err().to_string();
assert!(
err.contains("exceeds 64"),
"error should mention 64 chars: {err}"
);
});
}
#[test]
fn layer_name_with_invalid_chars_returns_error() {
for bad_name in ["has space", "has@at", "has.dot", "has/slash"] {
let json = format!(r#"[{{"name":"{bad_name}","scope":"alice"}}]"#);
with_env("MEMORY_LAYERS", Some(&json), || {
let result = WorkspaceConfig::resolve("alice");
assert!(
result.is_err(),
"layer name '{bad_name}' should fail validation"
);
let err = result.unwrap_err().to_string();
assert!(
err.contains("invalid characters"),
"error for '{bad_name}' should mention invalid characters: {err}"
);
});
}
}
#[test]
fn empty_scope_returns_error() {
let json = r#"[{"name":"private","scope":""}]"#;
with_env("MEMORY_LAYERS", Some(json), || {
let result = WorkspaceConfig::resolve("alice");
assert!(result.is_err(), "empty scope should fail");
let err = result.unwrap_err().to_string();
assert!(
err.contains("empty scope"),
"error should mention empty scope: {err}"
);
});
}
#[test]
fn duplicate_layer_names_returns_error() {
let json = r#"[{"name":"private","scope":"alice"},{"name":"private","scope":"bob"}]"#;
with_env("MEMORY_LAYERS", Some(json), || {
let result = WorkspaceConfig::resolve("alice");
assert!(result.is_err(), "duplicate names should fail");
let err = result.unwrap_err().to_string();
assert!(
err.contains("duplicate"),
"error should mention duplicate: {err}"
);
});
}
#[test]
fn missing_env_defaults_to_single_private_layer() {
with_env("MEMORY_LAYERS", None, || {
let config = WorkspaceConfig::resolve("alice").expect("should default");
assert_eq!(config.memory_layers.len(), 1);
assert_eq!(config.memory_layers[0].name, "private");
assert_eq!(config.memory_layers[0].scope, "alice");
assert!(config.memory_layers[0].writable);
});
}
}
+12
View File
@@ -304,6 +304,18 @@ pub enum WorkspaceError {
#[error("I/O error: {reason}")]
IoError { reason: String },
#[error("Not found: {path}")]
NotFound { path: String },
#[error("Layer not found: {name}")]
LayerNotFound { name: String },
#[error("Layer '{name}' is read-only")]
LayerReadOnly { name: String },
#[error("Cannot write sensitive content: no private layer available for redirect")]
PrivacyRedirectFailed,
#[error("Write rejected for '{path}': prompt injection detected ({reason})")]
InjectionRejected { path: String, reason: String },
}
+77 -48
View File
@@ -194,6 +194,15 @@ impl Tool for MemoryWriteTool {
"type": "boolean",
"description": "If true, append to existing content. If false, replace entirely.",
"default": true
},
"layer": {
"type": "string",
"description": "Memory layer to write to (e.g. 'private', 'household', 'finance'). When omitted, writes to the workspace's default scope."
},
"force": {
"type": "boolean",
"description": "Skip privacy classification and write directly to the specified layer without redirect. Use when you're certain the content belongs in the target layer.",
"default": false
}
},
"required": ["content"]
@@ -256,67 +265,86 @@ impl Tool for MemoryWriteTool {
.and_then(|v| v.as_bool())
.unwrap_or(true);
// Prompt injection scanning for system-prompt files is handled by
// Workspace::write() / Workspace::append() — no need to duplicate here.
let layer = params.get("layer").and_then(|v| v.as_str());
let force = params
.get("force")
.and_then(|v| v.as_bool())
.unwrap_or(false);
let path = match target {
"memory" => {
if append {
self.workspace
.append_memory(content)
.await
.map_err(map_write_err)?;
} else {
self.workspace
.write(paths::MEMORY, content)
.await
.map_err(map_write_err)?;
}
paths::MEMORY.to_string()
}
// Resolve the target to a workspace path
let resolved_path = match target {
"memory" => paths::MEMORY.to_string(),
"daily_log" => {
let tz = crate::timezone::parse_timezone(&ctx.user_timezone)
.unwrap_or(chrono_tz::Tz::UTC);
let now = chrono::Utc::now().with_timezone(&tz);
format!("daily/{}.md", now.format("%Y-%m-%d"))
}
"heartbeat" => paths::HEARTBEAT.to_string(),
path => path.to_string(),
};
// When a layer is specified, route through layer-aware methods for ALL targets.
// Otherwise, use default workspace methods (which include injection scanning).
let layer_result = if let Some(layer_name) = layer {
let result = if append {
self.workspace
.append_daily_log_tz(content, tz)
.append_to_layer(layer_name, &resolved_path, content, force)
.await
.map_err(map_write_err)?
}
"heartbeat" => {
if append {
} else {
self.workspace
.write_to_layer(layer_name, &resolved_path, content, force)
.await
.map_err(map_write_err)?
};
Some((result.actual_layer, result.redirected))
} else {
// No layer specified — use default workspace methods.
// Prompt injection scanning for system-prompt files is handled by
// Workspace::write() / Workspace::append().
match target {
"memory" => {
if append {
self.workspace
.append_memory(content)
.await
.map_err(map_write_err)?;
} else {
self.workspace
.write(paths::MEMORY, content)
.await
.map_err(map_write_err)?;
}
}
"daily_log" => {
let tz = crate::timezone::parse_timezone(&ctx.user_timezone)
.unwrap_or(chrono_tz::Tz::UTC);
self.workspace
.append(paths::HEARTBEAT, content)
.await
.map_err(map_write_err)?;
} else {
self.workspace
.write(paths::HEARTBEAT, content)
.append_daily_log_tz(content, tz)
.await
.map_err(map_write_err)?;
}
paths::HEARTBEAT.to_string()
}
path => {
if append {
self.workspace
.append(path, content)
.await
.map_err(map_write_err)?;
} else {
self.workspace
.write(path, content)
.await
.map_err(map_write_err)?;
_ => {
if append {
self.workspace
.append(&resolved_path, content)
.await
.map_err(map_write_err)?;
} else {
self.workspace
.write(&resolved_path, content)
.await
.map_err(map_write_err)?;
}
}
path.to_string()
}
None
};
// Sync derived identity documents when the profile is written.
// Normalize the path to match Workspace::normalize_path(): trim, strip
// leading/trailing slashes, collapse all consecutive slashes.
let normalized_path = {
let trimmed = path.trim().trim_matches('/');
let trimmed = resolved_path.trim().trim_matches('/');
let mut result = String::new();
let mut last_was_slash = false;
for c in trimmed.chars() {
@@ -339,9 +367,6 @@ impl Tool for MemoryWriteTool {
tracing::info!("profile write: synced USER.md + assistant-directives.md");
synced_docs.extend_from_slice(&[paths::USER, paths::ASSISTANT_DIRECTIVES]);
// Persist the onboarding-completed flag and set the
// in-memory safety net so BOOTSTRAP.md injection stops
// even if the LLM forgets to delete it.
self.workspace.mark_bootstrap_completed();
let toml_path = crate::settings::Settings::default_toml_path();
if let Ok(Some(mut settings)) = crate::settings::Settings::load_toml(&toml_path)
@@ -364,10 +389,14 @@ impl Tool for MemoryWriteTool {
let mut output = serde_json::json!({
"status": "written",
"path": path,
"path": resolved_path,
"append": append,
"content_length": content.len(),
});
if let Some((actual_layer, redirected)) = layer_result {
output["layer"] = serde_json::Value::String(actual_layer);
output["redirected"] = serde_json::Value::Bool(redirected);
}
if !synced_docs.is_empty() {
output["synced"] = serde_json::json!(synced_docs);
}
+158
View File
@@ -0,0 +1,158 @@
use serde::Deserialize;
/// Sensitivity level for a memory layer.
#[derive(Debug, Clone, Default, PartialEq, Eq, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum LayerSensitivity {
#[default]
Private,
Shared,
}
/// A named memory layer with read/write permissions and a scope.
///
/// Layers map to synthetic `user_id` values in the workspace tables.
/// The `scope` field is the user_id used for DB queries on this layer.
#[derive(Debug, Clone, Deserialize)]
pub struct MemoryLayer {
pub name: String,
pub scope: String,
#[serde(default = "default_true")]
pub writable: bool,
#[serde(default)]
pub sensitivity: LayerSensitivity,
}
fn default_true() -> bool {
true
}
impl MemoryLayer {
/// Build the default layer set: a single private layer for the given user_id.
pub fn default_for_user(user_id: &str) -> Vec<MemoryLayer> {
vec![MemoryLayer {
name: "private".to_string(),
scope: user_id.to_string(),
writable: true,
sensitivity: LayerSensitivity::Private,
}]
}
/// Extract read scopes (all layer scope values).
pub fn read_scopes(layers: &[MemoryLayer]) -> Vec<String> {
layers.iter().map(|l| l.scope.clone()).collect()
}
/// Extract writable scopes only.
pub fn writable_scopes(layers: &[MemoryLayer]) -> Vec<String> {
layers
.iter()
.filter(|l| l.writable)
.map(|l| l.scope.clone())
.collect()
}
/// Find a layer by name. Returns None if not found.
pub fn find<'a>(layers: &'a [MemoryLayer], name: &str) -> Option<&'a MemoryLayer> {
layers.iter().find(|l| l.name == name)
}
/// Find the private layer (first layer with Private sensitivity).
pub fn private_layer(layers: &[MemoryLayer]) -> Option<&MemoryLayer> {
layers
.iter()
.find(|l| l.sensitivity == LayerSensitivity::Private)
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn default_for_user_creates_single_private_layer() {
let layers = MemoryLayer::default_for_user("alice");
assert_eq!(layers.len(), 1);
assert_eq!(layers[0].name, "private");
assert_eq!(layers[0].scope, "alice");
assert!(layers[0].writable);
assert_eq!(layers[0].sensitivity, LayerSensitivity::Private);
}
#[test]
fn read_scopes_collects_all() {
let layers = 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,
},
];
let scopes = MemoryLayer::read_scopes(&layers);
assert_eq!(scopes, vec!["alice", "shared", "reports"]);
}
#[test]
fn writable_scopes_filters_read_only() {
let layers = vec![
MemoryLayer {
name: "private".into(),
scope: "alice".into(),
writable: true,
sensitivity: LayerSensitivity::Private,
},
MemoryLayer {
name: "reports".into(),
scope: "reports".into(),
writable: false,
sensitivity: LayerSensitivity::Shared,
},
];
let scopes = MemoryLayer::writable_scopes(&layers);
assert_eq!(scopes, vec!["alice"]);
}
#[test]
fn find_returns_matching_layer() {
let layers = MemoryLayer::default_for_user("alice");
assert!(MemoryLayer::find(&layers, "private").is_some());
assert!(MemoryLayer::find(&layers, "shared").is_none());
}
#[test]
fn deserialize_from_json() {
let json = serde_json::json!({
"name": "shared",
"scope": "shared",
"writable": true,
"sensitivity": "shared"
});
let layer: MemoryLayer = serde_json::from_value(json).unwrap();
assert_eq!(layer.name, "shared");
assert_eq!(layer.sensitivity, LayerSensitivity::Shared);
}
#[test]
fn deserialize_defaults() {
let json = serde_json::json!({
"name": "private",
"scope": "alice"
});
let layer: MemoryLayer = serde_json::from_value(json).unwrap();
assert!(layer.writable); // default true
assert_eq!(layer.sensitivity, LayerSensitivity::Private); // default
}
}
+178 -3
View File
@@ -45,6 +45,8 @@ mod document;
mod embedding_cache;
mod embeddings;
pub mod hygiene;
pub mod layer;
pub mod privacy;
#[cfg(feature = "postgres")]
mod repository;
mod search;
@@ -61,6 +63,17 @@ pub use search::{
FusionStrategy, RankedResult, SearchConfig, SearchResult, fuse_results, reciprocal_rank_fusion,
};
/// Result of a layer-aware write operation.
///
/// Contains the written document plus metadata about whether the write
/// was redirected to a different layer (e.g., sensitive content redirected
/// from shared to private).
pub struct WriteResult {
pub document: MemoryDocument,
pub redirected: bool,
pub actual_layer: String,
}
use std::sync::Arc;
use chrono::{NaiveDate, Utc};
@@ -344,20 +357,29 @@ pub struct Workspace {
bootstrap_completed: std::sync::atomic::AtomicBool,
/// Default search configuration applied to all queries.
search_defaults: SearchConfig,
/// Memory layers this workspace has access to.
memory_layers: Vec<crate::workspace::layer::MemoryLayer>,
/// Optional privacy classifier for shared layer writes.
/// When None, writes go exactly where requested — no silent redirect.
privacy_classifier: Option<Arc<dyn crate::workspace::privacy::PrivacyClassifier>>,
}
impl Workspace {
/// Create a new workspace backed by a PostgreSQL connection pool.
#[cfg(feature = "postgres")]
pub fn new(user_id: impl Into<String>, pool: Pool) -> Self {
let user_id_str = user_id.into();
let memory_layers = crate::workspace::layer::MemoryLayer::default_for_user(&user_id_str);
Self {
user_id: user_id.into(),
user_id: user_id_str,
agent_id: None,
storage: WorkspaceStorage::Repo(Repository::new(pool)),
embeddings: None,
bootstrap_pending: std::sync::atomic::AtomicBool::new(false),
bootstrap_completed: std::sync::atomic::AtomicBool::new(false),
search_defaults: SearchConfig::default(),
memory_layers,
privacy_classifier: None,
}
}
@@ -365,14 +387,18 @@ impl Workspace {
///
/// Use this for libSQL or any other backend that implements the Database trait.
pub fn new_with_db(user_id: impl Into<String>, db: Arc<dyn crate::db::Database>) -> Self {
let user_id_str = user_id.into();
let memory_layers = crate::workspace::layer::MemoryLayer::default_for_user(&user_id_str);
Self {
user_id: user_id.into(),
user_id: user_id_str,
agent_id: None,
storage: WorkspaceStorage::Db(db),
embeddings: None,
bootstrap_pending: std::sync::atomic::AtomicBool::new(false),
bootstrap_completed: std::sync::atomic::AtomicBool::new(false),
search_defaults: SearchConfig::default(),
memory_layers,
privacy_classifier: None,
}
}
@@ -444,6 +470,32 @@ impl Workspace {
self
}
/// Configure memory layers for this workspace.
///
/// Also updates read_user_ids to include all layer scopes.
pub fn with_memory_layers(mut self, layers: Vec<crate::workspace::layer::MemoryLayer>) -> Self {
self.memory_layers = layers;
self
}
/// Set a privacy classifier for shared layer writes.
///
/// When set, writes to shared layers are checked against the classifier
/// and redirected to the private layer if sensitive content is detected.
/// When unset (the default), writes go exactly where requested.
pub fn with_privacy_classifier(
mut self,
classifier: Arc<dyn crate::workspace::privacy::PrivacyClassifier>,
) -> Self {
self.privacy_classifier = Some(classifier);
self
}
/// Get the configured memory layers.
pub fn memory_layers(&self) -> &[crate::workspace::layer::MemoryLayer] {
&self.memory_layers
}
/// Get the user ID.
pub fn user_id(&self) -> &str {
&self.user_id
@@ -501,7 +553,9 @@ impl Workspace {
/// Append content to a file.
///
/// Creates the file if it doesn't exist.
/// Adds a newline separator between existing and new content.
/// Uses a single `\n` separator (suitable for log-style entries).
/// For semantic separation (e.g., memory entries), use `append_memory()`
/// which uses `\n\n`.
pub async fn append(&self, path: &str, content: &str) -> Result<(), WorkspaceError> {
let path = normalize_path(path);
let doc = self
@@ -526,6 +580,127 @@ impl Workspace {
Ok(())
}
/// Resolve the target scope for a layer write, optionally applying privacy guards.
///
/// Validates that the layer exists and is writable. When a privacy classifier
/// is configured on the workspace AND `force` is false, checks shared-layer
/// writes for sensitive content and redirects to the private layer.
///
/// By default no classifier is set — writes go exactly where requested.
/// This is intentional: the LLM chooses the correct layer via system prompt
/// guidance, and a regex classifier can't improve on that decision without
/// unacceptable false positive rates in household contexts (e.g., "doctor",
/// "therapy", phone numbers). Operators who want a safety net can configure
/// one via `with_privacy_classifier()`.
///
/// # Multi-tenant safety (Issue #59)
///
/// Layer scopes are currently used directly as `user_id` for DB operations.
/// In a multi-tenant deployment, an operator could configure a scope that
/// collides with another user's ID, granting write access to their data.
/// Future work should namespace or validate scopes to prevent this.
///
/// Returns `(scope, actual_layer_name, redirected)`.
fn resolve_layer_target(
&self,
layer_name: &str,
content: &str,
force: bool,
) -> Result<(String, String, bool), WorkspaceError> {
use crate::workspace::layer::{LayerSensitivity, MemoryLayer};
let layer = MemoryLayer::find(&self.memory_layers, layer_name).ok_or_else(|| {
WorkspaceError::LayerNotFound {
name: layer_name.to_string(),
}
})?;
if !layer.writable {
return Err(WorkspaceError::LayerReadOnly {
name: layer_name.to_string(),
});
}
if !force
&& layer.sensitivity == LayerSensitivity::Shared
&& let Some(ref classifier) = self.privacy_classifier
&& classifier.classify(content).is_sensitive
{
tracing::warn!(
layer = layer_name,
"Redirected sensitive content to private layer"
);
let private = MemoryLayer::private_layer(&self.memory_layers)
.ok_or(WorkspaceError::PrivacyRedirectFailed)?;
if !private.writable {
return Err(WorkspaceError::PrivacyRedirectFailed);
}
return Ok((private.scope.clone(), private.name.clone(), true));
}
Ok((layer.scope.clone(), layer_name.to_string(), false))
}
/// Write to a specific memory layer.
///
/// Checks that the layer exists and is writable. Uses the layer's scope
/// as the user_id for the database write. For shared layers, sensitive
/// content is automatically redirected to the private layer unless
/// `force` is set.
pub async fn write_to_layer(
&self,
layer_name: &str,
path: &str,
content: &str,
force: bool,
) -> Result<WriteResult, WorkspaceError> {
let (scope, actual_layer, redirected) =
self.resolve_layer_target(layer_name, content, force)?;
let path = normalize_path(path);
let doc = self
.storage
.get_or_create_document_by_path(&scope, self.agent_id, &path)
.await?;
self.storage.update_document(doc.id, content).await?;
self.reindex_document(doc.id).await?;
let document = self.storage.get_document_by_id(doc.id).await?;
Ok(WriteResult {
document,
redirected,
actual_layer,
})
}
/// Write to a layer, with append semantics.
pub async fn append_to_layer(
&self,
layer_name: &str,
path: &str,
content: &str,
force: bool,
) -> Result<WriteResult, WorkspaceError> {
let (scope, actual_layer, redirected) =
self.resolve_layer_target(layer_name, content, force)?;
let path = normalize_path(path);
let doc = self
.storage
.get_or_create_document_by_path(&scope, self.agent_id, &path)
.await?;
let new_content = if doc.content.is_empty() {
content.to_string()
} else {
format!("{}\n\n{}", doc.content, content)
};
self.storage.update_document(doc.id, &new_content).await?;
self.reindex_document(doc.id).await?;
let document = self.storage.get_document_by_id(doc.id).await?;
Ok(WriteResult {
document,
redirected,
actual_layer,
})
}
/// Check if a file exists.
pub async fn exists(&self, path: &str) -> Result<bool, WorkspaceError> {
let path = normalize_path(path);
+276
View File
@@ -0,0 +1,276 @@
use regex::Regex;
/// Result of privacy classification, including confidence level.
///
/// Confidence enables downstream callers to apply thresholds (e.g., only
/// redirect above 0.8) and supports future upgrade to LLM-based classifiers
/// that produce probabilistic scores.
#[derive(Debug, Clone)]
pub struct SensitivityResult {
pub is_sensitive: bool,
pub confidence: f32,
}
/// Classifies content as potentially sensitive for privacy purposes.
///
/// Used to guard writes to shared memory layers -- if content is flagged
/// as sensitive, it can be redirected to the private layer instead.
pub trait PrivacyClassifier: Send + Sync {
/// Classify content and return sensitivity with confidence score.
fn classify(&self, content: &str) -> SensitivityResult;
}
/// Pattern-based privacy classifier using regex matching.
///
/// Default patterns target hard PII (SSN, credit card numbers) where silent
/// redirect is clearly correct. Ambiguous terms (health vocabulary, contact
/// info) are intentionally excluded — they cause false positives in household
/// contexts and silently redirect content the user intended to share.
///
/// Operators who need broader coverage should use `ConfigurablePrivacyClassifier`
/// with domain-specific patterns.
pub struct PatternPrivacyClassifier {
patterns: Vec<Regex>,
}
impl PatternPrivacyClassifier {
pub fn new() -> Result<Self, regex::Error> {
let pattern_strs = [
// SSN — always PII
r"\b\d{3}-\d{2}-\d{4}\b",
// Credit card (basic) — always PII
r"\b\d{4}[\s-]?\d{4}[\s-]?\d{4}[\s-]?\d{4}\b",
// Credentials and auth tokens — high-confidence PII
r"(?i)\b(password|passwd|api[_-]?key|auth[_-]?token|secret[_-]?key)\b",
];
let patterns = pattern_strs
.iter()
.map(|p| Regex::new(p))
.collect::<Result<Vec<_>, _>>()?;
Ok(Self { patterns })
}
}
impl PrivacyClassifier for PatternPrivacyClassifier {
fn classify(&self, content: &str) -> SensitivityResult {
let is_sensitive = self.patterns.iter().any(|p| p.is_match(content));
SensitivityResult {
is_sensitive,
// Regex is binary — matched or not. Always full confidence.
confidence: if is_sensitive { 1.0 } else { 0.0 },
}
}
}
/// User-configurable privacy classifier.
///
/// Accepts custom regex patterns at construction time, allowing operators
/// to tune sensitivity for their use case (e.g., drop health terms that
/// cause false positives, add domain-specific patterns).
///
/// ```
/// use ironclaw::workspace::privacy::ConfigurablePrivacyClassifier;
/// use ironclaw::workspace::privacy::PrivacyClassifier;
///
/// let classifier = ConfigurablePrivacyClassifier::new(vec![
/// r"\b\d{3}-\d{2}-\d{4}\b".into(), // SSN only
/// ]).unwrap();
/// assert!(classifier.classify("SSN: 123-45-6789").is_sensitive);
/// assert!(!classifier.classify("saw the doctor today").is_sensitive);
/// ```
pub struct ConfigurablePrivacyClassifier {
patterns: Vec<Regex>,
}
impl ConfigurablePrivacyClassifier {
/// Create a classifier from user-supplied regex strings.
///
/// Returns an error if any pattern fails to compile.
pub fn new(pattern_strs: Vec<String>) -> Result<Self, regex::Error> {
let patterns = pattern_strs
.iter()
.map(|p| Regex::new(p))
.collect::<Result<Vec<_>, _>>()?;
Ok(Self { patterns })
}
}
impl PrivacyClassifier for ConfigurablePrivacyClassifier {
fn classify(&self, content: &str) -> SensitivityResult {
let is_sensitive = self.patterns.iter().any(|p| p.is_match(content));
SensitivityResult {
is_sensitive,
confidence: if is_sensitive { 1.0 } else { 0.0 },
}
}
}
#[cfg(test)]
mod tests {
use super::*;
fn classifier() -> PatternPrivacyClassifier {
PatternPrivacyClassifier::new().unwrap()
}
// Hard PII — must always trigger
#[test]
fn detects_ssn() {
let result = classifier().classify("My SSN is 123-45-6789");
assert!(result.is_sensitive);
assert_eq!(result.confidence, 1.0);
}
#[test]
fn detects_credit_card() {
let result = classifier().classify("Card: 4111 1111 1111 1111");
assert!(result.is_sensitive);
assert_eq!(result.confidence, 1.0);
}
#[test]
fn detects_password() {
assert!(classifier().classify("my password is hunter2").is_sensitive);
}
#[test]
fn detects_api_key() {
assert!(
classifier()
.classify("set the api_key to sk-1234")
.is_sensitive
);
}
// Household content — must NOT trigger (previous false positives)
#[test]
fn allows_normal_household_content() {
let result = classifier().classify("We need to buy groceries for dinner Saturday");
assert!(!result.is_sensitive);
assert_eq!(result.confidence, 0.0);
}
#[test]
fn allows_doctor_mention() {
assert!(
!classifier()
.classify("the doctor's office called about Saturday")
.is_sensitive
);
}
#[test]
fn allows_email_address() {
assert!(
!classifier()
.classify("email [email protected] about the leak")
.is_sensitive
);
}
#[test]
fn allows_phone_number() {
assert!(
!classifier()
.classify("call the restaurant at 555-123-4567")
.is_sensitive
);
}
#[test]
fn allows_medical_terms_in_context() {
assert!(
!classifier()
.classify("Started new medication for anxiety")
.is_sensitive
);
}
#[test]
fn configurable_with_custom_patterns() {
let c = ConfigurablePrivacyClassifier::new(vec![
r"\b\d{3}-\d{2}-\d{4}\b".into(), // SSN only
])
.unwrap();
assert!(c.classify("SSN: 123-45-6789").is_sensitive);
// Health terms no longer trigger with SSN-only config
assert!(!c.classify("saw the doctor today").is_sensitive);
}
#[test]
fn configurable_rejects_bad_regex() {
let result = ConfigurablePrivacyClassifier::new(vec!["[invalid".into()]);
assert!(result.is_err());
}
#[test]
fn configurable_empty_patterns_allows_everything() {
let c = ConfigurablePrivacyClassifier::new(vec![]).unwrap();
assert!(!c.classify("My SSN is 123-45-6789").is_sensitive);
}
// Format variants
#[test]
fn detects_credit_card_no_separators() {
assert!(
classifier()
.classify("card 4111111111111111 on file")
.is_sensitive
);
}
#[test]
fn detects_credit_card_with_dashes() {
assert!(
classifier()
.classify("Card: 4111-1111-1111-1111")
.is_sensitive
);
}
#[test]
fn detects_ssn_bare() {
assert!(classifier().classify("123-45-6789").is_sensitive);
}
#[test]
fn detects_auth_token_keyword() {
assert!(
classifier()
.classify("set auth_token to abc123")
.is_sensitive
);
}
#[test]
fn detects_secret_key_keyword() {
assert!(
classifier()
.classify("the secret_key is sk-prod-xyz")
.is_sensitive
);
}
#[test]
fn detects_pii_in_longer_document() {
let content = "Meeting notes from Thursday.\n\
Discussed budget and timeline.\n\
SSN is 999-88-7777 for the insurance form.\n\
Action items: follow up with vendor.";
assert!(classifier().classify(content).is_sensitive);
}
#[test]
fn empty_string_is_not_sensitive() {
assert!(!classifier().classify("").is_sensitive);
}
#[test]
fn partial_ssn_not_sensitive() {
assert!(
!classifier()
.classify("code 123-45 in the system")
.is_sensitive
);
}
}
+360
View File
@@ -0,0 +1,360 @@
#![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");
}