async fn evaluate_automation_phase_tool_policy(
state: &AppState,
ctx: &ToolPolicyContext,
tool: &str,
) -> Option<ToolPolicyDecision> {
let (run_id, mapped_node_id) = state
.automation_v2_session_run_and_node(&ctx.session_id)
.await?;
if !state.is_ready() {
return None;
}
let run = state.get_automation_v2_run(&run_id).await?;
let allowed_tools = state
.engine_loop
.get_session_allowed_tools(&ctx.session_id)
.await;
if allowed_tools.is_empty() {
return None;
}
let allowed_patterns = allowed_tools
.iter()
.map(|name| normalize_tool_name(name))
.collect::<Vec<_>>();
if any_policy_matches(&allowed_patterns, tool) {
return None;
}
let node_id = mapped_node_id.or_else(|| phase_policy_single_active_node_id(&run));
let phase = node_id
.as_deref()
.and_then(|id| phase_policy_node_label(&run, id))
.unwrap_or_else(|| "unknown".to_string());
let reason = format!(
"tool `{tool}` is not allowed during workflow phase `{phase}` for automation run `{}`",
run.run_id
);
let decision_id = record_automation_phase_tool_policy_decision(
state,
&run,
ctx,
node_id.as_deref(),
&phase,
tool,
&allowed_patterns,
&reason,
)
.await;
if state.is_ready() {
state.event_bus.publish(EngineEvent::new(
"automation.phase_tool.denied",
json!({
"sessionID": ctx.session_id.clone(),
"messageID": ctx.message_id.clone(),
"runID": run.run_id.clone(),
"automationID": run.automation_id.clone(),
"nodeID": node_id.clone(),
"phase": phase.clone(),
"tool": tool,
"allowedTools": allowed_patterns.clone(),
"policyDecisionID": decision_id.clone(),
"reason": reason.clone(),
"timestampMs": crate::now_ms(),
}),
));
}
Some(ToolPolicyDecision {
allowed: false,
reason: Some(reason),
policy_decision_id: decision_id,
})
}
async fn session_allowlist_would_deny_non_automation_tool(
state: &AppState,
ctx: &ToolPolicyContext,
tool: &str,
) -> bool {
if state
.automation_v2_session_run_and_node(&ctx.session_id)
.await
.is_some()
{
return false;
}
let allowed_tools = state
.engine_loop
.get_session_allowed_tools(&ctx.session_id)
.await;
if allowed_tools.is_empty() {
return false;
}
let allowed_patterns = allowed_tools
.iter()
.map(|name| normalize_tool_name(name))
.collect::<Vec<_>>();
!any_policy_matches(&allowed_patterns, tool)
}
fn phase_policy_single_active_node_id(
run: &crate::automation_v2::types::AutomationV2RunRecord,
) -> Option<String> {
if run.checkpoint.pending_nodes.len() == 1 {
return run.checkpoint.pending_nodes.first().cloned();
}
if run.checkpoint.blocked_nodes.len() == 1 {
return run.checkpoint.blocked_nodes.first().cloned();
}
None
}
fn phase_policy_node_label(
run: &crate::automation_v2::types::AutomationV2RunRecord,
node_id: &str,
) -> Option<String> {
let node = run
.automation_snapshot
.as_ref()?
.flow
.nodes
.iter()
.find(|node| node.node_id == node_id)?;
node.metadata
.as_ref()
.and_then(|metadata| {
metadata
.get("phase")
.or_else(|| metadata.pointer("/builder/phase"))
.or_else(|| metadata.pointer("/runtime/phase"))
})
.and_then(Value::as_str)
.map(str::trim)
.filter(|phase| !phase.is_empty())
.map(str::to_string)
.or_else(|| {
node.stage_kind.as_ref().and_then(|stage| {
serde_json::to_value(stage)
.ok()
.and_then(|value| value.as_str().map(str::to_string))
})
})
}
async fn record_automation_phase_tool_policy_decision(
state: &AppState,
run: &crate::automation_v2::types::AutomationV2RunRecord,
ctx: &ToolPolicyContext,
node_id: Option<&str>,
phase: &str,
tool: &str,
allowed_tools: &[String],
reason: &str,
) -> Option<String> {
let decision_id = format!("policy_decision_{}", Uuid::new_v4().simple());
let metadata = json!({
"phase_tool_authority": {
"phase": phase,
"allowed_tools": allowed_tools,
"requested_tool": tool,
"rule": "workflow_phase_tool_allowlist",
"source": "automation_session_allowed_tools",
}
});
let record = PolicyDecisionRecord {
decision_id: decision_id.clone(),
tenant_context: run.tenant_context.clone(),
actor_id: run.tenant_context.actor_id.clone(),
session_id: Some(ctx.session_id.clone()),
message_id: Some(ctx.message_id.clone()),
run_id: Some(run.run_id.clone()),
automation_id: Some(run.automation_id.clone()),
node_id: node_id.map(str::to_string),
tool: Some(tool.to_string()),
resource: None,
data_classes: Vec::new(),
risk_tier: Some("workflow_phase_tool_scope".to_string()),
decision: PolicyDecisionEffect::Deny,
reason_code: "phase_tool_not_allowed".to_string(),
reason: reason.to_string(),
policy_id: Some("workflow_phase_tool_authority".to_string()),
grant_id: None,
approval_id: None,
audit_event_id: None,
created_at_ms: crate::now_ms(),
metadata: metadata.clone(),
};
let recorded = match state.record_policy_decision(record).await {
Ok(record) => Some(record.decision_id),
Err(error) => {
tracing::warn!("failed to record workflow phase tool policy decision: {error:?}");
None
}
};
let _ = crate::audit::append_protected_audit_event(
state,
"automation.phase_tool.denied",
&run.tenant_context,
run.tenant_context.actor_id.clone(),
json!({
"decision_id": recorded.clone(),
"session_id": ctx.session_id.clone(),
"message_id": ctx.message_id.clone(),
"run_id": run.run_id.clone(),
"automation_id": run.automation_id.clone(),
"node_id": node_id,
"phase": phase,
"tool": tool,
"allowed_tools": allowed_tools,
"reason_code": "phase_tool_not_allowed",
}),
)
.await;
recorded
}