fix(approvals): end session grants when a thread is archived or deleted

"Allow for this conversation" grants were keyed by thread id in memory
and outlived the conversation: archiving a thread left them live, so an
unarchived thread silently kept pre-approved tool classes. Archiving now
drops every grant on the thread and emits approval.grant_revoked for
each; unarchiving does not restore them. Discarding (deleting) a thread
drops its grants with the record.

docs/RUNTIME_API.md: the grant scope list now says `fetch_url` host,
that a `web.run` open grant is scoped to the hosts it opened, and that
Computer Use consent / app_script and unclassified tools are granted for
the exact call only; it also documents the archive/delete lifecycle.

Checks: cargo test -p codewhale-tui --lib (filters approval_remember_grants,
archiving, discard, archiv): 58 passed, 0 failed.

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 19:14:09 -07:00
1 parent 5a5ef02eb0
commit 802002a51b
3 files changed
+131 -8

No files matched your search

+44 -6
View File
@@ -3711,9 +3711,10 @@ pub struct PendingApprovalRequest {
///
/// 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).
/// process or until the thread is archived or deleted. 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.
@@ -6023,6 +6024,16 @@ impl RuntimeThreadManager {
grant
}
/// Remove every session grant on `thread_id` and return them. Archiving or
/// deleting a thread calls this, so a grant never outlives the
/// conversation it was given in.
fn take_approval_grants(&self, thread_id: &str) -> Vec<RuntimeApprovalGrant> {
self.approval_grants
.lock()
.remove(thread_id)
.unwrap_or_default()
}
/// 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> {
@@ -7482,7 +7493,10 @@ impl RuntimeThreadManager {
// API-created jobs, and dropping the last handle kills them.
active.shell_managers.remove(thread_id);
drop(active);
self.store.remove_thread(thread_id)
self.store.remove_thread(thread_id)?;
// A deleted conversation keeps no session grants behind it.
self.take_approval_grants(thread_id);
Ok(())
}
pub async fn list_threads(
@@ -7995,7 +8009,7 @@ impl RuntimeThreadManager {
None
};
let configured_sandbox_mode = self.read_config().sandbox_mode.clone();
let (thread, changes, evicted_engine, posture_engine) = {
let (thread, changes, evicted_engine, posture_engine, ended_grants) = {
// Take the active guard first so a workspace mutation can check
// and evict the cached engine atomically with the durable update.
// Using the same order as start/compact avoids lock inversion.
@@ -8152,9 +8166,33 @@ impl RuntimeThreadManager {
} else {
None
};
(thread, changes, evicted_engine, posture_engine)
// Archiving ends the conversation's session grants: unarchiving
// later starts from a clean slate and the next call prompts.
let ended_grants = if changes.get("archived") == Some(&json!(true)) {
self.take_approval_grants(id)
} else {
Vec::new()
};
(
thread,
changes,
evicted_engine,
posture_engine,
ended_grants,
)
};
for grant in ended_grants {
self.emit_event(
&thread.id,
None,
None,
"approval.grant_revoked",
json!({ "grant": grant }),
)
.await?;
}
if let Some(engine) = evicted_engine {
let _ = engine.send(Op::Shutdown).await;
}
+81
View File
@@ -13715,6 +13715,87 @@ async fn approval_remember_grants_tool_class_without_changing_posture() -> Resul
Ok(())
}
#[tokio::test]
async fn archiving_or_deleting_a_thread_ends_its_session_grants() -> Result<()> {
let manager = test_manager(test_runtime_dir())?;
let archived = manager
.create_thread(CreateThreadRequest::default())
.await?;
let kept = manager
.create_thread(CreateThreadRequest::default())
.await?;
let grant = manager
.add_session_grant(
&archived.id,
"turn_1",
"web.run",
"web:web.run:search_query",
"Search the web for 'espresso'",
)
.await;
manager
.add_session_grant(
&kept.id,
"turn_1",
"web.run",
"web:web.run:search_query",
"s",
)
.await;
// A title edit leaves grants alone.
manager
.update_thread(
&archived.id,
UpdateThreadRequest {
title: Some("renamed".to_string()),
..UpdateThreadRequest::default()
},
)
.await?;
assert_eq!(manager.approval_grants_for_thread(&archived.id).len(), 1);
manager
.update_thread(
&archived.id,
UpdateThreadRequest {
archived: Some(true),
..UpdateThreadRequest::default()
},
)
.await?;
assert!(manager.approval_grants_for_thread(&archived.id).is_empty());
assert!(
manager
.session_grant_for(&archived.id, "web:web.run:search_query")
.is_none(),
"the next matching call on an archived thread prompts again"
);
let events = manager.events_since(&archived.id, None)?;
assert!(events.iter().any(|event| {
event.event == "approval.grant_revoked"
&& event.payload["grant"]["grant_id"] == grant.grant_id
}));
// Unarchiving does not bring the grant back.
manager
.update_thread(
&archived.id,
UpdateThreadRequest {
archived: Some(false),
..UpdateThreadRequest::default()
},
)
.await?;
assert!(manager.approval_grants_for_thread(&archived.id).is_empty());
// Another thread's grants are untouched.
assert_eq!(manager.approval_grants_for_thread(&kept.id).len(), 1);
// Deleting a thread drops its grants with it.
manager.discard_empty_thread(&kept.id).await?;
assert!(manager.approval_grants.lock().get(&kept.id).is_none());
Ok(())
}
#[tokio::test]
async fn elevation_required_with_stale_active_turn_is_denied() -> Result<()> {
let manager = test_manager(test_runtime_dir())?;
+6 -2
View File
@@ -934,7 +934,9 @@ 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
file set, a `fetch_url` host, an MCP tool, a `web.run` action kind — for
`open`, the hosts it opened). Computer Use consent and `app_script` calls, and
any tool without a class, are granted for the exact call only. 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
@@ -942,7 +944,9 @@ 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
prompts again. Archiving or deleting the thread ends all of its grants
(archiving emits `approval.grant_revoked` for each; unarchiving does not
restore them). 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**