返回 CodeWhale
approval_log.rs
根目录 / crates / tui / src / approval_log.rs
1 use std::collections::{BTreeMap, BTreeSet};
2 use std::fs::{self, File, OpenOptions};
3 use std::io::{self, BufRead, BufReader, Write};
4 use std::path::PathBuf;
5
6 use chrono::{DateTime, Utc};
7 use serde::{Deserialize, Serialize};
8
9 use crate::sandbox::SandboxPolicy;
10
11 const APPROVAL_LOG_FILE: &str = "approval_receipts.jsonl";
12 const APPROVAL_LOCK_FILE: &str = "approval_receipts.lock";
13
14 #[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
15 #[serde(tag = "outcome", rename_all = "snake_case")]
16 pub(crate) enum ApprovalOutcome {
17 ApprovedOnce,
18 Denied,
19 /// The interactive approval card expired unanswered (#6101): the
20 /// configured bound denied the call, not the operator.
21 Timeout,
22 Cancelled,
23 Unavailable,
24 RetryWithPolicy {
25 policy: SandboxPolicy,
26 },
27 }
28
29 #[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
30 #[serde(tag = "phase", rename_all = "snake_case")]
31 pub(crate) enum ApprovalReceipt {
32 Asked {
33 approval_id: String,
34 tool_call_id: String,
35 tool_name: String,
36 created_at: DateTime<Utc>,
37 },
38 Decided {
39 approval_id: String,
40 tool_call_id: String,
41 outcome: ApprovalOutcome,
42 created_at: DateTime<Utc>,
43 },
44 }
45
46 impl ApprovalReceipt {
47 pub(crate) fn asked(tool_call_id: impl Into<String>, tool_name: impl Into<String>) -> Self {
48 let tool_call_id = tool_call_id.into();
49 Self::Asked {
50 approval_id: tool_call_id.clone(),
51 tool_call_id,
52 tool_name: tool_name.into(),
53 created_at: Utc::now(),
54 }
55 }
56
57 pub(crate) fn decided(tool_call_id: impl Into<String>, outcome: ApprovalOutcome) -> Self {
58 let tool_call_id = tool_call_id.into();
59 Self::Decided {
60 approval_id: tool_call_id.clone(),
61 tool_call_id,
62 outcome,
63 created_at: Utc::now(),
64 }
65 }
66
67 pub(crate) fn approval_id(&self) -> &str {
68 match self {
69 Self::Asked { approval_id, .. } | Self::Decided { approval_id, .. } => approval_id,
70 }
71 }
72
73 fn tool_call_id(&self) -> &str {
74 match self {
75 Self::Asked { tool_call_id, .. } | Self::Decided { tool_call_id, .. } => tool_call_id,
76 }
77 }
78
79 /// The tool the agent wanted to run — present on the ask half only.
80 pub(crate) fn tool_name(&self) -> Option<&str> {
81 match self {
82 Self::Asked { tool_name, .. } => Some(tool_name),
83 Self::Decided { .. } => None,
84 }
85 }
86
87 pub(crate) fn created_at(&self) -> DateTime<Utc> {
88 match self {
89 Self::Asked { created_at, .. } | Self::Decided { created_at, .. } => *created_at,
90 }
91 }
92 }
93
94 #[derive(Debug, Clone, PartialEq, Eq)]
95 pub(crate) struct CompletedApproval {
96 pub(crate) ask: ApprovalReceipt,
97 pub(crate) outcome: ApprovalOutcome,
98 pub(crate) decided_at: DateTime<Utc>,
99 }
100
101 #[derive(Debug, Clone, Default, PartialEq, Eq)]
102 pub(crate) struct ApprovalReplay {
103 pub(crate) completed: Vec<CompletedApproval>,
104 pub(crate) unmatched_asks: Vec<ApprovalReceipt>,
105 }
106
107 impl ApprovalReplay {
108 pub(crate) fn from_receipts(receipts: &[ApprovalReceipt]) -> Result<Self, String> {
109 let mut open = BTreeMap::<String, ApprovalReceipt>::new();
110 let mut closed = BTreeSet::<String>::new();
111 let mut completed = Vec::new();
112
113 for receipt in receipts {
114 let approval_id = receipt.approval_id();
115 if approval_id.trim().is_empty() || receipt.tool_call_id().trim().is_empty() {
116 return Err("approval receipt has an empty correlation id".to_string());
117 }
118 match receipt {
119 ApprovalReceipt::Asked {
120 approval_id,
121 tool_call_id,
122 tool_name,
123 ..
124 } => {
125 if tool_name.trim().is_empty() {
126 return Err(format!(
127 "approval ask '{approval_id}' has an empty tool name"
128 ));
129 }
130 if approval_id != tool_call_id {
131 return Err(format!(
132 "approval ask '{approval_id}' does not match tool call '{tool_call_id}'"
133 ));
134 }
135 if closed.contains(approval_id) || open.contains_key(approval_id) {
136 return Err(format!("approval '{approval_id}' was asked more than once"));
137 }
138 open.insert(approval_id.clone(), receipt.clone());
139 }
140 ApprovalReceipt::Decided {
141 approval_id,
142 tool_call_id,
143 outcome,
144 created_at,
145 ..
146 } => {
147 if approval_id != tool_call_id {
148 return Err(format!(
149 "approval decision '{approval_id}' does not match tool call '{tool_call_id}'"
150 ));
151 }
152 let Some(ask) = open.remove(approval_id) else {
153 return Err(format!(
154 "approval decision '{approval_id}' has no unmatched ask"
155 ));
156 };
157 closed.insert(approval_id.clone());
158 completed.push(CompletedApproval {
159 ask,
160 outcome: outcome.clone(),
161 decided_at: *created_at,
162 });
163 }
164 }
165 }
166
167 Ok(Self {
168 completed,
169 unmatched_asks: open.into_values().collect(),
170 })
171 }
172 }
173
174 #[derive(Debug, Clone)]
175 pub(crate) struct ApprovalReceiptStore {
176 sessions_dir: PathBuf,
177 }
178
179 impl ApprovalReceiptStore {
180 pub(crate) fn new(sessions_dir: PathBuf) -> Self {
181 Self { sessions_dir }
182 }
183
184 #[cfg_attr(test, allow(dead_code))]
185 pub(crate) fn default_location() -> io::Result<Self> {
186 crate::session_manager::default_sessions_dir().map(Self::new)
187 }
188
189 fn validated_session_id(session_id: &str) -> io::Result<&str> {
190 let trimmed = session_id.trim();
191 if trimmed.is_empty()
192 || !trimmed
193 .chars()
194 .all(|c| c.is_ascii_alphanumeric() || c == '-' || c == '_')
195 {
196 return Err(io::Error::new(
197 io::ErrorKind::InvalidInput,
198 format!("Invalid session id '{session_id}'"),
199 ));
200 }
201 Ok(trimmed)
202 }
203
204 pub(crate) fn log_path(&self, session_id: &str) -> io::Result<PathBuf> {
205 let session_id = Self::validated_session_id(session_id)?;
206 Ok(self.sessions_dir.join(session_id).join(APPROVAL_LOG_FILE))
207 }
208
209 fn lock_path(&self, session_id: &str) -> io::Result<PathBuf> {
210 let session_id = Self::validated_session_id(session_id)?;
211 Ok(self.sessions_dir.join(session_id).join(APPROVAL_LOCK_FILE))
212 }
213
214 fn open_lock_file(&self, session_id: &str) -> io::Result<File> {
215 let path = self.lock_path(session_id)?;
216 let parent = path.parent().ok_or_else(|| {
217 io::Error::new(io::ErrorKind::InvalidInput, "approval lock has no parent")
218 })?;
219 fs::create_dir_all(parent)?;
220 OpenOptions::new()
221 .create(true)
222 .truncate(false)
223 .read(true)
224 .write(true)
225 .open(path)
226 }
227
228 fn open_existing_lock_file(&self, session_id: &str) -> io::Result<Option<File>> {
229 let path = self.lock_path(session_id)?;
230 match OpenOptions::new().read(true).open(path) {
231 Ok(file) => Ok(Some(file)),
232 Err(err) if err.kind() == io::ErrorKind::NotFound => Ok(None),
233 Err(err) => Err(err),
234 }
235 }
236
237 fn load_unlocked(&self, session_id: &str) -> io::Result<Vec<ApprovalReceipt>> {
238 let path = self.log_path(session_id)?;
239 let file = match File::open(path) {
240 Ok(file) => file,
241 Err(err) if err.kind() == io::ErrorKind::NotFound => return Ok(Vec::new()),
242 Err(err) => return Err(err),
243 };
244 let mut receipts = Vec::new();
245 for (index, line) in BufReader::new(file).lines().enumerate() {
246 let line = line?;
247 let receipt = serde_json::from_str(&line).map_err(|err| {
248 io::Error::new(
249 io::ErrorKind::InvalidData,
250 format!("invalid approval receipt at line {}: {err}", index + 1),
251 )
252 })?;
253 receipts.push(receipt);
254 }
255 ApprovalReplay::from_receipts(&receipts)
256 .map_err(|err| io::Error::new(io::ErrorKind::InvalidData, err))?;
257 Ok(receipts)
258 }
259
260 pub(crate) fn load(&self, session_id: &str) -> io::Result<Vec<ApprovalReceipt>> {
261 if !self.log_path(session_id)?.exists() {
262 return Ok(Vec::new());
263 }
264 let Some(lock_file) = self.open_existing_lock_file(session_id)? else {
265 // Imported or legacy snapshots can contain a receipt log without
266 // its ephemeral lock file. Preserve read-only session loading;
267 // live writers always publish the lock before creating the log.
268 return self.load_unlocked(session_id);
269 };
270 let lock = fd_lock::RwLock::new(lock_file);
271 let _guard = lock.read()?;
272 self.load_unlocked(session_id)
273 }
274
275 pub(crate) fn replay(&self, session_id: &str) -> io::Result<ApprovalReplay> {
276 let receipts = self.load(session_id)?;
277 ApprovalReplay::from_receipts(&receipts)
278 .map_err(|err| io::Error::new(io::ErrorKind::InvalidData, err))
279 }
280
281 /// Session ids that have an approval log. Only entries whose name is a
282 /// valid session id are considered, so a stray file in the sessions dir
283 /// can never become a path traversal. A missing or unreadable sessions
284 /// dir is an empty history, not an error.
285 pub(crate) fn sessions_with_logs(&self) -> Vec<String> {
286 let entries = match fs::read_dir(&self.sessions_dir) {
287 Ok(entries) => entries,
288 Err(_) => return Vec::new(),
289 };
290 let mut ids = Vec::new();
291 for entry in entries.flatten() {
292 let name = entry.file_name().to_string_lossy().into_owned();
293 if Self::validated_session_id(&name).is_ok()
294 && entry.path().join(APPROVAL_LOG_FILE).exists()
295 {
296 ids.push(name);
297 }
298 }
299 ids
300 }
301
302 pub(crate) fn append(&self, session_id: &str, receipt: &ApprovalReceipt) -> io::Result<()> {
303 let lock_file = self.open_lock_file(session_id)?;
304 let mut lock = fd_lock::RwLock::new(lock_file);
305 let _guard = lock.write()?;
306 let path = self.log_path(session_id)?;
307 let mut candidate = self.load_unlocked(session_id)?;
308 candidate.push(receipt.clone());
309 ApprovalReplay::from_receipts(&candidate)
310 .map_err(|err| io::Error::new(io::ErrorKind::InvalidData, err))?;
311
312 let parent = path.parent().ok_or_else(|| {
313 io::Error::new(io::ErrorKind::InvalidInput, "approval log has no parent")
314 })?;
315 fs::create_dir_all(parent)?;
316 let mut line = serde_json::to_vec(receipt)
317 .map_err(|err| io::Error::new(io::ErrorKind::InvalidData, err))?;
318 line.push(b'\n');
319 let mut file = OpenOptions::new().create(true).append(true).open(&path)?;
320 file.write_all(&line)?;
321 file.sync_all()?;
322 if let Ok(dir) = File::open(parent) {
323 let _ = dir.sync_all();
324 }
325 Ok(())
326 }
327
328 #[cfg(test)]
329 pub(crate) fn sessions_dir(&self) -> &std::path::Path {
330 &self.sessions_dir
331 }
332 }
333
334 #[cfg(test)]
335 mod tests {
336 use super::*;
337
338 fn completed_receipts(outcome: ApprovalOutcome) -> Vec<ApprovalReceipt> {
339 vec![
340 ApprovalReceipt::asked("tool-1", "exec_shell"),
341 ApprovalReceipt::decided("tool-1", outcome),
342 ]
343 }
344
345 #[test]
346 fn replay_reconstructs_every_closed_outcome() {
347 let outcomes = [
348 ApprovalOutcome::ApprovedOnce,
349 ApprovalOutcome::Denied,
350 ApprovalOutcome::Cancelled,
351 ApprovalOutcome::Unavailable,
352 ApprovalOutcome::RetryWithPolicy {
353 policy: crate::sandbox::SandboxPolicy::DangerFullAccess,
354 },
355 ];
356
357 for outcome in outcomes {
358 let replay = ApprovalReplay::from_receipts(&completed_receipts(outcome.clone()))
359 .expect("closed approval log replays");
360 assert_eq!(replay.completed.len(), 1);
361 assert_eq!(replay.completed[0].outcome, outcome);
362 assert!(replay.unmatched_asks.is_empty());
363 }
364 }
365
366 #[test]
367 fn replay_detects_an_unmatched_ask_after_interruption() {
368 let ask = ApprovalReceipt::asked("tool-interrupted", "write_file");
369 let replay = ApprovalReplay::from_receipts(std::slice::from_ref(&ask))
370 .expect("an unmatched ask is valid crash evidence");
371
372 assert!(replay.completed.is_empty());
373 assert_eq!(replay.unmatched_asks, vec![ask]);
374 }
375
376 #[test]
377 fn replay_rejects_decisions_without_one_open_ask() {
378 let orphan = ApprovalReceipt::decided("tool-orphan", ApprovalOutcome::ApprovedOnce);
379 assert!(ApprovalReplay::from_receipts(&[orphan]).is_err());
380
381 let duplicate = vec![
382 ApprovalReceipt::asked("tool-duplicate", "exec_shell"),
383 ApprovalReceipt::decided("tool-duplicate", ApprovalOutcome::Denied),
384 ApprovalReceipt::decided("tool-duplicate", ApprovalOutcome::ApprovedOnce),
385 ];
386 assert!(ApprovalReplay::from_receipts(&duplicate).is_err());
387 }
388
389 #[test]
390 fn store_appends_and_replays_a_session_owned_log() {
391 let tmp = tempfile::tempdir().expect("tempdir");
392 let store = ApprovalReceiptStore::new(tmp.path().join("sessions"));
393 let ask = ApprovalReceipt::asked("tool-persisted", "edit_file");
394 let decision = ApprovalReceipt::decided("tool-persisted", ApprovalOutcome::ApprovedOnce);
395
396 store
397 .append("session-1", &ask)
398 .expect("persist approval ask");
399 store
400 .append("session-1", &decision)
401 .expect("persist approval decision");
402
403 let receipts = store.load("session-1").expect("load approval log");
404 assert_eq!(receipts, vec![ask, decision]);
405 let replay = ApprovalReplay::from_receipts(&receipts).expect("replay approval log");
406 assert_eq!(replay.completed.len(), 1);
407 assert!(replay.unmatched_asks.is_empty());
408 }
409
410 #[test]
411 fn loading_missing_or_imported_logs_is_read_only() {
412 let tmp = tempfile::tempdir().expect("tempdir");
413 let sessions_dir = tmp.path().join("sessions");
414 let store = ApprovalReceiptStore::new(sessions_dir.clone());
415
416 assert!(store.load("session-empty").expect("missing log").is_empty());
417 assert!(!sessions_dir.join("session-empty").exists());
418
419 let imported_dir = sessions_dir.join("session-imported");
420 fs::create_dir_all(&imported_dir).expect("imported session dir");
421 let ask = ApprovalReceipt::asked("tool-imported", "exec_shell");
422 let mut line = serde_json::to_vec(&ask).expect("serialize imported ask");
423 line.push(b'\n');
424 fs::write(imported_dir.join(APPROVAL_LOG_FILE), line).expect("imported log");
425
426 assert_eq!(
427 store.load("session-imported").expect("load imported log"),
428 vec![ask]
429 );
430 assert!(!imported_dir.join(APPROVAL_LOCK_FILE).exists());
431 }
432
433 #[test]
434 fn store_serializes_competing_writers() {
435 let tmp = tempfile::tempdir().expect("tempdir");
436 let store = ApprovalReceiptStore::new(tmp.path().join("sessions"));
437 let barrier = std::sync::Arc::new(std::sync::Barrier::new(8));
438 let mut writers = Vec::new();
439
440 for _ in 0..8 {
441 let store = store.clone();
442 let barrier = barrier.clone();
443 writers.push(std::thread::spawn(move || {
444 barrier.wait();
445 store.append(
446 "session-race",
447 &ApprovalReceipt::asked("tool-race", "exec_shell"),
448 )
449 }));
450 }
451
452 let results = writers
453 .into_iter()
454 .map(|writer| writer.join().expect("writer thread"))
455 .collect::<Vec<_>>();
456 assert_eq!(results.iter().filter(|result| result.is_ok()).count(), 1);
457 assert_eq!(results.iter().filter(|result| result.is_err()).count(), 7);
458 assert_eq!(store.load("session-race").expect("load receipts").len(), 1);
459 }
460 }
461
461 lines RUST