Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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
}
11 changes: 8 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,15 @@ 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,
}

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
40 changes: 35 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,37 @@ 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.
///
/// 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
/// 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 +825,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
Original file line number Diff line number Diff line change
Expand Up @@ -351,6 +351,95 @@ 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_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
Loading