Harden local tool execution

This commit is contained in:
Georg Bauer
2026-07-26 14:36:38 +02:00
parent 171b041ba6
commit c9f0c3661c
7 changed files with 1018 additions and 141 deletions

26
PLAN.md
View File

@@ -27,6 +27,13 @@ execution targets one self-contained Mac.
starting tool set, unlimited tool rounds, queued user guidance between tool starting tool set, unlimited tool rounds, queued user guidance between tool
rounds, session date/time context, periodic tool-contract reminders, rounds, session date/time context, periodic tool-contract reminders,
cooperative Stop, and explicit activity/failure states are implemented. cooperative Stop, and explicit activity/failure states are implemented.
- Local tools are hardened for daily use: canonical project boundaries reject
parent and symlink escapes, shell commands receive a deliberate environment,
risky shell and visible-browser actions share one cancellable Allow once/Deny
approval path, and compact tool cards expose bounded parameters, results, and
parsing/approval/queue/run/completion lifecycle state without showing DSML.
Background jobs and bounded output files stop and clean up with Stop, session
switches, and application shutdown.
- Context compaction uses the reference soft and exact token-counted hard - Context compaction uses the reference soft and exact token-counted hard
triggers, private live-model summaries, bounded summary and tool-result triggers, private live-model summaries, bounded summary and tool-result
retries, a recent verbatim tail, running-job observations, and compatible KV retries, a recent verbatim tail, running-job observations, and compatible KV
@@ -38,21 +45,20 @@ execution targets one self-contained Mac.
- Focused coverage exercises triggers, summary bounds and sanitizing, tail - Focused coverage exercises triggers, summary bounds and sanitizing, tail
selection, queued guidance, checkpoint identity, running jobs, durable selection, queued guidance, checkpoint identity, running jobs, durable
compaction markers, relaunch, and continued tool work after rebuild. compaction markers, relaunch, and continued tool work after rebuild.
- The next baseline gap is tool hardening and safety. SSD streaming, - The next baseline gap is SSD streaming. Speculative decoding, steering, GLM
speculative decoding, steering, GLM 5.2 execution, and DeepSeek V4 Pro 5.2 execution, and DeepSeek V4 Pro execution are not implemented in the Rust
execution are not implemented in the Rust executor. Related catalog, executor. Related catalog, validation, and preference plumbing must not be
validation, and preference plumbing must not be treated as runtime support. treated as runtime support.
## Delivery order ## Delivery order
1. **Next:** tool hardening, approvals, and productive tool presentation. 1. **Next:** remaining DS4 execution technology, starting with SSD streaming, then
2. Remaining DS4 execution technology, starting with SSD streaming, then
speculative decoding and the other Metal/runtime parity work. speculative decoding and the other Metal/runtime parity work.
3. Additional model execution: GLM 5.2 and DeepSeek V4 Pro. 2. Additional model execution: GLM 5.2 and DeepSeek V4 Pro.
4. Product completion, exhaustive parity verification, and distribution. 3. Product completion, exhaustive parity verification, and distribution.
5. Optional extensions: Dev Brain and A2UI. 4. Optional extensions: Dev Brain and A2UI.
## 1. Next — tool hardening and safety ## 1. Completed — tool hardening and safety
Goal: make the existing tool set safe and clear enough for productive daily Goal: make the existing tool set safe and clear enough for productive daily
use without weakening its ability to inspect, edit, build, and test a project. use without weakening its ability to inspect, edit, build, and test a project.

File diff suppressed because it is too large Load Diff

View File

@@ -103,6 +103,11 @@ pub(crate) struct App {
#[cfg(target_os = "macos")] #[cfg(target_os = "macos")]
active_tools: Option<crate::agent::ActiveTools>, active_tools: Option<crate::agent::ActiveTools>,
#[cfg(target_os = "macos")] #[cfg(target_os = "macos")]
pub(super) tool_cards: Vec<crate::agent::ToolCard>,
#[cfg(target_os = "macos")]
pub(super) pending_tool_approval:
Option<(crate::agent::ApprovalPrompt, std::sync::mpsc::Sender<bool>)>,
#[cfg(target_os = "macos")]
active_titling: Option<generation::TitleRequest>, active_titling: Option<generation::TitleRequest>,
#[cfg(target_os = "macos")] #[cfg(target_os = "macos")]
runtime_config: Arc<RwLock<Config>>, runtime_config: Arc<RwLock<Config>>,
@@ -199,6 +204,10 @@ pub(crate) enum Message {
ComposerChanged(String), ComposerChanged(String),
ToggleReasoning(usize), ToggleReasoning(usize),
OpenLink(markdown::Url), OpenLink(markdown::Url),
CopyToolText(String),
OpenToolOutput(PathBuf),
AllowToolOnce,
DenyTool,
SubmitPrompt, SubmitPrompt,
StopGeneration, StopGeneration,
GenerationTick, GenerationTick,
@@ -316,6 +325,10 @@ impl App {
#[cfg(target_os = "macos")] #[cfg(target_os = "macos")]
active_tools: None, active_tools: None,
#[cfg(target_os = "macos")] #[cfg(target_os = "macos")]
tool_cards: Vec::new(),
#[cfg(target_os = "macos")]
pending_tool_approval: None,
#[cfg(target_os = "macos")]
active_titling: None, active_titling: None,
#[cfg(target_os = "macos")] #[cfg(target_os = "macos")]
runtime_config, runtime_config,
@@ -415,6 +428,10 @@ impl App {
#[cfg(target_os = "macos")] #[cfg(target_os = "macos")]
active_tools: None, active_tools: None,
#[cfg(target_os = "macos")] #[cfg(target_os = "macos")]
tool_cards: Vec::new(),
#[cfg(target_os = "macos")]
pending_tool_approval: None,
#[cfg(target_os = "macos")]
active_titling: None, active_titling: None,
#[cfg(target_os = "macos")] #[cfg(target_os = "macos")]
runtime_config, runtime_config,
@@ -778,6 +795,25 @@ impl App {
self.error = Some(format!("Could not open the link: {error}")); self.error = Some(format!("Could not open the link: {error}"));
} }
} }
Message::CopyToolText(value) => return iced::clipboard::write(value),
Message::OpenToolOutput(path) => {
if let Err(error) = std::process::Command::new("open").arg(path).spawn() {
self.error = Some(format!("Could not open the tool output: {error}"));
}
}
Message::AllowToolOnce => {
#[cfg(target_os = "macos")]
if let Some((_, decision)) = self.pending_tool_approval.take() {
let _ = decision.send(true);
}
}
Message::DenyTool =>
{
#[cfg(target_os = "macos")]
if let Some((_, decision)) = self.pending_tool_approval.take() {
let _ = decision.send(false);
}
}
Message::SubmitPrompt => { Message::SubmitPrompt => {
self.start_generation(); self.start_generation();
return scroll_chat_to_end(); return scroll_chat_to_end();
@@ -794,6 +830,12 @@ impl App {
active.cancel.store(true, Ordering::Relaxed); active.cancel.store(true, Ordering::Relaxed);
} }
#[cfg(target_os = "macos")] #[cfg(target_os = "macos")]
if let Some((_, decision)) = self.pending_tool_approval.take() {
let _ = decision.send(false);
}
#[cfg(target_os = "macos")]
self.stop_agent_jobs();
#[cfg(target_os = "macos")]
if let Some(compaction) = &self.active_compaction { if let Some(compaction) = &self.active_compaction {
compaction.active.cancel.store(true, Ordering::Relaxed); compaction.active.cancel.store(true, Ordering::Relaxed);
} }
@@ -1019,6 +1061,10 @@ impl App {
if self.selected_session == Some(session_id) { if self.selected_session == Some(session_id) {
return Task::none(); return Task::none();
} }
#[cfg(target_os = "macos")]
self.stop_agent_jobs();
#[cfg(target_os = "macos")]
self.tool_cards.clear();
let saved_context = self let saved_context = self
.projects .projects
.iter() .iter()
@@ -1298,6 +1344,10 @@ impl Drop for App {
if let Some(active) = &self.active_tools { if let Some(active) = &self.active_tools {
active.cancel.store(true, Ordering::Relaxed); active.cancel.store(true, Ordering::Relaxed);
} }
#[cfg(target_os = "macos")]
if let Some((_, decision)) = self.pending_tool_approval.take() {
let _ = decision.send(false);
}
} }
} }

View File

@@ -210,6 +210,8 @@ impl App {
return; return;
} }
#[cfg(target_os = "macos")] #[cfg(target_os = "macos")]
self.tool_cards.clear();
#[cfg(target_os = "macos")]
if !std::mem::take(&mut self.skip_compaction_once) if !std::mem::take(&mut self.skip_compaction_once)
&& crate::compaction::should_compact(self.context_used, self.context_limit) && crate::compaction::should_compact(self.context_used, self.context_limit)
{ {
@@ -388,11 +390,47 @@ impl App {
return self.poll_tool_result_check(); return self.poll_tool_result_check();
} }
#[cfg(target_os = "macos")] #[cfg(target_os = "macos")]
if self.active_tools.is_some() {
while let Some(event) = self
.active_tools
.as_ref()
.and_then(crate::agent::try_tool_event)
{
match event {
crate::agent::ToolEvent::State {
index,
state,
result,
} => {
if let Some(card) = self.tool_cards.get_mut(index) {
card.state = state;
if result.is_some() {
card.result = result;
}
}
self.activity = Some(format!("Tool {} · {}", index + 1, state.label()));
}
crate::agent::ToolEvent::Approval {
index,
prompt,
decision,
} => {
if let Some(card) = self.tool_cards.get_mut(index) {
card.state = crate::agent::ToolLifecycle::AwaitingApproval;
}
self.pending_tool_approval = Some((prompt, decision));
self.activity = Some(format!("Tool {} · Awaiting approval", index + 1));
}
}
}
}
#[cfg(target_os = "macos")]
if let Some(active) = &self.active_tools { if let Some(active) = &self.active_tools {
match crate::agent::try_tool_result(active) { match crate::agent::try_tool_result(active) {
Ok(Some(result)) => { Ok(Some(result)) => {
let cancelled = active.cancel.load(Ordering::Relaxed); let cancelled = active.cancel.load(Ordering::Relaxed);
self.active_tools = None; self.active_tools = None;
self.pending_tool_approval = None;
if cancelled { if cancelled {
self.generating = false; self.generating = false;
self.activity = Some("Stopped".into()); self.activity = Some("Stopped".into());
@@ -453,6 +491,13 @@ impl App {
&& !message.user && !message.user
{ {
message.append(reasoning, &content); message.append(reasoning, &content);
if !reasoning
&& self.tool_cards.is_empty()
&& crate::agent::has_tool_markup(&message.content)
{
self.tool_cards.push(crate::agent::ToolCard::streaming());
self.activity = Some("Parsing tool call…".into());
}
transcript_changed = true; transcript_changed = true;
} }
} }
@@ -501,6 +546,7 @@ impl App {
Ok(_) => { Ok(_) => {
self.generating = false; self.generating = false;
self.activity = None; self.activity = None;
self.tool_cards.clear();
start_queued = !self.queued_inputs.is_empty() start_queued = !self.queued_inputs.is_empty()
|| self.manual_compaction_queued; || self.manual_compaction_queued;
} }
@@ -628,8 +674,13 @@ impl App {
self.agent_tools = Some((session_id, Arc::new(Mutex::new(tools)))); self.agent_tools = Some((session_id, Arc::new(Mutex::new(tools))));
} }
let tools = Arc::clone(&self.agent_tools.as_ref().unwrap().1); let tools = Arc::clone(&self.agent_tools.as_ref().unwrap().1);
self.tool_cards = calls
.iter()
.cloned()
.map(crate::agent::ToolCard::parsing)
.collect();
self.active_tools = Some(crate::agent::execute_async(tools, calls)); self.active_tools = Some(crate::agent::execute_async(tools, calls));
self.activity = Some("Running tools…".into()); self.activity = Some("Parsing tool calls…".into());
Ok(()) Ok(())
} }
@@ -685,6 +736,7 @@ impl App {
.collect(); .collect();
assistant.reasoning_open = assistant_reasoning; assistant.reasoning_open = assistant_reasoning;
self.conversation.push(assistant); self.conversation.push(assistant);
self.tool_cards.clear();
let idle_timeout = Duration::from_secs(self.config.idle_timeout_minutes.max(1) as u64 * 60); let idle_timeout = Duration::from_secs(self.config.idle_timeout_minutes.max(1) as u64 * 60);
self.active_generation = Some( self.active_generation = Some(
self.generation_service self.generation_service
@@ -704,6 +756,20 @@ impl App {
Ok(()) Ok(())
} }
#[cfg(target_os = "macos")]
pub(super) fn stop_agent_jobs(&mut self) {
let Some((_, tools)) = &self.agent_tools else {
return;
};
let Ok(mut tools) = tools.try_lock() else {
return;
};
let failures = tools.stop_all_jobs();
if !failures.is_empty() {
self.error = Some(failures.join("\n"));
}
}
#[cfg(target_os = "macos")] #[cfg(target_os = "macos")]
fn start_tool_result_check( fn start_tool_result_check(
&mut self, &mut self,

View File

@@ -69,6 +69,10 @@ impl App {
if self.database.is_none() { if self.database.is_none() {
return; return;
} }
#[cfg(target_os = "macos")]
self.stop_agent_jobs();
#[cfg(target_os = "macos")]
self.tool_cards.clear();
let title = draft_title(&self.projects, project_id); let title = draft_title(&self.projects, project_id);
self.drafts.entry(project_id).or_insert(title); self.drafts.entry(project_id).or_insert(title);
self.remember_project(project_id); self.remember_project(project_id);

View File

@@ -69,6 +69,16 @@ impl App {
|| self.pending_project_path.is_some() || self.pending_project_path.is_some()
|| self.session_rename.is_some() || self.session_rename.is_some()
|| self.menu_session().is_some() || self.menu_session().is_some()
|| {
#[cfg(target_os = "macos")]
{
self.pending_tool_approval.is_some()
}
#[cfg(not(target_os = "macos"))]
{
false
}
}
} }
fn main_view(&self) -> Element<'_, Message> { fn main_view(&self) -> Element<'_, Message> {
@@ -112,6 +122,19 @@ impl App {
let content: Element<'_, Message> = shell.into(); let content: Element<'_, Message> = shell.into();
let mut layers = vec![content]; let mut layers = vec![content];
#[cfg(target_os = "macos")]
if let Some((prompt, _)) = &self.pending_tool_approval {
layers.push(self.tool_approval_panel(prompt));
} else if self.preferences_open {
layers.push(self.preferences_panel());
} else if let Some(path) = &self.pending_project_path {
layers.push(self.project_dialog(path));
} else if let Some((_, title)) = &self.session_rename {
layers.push(self.rename_dialog(title));
} else if let Some(session) = self.menu_session() {
layers.push(self.session_menu_panel(session));
}
#[cfg(not(target_os = "macos"))]
if self.preferences_open { if self.preferences_open {
layers.push(self.preferences_panel()); layers.push(self.preferences_panel());
} else if let Some(path) = &self.pending_project_path { } else if let Some(path) = &self.pending_project_path {
@@ -128,6 +151,39 @@ impl App {
.into() .into()
} }
#[cfg(target_os = "macos")]
fn tool_approval_panel<'a>(
&'a self,
prompt: &'a crate::agent::ApprovalPrompt,
) -> Element<'a, Message> {
let dialog = container(
column![
text(&prompt.title).size(22),
text(&prompt.detail).size(13),
text("Working directory").size(11).color(muted_text()),
text(prompt.working_directory.display().to_string()).size(13),
row![
Space::with_width(Length::Fill),
action_button("Deny").on_press(Message::DenyTool),
action_button("Allow once").on_press(Message::AllowToolOnce),
]
.spacing(8),
]
.spacing(12),
)
.padding(22)
.width(560)
.style(overview_style);
opaque(
container(dialog)
.center_x(Length::Fill)
.center_y(Length::Fill)
.style(|_| {
container::Style::default().background(Color::from_rgba8(0, 0, 0, 0.68))
}),
)
}
fn sidebar(&self) -> Element<'_, Message> { fn sidebar(&self) -> Element<'_, Message> {
let preference_content = row![ let preference_content = row![
icon(ICON_SETTINGS, 17), icon(ICON_SETTINGS, 17),

View File

@@ -75,6 +75,17 @@ impl App {
if message.system { if message.system {
continue; continue;
} }
if message.tool
&& index > 0
&& !crate::agent::stored_tool_cards(
self.config.model,
&self.conversation[index - 1].content,
None,
)
.is_empty()
{
continue;
}
let label = if message.user { let label = if message.user {
"You" "You"
} else if message.tool { } else if message.tool {
@@ -135,11 +146,39 @@ impl App {
text(self.activity.as_deref().unwrap_or("Loading model…")).size(14), text(self.activity.as_deref().unwrap_or("Loading model…")).size(14),
); );
} }
if !message.user && !message.tool { if !message.user {
for summary in let stored_result = self
crate::agent::tool_summaries(self.config.model, &message.content) .conversation
{ .get(index + 1)
body = body.push(text(summary).size(13).color(muted_text())); .filter(|message| message.tool)
.map(|message| message.content.as_str());
let cards = if stored_result.is_none() && {
#[cfg(target_os = "macos")]
{
!self.tool_cards.is_empty()
}
#[cfg(not(target_os = "macos"))]
{
false
}
} {
#[cfg(target_os = "macos")]
{
self.tool_cards.clone()
}
#[cfg(not(target_os = "macos"))]
{
Vec::new()
}
} else {
crate::agent::stored_tool_cards(
self.config.model,
&message.content,
stored_result,
)
};
if !cards.is_empty() {
body = body.push(tool_cards(cards));
} }
} }
let user = message.user; let user = message.user;
@@ -295,3 +334,80 @@ impl App {
.into() .into()
} }
} }
fn tool_cards(cards: Vec<crate::agent::ToolCard>) -> Element<'static, Message> {
let mut rows = column![].spacing(0);
for (index, card) in cards.into_iter().enumerate() {
if index > 0 {
rows = rows.push(horizontal_rule(1));
}
let parameters = crate::agent::tool_parameters(&card.call);
let call = crate::agent::tool_call_text(&card.call);
let copy_call = tooltip(
action_button(text("Copy call").size(11))
.padding([5, 9])
.on_press(Message::CopyToolText(call)),
container(text("Copy tool name and all arguments").size(11))
.padding(8)
.style(preference_group_style),
tooltip::Position::Top,
)
.gap(6);
let copy_result = action_button(text("Copy result").size(11)).padding([5, 9]);
let copy_result = if let Some(result) = &card.result {
copy_result.on_press(Message::CopyToolText(result.clone()))
} else {
copy_result
};
let copy_result = tooltip(
copy_result,
container(text("Copy the complete tool result").size(11))
.padding(8)
.style(preference_group_style),
tooltip::Position::Top,
)
.gap(6);
let mut actions = row![copy_call, copy_result]
.spacing(6)
.align_y(Alignment::Center);
if let Some(path) = card
.result
.as_deref()
.and_then(crate::agent::tool_output_path)
{
actions = actions.push(
tooltip(
action_button(text("Open output").size(11))
.padding([5, 9])
.on_press(Message::OpenToolOutput(path)),
container(text("Open the complete output file").size(11))
.padding(8)
.style(preference_group_style),
tooltip::Position::Top,
)
.gap(6),
);
}
let mut content = column![
row![
text(card.call.name).size(13),
Space::with_width(Length::Fill),
text(card.state.label()).size(11).color(muted_text()),
actions,
]
.spacing(8)
.align_y(Alignment::Center),
text(parameters).size(12).color(muted_text()),
]
.spacing(5);
if let Some(result) = card.result {
let bounded = crate::agent::bounded_tool_text(&result, 1_200);
content = content.push(text(bounded).size(12));
}
rows = rows.push(container(content).padding(10).width(Length::Fill));
}
container(rows)
.width(Length::Fill)
.style(preference_group_style)
.into()
}