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    approve_and_replay_with(&store, id)
19}
20
21/// Core of [`approve_and_replay`] with the store injected (so it is unit-testable
22/// against a temp DB).
23///
24/// `replay_pending_action` performs a filesystem write or a process spawn —
25/// effects SQLite cannot roll back — so they run *before* the "approved" mark is
26/// written. A crash mid-replay therefore leaves the approval undecided and
27/// safely re-runnable, never "approved but never applied" (#62). The single-shot
28/// `decide` is the last mutation; its `WHERE user_decision IS NULL` guard closes
29/// the residual same-user race and makes a second call a no-op error.
30pub(crate) fn approve_and_replay_with(
31    store: &RuntimeStore,
32    id: &str,
33) -> Result<ApprovalReplayResult> {
34    // Load *without* deciding, and refuse anything already decided or archived
35    // (mirrors `decide`'s `WHERE`), so a denied/archived approval can't be replayed.
36    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    // Claim atomically so two concurrent `approve <id>` calls can't both run the
46    // un-rollback-able effect (#118): exactly one wins the claim and proceeds.
47    // Any failure before the final mark releases the claim, keeping the action
48    // re-runnable — preserving the effect-before-mark crash-safety of #62.
49    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
62/// Run a claimed approval's pending action and finalize it. The caller holds the
63/// `approving` claim and releases it if this returns `Err`.
64fn 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        // Nothing to replay: just record the decision (no effect to order around).
71        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    // 1) Effect first — the un-rollback-able fs write / process spawn.
83    let summary = replay_pending_action(&action)?;
84    // 2) Mark approved last — only now is "approved" both true and durable.
85    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    // 3) Best-effort bookkeeping.
92    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    // The stored path comes from (potentially tampered) replay state, so confine
179    // every effect with the symlink-safe `*_beneath` helpers — the SAME
180    // kernel-confined (`openat2(RESOLVE_BENEATH)`) path the live filesystem tools
181    // use — rather than by-path `std::fs`, which follows an in-repo symlink out
182    // of the root (#F4/#F5). `relative_within` rejects an escaping path and names
183    // it relative to `workdir`, which the helpers resolve beneath a `workdir` fd.
184    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        "edit_file" => {
202            let path = string_arg(args, "path")?;
203            let old = string_arg(args, "old_string")?;
204            let new = string_arg(args, "new_string")?;
205            let rel = crate::pathguard::relative_within(&workdir, path)?;
206            replay_edit(&workdir, &rel, old, new)?;
207            Ok(format!(
208                "replayed edit_file {}",
209                workdir.join(&rel).display()
210            ))
211        },
212        "delete_file" => {
213            let path = string_arg(args, "path")?;
214            let rel = crate::pathguard::relative_within(&workdir, path)?;
215            crate::pathguard::remove_file_beneath(&workdir, &rel)?;
216            Ok(format!(
217                "replayed delete_file {}",
218                workdir.join(&rel).display()
219            ))
220        },
221        "create_directory" => {
222            let path = string_arg(args, "path")?;
223            let rel = crate::pathguard::relative_within(&workdir, path)?;
224            crate::pathguard::create_dir_all_beneath(&workdir, &rel)?;
225            Ok(format!(
226                "replayed create_directory {}",
227                workdir.join(&rel).display()
228            ))
229        },
230        other => anyhow::bail!("approval replay does not support tool `{other}`"),
231    }
232}
233
234/// Provider keys + name patterns that must not leak into a replayed child's
235/// environment. Mirrors `providers::tool::exec::is_secret_env_name` in the main
236/// crate (the runtime crate can't depend on it). Denylist, so ordinary
237/// build/run vars (`PATH`, toolchain, `XAUTHORITY`, …) survive.
238fn is_secret_env_name(name: &str) -> bool {
239    let upper = name.to_ascii_uppercase();
240    upper.contains("API_KEY")
241        || upper.contains("APIKEY")
242        || upper.contains("ACCESS_KEY")
243        || upper.contains("PRIVATE_KEY")
244        || upper.contains("SECRET")
245        || upper.contains("PASSWORD")
246        || upper.contains("PASSWD")
247        || upper.contains("TOKEN")
248        || upper.contains("CREDENTIAL")
249}
250
251/// Strip secret-bearing env vars from a replay child. The daemon's environment
252/// holds provider API keys and the pairing token; an approved shell command
253/// must not be able to read them back out (#24).
254fn scrub_secret_env(cmd: &mut Command) {
255    for (name, _) in std::env::vars() {
256        if is_secret_env_name(&name) {
257            cmd.env_remove(&name);
258        }
259    }
260}
261
262fn replay_execute_command(args: &serde_json::Value, workdir: &Path) -> Result<String> {
263    let command = string_arg(args, "command")?;
264    // Re-apply the destructive hard-deny on replay. The command was approved by a
265    // human originally, but `pending_action_json` is stored, tamperable state; a
266    // doctored record must not turn `approve` into arbitrary destructive exec
267    // (#F7). Destructive shapes are hard-denied (never merely "ask"), so a
268    // legitimate pending approval can't be destructive in the first place.
269    anyhow::ensure!(
270        !crate::policy::is_destructive_command(command),
271        "approval replay refused: the stored command is classified destructive \
272         (possible tampered approval record)"
273    );
274    // Confine the replay cwd to the recorded workdir. The command itself is an
275    // already-approved shell string (replayed verbatim), but a tampered
276    // `working_dir` must not let the replay escape the project root. Use the
277    // canonical (symlink-resolving) check so a symlinked working_dir whose real
278    // target is outside the root is rejected, not just a lexical `..` (#F6).
279    let effective_dir = match args.get("working_dir").and_then(|value| value.as_str()) {
280        Some(dir) => crate::pathguard::contain_within_canonical(workdir, dir)?,
281        None => workdir.to_path_buf(),
282    };
283    let mode = args
284        .get("mode")
285        .and_then(|value| value.as_str())
286        .unwrap_or("wait");
287    let mut cmd = Command::new("sh");
288    cmd.arg("-c").arg(command).current_dir(&effective_dir);
289    scrub_secret_env(&mut cmd);
290    if mode == "background" {
291        // New process group so the detached child (and anything it forks) can be
292        // signalled/reaped as a unit rather than leaking grandchildren (#24).
293        #[cfg(unix)]
294        {
295            use std::os::unix::process::CommandExt;
296            cmd.process_group(0);
297        }
298        let child = cmd
299            .spawn()
300            .with_context(|| format!("failed to replay background command `{command}`"))?;
301        return Ok(format!(
302            "replayed execute_command in background with pid {}",
303            child.id()
304        ));
305    }
306    let output = cmd
307        .output()
308        .with_context(|| format!("failed to replay command `{command}`"))?;
309    anyhow::ensure!(
310        output.status.success(),
311        "replayed command failed with {}: {}",
312        output.status,
313        String::from_utf8_lossy(&output.stderr)
314    );
315    Ok(format!(
316        "replayed execute_command successfully ({} stdout bytes)",
317        output.stdout.len()
318    ))
319}
320
321fn replay_edit(root: &Path, rel: &Path, old_string: &str, new_string: &str) -> Result<()> {
322    // Read AND write through the confined fd helpers so a symlinked leaf inside
323    // the root can neither leak an outside file's contents nor have the rewrite
324    // redirected onto it (#F5).
325    let mut current = String::new();
326    {
327        use std::io::Read;
328        let mut file =
329            crate::pathguard::open_beneath(root, rel, crate::pathguard::OpenIntent::Read)?;
330        file.read_to_string(&mut current)?;
331    }
332    let count = current.matches(old_string).count();
333    anyhow::ensure!(count > 0, "old_string not found during approval replay");
334    anyhow::ensure!(
335        count == 1,
336        "old_string appears {count} times during approval replay"
337    );
338    let updated = current.replacen(old_string, new_string, 1);
339    crate::pathguard::write_atomic_beneath(root, rel, updated.as_bytes())?;
340    Ok(())
341}
342
343fn string_arg<'a>(args: &'a serde_json::Value, name: &str) -> Result<&'a str> {
344    args.get(name)
345        .and_then(|value| value.as_str())
346        .with_context(|| format!("pending action missing string arg `{name}`"))
347}
348
349#[cfg(test)]
350mod tests {
351    use super::*;
352
353    #[test]
354    fn replay_path_rejects_parent_escape() {
355        let root = std::env::temp_dir().join("mermaid_replay_root");
356        assert!(crate::pathguard::relative_within(&root, "../escape").is_err());
357    }
358
359    /// RC-B: a write replayed through an in-repo symlink that escapes the root
360    /// must be refused, and nothing may be written outside. The live tool path
361    /// already had this guarantee via the `*_beneath` helpers; the replay path
362    /// now shares it instead of following the symlink with by-path `std::fs`.
363    #[cfg(target_os = "linux")]
364    #[test]
365    fn replay_write_refuses_escaping_symlink() {
366        let root =
367            std::env::temp_dir().join(format!("mermaid_replay_symlink_{}", std::process::id()));
368        let outside =
369            std::env::temp_dir().join(format!("mermaid_replay_outside_{}", std::process::id()));
370        let _ = std::fs::remove_dir_all(&root);
371        let _ = std::fs::remove_dir_all(&outside);
372        std::fs::create_dir_all(&root).unwrap();
373        std::fs::create_dir_all(&outside).unwrap();
374        // Plant a symlink inside the root that redirects outside it.
375        std::os::unix::fs::symlink(&outside, root.join("escape")).unwrap();
376
377        let action = serde_json::json!({
378            "tool": "write_file",
379            "workdir": root,
380            "args": {"path": "escape/evil.txt", "content": "pwned"}
381        });
382        assert!(
383            replay_pending_action(&action).is_err(),
384            "a write through an escaping symlink must be refused"
385        );
386        assert!(
387            !outside.join("evil.txt").exists(),
388            "nothing may be written outside the root"
389        );
390        let _ = std::fs::remove_dir_all(&root);
391        let _ = std::fs::remove_dir_all(&outside);
392    }
393
394    /// RC-B/#F7: a tampered pending record whose command is destructive must be
395    /// refused on replay rather than executed verbatim.
396    #[test]
397    fn replay_execute_command_refuses_destructive() {
398        let root =
399            std::env::temp_dir().join(format!("mermaid_replay_destr_{}", std::process::id()));
400        let _ = std::fs::remove_dir_all(&root);
401        std::fs::create_dir_all(&root).unwrap();
402        let action = serde_json::json!({
403            "tool": "execute_command",
404            "workdir": root,
405            "args": {"command": "rm -rf /"}
406        });
407        assert!(
408            replay_pending_action(&action).is_err(),
409            "a destructive stored command must be refused on replay"
410        );
411        let _ = std::fs::remove_dir_all(&root);
412    }
413
414    #[test]
415    fn replay_execute_command_rejects_escaping_working_dir() {
416        let root = std::env::temp_dir().join(format!("mermaid_replay_exec_{}", std::process::id()));
417        let action = serde_json::json!({
418            "tool": "execute_command",
419            "workdir": root,
420            "args": {"command": "true", "working_dir": "../escape"}
421        });
422        // Containment fails before any shell is spawned, so this is portable.
423        assert!(
424            replay_pending_action(&action).is_err(),
425            "an escaping working_dir must be rejected before exec"
426        );
427    }
428
429    #[test]
430    fn replay_write_file_creates_parent() {
431        let root =
432            std::env::temp_dir().join(format!("mermaid_replay_write_{}", std::process::id()));
433        let _ = std::fs::remove_dir_all(&root);
434        std::fs::create_dir_all(&root).unwrap();
435        let action = serde_json::json!({
436            "tool": "write_file",
437            "workdir": root,
438            "args": {"path": "a/b.txt", "content": "ok"}
439        });
440        let summary = replay_pending_action(&action).unwrap();
441        assert!(summary.contains("write_file"));
442    }
443
444    fn temp_store(name: &str) -> RuntimeStore {
445        let dir = std::env::temp_dir().join(format!("mermaid_approval_{name}"));
446        let _ = std::fs::remove_dir_all(&dir);
447        std::fs::create_dir_all(&dir).expect("create temp dir");
448        let path = dir.join("runtime.sqlite3");
449        RuntimeStore::open(&path).expect("open store")
450    }
451
452    fn pending_approval(store: &RuntimeStore, action: &serde_json::Value) -> String {
453        store
454            .approvals()
455            .create(crate::NewApproval {
456                task_id: None,
457                proposed_action: "test".to_string(),
458                risk_classification: "low".to_string(),
459                policy_decision: "ask".to_string(),
460                args_summary: None,
461                checkpoint_id: None,
462                pending_action_json: Some(action.to_string()),
463            })
464            .expect("create approval")
465            .id
466    }
467
468    fn temp_workdir(name: &str) -> PathBuf {
469        let root =
470            std::env::temp_dir().join(format!("mermaid_approve_{name}_{}", std::process::id()));
471        let _ = std::fs::remove_dir_all(&root);
472        std::fs::create_dir_all(&root).unwrap();
473        root
474    }
475
476    #[test]
477    fn approve_replay_marks_approved_only_after_effect() {
478        let store = temp_store("ok");
479        let root = temp_workdir("ok");
480        let action = serde_json::json!({
481            "tool": "write_file",
482            "workdir": root,
483            "args": {"path": "out.txt", "content": "hello"}
484        });
485        let id = pending_approval(&store, &action);
486
487        let result = approve_and_replay_with(&store, &id).expect("replay should succeed");
488        assert!(result.replayed);
489        assert!(
490            root.join("out.txt").exists(),
491            "the file effect must have run"
492        );
493        let decided = store.approvals().get(&id).unwrap().unwrap();
494        assert_eq!(decided.user_decision.as_deref(), Some("approved"));
495    }
496
497    #[test]
498    fn failed_replay_leaves_approval_pending() {
499        let store = temp_store("fail");
500        let root = temp_workdir("fail");
501        // delete_file of a path that doesn't exist → replay errors *after* the
502        // pending pre-check but *before* `decide`, so the approval must stay
503        // undecided (re-runnable), never "approved but never applied" (#62).
504        let action = serde_json::json!({
505            "tool": "delete_file",
506            "workdir": root,
507            "args": {"path": "nope.txt"}
508        });
509        let id = pending_approval(&store, &action);
510
511        assert!(approve_and_replay_with(&store, &id).is_err());
512        let still = store.approvals().get(&id).unwrap().unwrap();
513        assert!(
514            still.user_decision.is_none(),
515            "a failed replay must not mark the approval approved"
516        );
517    }
518
519    #[test]
520    fn second_approve_is_single_shot() {
521        let store = temp_store("twice");
522        let root = temp_workdir("twice");
523        let action = serde_json::json!({
524            "tool": "create_directory",
525            "workdir": root,
526            "args": {"path": "sub"}
527        });
528        let id = pending_approval(&store, &action);
529
530        approve_and_replay_with(&store, &id).expect("first approve succeeds");
531        assert!(
532            approve_and_replay_with(&store, &id).is_err(),
533            "a second approve must be rejected (already decided)"
534        );
535        let decided = store.approvals().get(&id).unwrap().unwrap();
536        assert_eq!(decided.user_decision.as_deref(), Some("approved"));
537    }
538}