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 "apply_patch" => {
202 let patch = string_arg(args, "patch")?;
203 let hunks = crate::apply_patch::parse_patch(patch)
204 .map_err(|e| anyhow::anyhow!("apply_patch replay: {e}"))?;
205 for hunk in &hunks {
206 replay_apply_hunk(&workdir, hunk)?;
207 }
208 Ok(format!("replayed apply_patch ({} hunk(s))", hunks.len()))
209 },
210 "delete_file" => {
211 let path = string_arg(args, "path")?;
212 let rel = crate::pathguard::relative_within(&workdir, path)?;
213 crate::pathguard::remove_file_beneath(&workdir, &rel)?;
214 Ok(format!(
215 "replayed delete_file {}",
216 workdir.join(&rel).display()
217 ))
218 },
219 "create_directory" => {
220 let path = string_arg(args, "path")?;
221 let rel = crate::pathguard::relative_within(&workdir, path)?;
222 crate::pathguard::create_dir_all_beneath(&workdir, &rel)?;
223 Ok(format!(
224 "replayed create_directory {}",
225 workdir.join(&rel).display()
226 ))
227 },
228 other => anyhow::bail!("approval replay does not support tool `{other}`"),
229 }
230}
231
232fn is_secret_env_name(name: &str) -> bool {
237 let upper = name.to_ascii_uppercase();
238 upper.contains("API_KEY")
239 || upper.contains("APIKEY")
240 || upper.contains("ACCESS_KEY")
241 || upper.contains("PRIVATE_KEY")
242 || upper.contains("SECRET")
243 || upper.contains("PASSWORD")
244 || upper.contains("PASSWD")
245 || upper.contains("TOKEN")
246 || upper.contains("CREDENTIAL")
247}
248
249fn scrub_secret_env(cmd: &mut Command) {
253 for (name, _) in std::env::vars() {
254 if is_secret_env_name(&name) {
255 cmd.env_remove(&name);
256 }
257 }
258}
259
260fn replay_execute_command(args: &serde_json::Value, workdir: &Path) -> Result<String> {
261 let command = string_arg(args, "command")?;
262 anyhow::ensure!(
268 !crate::policy::is_destructive_command(command),
269 "approval replay refused: the stored command is classified destructive \
270 (possible tampered approval record)"
271 );
272 let effective_dir = match args.get("working_dir").and_then(|value| value.as_str()) {
278 Some(dir) => crate::pathguard::contain_within_canonical(workdir, dir)?,
279 None => workdir.to_path_buf(),
280 };
281 let mode = args
282 .get("mode")
283 .and_then(|value| value.as_str())
284 .unwrap_or("wait");
285 let mut cmd = Command::new("sh");
286 cmd.arg("-c").arg(command).current_dir(&effective_dir);
287 scrub_secret_env(&mut cmd);
288 if mode == "background" {
289 #[cfg(unix)]
292 {
293 use std::os::unix::process::CommandExt;
294 cmd.process_group(0);
295 }
296 let child = cmd
297 .spawn()
298 .with_context(|| format!("failed to replay background command `{command}`"))?;
299 return Ok(format!(
300 "replayed execute_command in background with pid {}",
301 child.id()
302 ));
303 }
304 let output = cmd
305 .output()
306 .with_context(|| format!("failed to replay command `{command}`"))?;
307 anyhow::ensure!(
308 output.status.success(),
309 "replayed command failed with {}: {}",
310 output.status,
311 String::from_utf8_lossy(&output.stderr)
312 );
313 Ok(format!(
314 "replayed execute_command successfully ({} stdout bytes)",
315 output.stdout.len()
316 ))
317}
318
319fn replay_apply_hunk(root: &Path, hunk: &crate::apply_patch::Hunk) -> Result<()> {
323 use crate::apply_patch::Hunk;
324 match hunk {
325 Hunk::AddFile { path, contents } => {
326 let rel = crate::pathguard::relative_within(root, &path.to_string_lossy())?;
327 replay_ensure_parent(root, &rel)?;
328 crate::pathguard::write_atomic_beneath(root, &rel, contents.as_bytes())?;
329 },
330 Hunk::DeleteFile { path } => {
331 let rel = crate::pathguard::relative_within(root, &path.to_string_lossy())?;
332 crate::pathguard::remove_file_beneath(root, &rel)?;
333 },
334 Hunk::UpdateFile {
335 path,
336 move_path,
337 chunks,
338 } => {
339 let src_rel = crate::pathguard::relative_within(root, &path.to_string_lossy())?;
340 let original = replay_read_beneath(root, &src_rel)?;
341 let applied = crate::apply_patch::derive_new_contents(&original, chunks)
342 .map_err(|e| anyhow::anyhow!("apply_patch replay for {}: {e}", path.display()))?;
343 let dst_rel = match move_path {
344 Some(mv) => crate::pathguard::relative_within(root, &mv.to_string_lossy())?,
345 None => src_rel.clone(),
346 };
347 replay_ensure_parent(root, &dst_rel)?;
348 crate::pathguard::write_atomic_beneath(
349 root,
350 &dst_rel,
351 applied.new_contents.as_bytes(),
352 )?;
353 if src_rel != dst_rel {
354 crate::pathguard::remove_file_beneath(root, &src_rel)?;
355 }
356 },
357 }
358 Ok(())
359}
360
361fn replay_ensure_parent(root: &Path, rel: &Path) -> Result<()> {
362 if let Some(parent) = rel.parent()
363 && !parent.as_os_str().is_empty()
364 {
365 crate::pathguard::create_dir_all_beneath(root, parent)?;
366 }
367 Ok(())
368}
369
370fn replay_read_beneath(root: &Path, rel: &Path) -> Result<String> {
371 use std::io::Read;
372 let mut file = crate::pathguard::open_beneath(root, rel, crate::pathguard::OpenIntent::Read)?;
373 let mut s = String::new();
374 file.read_to_string(&mut s)?;
375 Ok(s)
376}
377
378fn string_arg<'a>(args: &'a serde_json::Value, name: &str) -> Result<&'a str> {
379 args.get(name)
380 .and_then(|value| value.as_str())
381 .with_context(|| format!("pending action missing string arg `{name}`"))
382}
383
384#[cfg(test)]
385mod tests {
386 use super::*;
387
388 #[test]
389 fn replay_path_rejects_parent_escape() {
390 let root = std::env::temp_dir().join("mermaid_replay_root");
391 assert!(crate::pathguard::relative_within(&root, "../escape").is_err());
392 }
393
394 #[cfg(target_os = "linux")]
399 #[test]
400 fn replay_write_refuses_escaping_symlink() {
401 let root =
402 std::env::temp_dir().join(format!("mermaid_replay_symlink_{}", std::process::id()));
403 let outside =
404 std::env::temp_dir().join(format!("mermaid_replay_outside_{}", std::process::id()));
405 let _ = std::fs::remove_dir_all(&root);
406 let _ = std::fs::remove_dir_all(&outside);
407 std::fs::create_dir_all(&root).unwrap();
408 std::fs::create_dir_all(&outside).unwrap();
409 std::os::unix::fs::symlink(&outside, root.join("escape")).unwrap();
411
412 let action = serde_json::json!({
413 "tool": "write_file",
414 "workdir": root,
415 "args": {"path": "escape/evil.txt", "content": "pwned"}
416 });
417 assert!(
418 replay_pending_action(&action).is_err(),
419 "a write through an escaping symlink must be refused"
420 );
421 assert!(
422 !outside.join("evil.txt").exists(),
423 "nothing may be written outside the root"
424 );
425 let _ = std::fs::remove_dir_all(&root);
426 let _ = std::fs::remove_dir_all(&outside);
427 }
428
429 #[test]
432 fn replay_execute_command_refuses_destructive() {
433 let root =
434 std::env::temp_dir().join(format!("mermaid_replay_destr_{}", std::process::id()));
435 let _ = std::fs::remove_dir_all(&root);
436 std::fs::create_dir_all(&root).unwrap();
437 let action = serde_json::json!({
438 "tool": "execute_command",
439 "workdir": root,
440 "args": {"command": "rm -rf /"}
441 });
442 assert!(
443 replay_pending_action(&action).is_err(),
444 "a destructive stored command must be refused on replay"
445 );
446 let _ = std::fs::remove_dir_all(&root);
447 }
448
449 #[test]
450 fn replay_execute_command_rejects_escaping_working_dir() {
451 let root = std::env::temp_dir().join(format!("mermaid_replay_exec_{}", std::process::id()));
452 let action = serde_json::json!({
453 "tool": "execute_command",
454 "workdir": root,
455 "args": {"command": "true", "working_dir": "../escape"}
456 });
457 assert!(
459 replay_pending_action(&action).is_err(),
460 "an escaping working_dir must be rejected before exec"
461 );
462 }
463
464 #[test]
465 fn replay_write_file_creates_parent() {
466 let root =
467 std::env::temp_dir().join(format!("mermaid_replay_write_{}", std::process::id()));
468 let _ = std::fs::remove_dir_all(&root);
469 std::fs::create_dir_all(&root).unwrap();
470 let action = serde_json::json!({
471 "tool": "write_file",
472 "workdir": root,
473 "args": {"path": "a/b.txt", "content": "ok"}
474 });
475 let summary = replay_pending_action(&action).unwrap();
476 assert!(summary.contains("write_file"));
477 }
478
479 fn temp_store(name: &str) -> RuntimeStore {
480 let dir = std::env::temp_dir().join(format!("mermaid_approval_{name}"));
481 let _ = std::fs::remove_dir_all(&dir);
482 std::fs::create_dir_all(&dir).expect("create temp dir");
483 let path = dir.join("runtime.sqlite3");
484 RuntimeStore::open(&path).expect("open store")
485 }
486
487 fn pending_approval(store: &RuntimeStore, action: &serde_json::Value) -> String {
488 store
489 .approvals()
490 .create(crate::NewApproval {
491 task_id: None,
492 proposed_action: "test".to_string(),
493 risk_classification: "low".to_string(),
494 policy_decision: "ask".to_string(),
495 args_summary: None,
496 checkpoint_id: None,
497 pending_action_json: Some(action.to_string()),
498 })
499 .expect("create approval")
500 .id
501 }
502
503 fn temp_workdir(name: &str) -> PathBuf {
504 let root =
505 std::env::temp_dir().join(format!("mermaid_approve_{name}_{}", std::process::id()));
506 let _ = std::fs::remove_dir_all(&root);
507 std::fs::create_dir_all(&root).unwrap();
508 root
509 }
510
511 #[test]
512 fn approve_replay_marks_approved_only_after_effect() {
513 let store = temp_store("ok");
514 let root = temp_workdir("ok");
515 let action = serde_json::json!({
516 "tool": "write_file",
517 "workdir": root,
518 "args": {"path": "out.txt", "content": "hello"}
519 });
520 let id = pending_approval(&store, &action);
521
522 let result = approve_and_replay_with(&store, &id).expect("replay should succeed");
523 assert!(result.replayed);
524 assert!(
525 root.join("out.txt").exists(),
526 "the file effect must have run"
527 );
528 let decided = store.approvals().get(&id).unwrap().unwrap();
529 assert_eq!(decided.user_decision.as_deref(), Some("approved"));
530 }
531
532 #[test]
533 fn failed_replay_leaves_approval_pending() {
534 let store = temp_store("fail");
535 let root = temp_workdir("fail");
536 let action = serde_json::json!({
540 "tool": "delete_file",
541 "workdir": root,
542 "args": {"path": "nope.txt"}
543 });
544 let id = pending_approval(&store, &action);
545
546 assert!(approve_and_replay_with(&store, &id).is_err());
547 let still = store.approvals().get(&id).unwrap().unwrap();
548 assert!(
549 still.user_decision.is_none(),
550 "a failed replay must not mark the approval approved"
551 );
552 }
553
554 #[test]
555 fn second_approve_is_single_shot() {
556 let store = temp_store("twice");
557 let root = temp_workdir("twice");
558 let action = serde_json::json!({
559 "tool": "create_directory",
560 "workdir": root,
561 "args": {"path": "sub"}
562 });
563 let id = pending_approval(&store, &action);
564
565 approve_and_replay_with(&store, &id).expect("first approve succeeds");
566 assert!(
567 approve_and_replay_with(&store, &id).is_err(),
568 "a second approve must be rejected (already decided)"
569 );
570 let decided = store.approvals().get(&id).unwrap().unwrap();
571 assert_eq!(decided.user_decision.as_deref(), Some("approved"));
572 }
573}