mirror of
https://github.com/codewhale-hq/Codewhale.git
synced 2026-11-10 17:10:18 -10:00
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:
1 parent
5a5ef02eb0
commit
802002a51b
3 files changed
+131
-8
No files matched your search
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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
@@ -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**
|
||||
|
||||
Reference in new issue
Block a user