mirror of
https://github.com/outbackdingo/optimclaw.git
synced 2026-08-29 08:59:31 +00:00
* 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]>
159 lines
4.8 KiB
Rust
159 lines
4.8 KiB
Rust
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
|
|
}
|
|
}
|