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 store.approvals().decide(id, "approved")?;
19 let approval = store
20 .approvals()
21 .get(id)?
22 .with_context(|| format!("approval not found after approval: {id}"))?;
23
24 let Some(raw_action) = approval.pending_action_json.as_deref() else {
25 return Ok(ApprovalReplayResult {
26 approval: Some(approval),
27 replayed: false,
28 summary: "approval recorded; no pending action was stored".to_string(),
29 });
30 };
31
32 let action: serde_json::Value = serde_json::from_str(raw_action)
33 .with_context(|| format!("approval {id} pending action was not valid JSON"))?;
34 let summary = replay_pending_action(&action)?;
35 let _ = crate::run_plugin_hooks(
36 "approval_decided",
37 &serde_json::json!({
38 "id": approval.id.clone(),
39 "decision": "approved",
40 "task_id": approval.task_id.clone(),
41 "replayed": true,
42 "summary": summary.clone(),
43 }),
44 );
45 if let Some(task_id) = approval.task_id.as_deref() {
46 let _ = store
47 .tasks()
48 .add_event(task_id, "approval_replayed", &summary);
49 }
50 if let Some(tool) = action.get("tool").and_then(|value| value.as_str()) {
51 let replay_run = store.tool_runs().start(crate::NewToolRun {
52 id: None,
53 task_id: approval.task_id.clone(),
54 turn_id: action
55 .get("turn_id")
56 .and_then(|value| value.as_i64())
57 .map(|value| value.to_string()),
58 call_id: action
59 .get("call_id")
60 .and_then(|value| value.as_i64())
61 .map(|value| value.to_string()),
62 tool_name: format!("approval_replay:{tool}"),
63 args_json: Some(raw_action.to_string()),
64 });
65 if let Ok(run) = replay_run {
66 let _ = store.tool_runs().finish(
67 &run.id,
68 "success",
69 Some(&serde_json::json!({"summary": summary}).to_string()),
70 );
71 }
72 }
73 Ok(ApprovalReplayResult {
74 approval: Some(approval),
75 replayed: true,
76 summary,
77 })
78}
79
80pub fn deny_approval(id: &str) -> Result<ApprovalReplayResult> {
81 let store = RuntimeStore::open_default()?;
82 store.approvals().decide(id, "denied")?;
83 let approval = store.approvals().get(id)?;
84 let _ = crate::run_plugin_hooks(
85 "approval_decided",
86 &serde_json::json!({
87 "id": id,
88 "decision": "denied",
89 "task_id": approval.as_ref().and_then(|record| record.task_id.clone()),
90 "replayed": false,
91 }),
92 );
93 if let Some(task_id) = approval
94 .as_ref()
95 .and_then(|record| record.task_id.as_deref())
96 {
97 let _ =
98 store
99 .tasks()
100 .add_event(task_id, "approval_denied", &format!("approval {id} denied"));
101 }
102 Ok(ApprovalReplayResult {
103 approval,
104 replayed: false,
105 summary: "approval denied".to_string(),
106 })
107}
108
109fn replay_pending_action(action: &serde_json::Value) -> Result<String> {
110 let tool = action
111 .get("tool")
112 .and_then(|value| value.as_str())
113 .context("pending action missing string `tool`")?;
114 let workdir = action
115 .get("workdir")
116 .and_then(|value| value.as_str())
117 .map(PathBuf::from)
118 .unwrap_or(std::env::current_dir()?);
119 let args = action.get("args").unwrap_or(action);
120
121 match tool {
122 "execute_command" => replay_execute_command(args, &workdir),
123 "write_file" => {
124 let path = string_arg(args, "path")?;
125 let content = string_arg(args, "content")?;
126 let target = resolve_replay_path(&workdir, path)?;
127 if let Some(parent) = target.parent() {
128 std::fs::create_dir_all(parent)?;
129 }
130 std::fs::write(&target, content)?;
131 Ok(format!("replayed write_file {}", target.display()))
132 },
133 "edit_file" => {
134 let path = string_arg(args, "path")?;
135 let old = string_arg(args, "old_string")?;
136 let new = string_arg(args, "new_string")?;
137 let target = resolve_replay_path(&workdir, path)?;
138 replay_edit(&target, old, new)?;
139 Ok(format!("replayed edit_file {}", target.display()))
140 },
141 "delete_file" => {
142 let path = string_arg(args, "path")?;
143 let target = resolve_replay_path(&workdir, path)?;
144 std::fs::remove_file(&target)?;
145 Ok(format!("replayed delete_file {}", target.display()))
146 },
147 "create_directory" => {
148 let path = string_arg(args, "path")?;
149 let target = resolve_replay_path(&workdir, path)?;
150 std::fs::create_dir_all(&target)?;
151 Ok(format!("replayed create_directory {}", target.display()))
152 },
153 other => anyhow::bail!("approval replay does not support tool `{other}`"),
154 }
155}
156
157fn replay_execute_command(args: &serde_json::Value, workdir: &Path) -> Result<String> {
158 let command = string_arg(args, "command")?;
159 let effective_dir = args
160 .get("working_dir")
161 .and_then(|value| value.as_str())
162 .map(PathBuf::from)
163 .unwrap_or_else(|| workdir.to_path_buf());
164 let mode = args
165 .get("mode")
166 .and_then(|value| value.as_str())
167 .unwrap_or("wait");
168 if mode == "background" {
169 let child = Command::new("sh")
170 .arg("-c")
171 .arg(command)
172 .current_dir(&effective_dir)
173 .spawn()
174 .with_context(|| format!("failed to replay background command `{command}`"))?;
175 return Ok(format!(
176 "replayed execute_command in background with pid {}",
177 child.id()
178 ));
179 }
180 let output = Command::new("sh")
181 .arg("-c")
182 .arg(command)
183 .current_dir(&effective_dir)
184 .output()
185 .with_context(|| format!("failed to replay command `{command}`"))?;
186 anyhow::ensure!(
187 output.status.success(),
188 "replayed command failed with {}: {}",
189 output.status,
190 String::from_utf8_lossy(&output.stderr)
191 );
192 Ok(format!(
193 "replayed execute_command successfully ({} stdout bytes)",
194 output.stdout.len()
195 ))
196}
197
198fn replay_edit(path: &Path, old_string: &str, new_string: &str) -> Result<()> {
199 let current = std::fs::read_to_string(path)?;
200 let count = current.matches(old_string).count();
201 anyhow::ensure!(count > 0, "old_string not found during approval replay");
202 anyhow::ensure!(
203 count == 1,
204 "old_string appears {count} times during approval replay"
205 );
206 std::fs::write(path, current.replacen(old_string, new_string, 1))?;
207 Ok(())
208}
209
210fn resolve_replay_path(workdir: &Path, path: &str) -> Result<PathBuf> {
211 let candidate = if Path::new(path).is_absolute() {
212 PathBuf::from(path)
213 } else {
214 workdir.join(path)
215 };
216 let lexical = normalize_lexical(&candidate);
217 let root = normalize_lexical(workdir);
218 anyhow::ensure!(
219 lexical.starts_with(&root),
220 "approval replay path escapes workdir: {}",
221 path
222 );
223 Ok(lexical)
224}
225
226fn normalize_lexical(path: &Path) -> PathBuf {
227 let mut out = PathBuf::new();
228 for component in path.components() {
229 match component {
230 std::path::Component::CurDir => {},
231 std::path::Component::ParentDir => {
232 out.pop();
233 },
234 other => out.push(other.as_os_str()),
235 }
236 }
237 out
238}
239
240fn string_arg<'a>(args: &'a serde_json::Value, name: &str) -> Result<&'a str> {
241 args.get(name)
242 .and_then(|value| value.as_str())
243 .with_context(|| format!("pending action missing string arg `{name}`"))
244}
245
246#[cfg(test)]
247mod tests {
248 use super::*;
249
250 #[test]
251 fn resolve_replay_path_rejects_parent_escape() {
252 let root = std::env::temp_dir().join("mermaid_replay_root");
253 assert!(resolve_replay_path(&root, "../escape").is_err());
254 }
255
256 #[test]
257 fn replay_write_file_creates_parent() {
258 let root =
259 std::env::temp_dir().join(format!("mermaid_replay_write_{}", std::process::id()));
260 let _ = std::fs::remove_dir_all(&root);
261 std::fs::create_dir_all(&root).unwrap();
262 let action = serde_json::json!({
263 "tool": "write_file",
264 "workdir": root,
265 "args": {"path": "a/b.txt", "content": "ok"}
266 });
267 let summary = replay_pending_action(&action).unwrap();
268 assert!(summary.contains("write_file"));
269 }
270}