feat(workspace): layered memory with sensitivity-based privacy redirect (#1112)

* feat(workspace): layered memory with sensitivity-based privacy redirect

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

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

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

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

* fix: add explicit default to memory_write layer schema

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

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

* refactor: extract resolve_layer_target to deduplicate layer writes

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

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

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

* fix: address review feedback on layered memory PR

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

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

* fix: address adversarial review findings

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

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

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

Address review feedback from @zmanian:

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

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

* fix: remove redundant heartbeat match arm in memory_write

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

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

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

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

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

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

* refactor: move memory_layers from GatewayConfig to WorkspaceConfig

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

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

* test: strengthen privacy classifier and layer isolation coverage

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

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

* fix: tautological test assertion and add WorkspaceConfig validation tests

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

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

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

* style: cargo fmt after staging merge

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

---------

Co-authored-by: Claude Opus 4.6 <[email protected]>
Co-authored-by: [email protected] <[email protected]>
This commit is contained in:
standardtoaster
2026-03-20 22:15:29 -07:00
committed by GitHub
co-authored by Claude Opus 4.6 [email protected] <[email protected]>
parent 0d1a5c210b
commit 212d661e20
13 changed files with 1363 additions and 78 deletions
+77 -48
View File
@@ -194,6 +194,15 @@ impl Tool for MemoryWriteTool {
"type": "boolean",
"description": "If true, append to existing content. If false, replace entirely.",
"default": true
},
"layer": {
"type": "string",
"description": "Memory layer to write to (e.g. 'private', 'household', 'finance'). When omitted, writes to the workspace's default scope."
},
"force": {
"type": "boolean",
"description": "Skip privacy classification and write directly to the specified layer without redirect. Use when you're certain the content belongs in the target layer.",
"default": false
}
},
"required": ["content"]
@@ -256,67 +265,86 @@ impl Tool for MemoryWriteTool {
.and_then(|v| v.as_bool())
.unwrap_or(true);
// Prompt injection scanning for system-prompt files is handled by
// Workspace::write() / Workspace::append() — no need to duplicate here.
let layer = params.get("layer").and_then(|v| v.as_str());
let force = params
.get("force")
.and_then(|v| v.as_bool())
.unwrap_or(false);
let path = match target {
"memory" => {
if append {
self.workspace
.append_memory(content)
.await
.map_err(map_write_err)?;
} else {
self.workspace
.write(paths::MEMORY, content)
.await
.map_err(map_write_err)?;
}
paths::MEMORY.to_string()
}
// Resolve the target to a workspace path
let resolved_path = match target {
"memory" => paths::MEMORY.to_string(),
"daily_log" => {
let tz = crate::timezone::parse_timezone(&ctx.user_timezone)
.unwrap_or(chrono_tz::Tz::UTC);
let now = chrono::Utc::now().with_timezone(&tz);
format!("daily/{}.md", now.format("%Y-%m-%d"))
}
"heartbeat" => paths::HEARTBEAT.to_string(),
path => path.to_string(),
};
// When a layer is specified, route through layer-aware methods for ALL targets.
// Otherwise, use default workspace methods (which include injection scanning).
let layer_result = if let Some(layer_name) = layer {
let result = if append {
self.workspace
.append_daily_log_tz(content, tz)
.append_to_layer(layer_name, &resolved_path, content, force)
.await
.map_err(map_write_err)?
}
"heartbeat" => {
if append {
} else {
self.workspace
.write_to_layer(layer_name, &resolved_path, content, force)
.await
.map_err(map_write_err)?
};
Some((result.actual_layer, result.redirected))
} else {
// No layer specified — use default workspace methods.
// Prompt injection scanning for system-prompt files is handled by
// Workspace::write() / Workspace::append().
match target {
"memory" => {
if append {
self.workspace
.append_memory(content)
.await
.map_err(map_write_err)?;
} else {
self.workspace
.write(paths::MEMORY, content)
.await
.map_err(map_write_err)?;
}
}
"daily_log" => {
let tz = crate::timezone::parse_timezone(&ctx.user_timezone)
.unwrap_or(chrono_tz::Tz::UTC);
self.workspace
.append(paths::HEARTBEAT, content)
.await
.map_err(map_write_err)?;
} else {
self.workspace
.write(paths::HEARTBEAT, content)
.append_daily_log_tz(content, tz)
.await
.map_err(map_write_err)?;
}
paths::HEARTBEAT.to_string()
}
path => {
if append {
self.workspace
.append(path, content)
.await
.map_err(map_write_err)?;
} else {
self.workspace
.write(path, content)
.await
.map_err(map_write_err)?;
_ => {
if append {
self.workspace
.append(&resolved_path, content)
.await
.map_err(map_write_err)?;
} else {
self.workspace
.write(&resolved_path, content)
.await
.map_err(map_write_err)?;
}
}
path.to_string()
}
None
};
// Sync derived identity documents when the profile is written.
// Normalize the path to match Workspace::normalize_path(): trim, strip
// leading/trailing slashes, collapse all consecutive slashes.
let normalized_path = {
let trimmed = path.trim().trim_matches('/');
let trimmed = resolved_path.trim().trim_matches('/');
let mut result = String::new();
let mut last_was_slash = false;
for c in trimmed.chars() {
@@ -339,9 +367,6 @@ impl Tool for MemoryWriteTool {
tracing::info!("profile write: synced USER.md + assistant-directives.md");
synced_docs.extend_from_slice(&[paths::USER, paths::ASSISTANT_DIRECTIVES]);
// Persist the onboarding-completed flag and set the
// in-memory safety net so BOOTSTRAP.md injection stops
// even if the LLM forgets to delete it.
self.workspace.mark_bootstrap_completed();
let toml_path = crate::settings::Settings::default_toml_path();
if let Ok(Some(mut settings)) = crate::settings::Settings::load_toml(&toml_path)
@@ -364,10 +389,14 @@ impl Tool for MemoryWriteTool {
let mut output = serde_json::json!({
"status": "written",
"path": path,
"path": resolved_path,
"append": append,
"content_length": content.len(),
});
if let Some((actual_layer, redirected)) = layer_result {
output["layer"] = serde_json::Value::String(actual_layer);
output["redirected"] = serde_json::Value::Bool(redirected);
}
if !synced_docs.is_empty() {
output["synced"] = serde_json::json!(synced_docs);
}