1use std::path::{Path, PathBuf};
2use std::process::Command;
3
4use anyhow::{Context, Result};
5use serde::{Deserialize, Serialize};
6
7use crate::{ApprovalRecord, RuntimeStore};
8
9#[derive(Debug, Clone, Serialize, Deserialize)]
10pub struct ApprovalReplayResult {
11 pub approval: Option<ApprovalRecord>,
12 pub replayed: bool,
13 pub summary: String,
14}
15
16pub fn approve_and_replay(id: &str) -> Result<ApprovalReplayResult> {
17 let store = RuntimeStore::open_default()?;
18 approve_and_replay_with(&store, id)
19}
20
21pub(crate) fn approve_and_replay_with(
31 store: &RuntimeStore,
32 id: &str,
33) -> Result<ApprovalReplayResult> {
34 let approval = store
37 .approvals()
38 .get(id)?
39 .with_context(|| format!("approval not found: {id}"))?;
40 anyhow::ensure!(
41 approval.user_decision.is_none() && approval.archived_at.is_none(),
42 "approval {id} cannot be replayed (already decided or archived)"
43 );
44
45 anyhow::ensure!(
50 store.approvals().claim(id)?,
51 "approval {id} is already being applied by a concurrent approve"
52 );
53 match replay_after_claim(store, id, &approval) {
54 Ok(result) => Ok(result),
55 Err(error) => {
56 let _ = store.approvals().release_claim(id);
57 Err(error)
58 },
59 }
60}
61
62fn replay_after_claim(
65 store: &RuntimeStore,
66 id: &str,
67 approval: &ApprovalRecord,
68) -> Result<ApprovalReplayResult> {
69 let Some(raw_action) = approval.pending_action_json.as_deref() else {
70 store.approvals().finalize_claimed(id, "approved")?;
72 let approval = store.approvals().get(id)?;
73 return Ok(ApprovalReplayResult {
74 approval,
75 replayed: false,
76 summary: "approval recorded; no pending action was stored".to_string(),
77 });
78 };
79
80 let action: serde_json::Value = serde_json::from_str(raw_action)
81 .with_context(|| format!("approval {id} pending action was not valid JSON"))?;
82 let summary = replay_pending_action(&action)?;
84 store.approvals().finalize_claimed(id, "approved")?;
86 let approval = store
87 .approvals()
88 .get(id)?
89 .with_context(|| format!("approval not found after approval: {id}"))?;
90
91 let _ = crate::run_plugin_hooks(
93 "approval_decided",
94 &serde_json::json!({
95 "id": approval.id.clone(),
96 "decision": "approved",
97 "task_id": approval.task_id.clone(),
98 "replayed": true,
99 "summary": summary.clone(),
100 }),
101 );
102 if let Some(task_id) = approval.task_id.as_deref() {
103 let _ = store
104 .tasks()
105 .add_event(task_id, "approval_replayed", &summary);
106 }
107 if let Some(tool) = action.get("tool").and_then(|value| value.as_str()) {
108 let replay_run = store.tool_runs().start(crate::NewToolRun {
109 id: None,
110 task_id: approval.task_id.clone(),
111 turn_id: action
112 .get("turn_id")
113 .and_then(|value| value.as_i64())
114 .map(|value| value.to_string()),
115 call_id: action
116 .get("call_id")
117 .and_then(|value| value.as_i64())
118 .map(|value| value.to_string()),
119 tool_name: format!("approval_replay:{tool}"),
120 args_json: Some(raw_action.to_string()),
121 });
122 if let Ok(run) = replay_run {
123 let _ = store.tool_runs().finish(
124 &run.id,
125 "success",
126 Some(&serde_json::json!({"summary": summary}).to_string()),
127 );
128 }
129 }
130 Ok(ApprovalReplayResult {
131 approval: Some(approval),
132 replayed: true,
133 summary,
134 })
135}
136
137pub fn deny_approval(id: &str) -> Result<ApprovalReplayResult> {
138 let store = RuntimeStore::open_default()?;
139 store.approvals().decide(id, "denied")?;
140 let approval = store.approvals().get(id)?;
141 let _ = crate::run_plugin_hooks(
142 "approval_decided",
143 &serde_json::json!({
144 "id": id,
145 "decision": "denied",
146 "task_id": approval.as_ref().and_then(|record| record.task_id.clone()),
147 "replayed": false,
148 }),
149 );
150 if let Some(task_id) = approval
151 .as_ref()
152 .and_then(|record| record.task_id.as_deref())
153 {
154 let _ =
155 store
156 .tasks()
157 .add_event(task_id, "approval_denied", &format!("approval {id} denied"));
158 }
159 Ok(ApprovalReplayResult {
160 approval,
161 replayed: false,
162 summary: "approval denied".to_string(),
163 })
164}
165
166fn replay_pending_action(action: &serde_json::Value) -> Result<String> {
167 let tool = action
168 .get("tool")
169 .and_then(|value| value.as_str())
170 .context("pending action missing string `tool`")?;
171 let workdir = action
172 .get("workdir")
173 .and_then(|value| value.as_str())
174 .map(PathBuf::from)
175 .unwrap_or(std::env::current_dir()?);
176 let args = action.get("args").unwrap_or(action);
177
178 match tool {
185 "execute_command" => replay_execute_command(args, &workdir),
186 "write_file" => {
187 let path = string_arg(args, "path")?;
188 let content = string_arg(args, "content")?;
189 let rel = crate::pathguard::relative_within(&workdir, path)?;
190 if let Some(parent) = rel.parent()
191 && !parent.as_os_str().is_empty()
192 {
193 crate::pathguard::create_dir_all_beneath(&workdir, parent)?;
194 }
195 crate::pathguard::write_atomic_beneath(&workdir, &rel, content.as_bytes())?;
196 Ok(format!(
197 "replayed write_file {}",
198 workdir.join(&rel).display()
199 ))
200 },
201 "edit_file" => {
202 let path = string_arg(args, "path")?;
203 let old = string_arg(args, "old_string")?;
204 let new = string_arg(args, "new_string")?;
205 let rel = crate::pathguard::relative_within(&workdir, path)?;
206 replay_edit(&workdir, &rel, old, new)?;
207 Ok(format!(
208 "replayed edit_file {}",
209 workdir.join(&rel).display()
210 ))
211 },
212 "delete_file" => {
213 let path = string_arg(args, "path")?;
214 let rel = crate::pathguard::relative_within(&workdir, path)?;
215 crate::pathguard::remove_file_beneath(&workdir, &rel)?;
216 Ok(format!(
217 "replayed delete_file {}",
218 workdir.join(&rel).display()
219 ))
220 },
221 "create_directory" => {
222 let path = string_arg(args, "path")?;
223 let rel = crate::pathguard::relative_within(&workdir, path)?;
224 crate::pathguard::create_dir_all_beneath(&workdir, &rel)?;
225 Ok(format!(
226 "replayed create_directory {}",
227 workdir.join(&rel).display()
228 ))
229 },
230 other => anyhow::bail!("approval replay does not support tool `{other}`"),
231 }
232}
233
234fn is_secret_env_name(name: &str) -> bool {
239 let upper = name.to_ascii_uppercase();
240 upper.contains("API_KEY")
241 || upper.contains("APIKEY")
242 || upper.contains("ACCESS_KEY")
243 || upper.contains("PRIVATE_KEY")
244 || upper.contains("SECRET")
245 || upper.contains("PASSWORD")
246 || upper.contains("PASSWD")
247 || upper.contains("TOKEN")
248 || upper.contains("CREDENTIAL")
249}
250
251fn scrub_secret_env(cmd: &mut Command) {
255 for (name, _) in std::env::vars() {
256 if is_secret_env_name(&name) {
257 cmd.env_remove(&name);
258 }
259 }
260}
261
262fn replay_execute_command(args: &serde_json::Value, workdir: &Path) -> Result<String> {
263 let command = string_arg(args, "command")?;
264 anyhow::ensure!(
270 !crate::policy::is_destructive_command(command),
271 "approval replay refused: the stored command is classified destructive \
272 (possible tampered approval record)"
273 );
274 let effective_dir = match args.get("working_dir").and_then(|value| value.as_str()) {
280 Some(dir) => crate::pathguard::contain_within_canonical(workdir, dir)?,
281 None => workdir.to_path_buf(),
282 };
283 let mode = args
284 .get("mode")
285 .and_then(|value| value.as_str())
286 .unwrap_or("wait");
287 let mut cmd = Command::new("sh");
288 cmd.arg("-c").arg(command).current_dir(&effective_dir);
289 scrub_secret_env(&mut cmd);
290 if mode == "background" {
291 #[cfg(unix)]
294 {
295 use std::os::unix::process::CommandExt;
296 cmd.process_group(0);
297 }
298 let child = cmd
299 .spawn()
300 .with_context(|| format!("failed to replay background command `{command}`"))?;
301 return Ok(format!(
302 "replayed execute_command in background with pid {}",
303 child.id()
304 ));
305 }
306 let output = cmd
307 .output()
308 .with_context(|| format!("failed to replay command `{command}`"))?;
309 anyhow::ensure!(
310 output.status.success(),
311 "replayed command failed with {}: {}",
312 output.status,
313 String::from_utf8_lossy(&output.stderr)
314 );
315 Ok(format!(
316 "replayed execute_command successfully ({} stdout bytes)",
317 output.stdout.len()
318 ))
319}
320
321fn replay_edit(root: &Path, rel: &Path, old_string: &str, new_string: &str) -> Result<()> {
322 let mut current = String::new();
326 {
327 use std::io::Read;
328 let mut file =
329 crate::pathguard::open_beneath(root, rel, crate::pathguard::OpenIntent::Read)?;
330 file.read_to_string(&mut current)?;
331 }
332 let count = current.matches(old_string).count();
333 anyhow::ensure!(count > 0, "old_string not found during approval replay");
334 anyhow::ensure!(
335 count == 1,
336 "old_string appears {count} times during approval replay"
337 );
338 let updated = current.replacen(old_string, new_string, 1);
339 crate::pathguard::write_atomic_beneath(root, rel, updated.as_bytes())?;
340 Ok(())
341}
342
343fn string_arg<'a>(args: &'a serde_json::Value, name: &str) -> Result<&'a str> {
344 args.get(name)
345 .and_then(|value| value.as_str())
346 .with_context(|| format!("pending action missing string arg `{name}`"))
347}
348
349#[cfg(test)]
350mod tests {
351 use super::*;
352
353 #[test]
354 fn replay_path_rejects_parent_escape() {
355 let root = std::env::temp_dir().join("mermaid_replay_root");
356 assert!(crate::pathguard::relative_within(&root, "../escape").is_err());
357 }
358
359 #[cfg(target_os = "linux")]
364 #[test]
365 fn replay_write_refuses_escaping_symlink() {
366 let root =
367 std::env::temp_dir().join(format!("mermaid_replay_symlink_{}", std::process::id()));
368 let outside =
369 std::env::temp_dir().join(format!("mermaid_replay_outside_{}", std::process::id()));
370 let _ = std::fs::remove_dir_all(&root);
371 let _ = std::fs::remove_dir_all(&outside);
372 std::fs::create_dir_all(&root).unwrap();
373 std::fs::create_dir_all(&outside).unwrap();
374 std::os::unix::fs::symlink(&outside, root.join("escape")).unwrap();
376
377 let action = serde_json::json!({
378 "tool": "write_file",
379 "workdir": root,
380 "args": {"path": "escape/evil.txt", "content": "pwned"}
381 });
382 assert!(
383 replay_pending_action(&action).is_err(),
384 "a write through an escaping symlink must be refused"
385 );
386 assert!(
387 !outside.join("evil.txt").exists(),
388 "nothing may be written outside the root"
389 );
390 let _ = std::fs::remove_dir_all(&root);
391 let _ = std::fs::remove_dir_all(&outside);
392 }
393
394 #[test]
397 fn replay_execute_command_refuses_destructive() {
398 let root =
399 std::env::temp_dir().join(format!("mermaid_replay_destr_{}", std::process::id()));
400 let _ = std::fs::remove_dir_all(&root);
401 std::fs::create_dir_all(&root).unwrap();
402 let action = serde_json::json!({
403 "tool": "execute_command",
404 "workdir": root,
405 "args": {"command": "rm -rf /"}
406 });
407 assert!(
408 replay_pending_action(&action).is_err(),
409 "a destructive stored command must be refused on replay"
410 );
411 let _ = std::fs::remove_dir_all(&root);
412 }
413
414 #[test]
415 fn replay_execute_command_rejects_escaping_working_dir() {
416 let root = std::env::temp_dir().join(format!("mermaid_replay_exec_{}", std::process::id()));
417 let action = serde_json::json!({
418 "tool": "execute_command",
419 "workdir": root,
420 "args": {"command": "true", "working_dir": "../escape"}
421 });
422 assert!(
424 replay_pending_action(&action).is_err(),
425 "an escaping working_dir must be rejected before exec"
426 );
427 }
428
429 #[test]
430 fn replay_write_file_creates_parent() {
431 let root =
432 std::env::temp_dir().join(format!("mermaid_replay_write_{}", std::process::id()));
433 let _ = std::fs::remove_dir_all(&root);
434 std::fs::create_dir_all(&root).unwrap();
435 let action = serde_json::json!({
436 "tool": "write_file",
437 "workdir": root,
438 "args": {"path": "a/b.txt", "content": "ok"}
439 });
440 let summary = replay_pending_action(&action).unwrap();
441 assert!(summary.contains("write_file"));
442 }
443
444 fn temp_store(name: &str) -> RuntimeStore {
445 let dir = std::env::temp_dir().join(format!("mermaid_approval_{name}"));
446 let _ = std::fs::remove_dir_all(&dir);
447 std::fs::create_dir_all(&dir).expect("create temp dir");
448 let path = dir.join("runtime.sqlite3");
449 RuntimeStore::open(&path).expect("open store")
450 }
451
452 fn pending_approval(store: &RuntimeStore, action: &serde_json::Value) -> String {
453 store
454 .approvals()
455 .create(crate::NewApproval {
456 task_id: None,
457 proposed_action: "test".to_string(),
458 risk_classification: "low".to_string(),
459 policy_decision: "ask".to_string(),
460 args_summary: None,
461 checkpoint_id: None,
462 pending_action_json: Some(action.to_string()),
463 })
464 .expect("create approval")
465 .id
466 }
467
468 fn temp_workdir(name: &str) -> PathBuf {
469 let root =
470 std::env::temp_dir().join(format!("mermaid_approve_{name}_{}", std::process::id()));
471 let _ = std::fs::remove_dir_all(&root);
472 std::fs::create_dir_all(&root).unwrap();
473 root
474 }
475
476 #[test]
477 fn approve_replay_marks_approved_only_after_effect() {
478 let store = temp_store("ok");
479 let root = temp_workdir("ok");
480 let action = serde_json::json!({
481 "tool": "write_file",
482 "workdir": root,
483 "args": {"path": "out.txt", "content": "hello"}
484 });
485 let id = pending_approval(&store, &action);
486
487 let result = approve_and_replay_with(&store, &id).expect("replay should succeed");
488 assert!(result.replayed);
489 assert!(
490 root.join("out.txt").exists(),
491 "the file effect must have run"
492 );
493 let decided = store.approvals().get(&id).unwrap().unwrap();
494 assert_eq!(decided.user_decision.as_deref(), Some("approved"));
495 }
496
497 #[test]
498 fn failed_replay_leaves_approval_pending() {
499 let store = temp_store("fail");
500 let root = temp_workdir("fail");
501 let action = serde_json::json!({
505 "tool": "delete_file",
506 "workdir": root,
507 "args": {"path": "nope.txt"}
508 });
509 let id = pending_approval(&store, &action);
510
511 assert!(approve_and_replay_with(&store, &id).is_err());
512 let still = store.approvals().get(&id).unwrap().unwrap();
513 assert!(
514 still.user_decision.is_none(),
515 "a failed replay must not mark the approval approved"
516 );
517 }
518
519 #[test]
520 fn second_approve_is_single_shot() {
521 let store = temp_store("twice");
522 let root = temp_workdir("twice");
523 let action = serde_json::json!({
524 "tool": "create_directory",
525 "workdir": root,
526 "args": {"path": "sub"}
527 });
528 let id = pending_approval(&store, &action);
529
530 approve_and_replay_with(&store, &id).expect("first approve succeeds");
531 assert!(
532 approve_and_replay_with(&store, &id).is_err(),
533 "a second approve must be rejected (already decided)"
534 );
535 let decided = store.approvals().get(&id).unwrap().unwrap();
536 assert_eq!(decided.user_decision.as_deref(), Some("approved"));
537 }
538}