#![allow(dead_code)]
use serde::{Deserialize, Serialize};
use crate::search::incident_categories::IncidentCategory;
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Hash, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub(crate) enum PrivateTextPolicy {
#[default]
SuppressAll,
RedactedSnippets,
RawOptIn,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub(crate) enum HashStrategy {
Blake3_256V1,
None,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
pub(crate) struct RedactionPolicy {
pub private_text: PrivateTextPolicy,
pub hash: HashStrategy,
pub allow_full_paths: bool,
}
impl Default for RedactionPolicy {
fn default() -> Self {
Self {
private_text: PrivateTextPolicy::SuppressAll,
hash: HashStrategy::Blake3_256V1,
allow_full_paths: false,
}
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub(crate) struct RawIncidentEvidence {
pub category: IncidentCategory,
pub occurrence_count: u64,
pub raw_prompt_text: Option<String>,
pub raw_tool_payload: Option<String>,
pub source_path: Option<String>,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub(crate) struct RedactedIncident {
pub category: IncidentCategory,
pub occurrence_count: u64,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub content_fingerprint: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub snippet: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub source_path: Option<String>,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub(crate) struct RedactionManifest {
pub private_text_policy: PrivateTextPolicy,
pub hash_strategy: HashStrategy,
pub fields_emitted: Vec<String>,
pub fields_suppressed: Vec<String>,
pub opt_in_flags: Vec<String>,
}
pub(crate) fn default_robot_manifest() -> RedactionManifest {
RedactionManifest {
private_text_policy: PrivateTextPolicy::SuppressAll,
hash_strategy: HashStrategy::Blake3_256V1,
fields_emitted: vec![
"conversation_id".to_string(),
"session_id".to_string(),
"agent".to_string(),
"host".to_string(),
"source_path".to_string(),
"source_id".to_string(),
"origin_host".to_string(),
"exists_state".to_string(),
"hit_count".to_string(),
"category".to_string(),
"category_breadth".to_string(),
"dominant_categories".to_string(),
"redaction_status".to_string(),
"evidence_summaries".to_string(),
"content_fingerprints".to_string(),
"evidence_paths".to_string(),
"suggested_command".to_string(),
],
fields_suppressed: vec![
"raw_prompt_text".to_string(),
"raw_tool_payload".to_string(),
"raw_snippet".to_string(),
],
opt_in_flags: Vec::new(),
}
}
fn content_fingerprint(text: &str) -> String {
const DOMAIN: &[u8] = b"cass-incident-content-fingerprint-v1\0";
let mut hasher = blake3::Hasher::new();
hasher.update(DOMAIN);
hasher.update(text.as_bytes());
hasher.finalize().to_hex().to_string()
}
fn masked_snippet(text: &str, max_chars: usize) -> String {
let bounded: String = text.chars().take(max_chars).collect();
bounded
.split_whitespace()
.map(|word| {
let alnum_run = word.chars().filter(|c| c.is_alphanumeric()).count();
if word.contains('@') || alnum_run >= 16 {
"[redacted]".to_string()
} else {
word.to_string()
}
})
.collect::<Vec<_>>()
.join(" ")
}
fn basename(path: &str) -> String {
path.trim_end_matches('/')
.trim_end_matches('\\')
.rsplit(['/', '\\'])
.next()
.unwrap_or(path)
.to_string()
}
pub(crate) fn redact(
evidence: &RawIncidentEvidence,
policy: RedactionPolicy,
) -> (RedactedIncident, RedactionManifest) {
let mut emitted = vec!["category".to_string(), "occurrence_count".to_string()];
let mut suppressed = Vec::new();
let content_fingerprint = match policy.hash {
HashStrategy::Blake3_256V1 => {
let raw = format!(
"{}\u{1f}{}",
evidence.raw_prompt_text.as_deref().unwrap_or(""),
evidence.raw_tool_payload.as_deref().unwrap_or("")
);
emitted.push("content_fingerprint".to_string());
Some(content_fingerprint(&raw))
}
HashStrategy::None => None,
};
let snippet = match policy.private_text {
PrivateTextPolicy::SuppressAll => {
if evidence.raw_prompt_text.is_some() {
suppressed.push("raw_prompt_text".to_string());
}
if evidence.raw_tool_payload.is_some() {
suppressed.push("raw_tool_payload".to_string());
}
None
}
PrivateTextPolicy::RedactedSnippets => {
if evidence.raw_tool_payload.is_some() {
suppressed.push("raw_tool_payload".to_string());
}
evidence.raw_prompt_text.as_deref().map(|t| {
emitted.push("snippet".to_string());
masked_snippet(t, 80)
})
}
PrivateTextPolicy::RawOptIn => {
evidence.raw_prompt_text.as_deref().map(|t| {
emitted.push("snippet".to_string());
t.to_string()
})
}
};
let source_path = evidence.source_path.as_deref().map(|p| {
emitted.push("source_path".to_string());
if policy.allow_full_paths {
p.to_string()
} else {
basename(p)
}
});
let redacted = RedactedIncident {
category: evidence.category,
occurrence_count: evidence.occurrence_count,
content_fingerprint,
snippet,
source_path,
};
let manifest = RedactionManifest {
private_text_policy: policy.private_text,
hash_strategy: policy.hash,
fields_emitted: emitted,
fields_suppressed: suppressed,
opt_in_flags: vec![
"--include-redacted-snippets".to_string(),
"--include-raw-evidence".to_string(),
"--allow-full-paths".to_string(),
],
};
(redacted, manifest)
}
#[cfg(test)]
mod tests {
use super::*;
const SECRET_PROMPT: &str = "please use api key sk_live_ABCDEF0123456789TOKEN to call svc";
const SECRET_TOOL: &str = "{\"tool\":\"bash\",\"args\":\"cat /home/dev/.env\"}";
fn evidence() -> RawIncidentEvidence {
RawIncidentEvidence {
category: IncidentCategory::QuarantineOom,
occurrence_count: 7,
raw_prompt_text: Some(SECRET_PROMPT.to_string()),
raw_tool_payload: Some(SECRET_TOOL.to_string()),
source_path: Some("/home/dev/proj/session.jsonl".to_string()),
}
}
#[test]
fn enums_serialize_snake_case() {
assert_eq!(
serde_json::to_string(&PrivateTextPolicy::SuppressAll).unwrap(),
"\"suppress_all\""
);
assert_eq!(
serde_json::to_string(&HashStrategy::Blake3_256V1).unwrap(),
"\"blake3_256_v1\""
);
}
#[test]
fn default_policy_leaks_no_raw_prompt_or_tool_payload() {
let (redacted, manifest) = redact(&evidence(), RedactionPolicy::default());
let json = serde_json::to_string(&redacted).unwrap();
assert!(!json.contains("sk_live_ABCDEF0123456789TOKEN"), "{json}");
assert!(!json.contains(SECRET_PROMPT), "{json}");
assert!(!json.contains(SECRET_TOOL), "{json}");
assert!(!json.contains(".env"), "{json}");
assert!(redacted.snippet.is_none());
assert!(
manifest
.fields_suppressed
.contains(&"raw_prompt_text".to_string())
);
assert!(
manifest
.fields_suppressed
.contains(&"raw_tool_payload".to_string())
);
let fp = redacted.content_fingerprint.unwrap();
assert_eq!(fp.len(), 64);
assert!(!fp.contains("sk_live"));
}
#[test]
fn default_policy_redacts_source_path_to_basename() {
let (redacted, _) = redact(&evidence(), RedactionPolicy::default());
assert_eq!(redacted.source_path.as_deref(), Some("session.jsonl"));
}
#[test]
fn allow_full_paths_emits_the_full_path() {
let policy = RedactionPolicy {
allow_full_paths: true,
..RedactionPolicy::default()
};
let (redacted, _) = redact(&evidence(), policy);
assert_eq!(
redacted.source_path.as_deref(),
Some("/home/dev/proj/session.jsonl")
);
}
#[test]
fn fingerprint_is_deterministic_and_distinguishes_content() {
let a = redact(&evidence(), RedactionPolicy::default())
.0
.content_fingerprint;
let b = redact(&evidence(), RedactionPolicy::default())
.0
.content_fingerprint;
assert_eq!(a, b, "fingerprint must be deterministic");
let mut other = evidence();
other.raw_prompt_text = Some("a different prompt".to_string());
let c = redact(&other, RedactionPolicy::default())
.0
.content_fingerprint;
assert_ne!(a, c, "different content must fingerprint differently");
}
#[test]
fn redacted_snippets_policy_masks_tokens_and_still_suppresses_tool_payload() {
let policy = RedactionPolicy {
private_text: PrivateTextPolicy::RedactedSnippets,
..RedactionPolicy::default()
};
let (redacted, manifest) = redact(&evidence(), policy);
let snippet = redacted.snippet.unwrap();
assert!(
!snippet.contains("sk_live_ABCDEF0123456789TOKEN"),
"{snippet}"
);
assert!(snippet.contains("[redacted]"), "{snippet}");
assert!(
manifest
.fields_suppressed
.contains(&"raw_tool_payload".to_string())
);
assert!(manifest.fields_emitted.contains(&"snippet".to_string()));
}
#[test]
fn raw_opt_in_emits_verbatim_only_when_explicitly_selected() {
let policy = RedactionPolicy {
private_text: PrivateTextPolicy::RawOptIn,
..RedactionPolicy::default()
};
let (redacted, _) = redact(&evidence(), policy);
assert_eq!(redacted.snippet.as_deref(), Some(SECRET_PROMPT));
assert_ne!(
RedactionPolicy::default().private_text,
PrivateTextPolicy::RawOptIn
);
}
#[test]
fn manifest_records_policy_hash_and_opt_in_flags() {
let (_, manifest) = redact(&evidence(), RedactionPolicy::default());
assert_eq!(manifest.private_text_policy, PrivateTextPolicy::SuppressAll);
assert_eq!(manifest.hash_strategy, HashStrategy::Blake3_256V1);
assert!(manifest.fields_emitted.contains(&"category".to_string()));
assert!(
manifest
.fields_emitted
.contains(&"content_fingerprint".to_string())
);
assert!(
manifest
.opt_in_flags
.iter()
.any(|f| f.contains("raw-evidence"))
);
}
#[test]
fn redacted_incident_round_trips_through_json() {
let (redacted, manifest) = redact(&evidence(), RedactionPolicy::default());
let rj = serde_json::to_string(&redacted).unwrap();
assert!(rj.contains("\"category\":\"quarantine_oom\""));
assert_eq!(
serde_json::from_str::<RedactedIncident>(&rj).unwrap(),
redacted
);
let mj = serde_json::to_string(&manifest).unwrap();
assert!(mj.contains("\"private_text_policy\":\"suppress_all\""));
assert_eq!(
serde_json::from_str::<RedactionManifest>(&mj).unwrap(),
manifest
);
}
#[test]
fn hash_strategy_none_emits_no_fingerprint() {
let policy = RedactionPolicy {
hash: HashStrategy::None,
..RedactionPolicy::default()
};
let (redacted, _) = redact(&evidence(), policy);
assert!(redacted.content_fingerprint.is_none());
}
#[test]
fn live_robot_manifest_is_truthful_even_for_an_empty_corpus() {
let manifest = default_robot_manifest();
assert_eq!(manifest.private_text_policy, PrivateTextPolicy::SuppressAll);
assert_eq!(manifest.hash_strategy, HashStrategy::Blake3_256V1);
assert!(
manifest
.fields_suppressed
.contains(&"raw_prompt_text".into())
);
assert_eq!(
manifest.fields_emitted,
[
"conversation_id",
"session_id",
"agent",
"host",
"source_path",
"source_id",
"origin_host",
"exists_state",
"hit_count",
"category",
"category_breadth",
"dominant_categories",
"redaction_status",
"evidence_summaries",
"content_fingerprints",
"evidence_paths",
"suggested_command",
]
.map(str::to_string)
);
assert!(manifest.opt_in_flags.is_empty());
}
#[test]
fn basename_handles_unix_and_windows_paths() {
assert_eq!(basename("/home/dev/session.jsonl"), "session.jsonl");
assert_eq!(basename(r"C:\\Users\\dev\\session.jsonl"), "session.jsonl");
}
}