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::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
22/// Core of [`approve_and_replay`] with the store injected (so it is unit-testable
23/// against a temp DB).
24///
25/// `replay_pending_action` performs a filesystem write or a process spawn —
26/// effects SQLite cannot roll back — so they run *before* the "approved" mark is
27/// written. A crash mid-replay therefore leaves the approval undecided and
28/// safely re-runnable, never "approved but never applied" (#62). The single-shot
29/// `decide` is the last mutation; its `WHERE user_decision IS NULL` guard closes
30/// the residual same-user race and makes a second call a no-op error.
31pub(crate) fn approve_and_replay_with(
32    store: &RuntimeStore,
33    id: &str,
34) -> Result<ApprovalReplayResult> {
35    // Load *without* deciding, and refuse anything already decided or archived
36    // (mirrors `decide`'s `WHERE`), so a denied/archived approval can't be replayed.
37    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        // Nothing to replay: just record the decision (no effect to order around).
48        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    // 1) Effect first — the un-rollback-able fs write / process spawn.
60    let summary = replay_pending_action(&action)?;
61    // 2) Mark approved last — only now is "approved" both true and durable.
62    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    // 3) Best-effort bookkeeping.
69    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
191/// Provider keys + name patterns that must not leak into a replayed child's
192/// environment. Mirrors `providers::tool::exec::is_secret_env_name` in the main
193/// crate (the runtime crate can't depend on it). Denylist, so ordinary
194/// build/run vars (`PATH`, toolchain, `XAUTHORITY`, …) survive.
195fn 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
208/// Strip secret-bearing env vars from a replay child. The daemon's environment
209/// holds provider API keys and the pairing token; an approved shell command
210/// must not be able to read them back out (#24).
211fn 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    // Confine the replay cwd to the recorded workdir. The command itself is an
222    // already-approved shell string (replayed verbatim), but a tampered
223    // `working_dir` must not let the replay escape the project root — this
224    // mirrors the containment the write/edit/delete arms already apply.
225    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        // New process group so the detached child (and anything it forks) can be
238        // signalled/reaped as a unit rather than leaking grandchildren (#24).
239        #[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        // Containment fails before any shell is spawned, so this is portable.
304        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        // delete_file of a path that doesn't exist → replay errors *after* the
383        // pending pre-check but *before* `decide`, so the approval must stay
384        // undecided (re-runnable), never "approved but never applied" (#62).
385        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}