Skip to content
Original file line number Diff line number Diff line change
Expand Up @@ -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;

Expand Down Expand Up @@ -53,6 +54,9 @@ impl ProfileRuntime for FixtureProfileRuntime {
struct UserRuntimeHarness {
profile_root: PathBuf,
registry: Arc<dyn ProfileRuntime>,
/// 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<dyn VerifiedGraphRuntimePortV1>,
_session_runtime: RegisteredGlobalDbTestRuntime,
_directory: TempDir,
}
Expand All @@ -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<dyn ProfileRuntime> = Arc::new(FixtureProfileRuntime {
profile_id: UserProfileId::new("profile.automation.fixture").expect("profile id"),
sessions: session_runtime.profile_database_arc(),
Expand All @@ -84,6 +88,7 @@ impl UserRuntimeHarness {
Self {
profile_root,
registry,
_memory_graph_runtime: memory_graph_runtime,
_session_runtime: session_runtime,
_directory: directory,
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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<dyn VerifiedGraphRuntimePortV1> {
let runtime: Arc<dyn VerifiedGraphRuntimePortV1> =
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
}
16 changes: 13 additions & 3 deletions crates/tracedecay-runtime-core/src/privacy/detect.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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")]
Comment thread
ScriptedAlchemy marked this conversation as resolved.
StructuredQuarantine,
Comment thread
ScriptedAlchemy marked this conversation as resolved.
/// 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 {
Expand Down
18 changes: 15 additions & 3 deletions crates/tracedecay-runtime-core/src/privacy/structured.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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,
}
Expand Down Expand Up @@ -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 {
Expand All @@ -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 {
Expand Down Expand Up @@ -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);
}
Expand Down
14 changes: 14 additions & 0 deletions crates/tracedecay-runtime-core/src/privacy/structured_tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down
42 changes: 37 additions & 5 deletions crates/tracedecay-runtime-core/src/privacy/structured_text.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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() {
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -771,7 +771,7 @@ fn detect_lcm_payload(raw: &str) -> Result<(String, Vec<SanitizationFindingV1>),
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() {
Expand All @@ -784,11 +784,39 @@ fn detect_lcm_payload(raw: &str) -> Result<(String, Vec<SanitizationFindingV1>),

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
Comment thread
ScriptedAlchemy marked this conversation as resolved.
}
}

/// 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 {
Expand All @@ -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,
}
}

Expand Down
111 changes: 111 additions & 0 deletions crates/tracedecay-runtime-core/src/privacy/structured_text_tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand Down