Skip to content

Commit 54cec5c

Browse files
bb-connorclaude
andauthored
refactor(agent): split 5 top-level files (updater, approval, policy, posture, claude_code) (#352)
Convert 5 single-file modules over 600 lines into directories with focused submodules, preserving public APIs via mod.rs re-exports. - updater.rs (844) -> updater/{mod,types,manifest,service,tests}.rs - approval.rs (753) -> approval/{mod,types,queue,tests}.rs - policy.rs (689) -> policy/{mod,types,audit,evaluate,tests}.rs - posture_commands.rs (601) -> posture_commands/{mod,types,signing,handler,tests}.rs - integrations/claude_code.rs (615) -> claude_code/{mod,hook_script,installer,tests}.rs All new production files are under 600 lines. Behavior is unchanged. Co-authored-by: bb-connor <bb-connor@users.noreply.github.com> Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent e34b1bb commit 54cec5c

26 files changed

Lines changed: 2655 additions & 2545 deletions

File tree

apps/agent/src-tauri/src/approval.rs

Lines changed: 0 additions & 753 deletions
This file was deleted.
Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
//! Per-action approval mechanism for the desktop agent.
2+
//!
3+
//! When a pre-flight guard denies a non-critical action, the adapter can submit
4+
//! an approval request. The agent queues it, surfaces it via OS notification and
5+
//! tray badge, and the user resolves it. Unresolved requests expire after a
6+
//! configurable TTL (default 60s) and are treated as denied.
7+
8+
mod queue;
9+
mod types;
10+
11+
pub use queue::ApprovalQueue;
12+
#[allow(unused_imports)]
13+
pub use types::ApprovalRequest;
14+
pub use types::{
15+
ApprovalError, ApprovalEvent, ApprovalRequestInput, ApprovalResolution, ApprovalResolveInput,
16+
ApprovalStatus, ApprovalStatusResponse,
17+
};
18+
19+
#[cfg(test)]
20+
#[allow(clippy::unwrap_used, clippy::expect_used)]
21+
mod tests;
Lines changed: 307 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,307 @@
1+
//! Approval queue: in-memory store, submission, resolution, and lifecycle.
2+
3+
use chrono::Utc;
4+
use std::collections::HashMap;
5+
use std::sync::Arc;
6+
use std::time::Duration;
7+
use tokio::sync::{broadcast, Mutex};
8+
use uuid::Uuid;
9+
10+
use super::types::{
11+
compute_expires_at, is_duplicate_pending, ApprovalError, ApprovalEvent, ApprovalRequest,
12+
ApprovalRequestInput, ApprovalResolution, ApprovalStatus, ApprovalStatusResponse,
13+
DEFAULT_TTL_SECS, MAX_QUEUE_SIZE, MAX_TTL_SECS,
14+
};
15+
16+
/// Manages the in-memory approval queue.
17+
pub struct ApprovalQueue {
18+
requests: Mutex<HashMap<String, ApprovalRequest>>,
19+
event_tx: broadcast::Sender<ApprovalEvent>,
20+
}
21+
22+
impl ApprovalQueue {
23+
pub fn new() -> Self {
24+
let (event_tx, _) = broadcast::channel(64);
25+
Self {
26+
requests: Mutex::new(HashMap::new()),
27+
event_tx,
28+
}
29+
}
30+
31+
/// Subscribe to approval events (for tray/notification integration).
32+
pub fn subscribe(&self) -> broadcast::Receiver<ApprovalEvent> {
33+
self.event_tx.subscribe()
34+
}
35+
36+
/// Submit a new approval request. Returns the created request or an error
37+
/// if the queue is full (all entries pending).
38+
pub async fn submit(
39+
&self,
40+
input: ApprovalRequestInput,
41+
) -> Result<ApprovalRequest, ApprovalError> {
42+
let id = Uuid::new_v4().to_string();
43+
let now = Utc::now();
44+
let ttl_secs = input.ttl_secs.unwrap_or(DEFAULT_TTL_SECS).min(MAX_TTL_SECS);
45+
let expires_at = compute_expires_at(now, ttl_secs);
46+
47+
let request = ApprovalRequest {
48+
id: id.clone(),
49+
tool: input.tool,
50+
resource: input.resource,
51+
guard: input.guard,
52+
reason: input.reason,
53+
severity: input.severity,
54+
session_id: input.session_id,
55+
created_at: now,
56+
expires_at,
57+
status: ApprovalStatus::Pending,
58+
resolution: None,
59+
resolved_at: None,
60+
resolved_by_trusted_authority: false,
61+
};
62+
63+
{
64+
let mut requests = self.requests.lock().await;
65+
// Expire stale pending entries before checking capacity.
66+
let now_expire = Utc::now();
67+
for request_entry in requests.values_mut() {
68+
if request_entry.status == ApprovalStatus::Pending
69+
&& now_expire >= request_entry.expires_at
70+
{
71+
request_entry.status = ApprovalStatus::Expired;
72+
request_entry.resolution = Some(ApprovalResolution::Deny);
73+
request_entry.resolved_at = Some(now_expire);
74+
let _ = self.event_tx.send(ApprovalEvent::Expired {
75+
id: request_entry.id.clone(),
76+
});
77+
}
78+
}
79+
80+
if let Some(existing) = requests
81+
.values()
82+
.find(|existing| is_duplicate_pending(existing, &request))
83+
{
84+
return Ok(existing.clone());
85+
}
86+
87+
// Evict resolved/expired entries first when at capacity.
88+
if requests.len() >= MAX_QUEUE_SIZE {
89+
let to_evict: Vec<String> = requests
90+
.iter()
91+
.filter(|(_, r)| r.status != ApprovalStatus::Pending)
92+
.map(|(id, _)| id.clone())
93+
.collect();
94+
for evict_id in to_evict {
95+
requests.remove(&evict_id);
96+
if requests.len() < MAX_QUEUE_SIZE {
97+
break;
98+
}
99+
}
100+
// If still at capacity (all entries are pending), reject submission.
101+
if requests.len() >= MAX_QUEUE_SIZE {
102+
return Err(ApprovalError::QueueFull);
103+
}
104+
}
105+
requests.insert(id, request.clone());
106+
}
107+
108+
let _ = self.event_tx.send(ApprovalEvent::NewRequest {
109+
request: ApprovalStatusResponse::from(&request),
110+
});
111+
112+
Ok(request)
113+
}
114+
115+
/// Get the current status of an approval request. Checks expiry.
116+
pub async fn get_status(&self, id: &str) -> Option<ApprovalStatusResponse> {
117+
let mut requests = self.requests.lock().await;
118+
119+
let request = requests.get_mut(id)?;
120+
121+
// Check if expired.
122+
if request.status == ApprovalStatus::Pending && Utc::now() >= request.expires_at {
123+
request.status = ApprovalStatus::Expired;
124+
request.resolution = Some(ApprovalResolution::Deny);
125+
request.resolved_at = Some(Utc::now());
126+
127+
let _ = self
128+
.event_tx
129+
.send(ApprovalEvent::Expired { id: id.to_string() });
130+
}
131+
132+
Some(ApprovalStatusResponse::from(&*request))
133+
}
134+
135+
/// Consume a resolved one-shot approval. Session and always approvals remain reusable.
136+
pub async fn consume_allow_once(&self, id: &str) -> Result<bool, ApprovalError> {
137+
let mut requests = self.requests.lock().await;
138+
let should_remove = {
139+
let request = requests.get_mut(id).ok_or(ApprovalError::NotFound)?;
140+
141+
if request.status == ApprovalStatus::Pending && Utc::now() >= request.expires_at {
142+
request.status = ApprovalStatus::Expired;
143+
request.resolution = Some(ApprovalResolution::Deny);
144+
request.resolved_at = Some(Utc::now());
145+
let _ = self
146+
.event_tx
147+
.send(ApprovalEvent::Expired { id: id.to_string() });
148+
return Err(ApprovalError::Expired);
149+
}
150+
151+
if request.status != ApprovalStatus::Resolved {
152+
return Ok(false);
153+
}
154+
request.resolution == Some(ApprovalResolution::AllowOnce)
155+
};
156+
if should_remove {
157+
requests.remove(id);
158+
return Ok(true);
159+
}
160+
Ok(false)
161+
}
162+
163+
/// Resolve an approval request.
164+
pub async fn resolve(
165+
&self,
166+
id: &str,
167+
resolution: ApprovalResolution,
168+
) -> Result<ApprovalStatusResponse, ApprovalError> {
169+
self.resolve_with_trust(id, resolution, true).await
170+
}
171+
172+
/// Resolve an approval request through the local API. These resolutions are
173+
/// intentionally not trusted for gates that require an independent operator
174+
/// or signed-control-plane decision.
175+
pub async fn resolve_local_api(
176+
&self,
177+
id: &str,
178+
resolution: ApprovalResolution,
179+
) -> Result<ApprovalStatusResponse, ApprovalError> {
180+
self.resolve_with_trust(id, resolution, false).await
181+
}
182+
183+
async fn resolve_with_trust(
184+
&self,
185+
id: &str,
186+
resolution: ApprovalResolution,
187+
resolved_by_trusted_authority: bool,
188+
) -> Result<ApprovalStatusResponse, ApprovalError> {
189+
let mut requests = self.requests.lock().await;
190+
191+
let request = requests.get_mut(id).ok_or(ApprovalError::NotFound)?;
192+
193+
// Preserve precise semantics for clients:
194+
// - Resolved -> 409 (AlreadyResolved)
195+
// - Expired -> 410 (Expired)
196+
match request.status {
197+
ApprovalStatus::Pending => {}
198+
ApprovalStatus::Resolved => return Err(ApprovalError::AlreadyResolved),
199+
ApprovalStatus::Expired => return Err(ApprovalError::Expired),
200+
}
201+
202+
// Check if expired.
203+
if Utc::now() >= request.expires_at {
204+
request.status = ApprovalStatus::Expired;
205+
request.resolution = Some(ApprovalResolution::Deny);
206+
request.resolved_at = Some(Utc::now());
207+
let _ = self
208+
.event_tx
209+
.send(ApprovalEvent::Expired { id: id.to_string() });
210+
return Err(ApprovalError::Expired);
211+
}
212+
213+
request.status = ApprovalStatus::Resolved;
214+
request.resolution = Some(resolution);
215+
request.resolved_at = Some(Utc::now());
216+
request.resolved_by_trusted_authority = resolved_by_trusted_authority;
217+
218+
let response = ApprovalStatusResponse::from(&*request);
219+
let _ = self.event_tx.send(ApprovalEvent::Resolved {
220+
request: response.clone(),
221+
});
222+
223+
Ok(response)
224+
}
225+
226+
/// List all pending approval requests.
227+
pub async fn list_pending(&self) -> Vec<ApprovalStatusResponse> {
228+
let mut requests = self.requests.lock().await;
229+
let now = Utc::now();
230+
231+
let mut pending = Vec::new();
232+
for (id, request) in requests.iter_mut() {
233+
if request.status == ApprovalStatus::Pending {
234+
if now >= request.expires_at {
235+
request.status = ApprovalStatus::Expired;
236+
request.resolution = Some(ApprovalResolution::Deny);
237+
request.resolved_at = Some(now);
238+
let _ = self
239+
.event_tx
240+
.send(ApprovalEvent::Expired { id: id.clone() });
241+
} else {
242+
pending.push(ApprovalStatusResponse::from(&*request));
243+
}
244+
}
245+
}
246+
247+
pending
248+
}
249+
250+
/// Number of pending approval requests.
251+
pub async fn pending_count(&self) -> usize {
252+
let requests = self.requests.lock().await;
253+
let now = Utc::now();
254+
requests
255+
.values()
256+
.filter(|r| r.status == ApprovalStatus::Pending && now < r.expires_at)
257+
.count()
258+
}
259+
260+
/// Start a background cleanup task that expires old requests.
261+
pub fn start_cleanup(self: &Arc<Self>, mut shutdown_rx: broadcast::Receiver<()>) {
262+
let queue = Arc::clone(self);
263+
tokio::spawn(async move {
264+
let cleanup_interval = Duration::from_secs(10);
265+
loop {
266+
tokio::select! {
267+
_ = shutdown_rx.recv() => break,
268+
_ = tokio::time::sleep(cleanup_interval) => {
269+
queue.expire_stale().await;
270+
}
271+
}
272+
}
273+
});
274+
}
275+
276+
/// Expire stale requests and remove very old resolved ones.
277+
async fn expire_stale(&self) {
278+
let mut requests = self.requests.lock().await;
279+
let now = Utc::now();
280+
let gc_threshold = now - chrono::Duration::minutes(10);
281+
282+
let mut to_remove = Vec::new();
283+
for (id, request) in requests.iter_mut() {
284+
if request.status == ApprovalStatus::Pending && now >= request.expires_at {
285+
request.status = ApprovalStatus::Expired;
286+
request.resolution = Some(ApprovalResolution::Deny);
287+
request.resolved_at = Some(now);
288+
let _ = self
289+
.event_tx
290+
.send(ApprovalEvent::Expired { id: id.clone() });
291+
}
292+
293+
// GC resolved/expired requests older than 10 minutes.
294+
if request.status != ApprovalStatus::Pending {
295+
if let Some(resolved_at) = request.resolved_at {
296+
if resolved_at < gc_threshold {
297+
to_remove.push(id.clone());
298+
}
299+
}
300+
}
301+
}
302+
303+
for id in to_remove {
304+
requests.remove(&id);
305+
}
306+
}
307+
}

0 commit comments

Comments
 (0)