1use std::path::{Path, PathBuf};
2use std::process::Command;
3
4use anyhow::{Context, Result};
5use serde::{Deserialize, Serialize};
6
7use crate::pathguard::contain_within;
8use crate::{ApprovalRecord, RuntimeStore};
9
10#[derive(Debug, Clone, Serialize, Deserialize)]
11pub struct ApprovalReplayResult {
12 pub approval: Option<ApprovalRecord>,
13 pub replayed: bool,
14 pub summary: String,
15}
16
17pub fn approve_and_replay(id: &str) -> Result<ApprovalReplayResult> {
18 let store = RuntimeStore::open_default()?;
19 approve_and_replay_with(&store, id)
20}
21
22pub(crate) fn approve_and_replay_with(
32 store: &RuntimeStore,
33 id: &str,
34) -> Result<ApprovalReplayResult> {
35 let approval = store
38 .approvals()
39 .get(id)?
40 .with_context(|| format!("approval not found: {id}"))?;
41 anyhow::ensure!(
42 approval.user_decision.is_none() && approval.archived_at.is_none(),
43 "approval {id} cannot be replayed (already decided or archived)"
44 );
45
46 let Some(raw_action) = approval.pending_action_json.as_deref() else {
47 store.approvals().decide(id, "approved")?;
49 let approval = store.approvals().get(id)?;
50 return Ok(ApprovalReplayResult {
51 approval,
52 replayed: false,
53 summary: "approval recorded; no pending action was stored".to_string(),
54 });
55 };
56
57 let action: serde_json::Value = serde_json::from_str(raw_action)
58 .with_context(|| format!("approval {id} pending action was not valid JSON"))?;
59 let summary = replay_pending_action(&action)?;
61 store.approvals().decide(id, "approved")?;
63 let approval = store
64 .approvals()
65 .get(id)?
66 .with_context(|| format!("approval not found after approval: {id}"))?;
67
68 let _ = crate::run_plugin_hooks(
70 "approval_decided",
71 &serde_json::json!({
72 "id": approval.id.clone(),
73 "decision": "approved",
74 "task_id": approval.task_id.clone(),
75 "replayed": true,
76 "summary": summary.clone(),
77 }),
78 );
79 if let Some(task_id) = approval.task_id.as_deref() {
80 let _ = store
81 .tasks()
82 .add_event(task_id, "approval_replayed", &summary);
83 }
84 if let Some(tool) = action.get("tool").and_then(|value| value.as_str()) {
85 let replay_run = store.tool_runs().start(crate::NewToolRun {
86 id: None,
87 task_id: approval.task_id.clone(),
88 turn_id: action
89 .get("turn_id")
90 .and_then(|value| value.as_i64())
91 .map(|value| value.to_string()),
92 call_id: action
93 .get("call_id")
94 .and_then(|value| value.as_i64())
95 .map(|value| value.to_string()),
96 tool_name: format!("approval_replay:{tool}"),
97 args_json: Some(raw_action.to_string()),
98 });
99 if let Ok(run) = replay_run {
100 let _ = store.tool_runs().finish(
101 &run.id,
102 "success",
103 Some(&serde_json::json!({"summary": summary}).to_string()),
104 );
105 }
106 }
107 Ok(ApprovalReplayResult {
108 approval: Some(approval),
109 replayed: true,
110 summary,
111 })
112}
113
114pub fn deny_approval(id: &str) -> Result<ApprovalReplayResult> {
115 let store = RuntimeStore::open_default()?;
116 store.approvals().decide(id, "denied")?;
117 let approval = store.approvals().get(id)?;
118 let _ = crate::run_plugin_hooks(
119 "approval_decided",
120 &serde_json::json!({
121 "id": id,
122 "decision": "denied",
123 "task_id": approval.as_ref().and_then(|record| record.task_id.clone()),
124 "replayed": false,
125 }),
126 );
127 if let Some(task_id) = approval
128 .as_ref()
129 .and_then(|record| record.task_id.as_deref())
130 {
131 let _ =
132 store
133 .tasks()
134 .add_event(task_id, "approval_denied", &format!("approval {id} denied"));
135 }
136 Ok(ApprovalReplayResult {
137 approval,
138 replayed: false,
139 summary: "approval denied".to_string(),
140 })
141}
142
143fn replay_pending_action(action: &serde_json::Value) -> Result<String> {
144 let tool = action
145 .get("tool")
146 .and_then(|value| value.as_str())
147 .context("pending action missing string `tool`")?;
148 let workdir = action
149 .get("workdir")
150 .and_then(|value| value.as_str())
151 .map(PathBuf::from)
152 .unwrap_or(std::env::current_dir()?);
153 let args = action.get("args").unwrap_or(action);
154
155 match tool {
156 "execute_command" => replay_execute_command(args, &workdir),
157 "write_file" => {
158 let path = string_arg(args, "path")?;
159 let content = string_arg(args, "content")?;
160 let target = contain_within(&workdir, path)?;
161 if let Some(parent) = target.parent() {
162 std::fs::create_dir_all(parent)?;
163 }
164 std::fs::write(&target, content)?;
165 Ok(format!("replayed write_file {}", target.display()))
166 },
167 "edit_file" => {
168 let path = string_arg(args, "path")?;
169 let old = string_arg(args, "old_string")?;
170 let new = string_arg(args, "new_string")?;
171 let target = contain_within(&workdir, path)?;
172 replay_edit(&target, old, new)?;
173 Ok(format!("replayed edit_file {}", target.display()))
174 },
175 "delete_file" => {
176 let path = string_arg(args, "path")?;
177 let target = contain_within(&workdir, path)?;
178 std::fs::remove_file(&target)?;
179 Ok(format!("replayed delete_file {}", target.display()))
180 },
181 "create_directory" => {
182 let path = string_arg(args, "path")?;
183 let target = contain_within(&workdir, path)?;
184 std::fs::create_dir_all(&target)?;
185 Ok(format!("replayed create_directory {}", target.display()))
186 },
187 other => anyhow::bail!("approval replay does not support tool `{other}`"),
188 }
189}
190
191fn is_secret_env_name(name: &str) -> bool {
196 let upper = name.to_ascii_uppercase();
197 upper.contains("API_KEY")
198 || upper.contains("APIKEY")
199 || upper.contains("ACCESS_KEY")
200 || upper.contains("PRIVATE_KEY")
201 || upper.contains("SECRET")
202 || upper.contains("PASSWORD")
203 || upper.contains("PASSWD")
204 || upper.contains("TOKEN")
205 || upper.contains("CREDENTIAL")
206}
207
208fn scrub_secret_env(cmd: &mut Command) {
212 for (name, _) in std::env::vars() {
213 if is_secret_env_name(&name) {
214 cmd.env_remove(&name);
215 }
216 }
217}
218
219fn replay_execute_command(args: &serde_json::Value, workdir: &Path) -> Result<String> {
220 let command = string_arg(args, "command")?;
221 let effective_dir = match args.get("working_dir").and_then(|value| value.as_str()) {
226 Some(dir) => contain_within(workdir, dir)?,
227 None => workdir.to_path_buf(),
228 };
229 let mode = args
230 .get("mode")
231 .and_then(|value| value.as_str())
232 .unwrap_or("wait");
233 let mut cmd = Command::new("sh");
234 cmd.arg("-c").arg(command).current_dir(&effective_dir);
235 scrub_secret_env(&mut cmd);
236 if mode == "background" {
237 #[cfg(unix)]
240 {
241 use std::os::unix::process::CommandExt;
242 cmd.process_group(0);
243 }
244 let child = cmd
245 .spawn()
246 .with_context(|| format!("failed to replay background command `{command}`"))?;
247 return Ok(format!(
248 "replayed execute_command in background with pid {}",
249 child.id()
250 ));
251 }
252 let output = cmd
253 .output()
254 .with_context(|| format!("failed to replay command `{command}`"))?;
255 anyhow::ensure!(
256 output.status.success(),
257 "replayed command failed with {}: {}",
258 output.status,
259 String::from_utf8_lossy(&output.stderr)
260 );
261 Ok(format!(
262 "replayed execute_command successfully ({} stdout bytes)",
263 output.stdout.len()
264 ))
265}
266
267fn replay_edit(path: &Path, old_string: &str, new_string: &str) -> Result<()> {
268 let current = std::fs::read_to_string(path)?;
269 let count = current.matches(old_string).count();
270 anyhow::ensure!(count > 0, "old_string not found during approval replay");
271 anyhow::ensure!(
272 count == 1,
273 "old_string appears {count} times during approval replay"
274 );
275 std::fs::write(path, current.replacen(old_string, new_string, 1))?;
276 Ok(())
277}
278
279fn string_arg<'a>(args: &'a serde_json::Value, name: &str) -> Result<&'a str> {
280 args.get(name)
281 .and_then(|value| value.as_str())
282 .with_context(|| format!("pending action missing string arg `{name}`"))
283}
284
285#[cfg(test)]
286mod tests {
287 use super::*;
288
289 #[test]
290 fn replay_path_rejects_parent_escape() {
291 let root = std::env::temp_dir().join("mermaid_replay_root");
292 assert!(contain_within(&root, "../escape").is_err());
293 }
294
295 #[test]
296 fn replay_execute_command_rejects_escaping_working_dir() {
297 let root = std::env::temp_dir().join(format!("mermaid_replay_exec_{}", std::process::id()));
298 let action = serde_json::json!({
299 "tool": "execute_command",
300 "workdir": root,
301 "args": {"command": "true", "working_dir": "../escape"}
302 });
303 assert!(
305 replay_pending_action(&action).is_err(),
306 "an escaping working_dir must be rejected before exec"
307 );
308 }
309
310 #[test]
311 fn replay_write_file_creates_parent() {
312 let root =
313 std::env::temp_dir().join(format!("mermaid_replay_write_{}", std::process::id()));
314 let _ = std::fs::remove_dir_all(&root);
315 std::fs::create_dir_all(&root).unwrap();
316 let action = serde_json::json!({
317 "tool": "write_file",
318 "workdir": root,
319 "args": {"path": "a/b.txt", "content": "ok"}
320 });
321 let summary = replay_pending_action(&action).unwrap();
322 assert!(summary.contains("write_file"));
323 }
324
325 fn temp_store(name: &str) -> RuntimeStore {
326 let dir = std::env::temp_dir().join(format!("mermaid_approval_{name}"));
327 let _ = std::fs::remove_dir_all(&dir);
328 std::fs::create_dir_all(&dir).expect("create temp dir");
329 let path = dir.join("runtime.sqlite3");
330 RuntimeStore::open(&path).expect("open store")
331 }
332
333 fn pending_approval(store: &RuntimeStore, action: &serde_json::Value) -> String {
334 store
335 .approvals()
336 .create(crate::NewApproval {
337 task_id: None,
338 proposed_action: "test".to_string(),
339 risk_classification: "low".to_string(),
340 policy_decision: "ask".to_string(),
341 args_summary: None,
342 checkpoint_id: None,
343 pending_action_json: Some(action.to_string()),
344 })
345 .expect("create approval")
346 .id
347 }
348
349 fn temp_workdir(name: &str) -> PathBuf {
350 let root =
351 std::env::temp_dir().join(format!("mermaid_approve_{name}_{}", std::process::id()));
352 let _ = std::fs::remove_dir_all(&root);
353 std::fs::create_dir_all(&root).unwrap();
354 root
355 }
356
357 #[test]
358 fn approve_replay_marks_approved_only_after_effect() {
359 let store = temp_store("ok");
360 let root = temp_workdir("ok");
361 let action = serde_json::json!({
362 "tool": "write_file",
363 "workdir": root,
364 "args": {"path": "out.txt", "content": "hello"}
365 });
366 let id = pending_approval(&store, &action);
367
368 let result = approve_and_replay_with(&store, &id).expect("replay should succeed");
369 assert!(result.replayed);
370 assert!(
371 root.join("out.txt").exists(),
372 "the file effect must have run"
373 );
374 let decided = store.approvals().get(&id).unwrap().unwrap();
375 assert_eq!(decided.user_decision.as_deref(), Some("approved"));
376 }
377
378 #[test]
379 fn failed_replay_leaves_approval_pending() {
380 let store = temp_store("fail");
381 let root = temp_workdir("fail");
382 let action = serde_json::json!({
386 "tool": "delete_file",
387 "workdir": root,
388 "args": {"path": "nope.txt"}
389 });
390 let id = pending_approval(&store, &action);
391
392 assert!(approve_and_replay_with(&store, &id).is_err());
393 let still = store.approvals().get(&id).unwrap().unwrap();
394 assert!(
395 still.user_decision.is_none(),
396 "a failed replay must not mark the approval approved"
397 );
398 }
399
400 #[test]
401 fn second_approve_is_single_shot() {
402 let store = temp_store("twice");
403 let root = temp_workdir("twice");
404 let action = serde_json::json!({
405 "tool": "create_directory",
406 "workdir": root,
407 "args": {"path": "sub"}
408 });
409 let id = pending_approval(&store, &action);
410
411 approve_and_replay_with(&store, &id).expect("first approve succeeds");
412 assert!(
413 approve_and_replay_with(&store, &id).is_err(),
414 "a second approve must be rejected (already decided)"
415 );
416 let decided = store.approvals().get(&id).unwrap().unwrap();
417 assert_eq!(decided.user_decision.as_deref(), Some("approved"));
418 }
419}