mirror of
https://github.com/outbackdingo/optimclaw.git
synced 2026-08-25 14:53:34 +00:00
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:
+9
-1
@@ -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)
|
||||
|
||||
@@ -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>>,
|
||||
|
||||
@@ -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,
|
||||
}))
|
||||
}
|
||||
|
||||
|
||||
@@ -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)]
|
||||
|
||||
@@ -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
@@ -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()),
|
||||
},
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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
@@ -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);
|
||||
|
||||
@@ -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
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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");
|
||||
}
|
||||
Reference in New Issue
Block a user