feat: record agent approvals

This commit is contained in:
Georg Bauer
2026-08-02 09:47:28 +02:00
parent 94ed73e235
commit 118a9d41e0
11 changed files with 154 additions and 19 deletions

View File

@@ -129,6 +129,7 @@ pub(crate) struct ToolCard {
pub(crate) call: ToolCall,
pub(crate) state: ToolLifecycle,
pub(crate) result: Option<String>,
pub(crate) approval_reason: Option<String>,
}
impl ToolCard {
@@ -137,6 +138,7 @@ impl ToolCard {
call,
state: ToolLifecycle::Parsing,
result: None,
approval_reason: None,
}
}
@@ -179,24 +181,38 @@ impl ShellApprovalMode {
call: &ToolCall,
root: &Path,
cancel: &AtomicBool,
) -> Option<ApprovalPrompt> {
let command = (call.name == "bash").then(|| string(call, "command"))??;
let reason = match self {
Self::Heuristic => risky_shell_reason(command, root).map(str::to_owned),
) -> (Option<ApprovalPrompt>, Option<String>) {
let Some(command) = (call.name == "bash")
.then(|| string(call, "command"))
.flatten()
else {
return (None, None);
};
let (reason, ai_reason) = match self {
Self::Heuristic => (risky_shell_reason(command, root).map(str::to_owned), None),
#[cfg(target_os = "macos")]
Self::Ai(classifier) => match classifier.assess(command, root, cancel) {
Ok(assessment) if !assessment.risky => None,
Ok(assessment) => Some(assessment.reason),
Err(error) => Some(format!(
"The AI risk check could not complete ({error}); approval is required."
)),
Ok(assessment) => {
let reason = (!assessment.reason.is_empty()).then_some(assessment.reason);
let approval = assessment.risky.then(|| reason.clone()).flatten();
(approval, reason)
}
Err(error) => (
Some(format!(
"The AI risk check could not complete ({error}); approval is required."
)),
None,
),
},
}?;
Some(ApprovalPrompt {
title: "Allow shell command?".into(),
detail: format!("{reason}\n\n{command}"),
working_directory: root.to_owned(),
})
};
(
reason.map(|reason| ApprovalPrompt {
title: "Allow shell command?".into(),
detail: format!("{reason}\n\n{command}"),
working_directory: root.to_owned(),
}),
ai_reason,
)
}
}
@@ -315,6 +331,10 @@ pub(crate) enum ToolEvent {
prompt: ApprovalPrompt,
decision: Sender<bool>,
},
ApprovalReason {
index: usize,
reason: String,
},
}
struct BashJob {
@@ -1225,9 +1245,16 @@ pub(crate) fn execute_async(
if approval_mode.uses_ai(call) {
send_state(&event_sender, index, ToolLifecycle::AssessingRisk, None);
}
let prompt = tools
.browser_approval(call)
.or_else(|| approval_mode.approval(call, &tools.root, &worker_cancel));
let browser_prompt = tools.browser_approval(call);
let (shell_prompt, ai_reason) = if browser_prompt.is_none() {
approval_mode.approval(call, &tools.root, &worker_cancel)
} else {
(None, None)
};
if let Some(reason) = ai_reason {
let _ = event_sender.send(ToolEvent::ApprovalReason { index, reason });
}
let prompt = browser_prompt.or(shell_prompt);
if let Some(prompt) = prompt {
match request_approval(&event_sender, index, prompt, &worker_cancel) {
Ok(()) => {}
@@ -1458,6 +1485,7 @@ pub(crate) fn stored_tool_cards(
model: ModelChoice,
assistant: &str,
result: Option<&str>,
approval_reasons: &[Option<String>],
) -> Vec<ToolCard> {
let calls = parse_tool_calls(model, assistant)
.map(|(_, calls)| calls)
@@ -1479,6 +1507,7 @@ pub(crate) fn stored_tool_cards(
call,
state,
result,
approval_reason: approval_reasons.get(index).cloned().flatten(),
}
})
.collect()
@@ -2054,6 +2083,26 @@ mod tests {
);
}
#[test]
fn stored_tool_cards_restore_ai_approval_reasons_by_call() {
let assistant = r#"<DSMLtool_calls>
<DSMLinvoke name="bash"><DSMLparameter name="command" string="true">pwd</DSMLparameter></DSMLinvoke>
<DSMLinvoke name="read"><DSMLparameter name="path" string="true">README.md</DSMLparameter></DSMLinvoke>
</DSMLtool_calls>"#;
let cards = stored_tool_cards(
ModelChoice::DeepSeekV4Flash,
assistant,
None,
&[Some("Only reads the working directory.".into()), None],
);
assert_eq!(
cards[0].approval_reason.as_deref(),
Some("Only reads the working directory.")
);
assert_eq!(cards[1].approval_reason, None);
}
#[test]
fn denial_and_stop_cancel_commands_awaiting_approval() {
let directory = std::env::temp_dir().join(format!(
@@ -2082,6 +2131,7 @@ mod tests {
break decision;
}
ToolEvent::State { .. } => {}
ToolEvent::ApprovalReason { .. } => {}
}
};
decision.send(false).unwrap();
@@ -2108,6 +2158,7 @@ mod tests {
break decision;
}
ToolEvent::State { .. } => {}
ToolEvent::ApprovalReason { .. } => {}
}
};
active.cancel.store(true, Ordering::Relaxed);