fix(approvals): session grant never changes posture; approvals carry a summary

"Allow for this conversation" on a Runtime approval (remember=true) used to
promote the whole thread to Full Access and queue a mid-turn ChangeMode. The
engine then saw a posture change after the approval and failed the very call
that was just approved ("Runtime permission posture changed before this tool
call executed"), plus later queued calls.

E1: remember=true now records a session grant scoped to the tool and its
argument class (the approval grouping key). Posture is untouched, no
ChangeMode is sent. Later matching calls are approved by the grant
(approval.decided carries auto + grant_id); a forced prompt is never
pre-answered. The grant is named in approval.grant_added, listed in thread
detail approval_grants[], and revocable via
DELETE /v1/threads/{id}/approval-grants/{grant_id} (approval.grant_revoked).
web.run grants group by action kind (search vs open ...), web_search by tool;
denials stay exact-call scoped.

E2: with no posture flip on approval, the approved call and queued calls run.
Regression test queues three gated calls behind one prompt: approving the
first with remember approves the queued same-class search via the grant,
cancels nothing, the write still prompts, posture/record/mailbox unchanged,
and revoke restores prompting. Replaces the test that encoded the flip.

E6: approval.required and pending_approvals[] carry a model-independent
`summary` ("Search the web for 'espresso'", "Write notes/espresso.md") built
from tool name + arguments, workspace-relative paths.

Checks: cargo test -p codewhale-tui --lib approval_ -> 158 passed, 1 failed
(task_manager pending_approval_suspends_idle_... is a timing test that fails
only under the parallel run; alone: 1 passed, 0 failed; it does not touch
this code path).

Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
This commit is contained in:
CodeWhale BotandClaude Opus 5.5 committed 2026-09-22 18:27:21 -07:00
1 parent 593be41dd3
commit af4858efd5
8 files changed
+669 -94

No files matched your search

+25
View File
@@ -1388,6 +1388,10 @@ pub fn build_router(state: RuntimeApiState) -> Router {
.route("/v1/threads/{id}/goal/block", post(block_thread_goal))
.route("/v1/approvals", get(list_approvals))
.route("/v1/approvals/{approval_id}", post(decide_approval))
.route(
"/v1/threads/{id}/approval-grants/{grant_id}",
delete(revoke_approval_grant),
)
.route(
"/v1/user-input/{thread_id}/{input_id}",
post(submit_user_input),
@@ -3952,6 +3956,27 @@ async fn decide_approval(
}))
}
/// `DELETE /v1/threads/{id}/approval-grants/{grant_id}` — revoke one
/// "allow for this conversation" grant. The next matching call prompts again.
async fn revoke_approval_grant(
State(state): State<RuntimeApiState>,
Path((thread_id, grant_id)): Path<(String, String)>,
) -> Result<Json<Value>, ApiError> {
let revoked = state
.runtime_threads
.revoke_approval_grant(&thread_id, &grant_id)
.await
.map_err(map_thread_err)?;
if !revoked {
return Err(ApiError::not_found(format!(
"no approval grant with id '{grant_id}' on thread '{thread_id}'"
)));
}
Ok(Json(
json!({ "ok": true, "grant_id": grant_id, "revoked": true }),
))
}
async fn submit_user_input(
State(state): State<RuntimeApiState>,
Path((thread_id, input_id)): Path<(String, String)>,
+2
View File
@@ -564,6 +564,7 @@ fn messages_from_thread_detail_batches_tool_results() {
pending_approvals: Vec::new(),
pending_user_inputs: Vec::new(),
pending_dynamic_tool_calls: Vec::new(),
approval_grants: Vec::new(),
};
let messages = messages_from_thread_detail(&detail);
@@ -644,6 +645,7 @@ fn legacy_exact_thread_export_normalizes_provider_kind_and_id() {
pending_approvals: Vec::new(),
pending_user_inputs: Vec::new(),
pending_dynamic_tool_calls: Vec::new(),
approval_grants: Vec::new(),
};
let config = Config {
provider: Some("lm-studio".to_string()),
+197 -45
View File
@@ -3678,6 +3678,10 @@ pub struct ThreadDetail {
/// `tool_call.requested` event is already behind that cursor.
#[serde(default)]
pub pending_dynamic_tool_calls: Vec<DynamicToolCallParams>,
/// Live session approval grants on this thread (see
/// [`RuntimeApprovalGrant`]); each can be revoked by `grant_id`.
#[serde(default)]
pub approval_grants: Vec<RuntimeApprovalGrant>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
@@ -3697,6 +3701,29 @@ pub struct PendingApprovalRequest {
/// matches `id` only, so this value settles nothing.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub tool_call_id: Option<String>,
/// Model-independent one-line summary of the gated call ("Search the web
/// for '…'"), with workspace-relative paths. Clients show it first.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub summary: Option<String>,
}
/// A session approval grant: "allow for this conversation" on one call.
///
/// The grant covers later calls of the same tool and argument class (the
/// approval grouping key) on this thread, for the life of this Runtime
/// process. It never changes the thread's permission posture, and it can be
/// revoked. Known limit: grants are in memory only, so a Runtime restart
/// forgets them and the next matching call prompts again (fail closed).
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
pub struct RuntimeApprovalGrant {
/// Runtime-minted `grant_<32 hex>`; the revoke endpoint accepts only this.
pub grant_id: String,
pub tool_name: String,
/// The approval grouping key the grant matches (tool + argument class).
pub scope: String,
/// The summary of the call the person approved.
pub summary: String,
pub granted_at: DateTime<Utc>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
@@ -4536,6 +4563,8 @@ pub struct RuntimeThreadManager {
automations:
Arc<parking_lot::Mutex<Option<crate::automation_manager::SharedAutomationManager>>>,
pending_approvals: Arc<parking_lot::Mutex<HashMap<String, PendingApprovalEntry>>>,
/// Session approval grants per thread id.
approval_grants: Arc<parking_lot::Mutex<HashMap<String, Vec<RuntimeApprovalGrant>>>>,
pending_user_inputs: Arc<parking_lot::Mutex<HashMap<(String, String), PendingUserInputEntry>>>,
pending_dynamic_tools: Arc<parking_lot::Mutex<HashMap<String, PendingDynamicToolEntry>>>,
recovery_receipts: Arc<parking_lot::Mutex<HashMap<String, Vec<RecoveredTurnReceipt>>>>,
@@ -5116,6 +5145,7 @@ impl RuntimeThreadManager {
task_execution_lease: Arc::new(parking_lot::Mutex::new(None)),
automations: Arc::new(parking_lot::Mutex::new(None)),
pending_approvals: Arc::new(parking_lot::Mutex::new(HashMap::new())),
approval_grants: Arc::new(parking_lot::Mutex::new(HashMap::new())),
pending_user_inputs: Arc::new(parking_lot::Mutex::new(HashMap::new())),
pending_dynamic_tools: Arc::new(parking_lot::Mutex::new(HashMap::new())),
recovery_receipts: Arc::new(parking_lot::Mutex::new(HashMap::new())),
@@ -5895,6 +5925,7 @@ impl RuntimeThreadManager {
// Stands in for the provider's raw call ID so tests can prove
// the correlator is visible and still not deliverable.
tool_call_id: Some(label.to_string()),
summary: None,
},
)
}
@@ -5934,42 +5965,93 @@ impl RuntimeThreadManager {
})
}
fn remember_thread_auto_approve(&self, thread_id: &str, engine: &EngineHandle) {
let thread = {
let _thread_mutation = self.store.thread_mutation.lock();
let Ok(mut thread) = self.store.load_thread(thread_id) else {
return;
};
if !thread.auto_approve || thread.permission_posture.as_deref() != Some("full_access") {
thread.auto_approve = true;
thread.permission_posture = Some("full_access".to_string());
thread.updated_at = Utc::now();
if let Err(err) = self.store.save_thread(&thread) {
tracing::warn!(
"Failed to persist full-access posture for thread {}: {}",
thread_id,
err
);
return;
}
}
thread
};
/// Live session approval grants on `thread_id`, oldest first.
#[must_use]
pub fn approval_grants_for_thread(&self, thread_id: &str) -> Vec<RuntimeApprovalGrant> {
self.approval_grants
.lock()
.get(thread_id)
.cloned()
.unwrap_or_default()
}
let configured_sandbox_mode = self.read_config().sandbox_mode.clone();
let policy = RuntimePolicyProjection::from_persisted(
&thread.mode,
thread.permission_posture.as_deref(),
thread.auto_approve,
);
let _ = engine.try_send(Op::ChangeMode {
mode: policy.mode,
allow_shell: thread.allow_shell,
trust_mode: thread.trust_mode,
auto_approve: policy.auto_approve(),
approval_mode: policy.permission,
configured_sandbox_mode,
});
fn session_grant_for(&self, thread_id: &str, scope: &str) -> Option<RuntimeApprovalGrant> {
self.approval_grants
.lock()
.get(thread_id)?
.iter()
.find(|grant| grant.scope == scope)
.cloned()
}
/// Record "allow for this conversation" as a grant scoped to the tool and
/// its argument class (E1). The thread's permission posture is untouched:
/// promoting a one-call approval to Full Access is what this replaced.
async fn add_session_grant(
&self,
thread_id: &str,
turn_id: &str,
tool_name: &str,
scope: &str,
summary: &str,
) -> RuntimeApprovalGrant {
let grant = {
let mut grants = self.approval_grants.lock();
let thread_grants = grants.entry(thread_id.to_string()).or_default();
if let Some(existing) = thread_grants.iter().find(|grant| grant.scope == scope) {
return existing.clone();
}
let grant = RuntimeApprovalGrant {
grant_id: format!("grant_{}", Uuid::new_v4().simple()),
tool_name: tool_name.to_string(),
scope: scope.to_string(),
summary: summary.to_string(),
granted_at: Utc::now(),
};
thread_grants.push(grant.clone());
grant
};
self.emit_event(
thread_id,
Some(turn_id),
None,
"approval.grant_added",
json!({ "grant": grant.clone() }),
)
.await
.ok();
grant
}
/// Revoke one session grant. Returns `false` when the thread holds no
/// grant with that id. The next matching call prompts again.
pub async fn revoke_approval_grant(&self, thread_id: &str, grant_id: &str) -> Result<bool> {
let revoked = {
let mut grants = self.approval_grants.lock();
let Some(thread_grants) = grants.get_mut(thread_id) else {
return Ok(false);
};
let Some(index) = thread_grants
.iter()
.position(|grant| grant.grant_id == grant_id)
else {
return Ok(false);
};
let revoked = thread_grants.remove(index);
if thread_grants.is_empty() {
grants.remove(thread_id);
}
revoked
};
self.emit_event(
thread_id,
None,
None,
"approval.grant_revoked",
json!({ "grant": revoked }),
)
.await?;
Ok(true)
}
#[must_use]
@@ -8292,6 +8374,7 @@ impl RuntimeThreadManager {
pending_approvals,
pending_user_inputs,
pending_dynamic_tool_calls,
approval_grants: self.approval_grants_for_thread(id),
})
}
@@ -12345,7 +12428,10 @@ impl RuntimeThreadManager {
id,
tool_name,
description,
input,
approval_grouping_key,
intent_summary,
approval_force_prompt,
..
} => {
let Some(authority) = self
@@ -12358,6 +12444,16 @@ impl RuntimeThreadManager {
let auto_approve = authority.auto_approve;
let trust_mode = authority.trust_mode;
let approval_mode = authority.approval_mode;
let summary_workspace = self
.store
.load_thread(&thread_id)
.ok()
.map(|thread| thread.workspace);
let summary = crate::tools::approval_summary::approval_summary(
&tool_name,
&input,
summary_workspace.as_deref(),
);
let pending_request = PendingApprovalRequest {
// Replaced by the minted ID at registration. The raw
@@ -12370,6 +12466,7 @@ impl RuntimeThreadManager {
description: description.clone(),
intent_summary: intent_summary.clone(),
tool_call_id: Some(id.clone()),
summary: Some(summary.clone()),
};
if auto_approve {
@@ -12389,6 +12486,7 @@ impl RuntimeThreadManager {
"approval_id": approval_id,
"tool_call_id": id,
"tool_name": tool_name,
"summary": summary,
"description": description,
"intent_summary": intent_summary,
}),
@@ -12455,6 +12553,50 @@ impl RuntimeThreadManager {
continue;
}
// A session grant for this tool and argument class
// answers the prompt without a modal and without touching
// posture (E1). A forced prompt is never pre-answered.
if !approval_force_prompt
&& let Some(grant) =
self.session_grant_for(&thread_id, &approval_grouping_key)
{
let approval_id = Self::mint_approval_id();
self.emit_event(
&thread_id,
Some(&turn_id),
None,
"approval.required",
json!({
"id": approval_id,
"approval_id": approval_id,
"tool_call_id": id,
"tool_name": tool_name,
"summary": summary,
"description": description,
"intent_summary": intent_summary,
}),
)
.await?;
self.emit_event(
&thread_id,
Some(&turn_id),
None,
"approval.decided",
json!({
"approval_id": approval_id,
"tool_call_id": id,
"decision": "allow",
"remember": false,
"auto": true,
"grant_id": grant.grant_id,
}),
)
.await
.ok();
let _ = engine.approve_tool_call(id).await;
continue;
}
// Register before sequencing the event. A snapshot racing
// this branch therefore either contains the request or
// subscribes from an older cursor that will replay it.
@@ -12488,6 +12630,7 @@ impl RuntimeThreadManager {
"approval_id": approval_id,
"tool_call_id": id,
"tool_name": tool_name,
"summary": summary,
"description": description,
"intent_summary": intent_summary,
}),
@@ -12516,16 +12659,6 @@ impl RuntimeThreadManager {
.is_some_and(|turn| {
turn.turn_id == turn_id && !turn.interrupt_requested
});
if accepting
&& matches!(
decision,
Ok(Ok(ExternalApprovalDecision::Allow { remember: true }))
)
{
// Keep Stop excluded until its competing permission
// change has committed to the same active turn.
self.remember_thread_auto_approve(&thread_id, &engine);
}
!accepting
};
if cancelled {
@@ -12550,6 +12683,24 @@ impl RuntimeThreadManager {
}
match decision {
Ok(Ok(ExternalApprovalDecision::Allow { remember })) => {
// "Allow for this conversation" records a grant
// for this tool and argument class. It must not
// change posture: a posture change mid-turn used
// to fail the very call it approved (E1/E2).
let grant = if remember {
Some(
self.add_session_grant(
&thread_id,
&turn_id,
&tool_name,
&approval_grouping_key,
&summary,
)
.await,
)
} else {
None
};
self.emit_event(
&thread_id,
Some(&turn_id),
@@ -12560,6 +12711,7 @@ impl RuntimeThreadManager {
"tool_call_id": id,
"decision": "allow",
"remember": remember,
"grant_id": grant.map(|grant| grant.grant_id),
}),
)
.await
+129 -47
View File
@@ -13543,23 +13543,19 @@ async fn deliver_external_approval_for_unknown_id_returns_false() {
assert_eq!(manager.pending_approvals_count(), 0);
}
/// E1/E2 regression: "allow for this conversation" on one call is a grant for
/// that tool and argument class. It never changes the thread's posture (which
/// used to flip to Full Access and publish a mid-turn posture change that
/// failed the approved call), it answers the queued same-class call without a
/// prompt, it cancels nothing, a different class still prompts, and it can be
/// revoked.
#[tokio::test]
async fn approval_required_remember_flips_thread_auto_approve() -> Result<()> {
async fn approval_remember_grants_tool_class_without_changing_posture() -> Result<()> {
let manager = test_manager(test_runtime_dir())?;
let thread = manager
.create_thread(CreateThreadRequest {
model: None,
workspace: None,
mode: None,
allow_shell: None,
trust_mode: None,
auto_approve: None,
archived: false,
system_prompt: None,
task_id: None,
..Default::default()
})
.create_thread(CreateThreadRequest::default())
.await?;
let posture_before = manager.store.load_thread(&thread.id)?.permission_posture;
assert!(!manager.store.load_thread(&thread.id)?.auto_approve);
let mut harness = install_mock_engine(&manager, &thread.id).await;
@@ -13567,13 +13563,7 @@ async fn approval_required_remember_flips_thread_auto_approve() -> Result<()> {
.start_turn(
&thread.id,
StartTurnRequest {
prompt: "needs approval".to_string(),
input_summary: None,
model: None,
mode: None,
allow_shell: None,
trust_mode: None,
auto_approve: None,
prompt: "compare espresso machines".to_string(),
..Default::default()
},
)
@@ -13583,53 +13573,145 @@ async fn approval_required_remember_flips_thread_auto_approve() -> Result<()> {
Some(Op::SendMessage(TurnSpec { .. }))
));
let search = |id: &str, q: &str| {
let input = json!({ "search_query": [{ "q": q }] });
EngineEvent::ApprovalRequired {
approval_key: crate::tools::approval_cache::build_approval_key("web.run", &input).0,
approval_grouping_key: crate::tools::approval_cache::build_approval_grouping_key(
"web.run", &input,
)
.0,
id: id.to_string(),
tool_name: "web.run".to_string(),
description: "Browse the web".to_string(),
input,
intent_summary: None,
approval_force_prompt: false,
}
};
// Three gated calls queued behind one prompt: two searches, one write.
harness
.tx_event
.send(search("call_search_1", "espresso"))
.await?;
harness
.tx_event
.send(search("call_search_2", "grinders"))
.await?;
harness
.tx_event
.send(EngineEvent::ApprovalRequired {
approval_key: "key3".to_string(),
approval_grouping_key: "key3".to_string(),
id: "tool_remember".to_string(),
tool_name: "exec_command".to_string(),
description: "remember=true".to_string(),
input: serde_json::json!({}),
approval_key: "write-key".to_string(),
approval_grouping_key: "write-group".to_string(),
id: "call_write".to_string(),
tool_name: "write_file".to_string(),
description: "write".to_string(),
input: json!({ "path": thread.workspace.join("espresso.md") }),
intent_summary: None,
approval_force_prompt: false,
})
.await?;
let deadline = Instant::now() + Duration::from_secs(2);
while Instant::now() < deadline && manager.pending_approvals_count() == 0 {
sleep(Duration::from_millis(20)).await;
}
let approval_id = await_approval_identity(&manager, &thread.id, "tool_remember").await?;
let approval_id = await_approval_identity(&manager, &thread.id, "call_search_1").await?;
let pending = manager
.get_thread_detail(&thread.id)
.await?
.pending_approvals;
assert_eq!(
pending[0].summary.as_deref(),
Some("Search the web for 'espresso'"),
"the approval carries a model-independent summary (E6)"
);
assert!(manager.deliver_external_approval(
&approval_id,
ExternalApprovalDecision::Allow { remember: true },
));
let _ = harness.recv_approval_event().await;
assert!(
manager.store.load_thread(&thread.id)?.auto_approve,
"remember=true should flip thread auto_approve"
assert_eq!(
harness.recv_approval_event().await,
Some(MockApprovalEvent::Approved {
id: "call_search_1".to_string()
})
);
// The queued same-class search is answered by the grant, not cancelled.
assert_eq!(
harness.recv_approval_event().await,
Some(MockApprovalEvent::Approved {
id: "call_search_2".to_string()
})
);
// A different tool still asks.
let write_approval = await_approval_identity(&manager, &thread.id, "call_write").await?;
let detail = manager.get_thread_detail(&thread.id).await?;
assert_eq!(detail.pending_approvals.len(), 1);
assert_eq!(
detail.pending_approvals[0].summary.as_deref(),
Some("Write espresso.md"),
"paths are workspace-relative"
);
// Posture is untouched everywhere: record, live engine, mailbox.
let record = manager.store.load_thread(&thread.id)?;
assert!(
!record.auto_approve,
"a session grant must not enable auto-approve"
);
assert_eq!(record.permission_posture, posture_before);
assert_eq!(
manager.active_turn_flags(&thread.id, &turn.id).await,
Some((true, false)),
"remember=true should update the active turn used by subsequent approvals"
Some((false, false))
);
assert!(
harness.rx_op.try_recv().is_err(),
"approving must not queue a posture change"
);
// The grant is named on the event stream and in the snapshot.
assert_eq!(detail.approval_grants.len(), 1);
let grant = detail.approval_grants[0].clone();
assert_eq!(grant.tool_name, "web.run");
assert_eq!(grant.summary, "Search the web for 'espresso'");
let events = manager.events_since(&thread.id, None)?;
assert!(events.iter().any(|event| {
event.event == "approval.grant_added"
&& event.payload["grant"]["grant_id"] == grant.grant_id
}));
assert!(events.iter().any(|event| {
event.event == "approval.decided"
&& event.payload["tool_call_id"] == "call_search_2"
&& event.payload["grant_id"] == grant.grant_id
}));
assert!(manager.deliver_external_approval(
&write_approval,
ExternalApprovalDecision::Deny { remember: false },
));
let _ = harness.recv_approval_event().await;
// Revoked, the next search prompts again.
assert!(
manager
.revoke_approval_grant(&thread.id, &grant.grant_id)
.await?
);
assert!(
!manager
.revoke_approval_grant(&thread.id, &grant.grant_id)
.await?
);
harness
.tx_event
.send(EngineEvent::TurnComplete {
usage: Usage::default(),
parent_route_usage: Usage::default(),
routed_usage_dropped_records: 0,
status: TurnOutcomeStatus::Completed,
error: None,
tool_catalog: None,
base_url: None,
})
.send(search("call_search_3", "tampers"))
.await?;
await_approval_identity(&manager, &thread.id, "call_search_3").await?;
assert!(
manager
.get_thread_detail(&thread.id)
.await?
.approval_grants
.is_empty()
);
manager.interrupt_turn(&thread.id, &turn.id).await?;
Ok(())
}
+48
View File
@@ -135,11 +135,36 @@ pub fn build_approval_grouping_key(tool_name: &str, input: &serde_json::Value) -
format!("cu:{name}:{}", hash_json_value(input))
}
name if crate::mcp::McpPool::is_mcp_tool(name) => format!("mcp:{name}"),
// E1: a session grant for web browsing covers the argument class the
// person approved (search, open, …), not the one exact query.
"web.run" => format!("web:{tool_name}:{}", web_run_action_class(input)),
"web_search" => format!("web:{tool_name}"),
_ => format!("tool:{tool_name}:{}", hash_json_value(input)),
};
ApprovalKey(fingerprint)
}
/// The sorted `web.run` action kinds present in `input`, e.g. `open+search_query`.
fn web_run_action_class(input: &Value) -> String {
const ACTIONS: [&str; 6] = [
"click",
"find",
"image_query",
"open",
"screenshot",
"search_query",
];
let present: Vec<&str> = ACTIONS
.into_iter()
.filter(|action| input.get(*action).is_some_and(|value| !value.is_null()))
.collect();
if present.is_empty() {
"none".to_string()
} else {
present.join("+")
}
}
/// A Computer Use call whose approval must come from a person (K1 / K2).
#[derive(Debug, Clone, PartialEq, Eq)]
pub(crate) enum ComputerUseUserGate {
@@ -801,4 +826,27 @@ mod tests {
assert!(!canonical.contains(",]"));
assert!(!canonical.contains(",}"));
}
#[test]
fn web_run_session_grant_covers_its_argument_class_only() {
let search = |q: &str| json!({"search_query": [{"q": q}]});
assert_eq!(
build_approval_grouping_key("web.run", &search("espresso")),
build_approval_grouping_key("web.run", &search("grinders")),
"approving one search covers later searches"
);
assert_ne!(
build_approval_grouping_key("web.run", &search("espresso")),
build_approval_grouping_key(
"web.run",
&json!({"open": [{"ref_id": "https://x.test"}]})
),
"a search grant never covers opening a page"
);
assert_ne!(
build_approval_key("web.run", &search("espresso")),
build_approval_key("web.run", &search("grinders")),
"denials stay exact-call scoped"
);
}
}
+248
View File
@@ -0,0 +1,248 @@
//! One plain-language line per gated tool call (E6).
//!
//! An approval card used to carry the model's raw JSON arguments and a static
//! tool description. [`approval_summary`] derives a short sentence from the
//! tool name and its arguments alone — never from model prose — so every
//! client can show the same first line ("Search the web for 'espresso'") and
//! put the raw arguments behind it. Paths are shown relative to the
//! workspace when they sit inside it.
use std::path::Path;
use serde_json::Value;
/// Longest quoted argument a summary carries before it is cut with `…`.
const MAX_QUOTED_CHARS: usize = 80;
/// Summarize a gated tool call for an approval prompt.
#[must_use]
pub fn approval_summary(tool_name: &str, input: &Value, workspace: Option<&Path>) -> String {
let name = crate::tools::canonical_action::canonical_action_alias(tool_name, input);
let text = |key: &str| {
input
.get(key)
.and_then(Value::as_str)
.map(str::trim)
.filter(|value| !value.is_empty())
};
let path = |key: &str| text(key).map(|raw| relative_path(raw, workspace));
match name {
"exec_shell" | "task_shell_start" => match text("command") {
Some(command) => format!("Run `{}`", clip(command)),
None => "Run a shell command".to_string(),
},
"exec_shell_wait" | "exec_wait" => "Wait for a running shell command".to_string(),
"exec_shell_interact" | "exec_interact" => {
"Send input to a running shell command".to_string()
}
"exec_shell_cancel" => "Stop a running shell command".to_string(),
"write_file" => match path("path") {
Some(path) => format!("Write {path}"),
None => "Write a file".to_string(),
},
"edit_file" | "fim_edit" => match path("path") {
Some(path) => format!("Edit {path}"),
None => "Edit a file".to_string(),
},
"apply_patch" => patch_summary(input, workspace),
"read_file" => match path("path") {
Some(path) => format!("Read {path}"),
None => "Read a file".to_string(),
},
"list_dir" => match path("path") {
Some(path) => format!("List {path}"),
None => "List the workspace".to_string(),
},
"fetch_url" | "web.fetch" | "web_fetch" => match text("url") {
Some(url) => format!("Fetch {}", clip(url)),
None => "Fetch a web page".to_string(),
},
"web_search" => match text("query").or_else(|| text("q")) {
Some(query) => format!("Search the web for '{}'", clip(query)),
None => "Search the web".to_string(),
},
"web.run" => web_run_summary(input),
name if name.starts_with("mcp_") => mcp_summary(name),
name => format!("Use the {name} tool"),
}
}
fn web_run_summary(input: &Value) -> String {
let first = |key: &str, field: &str| {
input
.get(key)
.and_then(Value::as_array)
.and_then(|items| items.first())
.and_then(|item| item.get(field))
.and_then(Value::as_str)
.map(str::trim)
.filter(|value| !value.is_empty())
.map(str::to_string)
};
let count = |key: &str| input.get(key).and_then(Value::as_array).map_or(0, Vec::len);
let more = |key: &str| match count(key) {
0 | 1 => String::new(),
n => format!(" (+{} more)", n - 1),
};
if let Some(query) = first("search_query", "q") {
return format!(
"Search the web for '{}'{}",
clip(&query),
more("search_query")
);
}
if let Some(query) = first("image_query", "q") {
return format!(
"Search the web for images of '{}'{}",
clip(&query),
more("image_query")
);
}
if let Some(target) = first("open", "ref_id") {
return format!("Open {}{}", clip(&target), more("open"));
}
if count("click") > 0 {
return "Follow a link on an opened page".to_string();
}
if let Some(pattern) = first("find", "pattern") {
return format!("Find '{}' on an opened page", clip(&pattern));
}
if count("screenshot") > 0 {
return "Take a screenshot of an opened page".to_string();
}
"Browse the web".to_string()
}
fn patch_summary(input: &Value, workspace: Option<&Path>) -> String {
let Ok(preflight) = crate::tools::apply_patch::preflight_apply_patch(input) else {
return "Apply a patch".to_string();
};
let mut paths: Vec<String> = preflight
.touched_files
.iter()
.map(|raw| relative_path(raw, workspace))
.collect();
paths.sort_unstable();
paths.dedup();
match paths.as_slice() {
[] => "Apply a patch".to_string(),
[one] => format!("Edit {one}"),
[first, rest @ ..] => format!("Edit {first} and {} more file(s)", rest.len()),
}
}
fn mcp_summary(name: &str) -> String {
// `mcp_<server>_<tool>`; server names may themselves hold `_`, so this is
// presentation only and never a policy decision.
let rest = name.trim_start_matches("mcp_");
match rest.split_once('_') {
Some((server, tool)) if !server.is_empty() && !tool.is_empty() => {
format!("Use {tool} from {server}")
}
_ => format!("Use {rest}"),
}
}
/// Show `raw` relative to `workspace` when it names a path inside it.
fn relative_path(raw: &str, workspace: Option<&Path>) -> String {
let candidate = Path::new(raw);
if let Some(workspace) = workspace
&& candidate.is_absolute()
&& let Ok(relative) = candidate.strip_prefix(workspace)
{
let shown = relative.display().to_string();
return if shown.is_empty() {
".".to_string()
} else {
clip(&shown)
};
}
// A relative path is already workspace-relative; drop a leading `./`.
if let Ok(relative) = candidate.strip_prefix(".")
&& !relative.as_os_str().is_empty()
{
return clip(&relative.display().to_string());
}
clip(raw)
}
fn clip(value: &str) -> String {
let single_line = value.split_whitespace().collect::<Vec<_>>().join(" ");
if single_line.chars().count() <= MAX_QUOTED_CHARS {
return single_line;
}
let mut clipped: String = single_line.chars().take(MAX_QUOTED_CHARS - 1).collect();
clipped.push('…');
clipped
}
#[cfg(test)]
mod tests {
use super::*;
use serde_json::json;
#[test]
fn web_run_search_names_the_query() {
let summary = approval_summary(
"web.run",
&json!({"search_query": [{"q": "best espresso machine"}, {"q": "reviews"}]}),
None,
);
assert_eq!(
summary,
"Search the web for 'best espresso machine' (+1 more)"
);
}
#[test]
fn file_paths_are_workspace_relative() {
let workspace = Path::new("/work/repo");
assert_eq!(
approval_summary(
"write_file",
&json!({"path": "/work/repo/notes/espresso.md", "content": "x"}),
Some(workspace),
),
"Write notes/espresso.md"
);
// Outside the workspace stays absolute, so the card never hides where
// a write lands.
assert_eq!(
approval_summary("edit_file", &json!({"path": "/etc/hosts"}), Some(workspace)),
"Edit /etc/hosts"
);
// The model-facing `File{action}` family resolves to the same line.
assert_eq!(
approval_summary(
"File",
&json!({"action": "write", "path": "/work/repo/a.txt"}),
Some(workspace),
),
"Write a.txt"
);
}
#[test]
fn shell_and_fallbacks_are_plain_and_bounded() {
assert_eq!(
approval_summary(
"exec_shell",
&json!({"command": "cargo test\n-p tui"}),
None
),
"Run `cargo test -p tui`"
);
let long = "x".repeat(500);
let summary = approval_summary("exec_shell", &json!({ "command": long }), None);
assert!(summary.chars().count() < 100, "{summary}");
assert_eq!(
approval_summary("mcp_github_create_issue", &json!({}), None),
"Use create_issue from github"
);
assert_eq!(
approval_summary("some_tool", &json!({"a": 1}), None),
"Use the some_tool tool"
);
}
}
+1
View File
@@ -10,6 +10,7 @@
pub mod apply_patch;
pub mod approval_cache;
pub mod approval_summary;
pub mod arg_repair;
pub mod automation;
pub mod canonical_action;
+19 -2
View File
@@ -925,8 +925,25 @@ The raw provider call ID travels separately as `tool_call_id` on
`pending_approvals[]` and on the approval events. It is a correlator for
attaching a prompt to the tool row it gates, and never accepted as a decision.
Each thread-detail `pending_approvals[]` entry is
`{ "id", "turn_id", "tool_name", "description", "intent_summary"?, "tool_call_id"? }`,
where `id` is the capability above.
`{ "id", "turn_id", "tool_name", "description", "intent_summary"?, "tool_call_id"?, "summary"? }`,
where `id` is the capability above. `summary` (also on `approval.required`) is
a one-line description of the gated call built from the tool name and its
arguments only, never from model text ("Search the web for 'espresso'",
"Write notes/espresso.md"); paths inside the workspace are workspace-relative.
Clients show it first and keep the raw arguments behind it.
`"remember": true` on an `allow` records a **session grant** for that tool and
argument class (the approval grouping key: a shell command family, a patch's
file set, a URL host, an MCP tool, a `web.run` action kind). A grant never
changes the thread's permission posture. Later matching calls on the thread are
approved without a prompt: they still emit `approval.required`, then
`approval.decided` with `"auto": true` and the `grant_id`. Creating a grant
emits `approval.grant_added` with `{ "grant": { "grant_id", "tool_name",
"scope", "summary", "granted_at" } }`; thread detail lists live grants in
`approval_grants[]`. `DELETE /v1/threads/{id}/approval-grants/{grant_id}`
revokes one (emitting `approval.grant_revoked`); the next matching call
prompts again. Grants live in memory for the Runtime process: a restart
forgets them, and a forced (non-bypassable) prompt is never answered by one.
**User input**
- `POST /v1/user-input/{thread_id}/{input_id}` with body