From dbf8192255d3d39c4133e05bbd443bfd9e13fb81 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Wed, 19 Aug 2026 23:49:04 +0000 Subject: [PATCH 1/6] fix(privacy): quarantine LCM credential-bearing keys instead of Receipt A successfully parsed LCM JSON payload whose object keys carry credential material was collapsed into StructuredSanitizationError::SanitizerUnavailable and then mapped to DetectionError::Receipt, so the sanitizer's own fail-closed quarantine surfaced as 'privacy sanitizer receipt construction failed' in every projection-drain refusal. Give quarantine findings their own typed state (CredentialKeyQuarantine -> StructuredQuarantine), keep SanitizerUnavailable for detector initialization failure (-> Initialization, with InvalidLimits), and reserve Receipt for real canonical/receipt construction faults (new CanonicalEncoding). Co-authored-by: Zack Jackson --- .../src/privacy/detect.rs | 9 ++++-- .../src/privacy/structured.rs | 18 ++++++++++-- .../src/privacy/structured_tests.rs | 14 +++++++++ .../src/privacy/structured_text.rs | 17 ++++++++--- .../src/privacy/structured_text_tests.rs | 29 +++++++++++++++++++ 5 files changed, 77 insertions(+), 10 deletions(-) diff --git a/crates/tracedecay-runtime-core/src/privacy/detect.rs b/crates/tracedecay-runtime-core/src/privacy/detect.rs index 6b6bf12f35..8bb94cf764 100644 --- a/crates/tracedecay-runtime-core/src/privacy/detect.rs +++ b/crates/tracedecay-runtime-core/src/privacy/detect.rs @@ -404,9 +404,12 @@ pub enum DetectionError { ScanLimitExceeded, #[error("privacy sanitizer receipt construction failed")] Receipt, - /// The document declared a structured data format but could not be parsed - /// without ambiguity, so field semantics cannot be proven scanned. This is - /// the sanitizer's own fail-closed refusal, not a construction fault. + /// The sanitizer refused a structured document fail-closed: either it + /// declared a structured data format but could not be parsed without + /// ambiguity, or it parsed but carries credential material where redaction + /// cannot rewrite it (an object key). Field semantics cannot be proven + /// safely scanned either way. This is the sanitizer's own fail-closed + /// refusal, not a construction fault. #[error("privacy sanitizer quarantined an ambiguous structured document")] StructuredQuarantine, } diff --git a/crates/tracedecay-runtime-core/src/privacy/structured.rs b/crates/tracedecay-runtime-core/src/privacy/structured.rs index d73c4f3b20..b29b27b86f 100644 --- a/crates/tracedecay-runtime-core/src/privacy/structured.rs +++ b/crates/tracedecay-runtime-core/src/privacy/structured.rs @@ -80,6 +80,18 @@ pub(crate) enum StructuredSanitizationError { InvalidEncoding, #[error("structured JSON has an ambiguous duplicate key or exceeds parse limits")] UnsafeJsonStructure, + /// An object key carries credential material. A key cannot be redacted in + /// place without rewriting the document's structure, so the sanitizer + /// refuses the payload fail-closed. This is the sanitizer doing its job, + /// not a fault in the sanitizer or in receipt construction. + #[error("structured payload carries credential material in object keys")] + CredentialKeyQuarantine, + /// The sanitized payload could not be canonically re-encoded, so its + /// expansion cannot be measured or bound to a receipt. + #[error("structured payload could not be canonically encoded")] + CanonicalEncoding, + /// The detector kernel itself failed to initialize (credential patterns + /// did not compile), so no payload can be scanned at all. #[error("structured sanitizer is unavailable")] SanitizerUnavailable, } @@ -136,7 +148,7 @@ fn sanitize_parsed( let detected = redact_sensitive_values(value, &BTreeSet::new()) .map_err(|_| StructuredSanitizationError::SanitizerUnavailable)?; if !detected.quarantine_findings.is_empty() { - return Err(StructuredSanitizationError::SanitizerUnavailable); + return Err(StructuredSanitizationError::CredentialKeyQuarantine); } validate_expansion(&detected.payload, limits)?; Ok(StructuredSanitizedPayload { @@ -153,7 +165,7 @@ fn sanitize_malformed( let detected = redact_sensitive_values(Value::String(text.to_owned()), &BTreeSet::new()) .map_err(|_| StructuredSanitizationError::SanitizerUnavailable)?; if !detected.quarantine_findings.is_empty() { - return Err(StructuredSanitizationError::SanitizerUnavailable); + return Err(StructuredSanitizationError::CredentialKeyQuarantine); } validate_expansion(&detected.payload, limits)?; Ok(StructuredSanitizedPayload { @@ -1042,7 +1054,7 @@ fn validate_expansion( limits: StructuredSanitizationLimits, ) -> Result<(), StructuredSanitizationError> { let expanded = - serde_json::to_vec(value).map_err(|_| StructuredSanitizationError::SanitizerUnavailable)?; + serde_json::to_vec(value).map_err(|_| StructuredSanitizationError::CanonicalEncoding)?; if expanded.len() > limits.expanded_bytes { return Err(StructuredSanitizationError::ExpandedBytesExceeded); } diff --git a/crates/tracedecay-runtime-core/src/privacy/structured_tests.rs b/crates/tracedecay-runtime-core/src/privacy/structured_tests.rs index cb2654f6e7..f74ff4159d 100644 --- a/crates/tracedecay-runtime-core/src/privacy/structured_tests.rs +++ b/crates/tracedecay-runtime-core/src/privacy/structured_tests.rs @@ -31,6 +31,20 @@ fn malformed_json_is_scanned_without_claiming_structural_parse() { assert!(!sanitized.was_structurally_parsed()); } +#[test] +fn credential_bearing_object_keys_are_a_typed_quarantine_state() { + // A key carrying credential material cannot be redacted without rewriting + // the document's structure, so the sanitizer quarantines the payload. The + // typed state must say so — collapsing it into "sanitizer unavailable" + // made real quarantines surface as construction faults downstream. + let input = format!(r#"{{"{SECRET}":"ordinary-value"}}"#); + let quarantined = sanitize_structured_payload(input.as_bytes(), limits()); + assert_eq!( + quarantined.unwrap_err(), + StructuredSanitizationError::CredentialKeyQuarantine + ); +} + #[test] fn structured_limits_deny_raw_expansion_depth_and_item_overruns() { let raw = sanitize_structured_payload( diff --git a/crates/tracedecay-runtime-core/src/privacy/structured_text.rs b/crates/tracedecay-runtime-core/src/privacy/structured_text.rs index b068858456..aeca7e511a 100644 --- a/crates/tracedecay-runtime-core/src/privacy/structured_text.rs +++ b/crates/tracedecay-runtime-core/src/privacy/structured_text.rs @@ -172,7 +172,7 @@ pub(crate) fn sanitize_structured_text( } }; validate_structured_text_limits(&parsed.value) - .map_err(|_| DetectionError::ScanLimitExceeded)?; + .map_err(detection_error_from_structured_sanitization)?; let mut quarantine_findings = Vec::new(); let candidates = if parsed.fields.is_empty() { @@ -771,7 +771,7 @@ fn detect_lcm_payload(raw: &str) -> Result<(String, Vec), policy.depth, policy.values, ) - .map_err(|_| DetectionError::Receipt)?; + .map_err(detection_error_from_structured_sanitization)?; let sanitized = sanitize_structured_payload(raw.as_bytes(), limits) .map_err(detection_error_from_structured_sanitization)?; if !sanitized.was_structurally_parsed() { @@ -789,6 +789,11 @@ fn detect_lcm_payload(raw: &str) -> Result<(String, Vec), Ok(detected.into_parts()) } +/// Maps the structured sanitizer's typed refusals onto detection errors +/// without conflating classes: quarantines stay quarantines, limit overruns +/// stay bounded-scan refusals, an unavailable or misconfigured detector is an +/// initialization failure, and [`DetectionError::Receipt`] is reserved for +/// actual receipt/canonical construction faults. fn detection_error_from_structured_sanitization( error: StructuredSanitizationError, ) -> DetectionError { @@ -798,9 +803,13 @@ fn detection_error_from_structured_sanitization( | StructuredSanitizationError::NestingDepthExceeded | StructuredSanitizationError::ItemCountExceeded => DetectionError::ScanLimitExceeded, StructuredSanitizationError::UnsafeJsonStructure - | StructuredSanitizationError::InvalidEncoding => DetectionError::StructuredQuarantine, + | StructuredSanitizationError::InvalidEncoding + | StructuredSanitizationError::CredentialKeyQuarantine => { + DetectionError::StructuredQuarantine + } StructuredSanitizationError::InvalidLimits - | StructuredSanitizationError::SanitizerUnavailable => DetectionError::Receipt, + | StructuredSanitizationError::SanitizerUnavailable => DetectionError::Initialization, + StructuredSanitizationError::CanonicalEncoding => DetectionError::Receipt, } } diff --git a/crates/tracedecay-runtime-core/src/privacy/structured_text_tests.rs b/crates/tracedecay-runtime-core/src/privacy/structured_text_tests.rs index 14db96e55a..f473138139 100644 --- a/crates/tracedecay-runtime-core/src/privacy/structured_text_tests.rs +++ b/crates/tracedecay-runtime-core/src/privacy/structured_text_tests.rs @@ -351,6 +351,35 @@ fn lcm_json_duplicate_keys_are_rejected_before_value_materialization() { )); } +#[test] +fn lcm_json_credential_bearing_keys_quarantine_instead_of_faulting_the_receipt() { + // A successfully parsed LCM JSON payload whose object *key* carries + // credential material cannot be redacted in place (rewriting a key changes + // the document's structure), so the sanitizer refuses it fail-closed. That + // refusal is the sanitizer doing its job — it must surface as a structured + // quarantine, never as a receipt-construction fault. + let credential_key = ["sk", "-test-", "1234567890abcdef"].concat(); + let raw = format!(r#"{{"{credential_key}":"ordinary-value"}}"#); + + assert!(matches!( + sanitize_lcm_payload_text(&raw), + Err(DetectionError::StructuredQuarantine) + )); +} + +#[test] +fn lcm_json_credential_values_under_ordinary_keys_still_redact_durably() { + // The quarantine above is specific to key positions. The same credential in + // a *value* position is redactable, so sanitization must stay a durable + // redaction rather than widening into a quarantine of every credential hit. + let credential = ["sk", "-test-", "1234567890abcdef"].concat(); + let raw = format!(r#"{{"note":"{credential}"}}"#); + + let sanitized = sanitize_lcm_payload_text(&raw).expect("credential values redact durably"); + assert!(!sanitized.sanitized_text().contains(&credential)); + assert!(!sanitized.findings().is_empty()); +} + #[test] fn json_preflight_rejects_depth_beyond_the_canonical_parse_limit() { let limits = ParseLimits::default_policy(); From c79d5c515e1e9790eb9f2ccd3b41ec193b023a33 Mon Sep 17 00:00:00 2001 From: ScriptedAlchemy Date: Wed, 19 Aug 2026 17:22:42 -0700 Subject: [PATCH 2/6] fix(privacy): split credential-key quarantine Display Codex P2: key quarantine no longer shares the "ambiguous structured document" message. Parse ambiguity stays StructuredQuarantine. --- crates/tracedecay-runtime-core/src/privacy/detect.rs | 12 ++++++------ .../src/privacy/structured_text.rs | 6 +++--- .../src/privacy/structured_text_tests.rs | 10 ++++++---- 3 files changed, 15 insertions(+), 13 deletions(-) diff --git a/crates/tracedecay-runtime-core/src/privacy/detect.rs b/crates/tracedecay-runtime-core/src/privacy/detect.rs index 8bb94cf764..8c15ccbee3 100644 --- a/crates/tracedecay-runtime-core/src/privacy/detect.rs +++ b/crates/tracedecay-runtime-core/src/privacy/detect.rs @@ -404,14 +404,14 @@ pub enum DetectionError { ScanLimitExceeded, #[error("privacy sanitizer receipt construction failed")] Receipt, - /// The sanitizer refused a structured document fail-closed: either it - /// declared a structured data format but could not be parsed without - /// ambiguity, or it parsed but carries credential material where redaction - /// cannot rewrite it (an object key). Field semantics cannot be proven - /// safely scanned either way. This is the sanitizer's own fail-closed - /// refusal, not a construction fault. + /// The document declared a structured data format but could not be parsed + /// without ambiguity, so field semantics cannot be proven scanned. #[error("privacy sanitizer quarantined an ambiguous structured document")] StructuredQuarantine, + /// The document parsed, but an object key carries credential material that + /// cannot be redacted in place. Fail-closed quarantine, not a receipt fault. + #[error("privacy sanitizer quarantined credential-bearing keys")] + CredentialKeyQuarantine, } pub enum MemoryFactSanitizationV1 { diff --git a/crates/tracedecay-runtime-core/src/privacy/structured_text.rs b/crates/tracedecay-runtime-core/src/privacy/structured_text.rs index aeca7e511a..0667c2b1eb 100644 --- a/crates/tracedecay-runtime-core/src/privacy/structured_text.rs +++ b/crates/tracedecay-runtime-core/src/privacy/structured_text.rs @@ -803,9 +803,9 @@ fn detection_error_from_structured_sanitization( | StructuredSanitizationError::NestingDepthExceeded | StructuredSanitizationError::ItemCountExceeded => DetectionError::ScanLimitExceeded, StructuredSanitizationError::UnsafeJsonStructure - | StructuredSanitizationError::InvalidEncoding - | StructuredSanitizationError::CredentialKeyQuarantine => { - DetectionError::StructuredQuarantine + | StructuredSanitizationError::InvalidEncoding => DetectionError::StructuredQuarantine, + StructuredSanitizationError::CredentialKeyQuarantine => { + DetectionError::CredentialKeyQuarantine } StructuredSanitizationError::InvalidLimits | StructuredSanitizationError::SanitizerUnavailable => DetectionError::Initialization, diff --git a/crates/tracedecay-runtime-core/src/privacy/structured_text_tests.rs b/crates/tracedecay-runtime-core/src/privacy/structured_text_tests.rs index f473138139..5222c354e5 100644 --- a/crates/tracedecay-runtime-core/src/privacy/structured_text_tests.rs +++ b/crates/tracedecay-runtime-core/src/privacy/structured_text_tests.rs @@ -361,10 +361,12 @@ fn lcm_json_credential_bearing_keys_quarantine_instead_of_faulting_the_receipt() let credential_key = ["sk", "-test-", "1234567890abcdef"].concat(); let raw = format!(r#"{{"{credential_key}":"ordinary-value"}}"#); - assert!(matches!( - sanitize_lcm_payload_text(&raw), - Err(DetectionError::StructuredQuarantine) - )); + let error = sanitize_lcm_payload_text(&raw).expect_err("key quarantine"); + assert_eq!(error, DetectionError::CredentialKeyQuarantine); + assert_eq!( + error.to_string(), + "privacy sanitizer quarantined credential-bearing keys" + ); } #[test] From fd6997958af9801d7683d52bb8efb2e8cccc7904 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Thu, 20 Aug 2026 00:31:21 +0000 Subject: [PATCH 3/6] fix(privacy): derive PartialEq for DetectionError The credential-key quarantine test asserts variant equality directly, which requires PartialEq on the public error enum. Matches the derive set already carried by StructuredSanitizationError. Co-authored-by: Zack Jackson --- crates/tracedecay-runtime-core/src/privacy/detect.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/crates/tracedecay-runtime-core/src/privacy/detect.rs b/crates/tracedecay-runtime-core/src/privacy/detect.rs index 8c15ccbee3..f0d3e2e721 100644 --- a/crates/tracedecay-runtime-core/src/privacy/detect.rs +++ b/crates/tracedecay-runtime-core/src/privacy/detect.rs @@ -396,7 +396,7 @@ fn is_safe_structural_location(location: &str) -> bool { true } -#[derive(Debug, Error)] +#[derive(Debug, Error, PartialEq, Eq)] pub enum DetectionError { #[error("privacy detector initialization failed")] Initialization, From 0103cffc906385c8a28153d559cd9a4747be02a0 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Thu, 20 Aug 2026 00:47:50 +0000 Subject: [PATCH 4/6] fix(privacy): route non-JSON key quarantines to CredentialKeyQuarantine detect_lcm_payload's non-JSON route and sanitize_code_source_bytes collapsed every non-empty quarantine-finding set into DetectionError::StructuredQuarantine, so a parsed TOML/YAML document whose keys carry credential material displayed as parse ambiguity. Route by finding: a malformed-record finding stays the parse-ambiguity quarantine; key-anchored findings from a parsed document surface as CredentialKeyQuarantine, matching the JSON container path. Co-authored-by: Zack Jackson --- .../src/privacy/detect.rs | 6 +- .../src/privacy/structured_text.rs | 25 +++++++- .../src/privacy/structured_text_tests.rs | 58 +++++++++++++++++++ 3 files changed, 85 insertions(+), 4 deletions(-) diff --git a/crates/tracedecay-runtime-core/src/privacy/detect.rs b/crates/tracedecay-runtime-core/src/privacy/detect.rs index f0d3e2e721..620a931c42 100644 --- a/crates/tracedecay-runtime-core/src/privacy/detect.rs +++ b/crates/tracedecay-runtime-core/src/privacy/detect.rs @@ -408,8 +408,10 @@ pub enum DetectionError { /// without ambiguity, so field semantics cannot be proven scanned. #[error("privacy sanitizer quarantined an ambiguous structured document")] StructuredQuarantine, - /// The document parsed, but an object key carries credential material that - /// cannot be redacted in place. Fail-closed quarantine, not a receipt fault. + /// The document parsed, but key-anchored material cannot be redacted in + /// place: an object key carries credential material, or a key-proven + /// sensitive field cannot be located byte-exactly. Fail-closed quarantine, + /// not a receipt fault. #[error("privacy sanitizer quarantined credential-bearing keys")] CredentialKeyQuarantine, } diff --git a/crates/tracedecay-runtime-core/src/privacy/structured_text.rs b/crates/tracedecay-runtime-core/src/privacy/structured_text.rs index 0667c2b1eb..9e474f2ef9 100644 --- a/crates/tracedecay-runtime-core/src/privacy/structured_text.rs +++ b/crates/tracedecay-runtime-core/src/privacy/structured_text.rs @@ -631,7 +631,7 @@ pub fn sanitize_code_source_bytes( CodeSourceShapeV1::CodeOrProse => raw_only(&source, credential_patterns()?), }; if !detected.quarantine_findings().is_empty() { - return Err(DetectionError::StructuredQuarantine); + return Err(quarantine_detection_error(detected.quarantine_findings())); } let (sanitized, findings) = detected.into_parts(); let clean = findings.is_empty() && !invalid_utf8; @@ -784,11 +784,32 @@ fn detect_lcm_payload(raw: &str) -> Result<(String, Vec), let detected = sanitize_structured_text(raw)?; if !detected.quarantine_findings().is_empty() { - return Err(DetectionError::StructuredQuarantine); + return Err(quarantine_detection_error(detected.quarantine_findings())); } Ok(detected.into_parts()) } +/// Routes a non-empty quarantine-finding set to its typed refusal. +/// +/// A malformed-record finding means the document declared a structured format +/// but could not be parsed without ambiguity — that is parse-ambiguity +/// quarantine. Every other quarantine finding comes from a *parsed* document +/// whose key-anchored material cannot be redacted in place (a credential +/// carried in a key, or a key-proven sensitive field the sanitizer cannot +/// locate byte-exactly), which is the key-quarantine refusal. The two never +/// mix: malformed-record findings are only emitted instead of, never alongside, +/// parsed-document findings. +fn quarantine_detection_error(findings: &[SanitizationFindingV1]) -> DetectionError { + if findings + .iter() + .any(|finding| finding.detector() == PrivacyDetectorV1::MalformedRecord) + { + DetectionError::StructuredQuarantine + } else { + DetectionError::CredentialKeyQuarantine + } +} + /// Maps the structured sanitizer's typed refusals onto detection errors /// without conflating classes: quarantines stay quarantines, limit overruns /// stay bounded-scan refusals, an unavailable or misconfigured detector is an diff --git a/crates/tracedecay-runtime-core/src/privacy/structured_text_tests.rs b/crates/tracedecay-runtime-core/src/privacy/structured_text_tests.rs index 5222c354e5..0c31c3920e 100644 --- a/crates/tracedecay-runtime-core/src/privacy/structured_text_tests.rs +++ b/crates/tracedecay-runtime-core/src/privacy/structured_text_tests.rs @@ -369,6 +369,64 @@ fn lcm_json_credential_bearing_keys_quarantine_instead_of_faulting_the_receipt() ); } +#[test] +fn lcm_non_json_credential_bearing_keys_are_key_quarantine_not_parse_ambiguity() { + // Same key-quarantine contract as the JSON container path, reached through + // the non-JSON structured-text route: a parsed TOML table whose *key* is + // itself credential material. (TOML rather than `key: value`, which the + // format probe reads as an HTTP header block whose line path never scans + // keys.) The refusal must name the key quarantine, not claim the document + // was ambiguous — it parsed fine. + let credential_key = ["sk", "-test-", "1234567890abcdef"].concat(); + let raw = format!("{credential_key} = \"ordinary-value\"\nregion = \"us-east\"\n"); + + let error = sanitize_lcm_payload_text(&raw).expect_err("key quarantine"); + assert_eq!(error, DetectionError::CredentialKeyQuarantine); + assert_eq!( + error.to_string(), + "privacy sanitizer quarantined credential-bearing keys" + ); +} + +#[test] +fn lcm_non_json_parse_ambiguity_stays_a_structured_quarantine() { + // The routing split must not widen: a document that declared a structured + // format but failed to parse is still the parse-ambiguity quarantine. + let raw = format!("vault_passphrase: {PLACEHOLDER}\n broken: [unclosed\n"); + + let error = sanitize_lcm_payload_text(&raw).expect_err("parse ambiguity quarantine"); + assert_eq!(error, DetectionError::StructuredQuarantine); + assert_eq!( + error.to_string(), + "privacy sanitizer quarantined an ambiguous structured document" + ); +} + +#[test] +fn code_source_credential_bearing_keys_are_key_quarantine_not_parse_ambiguity() { + let credential_key = ["sk", "-test-", "1234567890abcdef"].concat(); + let raw = format!("{credential_key} = \"ordinary-value\"\nregion = \"us-east\"\n"); + + let error = sanitize_code_source_bytes(raw.as_bytes(), CodeSourceShapeV1::StructuredData) + .map(|_| ()) + .expect_err("key quarantine"); + assert_eq!(error, DetectionError::CredentialKeyQuarantine); + assert_eq!( + error.to_string(), + "privacy sanitizer quarantined credential-bearing keys" + ); +} + +#[test] +fn code_source_parse_ambiguity_stays_a_structured_quarantine() { + let raw = format!("vault_passphrase: {PLACEHOLDER}\n broken: [unclosed\n"); + + let error = sanitize_code_source_bytes(raw.as_bytes(), CodeSourceShapeV1::StructuredData) + .map(|_| ()) + .expect_err("parse ambiguity quarantine"); + assert_eq!(error, DetectionError::StructuredQuarantine); +} + #[test] fn lcm_json_credential_values_under_ordinary_keys_still_redact_durably() { // The quarantine above is specific to key positions. The same credential in From ef23c7c153e0a470e6bee3c1f5f8aa09a68f9ded Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Thu, 20 Aug 2026 05:23:24 +0000 Subject: [PATCH 5/6] fix(agent-hosts): retain profile memory graph port in user harness Co-authored-by: Zack Jackson --- .../src/automation/runner/user_scope_tests.rs | 7 ++++++- .../user_scope_tests/user_scope_graph_runtime.rs | 12 ++++++++++-- 2 files changed, 16 insertions(+), 3 deletions(-) diff --git a/crates/tracedecay-agent-hosts/src/automation/runner/user_scope_tests.rs b/crates/tracedecay-agent-hosts/src/automation/runner/user_scope_tests.rs index 36f7fd8d36..c958d856ff 100644 --- a/crates/tracedecay-agent-hosts/src/automation/runner/user_scope_tests.rs +++ b/crates/tracedecay-agent-hosts/src/automation/runner/user_scope_tests.rs @@ -24,6 +24,7 @@ use crate::automation::run_ledger::AutomationRunStatus; use crate::db::{Database, DatabaseAuthority, TestDatabaseRuntimeMode}; use crate::ports::project_runtime::{ProfileRuntime, RuntimeFuture}; use crate::store::memory::DatabaseFactStore; +use tracedecay_runtime_core::store_runtime::VerifiedGraphRuntimePortV1; use tracedecay_store::{FactStoreError, ProjectMemoryGraphQueryV1}; use tracedecay_usecases::memory::MemoryApplicationError; @@ -53,6 +54,9 @@ impl ProfileRuntime for FixtureProfileRuntime { struct UserRuntimeHarness { profile_root: PathBuf, registry: Arc, + /// Strong graph port; the database keeps only a weak binding, so this + /// handle keeps the profile memory graph mountable for the test lifetime. + _memory_graph_runtime: Arc, _session_runtime: RegisteredGlobalDbTestRuntime, _directory: TempDir, } @@ -75,7 +79,7 @@ impl UserRuntimeHarness { ) .await .expect("registered profile memory"); - bind_profile_memory_graph_runtime(&memory); + let memory_graph_runtime = bind_profile_memory_graph_runtime(&memory); let registry: Arc = Arc::new(FixtureProfileRuntime { profile_id: UserProfileId::new("profile.automation.fixture").expect("profile id"), sessions: session_runtime.profile_database_arc(), @@ -84,6 +88,7 @@ impl UserRuntimeHarness { Self { profile_root, registry, + _memory_graph_runtime: memory_graph_runtime, _session_runtime: session_runtime, _directory: directory, } diff --git a/crates/tracedecay-agent-hosts/src/automation/runner/user_scope_tests/user_scope_graph_runtime.rs b/crates/tracedecay-agent-hosts/src/automation/runner/user_scope_tests/user_scope_graph_runtime.rs index d31033647a..8a52dde1ca 100644 --- a/crates/tracedecay-agent-hosts/src/automation/runner/user_scope_tests/user_scope_graph_runtime.rs +++ b/crates/tracedecay-agent-hosts/src/automation/runner/user_scope_tests/user_scope_graph_runtime.rs @@ -92,10 +92,18 @@ impl VerifiedGraphRuntimePortV1 for ProfileMemoryGraphRuntime { } } -pub(super) fn bind_profile_memory_graph_runtime(database: &Database) { +/// Binds the profile memory graph fixture and returns the strong port. +/// +/// `Database::bind_memory_graph_runtime` retains only a weak binding, so the +/// caller must hold the returned `Arc` for as long as graph operations should +/// stay mountable. +pub(super) fn bind_profile_memory_graph_runtime( + database: &Database, +) -> Arc { let runtime: Arc = Arc::new(ProfileMemoryGraphRuntime::new(database)); database - .bind_memory_graph_runtime(runtime) + .bind_memory_graph_runtime(Arc::clone(&runtime)) .expect("bind profile memory graph fixture"); + runtime } From 4842360b52f1567e27814a02c7ec0527416491d3 Mon Sep 17 00:00:00 2001 From: ScriptedAlchemy Date: Wed, 19 Aug 2026 23:31:58 -0700 Subject: [PATCH 6/6] fix(privacy): preserve sensitive-field quarantine --- .../src/privacy/detect.rs | 5 +++++ .../src/privacy/structured_text.rs | 18 ++++++++------- .../src/privacy/structured_text_tests.rs | 22 +++++++++++++++++++ 3 files changed, 37 insertions(+), 8 deletions(-) diff --git a/crates/tracedecay-runtime-core/src/privacy/detect.rs b/crates/tracedecay-runtime-core/src/privacy/detect.rs index 620a931c42..227f00eaae 100644 --- a/crates/tracedecay-runtime-core/src/privacy/detect.rs +++ b/crates/tracedecay-runtime-core/src/privacy/detect.rs @@ -414,6 +414,11 @@ pub enum DetectionError { /// not a receipt fault. #[error("privacy sanitizer quarantined credential-bearing keys")] CredentialKeyQuarantine, + /// A parsed sensitive field could not be mapped back to one exact byte + /// span. Redacting a guessed span could leave the real value intact, so + /// the payload is quarantined instead. + #[error("privacy sanitizer quarantined an unlocatable sensitive field")] + SensitiveFieldQuarantine, } pub enum MemoryFactSanitizationV1 { diff --git a/crates/tracedecay-runtime-core/src/privacy/structured_text.rs b/crates/tracedecay-runtime-core/src/privacy/structured_text.rs index 9e474f2ef9..3266a0b819 100644 --- a/crates/tracedecay-runtime-core/src/privacy/structured_text.rs +++ b/crates/tracedecay-runtime-core/src/privacy/structured_text.rs @@ -791,20 +791,22 @@ fn detect_lcm_payload(raw: &str) -> Result<(String, Vec), /// Routes a non-empty quarantine-finding set to its typed refusal. /// -/// A malformed-record finding means the document declared a structured format -/// but could not be parsed without ambiguity — that is parse-ambiguity -/// quarantine. Every other quarantine finding comes from a *parsed* document -/// whose key-anchored material cannot be redacted in place (a credential -/// carried in a key, or a key-proven sensitive field the sanitizer cannot -/// locate byte-exactly), which is the key-quarantine refusal. The two never -/// mix: malformed-record findings are only emitted instead of, never alongside, -/// parsed-document findings. +/// Malformed records, unlocatable sensitive values, and credential-bearing +/// keys require different remediation, so preserve their distinct typed +/// refusals. A parsed document can contain both an unlocatable sensitive value +/// and a credential-bearing key; the unlocatable-field result takes precedence +/// because reporting only the key would conceal the value-location failure. fn quarantine_detection_error(findings: &[SanitizationFindingV1]) -> DetectionError { if findings .iter() .any(|finding| finding.detector() == PrivacyDetectorV1::MalformedRecord) { DetectionError::StructuredQuarantine + } else if findings + .iter() + .any(|finding| finding.detector() == PrivacyDetectorV1::SensitiveField) + { + DetectionError::SensitiveFieldQuarantine } else { DetectionError::CredentialKeyQuarantine } diff --git a/crates/tracedecay-runtime-core/src/privacy/structured_text_tests.rs b/crates/tracedecay-runtime-core/src/privacy/structured_text_tests.rs index 0c31c3920e..a7a9885d11 100644 --- a/crates/tracedecay-runtime-core/src/privacy/structured_text_tests.rs +++ b/crates/tracedecay-runtime-core/src/privacy/structured_text_tests.rs @@ -427,6 +427,28 @@ fn code_source_parse_ambiguity_stays_a_structured_quarantine() { assert_eq!(error, DetectionError::StructuredQuarantine); } +#[test] +fn lcm_unlocatable_sensitive_fields_are_not_reported_as_credential_keys() { + let raw = "# rotate the vault_passphrase monthly\nvault_passphrase: >\n line-one-of-secret\n line-two-of-secret\nregion: us-east\n"; + + let error = sanitize_lcm_payload_text(raw).expect_err("unlocatable sensitive field"); + assert_eq!(error, DetectionError::SensitiveFieldQuarantine); + assert_eq!( + error.to_string(), + "privacy sanitizer quarantined an unlocatable sensitive field" + ); +} + +#[test] +fn code_source_unlocatable_sensitive_fields_keep_their_typed_refusal() { + let raw = "# rotate the vault_passphrase monthly\nvault_passphrase: >\n line-one-of-secret\n line-two-of-secret\nregion: us-east\n"; + + let error = sanitize_code_source_bytes(raw.as_bytes(), CodeSourceShapeV1::StructuredData) + .map(|_| ()) + .expect_err("unlocatable sensitive field"); + assert_eq!(error, DetectionError::SensitiveFieldQuarantine); +} + #[test] fn lcm_json_credential_values_under_ordinary_keys_still_redact_durably() { // The quarantine above is specific to key positions. The same credential in