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

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

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

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

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

* fix: add explicit default to memory_write layer schema

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

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

* refactor: extract resolve_layer_target to deduplicate layer writes

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

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

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

* fix: address review feedback on layered memory PR

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

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

* fix: address adversarial review findings

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

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

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

Address review feedback from @zmanian:

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

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

* fix: remove redundant heartbeat match arm in memory_write

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

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

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

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

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

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

* refactor: move memory_layers from GatewayConfig to WorkspaceConfig

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

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

* test: strengthen privacy classifier and layer isolation coverage

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

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

* fix: tautological test assertion and add WorkspaceConfig validation tests

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

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

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

* style: cargo fmt after staging merge

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

---------

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

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
}
}