Skip to main content

mermaid_runtime/
approval.rs

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}