pub async fn list_approvals(State(state): State<AppState>) -> impl IntoResponse {
state.approvals.expire_timed_out();
let pending = state.approvals.list_pending();
let all = state.approvals.list_all();
Json(json!({
"pending": pending,
"total": all.len(),
}))
}
const MAX_DECIDED_BY_LEN: usize = 256;
#[derive(Deserialize)]
pub struct ApprovalDecisionRequest {
#[serde(default = "default_decided_by")]
pub decided_by: String,
}
fn default_decided_by() -> String {
"api".into()
}
fn sanitize_decided_by(raw: &str) -> Result<String, JsonError> {
if raw.len() > MAX_DECIDED_BY_LEN {
return Err(bad_request(format!(
"decided_by exceeds max length of {MAX_DECIDED_BY_LEN} characters"
)));
}
let sanitized: String = raw.chars().filter(|c| !c.is_control()).collect();
Ok(sanitized)
}
pub async fn approve_request(
State(state): State<AppState>,
Path(id): Path<String>,
body: Option<axum::Json<ApprovalDecisionRequest>>,
) -> std::result::Result<impl IntoResponse, JsonError> {
let decided_by = match body {
Some(axum::Json(b)) => sanitize_decided_by(&b.decided_by)?,
None => default_decided_by(),
};
match state.approvals.approve(&id, &decided_by) {
Ok(req) => {
if let Some(decided_at) = req.decided_at {
ironclad_db::approvals::record_approval_decision(
&state.db,
&req.id,
"approved",
req.decided_by.as_deref().unwrap_or(&decided_by),
&decided_at.to_rfc3339(),
)
.inspect_err(|e| tracing::warn!(error = %e, "failed to persist approval decision"))
.ok();
}
let replay_req = req.clone();
let replay_state = state.clone();
tokio::spawn(async move {
let params: serde_json::Value = serde_json::from_str(&replay_req.tool_input)
.unwrap_or_else(|e| {
tracing::warn!(request_id = %replay_req.id, error = %e, "tool_input JSON parse failed in approval replay");
json!({ "raw_input": replay_req.tool_input })
});
let replay_turn_id = replay_req
.session_id
.clone()
.unwrap_or_else(|| replay_req.id.clone());
replay_state.event_bus.publish(
json!({
"type": "approval_replay_started",
"request_id": replay_req.id,
"tool": replay_req.tool_name,
"turn_id": replay_turn_id,
})
.to_string(),
);
let replay_result = super::agent::execute_tool_call_after_approval(
&replay_state,
&replay_req.tool_name,
¶ms,
&replay_turn_id,
replay_req.requested_authority,
None,
)
.await;
match replay_result {
Ok(output) => replay_state.event_bus.publish(
json!({
"type": "approval_replay_succeeded",
"request_id": replay_req.id,
"tool": replay_req.tool_name,
"turn_id": replay_turn_id,
"output": output,
})
.to_string(),
),
Err(error) => replay_state.event_bus.publish(
json!({
"type": "approval_replay_failed",
"request_id": replay_req.id,
"tool": replay_req.tool_name,
"turn_id": replay_turn_id,
"error": error,
})
.to_string(),
),
}
});
Ok(Json(json!({
"approval": req,
"replay_queued": true,
})))
}
Err(e) => Err(not_found(e.to_string())),
}
}
pub async fn deny_request(
State(state): State<AppState>,
Path(id): Path<String>,
body: Option<axum::Json<ApprovalDecisionRequest>>,
) -> std::result::Result<impl IntoResponse, JsonError> {
let decided_by = match body {
Some(axum::Json(b)) => sanitize_decided_by(&b.decided_by)?,
None => default_decided_by(),
};
match state.approvals.deny(&id, &decided_by) {
Ok(req) => Ok(Json(json!(req))),
Err(e) => Err(not_found(e.to_string())),
}
}
pub async fn get_policy_audit(
State(state): State<AppState>,
Path(turn_id): Path<String>,
) -> std::result::Result<impl IntoResponse, JsonError> {
let decisions =
ironclad_db::policy::get_decisions_for_turn(&state.db, &turn_id).map_err(|e| {
tracing::error!(error = %e, "failed to fetch policy audit");
JsonError(
StatusCode::INTERNAL_SERVER_ERROR,
"internal server error".into(),
)
})?;
Ok(Json(json!({
"turn_id": turn_id,
"decisions": decisions.iter().map(|d| json!({
"id": d.id,
"tool_name": d.tool_name,
"decision": d.decision,
"rule_name": d.rule_name,
"reason": d.reason,
"created_at": d.created_at,
})).collect::<Vec<_>>(),
})))
}
pub async fn get_tool_audit(
State(state): State<AppState>,
Path(turn_id): Path<String>,
) -> std::result::Result<impl IntoResponse, JsonError> {
let calls = ironclad_db::tools::get_tool_calls_for_turn(&state.db, &turn_id).map_err(|e| {
tracing::error!(error = %e, "failed to fetch tool audit");
JsonError(
StatusCode::INTERNAL_SERVER_ERROR,
"internal server error".into(),
)
})?;
Ok(Json(json!({
"turn_id": turn_id,
"tool_calls": calls.iter().map(|c| json!({
"id": c.id,
"tool_name": c.tool_name,
"skill_id": c.skill_id,
"skill_name": c.skill_name,
"skill_hash": c.skill_hash,
"input": c.input,
"output": c.output,
"status": c.status,
"duration_ms": c.duration_ms,
"created_at": c.created_at,
})).collect::<Vec<_>>(),
})))
}