mirror of
https://github.com/outbackdingo/optimclaw.git
synced 2026-09-01 09:09:19 +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]>
155 lines
4.3 KiB
Rust
155 lines
4.3 KiB
Rust
//! Memory/workspace API handlers.
|
|
|
|
use std::sync::Arc;
|
|
|
|
use axum::{
|
|
Json,
|
|
extract::{Query, State},
|
|
http::StatusCode,
|
|
};
|
|
use serde::Deserialize;
|
|
|
|
use crate::channels::web::server::GatewayState;
|
|
use crate::channels::web::types::*;
|
|
|
|
#[derive(Deserialize)]
|
|
pub struct TreeQuery {
|
|
#[allow(dead_code)]
|
|
pub depth: Option<usize>,
|
|
}
|
|
|
|
pub async fn memory_tree_handler(
|
|
State(state): State<Arc<GatewayState>>,
|
|
Query(_query): Query<TreeQuery>,
|
|
) -> Result<Json<MemoryTreeResponse>, (StatusCode, String)> {
|
|
let workspace = state.workspace.as_ref().ok_or((
|
|
StatusCode::SERVICE_UNAVAILABLE,
|
|
"Workspace not available".to_string(),
|
|
))?;
|
|
|
|
// Build tree from list_all (flat list of all paths)
|
|
let all_paths = workspace
|
|
.list_all()
|
|
.await
|
|
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
|
|
|
|
// Collect unique directories and files
|
|
let mut entries: Vec<TreeEntry> = Vec::new();
|
|
let mut seen_dirs: std::collections::HashSet<String> = std::collections::HashSet::new();
|
|
|
|
for path in &all_paths {
|
|
// Add parent directories
|
|
let parts: Vec<&str> = path.split('/').collect();
|
|
for i in 0..parts.len().saturating_sub(1) {
|
|
let dir_path = parts[..=i].join("/");
|
|
if seen_dirs.insert(dir_path.clone()) {
|
|
entries.push(TreeEntry {
|
|
path: dir_path,
|
|
is_dir: true,
|
|
});
|
|
}
|
|
}
|
|
// Add the file itself
|
|
entries.push(TreeEntry {
|
|
path: path.clone(),
|
|
is_dir: false,
|
|
});
|
|
}
|
|
|
|
entries.sort_by(|a, b| a.path.cmp(&b.path));
|
|
|
|
Ok(Json(MemoryTreeResponse { entries }))
|
|
}
|
|
|
|
#[derive(Deserialize)]
|
|
pub struct ListQuery {
|
|
pub path: Option<String>,
|
|
}
|
|
|
|
pub async fn memory_list_handler(
|
|
State(state): State<Arc<GatewayState>>,
|
|
Query(query): Query<ListQuery>,
|
|
) -> Result<Json<MemoryListResponse>, (StatusCode, String)> {
|
|
let workspace = state.workspace.as_ref().ok_or((
|
|
StatusCode::SERVICE_UNAVAILABLE,
|
|
"Workspace not available".to_string(),
|
|
))?;
|
|
|
|
let path = query.path.as_deref().unwrap_or("");
|
|
let entries = workspace
|
|
.list(path)
|
|
.await
|
|
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
|
|
|
|
let list_entries: Vec<ListEntry> = entries
|
|
.iter()
|
|
.map(|e| ListEntry {
|
|
name: e.path.rsplit('/').next().unwrap_or(&e.path).to_string(),
|
|
path: e.path.clone(),
|
|
is_dir: e.is_directory,
|
|
updated_at: e.updated_at.map(|dt| dt.to_rfc3339()),
|
|
})
|
|
.collect();
|
|
|
|
Ok(Json(MemoryListResponse {
|
|
path: path.to_string(),
|
|
entries: list_entries,
|
|
}))
|
|
}
|
|
|
|
#[derive(Deserialize)]
|
|
pub struct ReadQuery {
|
|
pub path: String,
|
|
}
|
|
|
|
pub async fn memory_read_handler(
|
|
State(state): State<Arc<GatewayState>>,
|
|
Query(query): Query<ReadQuery>,
|
|
) -> Result<Json<MemoryReadResponse>, (StatusCode, String)> {
|
|
let workspace = state.workspace.as_ref().ok_or((
|
|
StatusCode::SERVICE_UNAVAILABLE,
|
|
"Workspace not available".to_string(),
|
|
))?;
|
|
|
|
let doc = workspace
|
|
.read(&query.path)
|
|
.await
|
|
.map_err(|e| (StatusCode::NOT_FOUND, e.to_string()))?;
|
|
|
|
Ok(Json(MemoryReadResponse {
|
|
path: query.path,
|
|
content: doc.content,
|
|
updated_at: Some(doc.updated_at.to_rfc3339()),
|
|
}))
|
|
}
|
|
|
|
// 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>>,
|
|
Json(req): Json<MemorySearchRequest>,
|
|
) -> Result<Json<MemorySearchResponse>, (StatusCode, String)> {
|
|
let workspace = state.workspace.as_ref().ok_or((
|
|
StatusCode::SERVICE_UNAVAILABLE,
|
|
"Workspace not available".to_string(),
|
|
))?;
|
|
|
|
let limit = req.limit.unwrap_or(10);
|
|
let results = workspace
|
|
.search(&req.query, limit)
|
|
.await
|
|
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
|
|
|
|
let hits: Vec<SearchHit> = results
|
|
.into_iter()
|
|
.map(|r| SearchHit {
|
|
path: r.document_path,
|
|
content: r.content,
|
|
score: r.score as f64,
|
|
})
|
|
.collect();
|
|
|
|
Ok(Json(MemorySearchResponse { results: hits }))
|
|
}
|