fix(approval): make "always" auto-approve work for credentialed HTTP requests (#1257)

The HTTP tool returned `ApprovalRequirement::Always` for requests with
credentials, but `Always` is hardcoded to ignore the session auto-approve
set. This meant users who clicked "always" were re-prompted on every
subsequent HTTP call — the UI offered "always" but the backend ignored it.

Two fixes:
1. HTTP credentialed requests now return `UnlessAutoApproved` instead of
   `Always`, so the session auto-approve set is respected.
2. `StatusUpdate::ApprovalNeeded` now carries `allow_always: bool`. All
   channel UIs (Telegram, Slack, Signal, REPL, Web) conditionally hide
   the "always" option when a tool truly requires per-invocation approval
   (`ApprovalRequirement::Always`, e.g. destructive shell commands).

Also boxes `PendingApproval` in `AgenticLoopResult::NeedApproval` to fix
a pre-existing clippy `large_enum_variant` warning.

Regression tests included (test_credentialed_requests_respect_auto_approve,
test_allow_always_matches_approval_requirement) but CI heuristic cannot
detect them in cross-fork PR diffs.

[skip-regression-check]

Co-authored-by: Tyler <[email protected]>
This commit is contained in:
nearfamiliarcow
2026-03-19 11:45:32 -07:00
committed by GitHub
co-authored by Tyler
parent 71f41dd123
commit 09e1c97a27
13 changed files with 199 additions and 51 deletions
+5
View File
@@ -305,6 +305,11 @@ pub enum StatusUpdate {
tool_name: String,
description: String,
parameters: serde_json::Value,
/// When `true`, the UI should offer an "always" option that auto-approves
/// future calls to this tool for the rest of the session. When `false`
/// (i.e. `ApprovalRequirement::Always`), the tool must be approved every
/// time and the "always" button should be hidden.
allow_always: bool,
},
/// Extension needs user authentication (token or OAuth).
AuthRequired {
+4
View File
@@ -423,6 +423,7 @@ impl Channel for RelayChannel {
tool_name,
description,
parameters,
allow_always: _,
} = status
else {
return Ok(());
@@ -794,6 +795,7 @@ mod tests {
tool_name: "shell".into(),
description: "run command".into(),
parameters: serde_json::json!({}),
allow_always: true,
},
&metadata,
)
@@ -822,6 +824,7 @@ mod tests {
tool_name: "shell".into(),
description: "run command".into(),
parameters: serde_json::json!({}),
allow_always: true,
},
&metadata,
)
@@ -854,6 +857,7 @@ mod tests {
tool_name: "shell".into(),
description: "run command".into(),
parameters: serde_json::json!({}),
allow_always: true,
},
&metadata,
)
+8 -3
View File
@@ -539,6 +539,7 @@ impl Channel for ReplChannel {
tool_name,
description,
parameters,
allow_always,
} => {
let term_width = crossterm::terminal::size()
.map(|(w, _)| w as usize)
@@ -582,9 +583,13 @@ impl Channel for ReplChannel {
}
eprintln!(" \u{2502}");
eprintln!(
" \u{2502} \x1b[32myes\x1b[0m (y) / \x1b[34malways\x1b[0m (a) / \x1b[31mno\x1b[0m (n)"
);
if allow_always {
eprintln!(
" \u{2502} \x1b[32myes\x1b[0m (y) / \x1b[34malways\x1b[0m (a) / \x1b[31mno\x1b[0m (n)"
);
} else {
eprintln!(" \u{2502} \x1b[32myes\x1b[0m (y) / \x1b[31mno\x1b[0m (n)");
}
eprintln!(" {bot_border}");
eprintln!();
}
+11 -3
View File
@@ -915,20 +915,28 @@ impl Channel for SignalChannel {
tool_name,
description: _,
parameters,
allow_always,
} = &status
&& let Some(target_str) = metadata.get("signal_target").and_then(|v| v.as_str())
{
let params_json = serde_json::to_string_pretty(parameters).unwrap_or_default();
let always_line = if *allow_always {
format!(
"\n• `always` or `a` - Approve and auto-approve future {} requests",
tool_name
)
} else {
String::new()
};
let message = format!(
"⚠️ *Approval Required*\n\n\
*Request ID:* `{}`\n\
*Tool:* {}\n\
*Parameters:*\n```\n{}\n```\n\n\
Reply with:\n\
• `yes` or `y` - Approve this request\n\
• `always` or `a` - Approve and auto-approve future {} requests\n\
• `yes` or `y` - Approve this request{}\n\
• `no` or `n` - Deny",
request_id, tool_name, params_json, tool_name
request_id, tool_name, params_json, always_line
);
self.send_status_message(target_str, &message).await;
}
+26 -9
View File
@@ -2043,6 +2043,7 @@ impl WasmChannel {
tool_name,
description,
parameters,
allow_always,
..
} => {
// WASM channels (Telegram, Slack, etc.) cannot render
@@ -2081,6 +2082,11 @@ impl WasmChannel {
})
.unwrap_or_default();
let reply_hint = if *allow_always {
"Reply \"yes\" to approve, \"no\" to deny, or \"always\" to auto-approve."
} else {
"Reply \"yes\" to approve or \"no\" to deny."
};
let prompt = format!(
"Approval needed: {tool_name}\n\
{description}\n\
@@ -2088,7 +2094,7 @@ impl WasmChannel {
Parameters:\n\
{params_preview}\n\
\n\
Reply \"yes\" to approve, \"no\" to deny, or \"always\" to auto-approve."
{reply_hint}"
);
let metadata_json = serde_json::to_string(metadata).unwrap_or_default();
@@ -2981,15 +2987,23 @@ fn status_to_wit(
request_id,
tool_name,
description,
allow_always,
..
} => wit_channel::StatusUpdate {
status: wit_channel::StatusType::ApprovalNeeded,
message: format!(
"Approval needed for tool '{}'. {}\nRequest ID: {}\nReply with: yes (or /approve), no (or /deny), or always (or /always).",
tool_name, description, request_id
),
metadata_json,
},
} => {
let reply_hint = if *allow_always {
"yes (or /approve), no (or /deny), or always (or /always)"
} else {
"yes (or /approve) or no (or /deny)"
};
wit_channel::StatusUpdate {
status: wit_channel::StatusType::ApprovalNeeded,
message: format!(
"Approval needed for tool '{}'. {}\nRequest ID: {}\nReply with: {}.",
tool_name, description, request_id, reply_hint
),
metadata_json,
}
}
StatusUpdate::JobStarted {
job_id,
title,
@@ -3670,6 +3684,7 @@ mod tests {
tool_name: "http_request".into(),
description: "Fetch weather".into(),
parameters: serde_json::json!({"url": "https://wttr.in"}),
allow_always: true,
},
&metadata,
)
@@ -4131,6 +4146,7 @@ mod tests {
tool_name: "http_request".to_string(),
description: "Fetch weather data".to_string(),
parameters: serde_json::json!({"url": "https://api.weather.test"}),
allow_always: true,
},
&metadata,
)
@@ -4156,6 +4172,7 @@ mod tests {
tool_name: "http_request".to_string(),
description: "Fetch weather data".to_string(),
parameters: serde_json::json!({"url": "https://api.weather.test"}),
allow_always: true,
},
&metadata,
)
+2
View File
@@ -374,6 +374,7 @@ impl Channel for GatewayChannel {
tool_name,
description,
parameters,
allow_always,
} => SseEvent::ApprovalNeeded {
request_id,
tool_name,
@@ -381,6 +382,7 @@ impl Channel for GatewayChannel {
parameters: serde_json::to_string_pretty(&parameters)
.unwrap_or_else(|_| parameters.to_string()),
thread_id,
allow_always,
},
StatusUpdate::AuthRequired {
extension_name,
+7 -6
View File
@@ -1138,18 +1138,19 @@ function showApproval(data) {
approveBtn.textContent = I18n.t('approval.approve');
approveBtn.addEventListener('click', () => sendApprovalAction(data.request_id, 'approve'));
const alwaysBtn = document.createElement('button');
alwaysBtn.className = 'always';
alwaysBtn.textContent = I18n.t('approval.always');
alwaysBtn.addEventListener('click', () => sendApprovalAction(data.request_id, 'always'));
const denyBtn = document.createElement('button');
denyBtn.className = 'deny';
denyBtn.textContent = I18n.t('approval.deny');
denyBtn.addEventListener('click', () => sendApprovalAction(data.request_id, 'deny'));
actions.appendChild(approveBtn);
actions.appendChild(alwaysBtn);
if (data.allow_always !== false) {
const alwaysBtn = document.createElement('button');
alwaysBtn.className = 'always';
alwaysBtn.textContent = I18n.t('approval.always');
alwaysBtn.addEventListener('click', () => sendApprovalAction(data.request_id, 'always'));
actions.appendChild(alwaysBtn);
}
actions.appendChild(denyBtn);
card.appendChild(actions);
+3
View File
@@ -177,6 +177,8 @@ pub enum SseEvent {
parameters: String,
#[serde(skip_serializing_if = "Option::is_none")]
thread_id: Option<String>,
/// Whether the "always" auto-approve option should be shown.
allow_always: bool,
},
#[serde(rename = "auth_required")]
AuthRequired {
@@ -1080,6 +1082,7 @@ mod tests {
description: "Run ls".to_string(),
parameters: "{}".to_string(),
thread_id: Some("t1".to_string()),
allow_always: true,
};
let ws = WsServerMessage::from_sse_event(&sse);
match ws {