use aa_core::identity::{AgentId, SessionId};
use aa_core::time::Timestamp;
use aa_core::{AgentContext, FileMode, GovernanceAction, PolicyResult};
use aa_proto::assembly::approval::v1::{ApprovalDecisionType, ApprovalEvent, DecideRequest, PendingApproval};
use aa_proto::assembly::common::v1::Decision;
use aa_proto::assembly::policy::v1::action_context::Action;
use aa_proto::assembly::policy::v1::{CheckActionRequest, CheckActionResponse, RedactInstructions, RedactRule};
use aa_runtime::approval::PendingApprovalRequest;
use aa_runtime::approval::{ApprovalDecision, ApprovalRequest, ApprovalRequestId};
use sha2::{Digest, Sha256};
use std::collections::BTreeMap;
use crate::engine::EvaluationResult;
#[derive(Debug, thiserror::Error)]
pub enum ConvertError {
#[error("missing agent_id")]
MissingAgentId,
#[error("missing action context")]
MissingContext,
#[error("unknown file operation: {0}")]
UnknownFileOp(String),
}
pub fn hash_to_16(s: &str) -> [u8; 16] {
let digest = Sha256::digest(s.as_bytes());
let mut out = [0u8; 16];
out.copy_from_slice(&digest[..16]);
out
}
pub fn request_to_core(req: &CheckActionRequest) -> Result<(AgentContext, GovernanceAction), ConvertError> {
let proto_agent = req.agent_id.as_ref().ok_or(ConvertError::MissingAgentId)?;
let agent_id = AgentId::from_bytes(hash_to_16(&proto_agent.agent_id));
let session_id = SessionId::from_bytes(hash_to_16(&req.trace_id));
let mut metadata = BTreeMap::new();
if !proto_agent.org_id.is_empty() {
metadata.insert("org_id".into(), proto_agent.org_id.clone());
}
if !proto_agent.team_id.is_empty() {
metadata.insert("team_id".into(), proto_agent.team_id.clone());
}
if !req.credential_token.is_empty() {
metadata.insert("credential_token".into(), req.credential_token.clone());
}
if !req.span_id.is_empty() {
metadata.insert("span_id".into(), req.span_id.clone());
}
let ctx = AgentContext {
agent_id,
session_id,
pid: 0, started_at: Timestamp::from_nanos(0),
metadata,
governance_level: aa_core::GovernanceLevel::default(),
parent_agent_id: None,
team_id: None,
depth: 0,
delegation_reason: None,
spawned_by_tool: None,
root_agent_id: None,
};
let context = req.context.as_ref().ok_or(ConvertError::MissingContext)?;
let action_oneof = context.action.as_ref().ok_or(ConvertError::MissingContext)?;
let action = match action_oneof {
Action::ToolCall(tc) => GovernanceAction::ToolCall {
name: tc.tool_name.clone(),
args: String::from_utf8_lossy(&tc.args_json).into_owned(),
},
Action::ToolResult(tr) => GovernanceAction::ToolResult {
tool_name: tr.tool_name.clone(),
result: String::from_utf8_lossy(&tr.result_json).into_owned(),
},
Action::FileOp(fo) => {
let mode = match fo.operation.as_str() {
"read" => FileMode::Read,
"write" | "create" => FileMode::Write,
"append" => FileMode::Append,
"delete" => FileMode::Delete,
other => return Err(ConvertError::UnknownFileOp(other.to_string())),
};
GovernanceAction::FileAccess {
path: fo.path.clone(),
mode,
}
}
Action::NetworkCall(nc) => {
let url = format!("{}://{}:{}", nc.protocol, nc.host, nc.port);
GovernanceAction::NetworkRequest {
url,
method: "CONNECT".into(),
}
}
Action::ProcessExec(pe) => {
let command = if pe.args.is_empty() {
pe.command.clone()
} else {
format!("{} {}", pe.command, pe.args.join(" "))
};
GovernanceAction::ProcessExec { command }
}
Action::LlmCall(lc) => {
let args = serde_json::json!({
"model": lc.model,
"prompt_tokens": lc.prompt_tokens,
"contains_pii": lc.contains_pii,
})
.to_string();
GovernanceAction::ToolCall {
name: "llm_call".into(),
args,
}
}
};
Ok((ctx, action))
}
pub fn eval_result_to_response(eval: &EvaluationResult, latency_us: i64, policy_rule: &str) -> CheckActionResponse {
if let PolicyResult::Deny { reason } = &eval.decision {
return CheckActionResponse {
decision: Decision::Deny as i32,
reason: reason.clone(),
policy_rule: policy_rule.to_string(),
approval_id: String::new(),
redact: None,
decision_latency_us: latency_us,
};
}
if !eval.credential_findings.is_empty() && eval.redacted_payload.is_some() {
let rules: Vec<RedactRule> = eval
.credential_findings
.iter()
.map(|f| RedactRule {
field_path: format!("$.{:?}", f.kind),
replacement: "[REDACTED]".into(),
})
.collect();
return CheckActionResponse {
decision: Decision::Redact as i32,
reason: "sensitive data detected".into(),
policy_rule: "data_pattern_scan".into(),
approval_id: String::new(),
redact: Some(RedactInstructions { rules }),
decision_latency_us: latency_us,
};
}
match &eval.decision {
PolicyResult::Allow => CheckActionResponse {
decision: Decision::Allow as i32,
reason: String::new(),
policy_rule: String::new(),
approval_id: String::new(),
redact: None,
decision_latency_us: latency_us,
},
PolicyResult::Deny { .. } => unreachable!("Deny is handled by the short-circuit above"),
PolicyResult::RequiresApproval { .. } => {
panic!(
"RequiresApproval must be handled before conversion — \
use approval_decision_to_response() after submitting to the ApprovalQueue"
);
}
}
}
pub fn result_to_response(result: &PolicyResult, latency_us: i64, policy_rule: &str) -> CheckActionResponse {
let eval = EvaluationResult {
decision: result.clone(),
redacted_payload: None,
credential_findings: Vec::new(),
deny_action: None,
};
eval_result_to_response(&eval, latency_us, policy_rule)
}
pub fn approval_decision_to_response(
decision: &ApprovalDecision,
approval_id: &ApprovalRequestId,
latency_us: i64,
policy_rule: &str,
) -> CheckActionResponse {
let id_str = approval_id.to_string();
match decision {
ApprovalDecision::Approved { .. } => CheckActionResponse {
decision: Decision::Allow as i32,
reason: String::new(),
policy_rule: policy_rule.to_string(),
approval_id: id_str,
redact: None,
decision_latency_us: latency_us,
},
ApprovalDecision::Rejected { reason, .. } => CheckActionResponse {
decision: Decision::Deny as i32,
reason: reason.clone(),
policy_rule: policy_rule.to_string(),
approval_id: id_str,
redact: None,
decision_latency_us: latency_us,
},
ApprovalDecision::TimedOut { fallback } => {
let (proto_decision, reason) = match fallback {
PolicyResult::Allow => (Decision::Allow, String::new()),
PolicyResult::Deny { reason } => (Decision::Deny, reason.clone()),
PolicyResult::RequiresApproval { .. } => (Decision::Deny, "approval timed out".to_string()),
};
CheckActionResponse {
decision: proto_decision as i32,
reason,
policy_rule: policy_rule.to_string(),
approval_id: id_str,
redact: None,
decision_latency_us: latency_us,
}
}
}
}
pub fn pending_to_proto(p: &PendingApprovalRequest) -> PendingApproval {
let team_id = p.team_id.clone().unwrap_or_default();
let routing_status = p.routing_status.clone().unwrap_or_else(|| {
p.team_id
.as_ref()
.map_or_else(|| "no_team_id".to_string(), |tid| format!("routed:{tid}"))
});
PendingApproval {
request_id: p.request_id.to_string(),
agent_id: p.agent_id.clone(),
action: p.action.clone(),
condition_triggered: p.condition_triggered.clone(),
submitted_at: p.submitted_at,
timeout_secs: p.timeout_secs,
team_id,
routing_status,
}
}
pub fn approval_event_to_proto(req: &ApprovalRequest) -> ApprovalEvent {
ApprovalEvent {
request_id: req.request_id.to_string(),
agent_id: req.agent_id.clone(),
action: req.action.clone(),
condition_triggered: req.condition_triggered.clone(),
submitted_at: req.submitted_at,
timeout_secs: req.timeout_secs,
}
}
#[derive(Debug, thiserror::Error)]
pub enum ApprovalConvertError {
#[error("invalid request_id UUID: {0}")]
InvalidRequestId(#[from] uuid::Error),
#[error("decision type is unspecified")]
UnspecifiedDecision,
#[error("rejection reason is required")]
MissingRejectionReason,
}
pub fn decide_request_to_core(
req: &DecideRequest,
) -> Result<(ApprovalRequestId, ApprovalDecision), ApprovalConvertError> {
let id: ApprovalRequestId = req.request_id.parse()?;
let decision_type =
ApprovalDecisionType::try_from(req.decision).unwrap_or(ApprovalDecisionType::DecisionUnspecified);
let decision = match decision_type {
ApprovalDecisionType::Approved => ApprovalDecision::Approved {
by: req.decided_by.clone(),
reason: if req.reason.is_empty() {
None
} else {
Some(req.reason.clone())
},
},
ApprovalDecisionType::Rejected => {
if req.reason.is_empty() {
return Err(ApprovalConvertError::MissingRejectionReason);
}
ApprovalDecision::Rejected {
by: req.decided_by.clone(),
reason: req.reason.clone(),
}
}
ApprovalDecisionType::DecisionUnspecified => {
return Err(ApprovalConvertError::UnspecifiedDecision);
}
};
Ok((id, decision))
}
#[cfg(test)]
mod tests {
use super::*;
use uuid::Uuid;
fn make_pending(team_id: Option<&str>) -> PendingApprovalRequest {
PendingApprovalRequest {
request_id: Uuid::new_v4(),
agent_id: "agent-1".to_string(),
action: "delete_file".to_string(),
condition_triggered: "requires_approval".to_string(),
submitted_at: 1_700_000_000,
timeout_secs: 300,
team_id: team_id.map(str::to_string),
routing_status: None,
target_role: None,
routed_at: None,
escalate_at: None,
routing_history: vec![],
}
}
#[test]
fn pending_to_proto_with_team_id_sets_routed_status() {
let p = make_pending(Some("team-x"));
let proto = pending_to_proto(&p);
assert_eq!(proto.team_id, "team-x");
assert_eq!(proto.routing_status, "routed:team-x");
}
#[test]
fn pending_to_proto_without_team_id_sets_no_team_id_status() {
let p = make_pending(None);
let proto = pending_to_proto(&p);
assert_eq!(proto.team_id, "");
assert_eq!(proto.routing_status, "no_team_id");
}
#[test]
fn eval_with_deny_and_findings_maps_to_decision_deny() {
let eval = EvaluationResult {
decision: PolicyResult::Deny {
reason: "credential detected".into(),
},
redacted_payload: None,
credential_findings: vec![aa_security::CredentialFinding::from_regex_match(0, 4)],
deny_action: None,
};
let resp = eval_result_to_response(&eval, 0, "data_pattern_scan");
assert_eq!(resp.decision, Decision::Deny as i32);
assert_eq!(resp.reason, "credential detected");
assert!(resp.redact.is_none());
}
#[test]
fn eval_with_allow_and_findings_but_no_redacted_payload_maps_to_allow() {
let eval = EvaluationResult {
decision: PolicyResult::Allow,
redacted_payload: None,
credential_findings: vec![aa_security::CredentialFinding::from_regex_match(0, 4)],
deny_action: None,
};
let resp = eval_result_to_response(&eval, 0, "");
assert_eq!(resp.decision, Decision::Allow as i32);
assert!(resp.redact.is_none());
}
}