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 36f7fd8d3..c958d856f 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 d31033647..8a52dde1c 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 } diff --git a/crates/tracedecay-runtime-core/src/privacy/detect.rs b/crates/tracedecay-runtime-core/src/privacy/detect.rs index 6b6bf12f3..227f00eaa 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, @@ -405,10 +405,20 @@ pub enum DetectionError { #[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. + /// without ambiguity, so field semantics cannot be proven scanned. #[error("privacy sanitizer quarantined an ambiguous structured document")] StructuredQuarantine, + /// 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, + /// 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.rs b/crates/tracedecay-runtime-core/src/privacy/structured.rs index d73c4f3b2..b29b27b86 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 cb2654f6e..f74ff4159 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 b06885845..3266a0b81 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() { @@ -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; @@ -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() { @@ -784,11 +784,39 @@ 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. +/// +/// 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 + } +} + +/// 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 { @@ -799,8 +827,12 @@ fn detection_error_from_structured_sanitization( | StructuredSanitizationError::ItemCountExceeded => DetectionError::ScanLimitExceeded, StructuredSanitizationError::UnsafeJsonStructure | StructuredSanitizationError::InvalidEncoding => DetectionError::StructuredQuarantine, + StructuredSanitizationError::CredentialKeyQuarantine => { + DetectionError::CredentialKeyQuarantine + } 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 14db96e55..a7a9885d1 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,117 @@ 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"}}"#); + + 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_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_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 + // 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();