use crate::findings::{
ArtifactScope, Finding, RecommendedAction, RootCauseGroup, SignalClass, ThreatCategory,
VerdictReason,
};
const TRUSTED_HOST_DOWNGRADE_TAINT_RULES: &[&str] = &[
"ARTIFACT_TAINT_SECRET_TO_EXTERNAL_NETWORK",
"ARTIFACT_TAINT_IDENTITY_TO_EXTERNAL_NETWORK",
];
pub(super) fn detect_compound_verdict_reasons(
findings: &[Finding],
raw_root_cause_groups: &[RootCauseGroup],
) -> Vec<VerdictReason> {
[
detect_prompt_tampering_with_exec(findings, raw_root_cause_groups),
detect_credential_exfil_chain(findings, raw_root_cause_groups),
detect_install_hook_with_exec_surface(findings, raw_root_cause_groups),
detect_broad_permissions_with_autonomy(findings, raw_root_cause_groups),
detect_mcp_remote_endpoint_with_exec(findings, raw_root_cause_groups),
detect_heartbeat_poll_with_credential_read(findings, raw_root_cause_groups),
]
.into_iter()
.flatten()
.collect()
}
fn compound_has_category(
raw_root_cause_groups: &[RootCauseGroup],
category: ThreatCategory,
) -> bool {
raw_root_cause_groups
.iter()
.any(|group| group.category == category && group.strongest_action != RecommendedAction::Log)
}
fn most_specific_scope_for_category(
raw_root_cause_groups: &[RootCauseGroup],
category: ThreatCategory,
) -> Option<ArtifactScope> {
raw_root_cause_groups
.iter()
.filter(|g| g.category == category && g.strongest_action != RecommendedAction::Log)
.map(|g| g.scope)
.min()
}
fn compound_has_rule(findings: &[Finding], rule_id: &str) -> bool {
debug_assert!(
!crate::verdict_calibration::CALIBRATED_RULE_IDS.contains(&rule_id),
"compound_has_rule checks pre-calibration actions; use compound_has_category for calibrated rule {rule_id}"
);
findings
.iter()
.any(|f| f.rule_id == rule_id && f.recommended_action != RecommendedAction::Log)
}
fn compound_has_rule_in_scope(findings: &[Finding], rule_id: &str, scope: ArtifactScope) -> bool {
debug_assert!(
!crate::verdict_calibration::CALIBRATED_RULE_IDS.contains(&rule_id),
"compound_has_rule_in_scope checks pre-calibration actions; use compound_has_category for calibrated rule {rule_id}"
);
findings.iter().any(|f| {
f.rule_id == rule_id
&& f.recommended_action != RecommendedAction::Log
&& f.artifact_scope == scope
})
}
fn compound_has_declared_permission_rule(findings: &[Finding], rule_id: &str) -> bool {
findings
.iter()
.any(|f| f.rule_id == rule_id && f.artifact_scope == ArtifactScope::AgentEntrypoint)
}
fn compound_has_high_risk_autonomy(
findings: &[Finding],
raw_root_cause_groups: &[RootCauseGroup],
) -> bool {
raw_root_cause_groups.iter().any(|group| {
group.category == ThreatCategory::AutonomyEscalation
&& group.scope == ArtifactScope::AgentEntrypoint
&& (group.strongest_action == RecommendedAction::Block
|| group.signal_class == SignalClass::MaliciousBehavior)
}) || compound_has_rule(findings, "OFFICIAL_APPROVAL_BYPASS_WITH_EXECUTION")
|| compound_has_rule(findings, "OFFICIAL_APPROVAL_BYPASS_DELETE_OR_MODIFY")
|| compound_has_rule(findings, "OFFICIAL_PROMPT_OVERRIDE_WITH_PERSISTENCE")
|| compound_has_rule(findings, "OFFICIAL_FORCED_APPROVAL_BYPASS")
}
fn detect_prompt_tampering_with_exec(
_findings: &[Finding],
raw_root_cause_groups: &[RootCauseGroup],
) -> Option<VerdictReason> {
if compound_has_category(
raw_root_cause_groups,
ThreatCategory::PersistentPromptTampering,
) && compound_has_category(raw_root_cause_groups, ThreatCategory::RemoteExec)
{
Some(VerdictReason {
scope: ArtifactScope::AgentEntrypoint,
category: ThreatCategory::RemoteExec,
signal_class: SignalClass::MaliciousBehavior,
rationale: "Compound verdict: prompt override is paired with execution behavior"
.to_string(),
})
} else {
None
}
}
fn detect_credential_exfil_chain(
findings: &[Finding],
raw_root_cause_groups: &[RootCauseGroup],
) -> Option<VerdictReason> {
let cred_scope = most_specific_scope_for_category(
raw_root_cause_groups,
ThreatCategory::CredentialExposure,
)?;
let exfil_scope =
most_specific_scope_for_category(raw_root_cause_groups, ThreatCategory::DataExfiltration)?;
let scope = cred_scope.min(exfil_scope);
let exfil_findings_in_scope: Vec<&Finding> = findings
.iter()
.filter(|f| {
f.category == ThreatCategory::DataExfiltration
&& f.artifact_scope == scope
&& f.recommended_action != RecommendedAction::Log
})
.collect();
let signal_class = if !exfil_findings_in_scope.is_empty()
&& exfil_findings_in_scope
.iter()
.all(|f| is_trust_downgraded_taint(f))
{
SignalClass::ReviewSignal
} else {
SignalClass::MaliciousBehavior
};
Some(VerdictReason {
scope,
category: ThreatCategory::DataExfiltration,
signal_class,
rationale: "Compound verdict: token or session access is paired with outbound transmission"
.to_string(),
})
}
fn is_trust_downgraded_taint(finding: &Finding) -> bool {
if !TRUSTED_HOST_DOWNGRADE_TAINT_RULES.contains(&finding.rule_id.as_str()) {
return false;
}
finding.match_value.contains("sinks_trusted=true")
}
fn detect_install_hook_with_exec_surface(
findings: &[Finding],
raw_root_cause_groups: &[RootCauseGroup],
) -> Option<VerdictReason> {
if compound_has_rule_in_scope(
findings,
"MANIFEST_PACKAGE_JSON_INSTALL_HOOK",
ArtifactScope::PackageRootArtifact,
) && (compound_has_category(raw_root_cause_groups, ThreatCategory::RemoteExec)
|| compound_has_rule(findings, "OFFICIAL_REMOTE_FETCH_EXEC_POLYGLOT"))
{
Some(VerdictReason {
scope: ArtifactScope::PackageRootArtifact,
category: ThreatCategory::SupplyChain,
signal_class: SignalClass::MaliciousBehavior,
rationale: "Compound verdict: install hook is paired with remote fetch or execution"
.to_string(),
})
} else {
None
}
}
fn detect_broad_permissions_with_autonomy(
findings: &[Finding],
raw_root_cause_groups: &[RootCauseGroup],
) -> Option<VerdictReason> {
let has_broad_permission_combo =
compound_has_declared_permission_rule(findings, "DECLARED_PERMISSION_BROWSER_FULL")
|| compound_has_declared_permission_rule(findings, "DECLARED_PERMISSION_SHELL_EXEC")
|| (compound_has_declared_permission_rule(
findings,
"DECLARED_PERMISSION_OAUTH_SCOPES",
) && compound_has_declared_permission_rule(
findings,
"DECLARED_PERMISSION_SECRETS_ACCESS",
));
if has_broad_permission_combo
&& compound_has_high_risk_autonomy(findings, raw_root_cause_groups)
{
Some(VerdictReason {
scope: ArtifactScope::AgentEntrypoint,
category: ThreatCategory::AutonomyEscalation,
signal_class: SignalClass::MaliciousBehavior,
rationale:
"Compound verdict: broad permissions are paired with autonomous execution semantics"
.to_string(),
})
} else {
None
}
}
fn detect_heartbeat_poll_with_credential_read(
findings: &[Finding],
raw_root_cause_groups: &[RootCauseGroup],
) -> Option<VerdictReason> {
if compound_has_rule(findings, "SKILL_HEARTBEAT_REMOTE_POLL")
&& compound_has_category(raw_root_cause_groups, ThreatCategory::CredentialExposure)
{
Some(VerdictReason {
scope: ArtifactScope::AgentEntrypoint,
category: ThreatCategory::AutonomyEscalation,
signal_class: SignalClass::MaliciousBehavior,
rationale:
"Compound verdict: heartbeat polling is paired with credential or token access"
.to_string(),
})
} else {
None
}
}
fn detect_mcp_remote_endpoint_with_exec(
findings: &[Finding],
_raw_root_cause_groups: &[RootCauseGroup],
) -> Option<VerdictReason> {
if compound_has_rule_in_scope(
findings,
"MCP_REMOTE_SERVER_ENDPOINT",
ArtifactScope::PackageRootArtifact,
) && (compound_has_rule(findings, "MCP_REMOTE_EXEC_SURFACE")
|| compound_has_rule(findings, "MCP_TOOLING_TRANSPORT_DECLARED"))
{
Some(VerdictReason {
scope: ArtifactScope::PackageRootArtifact,
category: ThreatCategory::RemoteExec,
signal_class: SignalClass::MaliciousBehavior,
rationale: "Compound verdict: MCP remote endpoint is paired with command or stdio execution semantics"
.to_string(),
})
} else {
None
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::findings::Finding;
fn taint_finding(rule_id: &str, sinks_trusted: bool) -> Finding {
let suffix = if sinks_trusted {
" sinks_trusted=true"
} else {
""
};
Finding {
rule_id: rule_id.to_string(),
category: ThreatCategory::DataExfiltration,
severity: crate::findings::Severity::Critical,
confidence: 0.9,
raw_confidence: 0.9,
confidence_rationale: String::new(),
matched_on: crate::findings::MatchTarget::ReferencedFile {
path: "SKILL.md".to_string(),
},
match_value: format!(
"family=exfil source=secret_access sink=https://api.openai.com/v1{suffix}"
),
reason: String::new(),
remediation: String::new(),
recommended_action: if sinks_trusted {
RecommendedAction::RequireApproval
} else {
RecommendedAction::Block
},
evidence_kind: crate::findings::EvidenceKind::Behavior,
artifact_kind: crate::findings::ArtifactKind::SkillDocument,
artifact_scope: ArtifactScope::AgentEntrypoint,
signal_class: if sinks_trusted {
SignalClass::ReviewSignal
} else {
SignalClass::MaliciousBehavior
},
artifact_path: Some("SKILL.md".to_string()),
operational_contexts: Vec::new(),
line_number: None,
suppression: None,
}
}
fn cred_group() -> RootCauseGroup {
RootCauseGroup {
scope: ArtifactScope::AgentEntrypoint,
category: ThreatCategory::CredentialExposure,
signal_class: SignalClass::ReviewSignal,
finding_count: 1,
strongest_action: RecommendedAction::RequireApproval,
representative_rules: vec!["SKILL_SECRETS_DIR_WRITE".to_string()],
}
}
fn exfil_group() -> RootCauseGroup {
RootCauseGroup {
scope: ArtifactScope::AgentEntrypoint,
category: ThreatCategory::DataExfiltration,
signal_class: SignalClass::ReviewSignal,
finding_count: 1,
strongest_action: RecommendedAction::RequireApproval,
representative_rules: vec!["ARTIFACT_TAINT_SECRET_TO_EXTERNAL_NETWORK".to_string()],
}
}
#[test]
fn credential_exfil_chain_respects_trust_downgrade() {
let findings = vec![taint_finding(
"ARTIFACT_TAINT_SECRET_TO_EXTERNAL_NETWORK",
true,
)];
let groups = vec![cred_group(), exfil_group()];
let reason = detect_credential_exfil_chain(&findings, &groups)
.expect("chain should still emit a verdict reason");
assert_eq!(
reason.signal_class,
SignalClass::ReviewSignal,
"trust-downgraded taint must downgrade compound chain to ReviewSignal"
);
}
#[test]
fn credential_exfil_chain_one_untrusted_defeats_downgrade() {
let findings = vec![
taint_finding("ARTIFACT_TAINT_SECRET_TO_EXTERNAL_NETWORK", true),
taint_finding("ARTIFACT_TAINT_SECRET_TO_EXTERNAL_NETWORK", false),
];
let groups = vec![cred_group(), exfil_group()];
let reason = detect_credential_exfil_chain(&findings, &groups)
.expect("chain should still emit a verdict reason");
assert_eq!(
reason.signal_class,
SignalClass::MaliciousBehavior,
"one untrusted taint sink must keep compound chain at MaliciousBehavior"
);
}
#[test]
fn credential_exfil_chain_no_in_scope_findings_stays_malicious() {
let findings: Vec<Finding> = Vec::new();
let groups = vec![cred_group(), exfil_group()];
let reason = detect_credential_exfil_chain(&findings, &groups)
.expect("chain should still emit a verdict reason");
assert_eq!(
reason.signal_class,
SignalClass::MaliciousBehavior,
"no in-scope exfil findings must keep chain at MaliciousBehavior"
);
}
}