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        "apply_patch" => {
202            let patch = string_arg(args, "patch")?;
203            let hunks = crate::apply_patch::parse_patch(patch)
204                .map_err(|e| anyhow::anyhow!("apply_patch replay: {e}"))?;
205            for hunk in &hunks {
206                replay_apply_hunk(&workdir, hunk)?;
207            }
208            Ok(format!("replayed apply_patch ({} hunk(s))", hunks.len()))
209        },
210        "delete_file" => {
211            let path = string_arg(args, "path")?;
212            let rel = crate::pathguard::relative_within(&workdir, path)?;
213            crate::pathguard::remove_file_beneath(&workdir, &rel)?;
214            Ok(format!(
215                "replayed delete_file {}",
216                workdir.join(&rel).display()
217            ))
218        },
219        "create_directory" => {
220            let path = string_arg(args, "path")?;
221            let rel = crate::pathguard::relative_within(&workdir, path)?;
222            crate::pathguard::create_dir_all_beneath(&workdir, &rel)?;
223            Ok(format!(
224                "replayed create_directory {}",
225                workdir.join(&rel).display()
226            ))
227        },
228        other => anyhow::bail!("approval replay does not support tool `{other}`"),
229    }
230}
231
232/// Provider keys + name patterns that must not leak into a replayed child's
233/// environment. Mirrors `providers::tool::exec::is_secret_env_name` in the main
234/// crate (the runtime crate can't depend on it). Denylist, so ordinary
235/// build/run vars (`PATH`, toolchain, `XAUTHORITY`, …) survive.
236fn is_secret_env_name(name: &str) -> bool {
237    let upper = name.to_ascii_uppercase();
238    upper.contains("API_KEY")
239        || upper.contains("APIKEY")
240        || upper.contains("ACCESS_KEY")
241        || upper.contains("PRIVATE_KEY")
242        || upper.contains("SECRET")
243        || upper.contains("PASSWORD")
244        || upper.contains("PASSWD")
245        || upper.contains("TOKEN")
246        || upper.contains("CREDENTIAL")
247}
248
249/// Strip secret-bearing env vars from a replay child. The daemon's environment
250/// holds provider API keys and the pairing token; an approved shell command
251/// must not be able to read them back out (#24).
252fn scrub_secret_env(cmd: &mut Command) {
253    for (name, _) in std::env::vars() {
254        if is_secret_env_name(&name) {
255            cmd.env_remove(&name);
256        }
257    }
258}
259
260fn replay_execute_command(args: &serde_json::Value, workdir: &Path) -> Result<String> {
261    let command = string_arg(args, "command")?;
262    // Re-apply the destructive hard-deny on replay. The command was approved by a
263    // human originally, but `pending_action_json` is stored, tamperable state; a
264    // doctored record must not turn `approve` into arbitrary destructive exec
265    // (#F7). Destructive shapes are hard-denied (never merely "ask"), so a
266    // legitimate pending approval can't be destructive in the first place.
267    anyhow::ensure!(
268        !crate::policy::is_destructive_command(command),
269        "approval replay refused: the stored command is classified destructive \
270         (possible tampered approval record)"
271    );
272    // Confine the replay cwd to the recorded workdir. The command itself is an
273    // already-approved shell string (replayed verbatim), but a tampered
274    // `working_dir` must not let the replay escape the project root. Use the
275    // canonical (symlink-resolving) check so a symlinked working_dir whose real
276    // target is outside the root is rejected, not just a lexical `..` (#F6).
277    let effective_dir = match args.get("working_dir").and_then(|value| value.as_str()) {
278        Some(dir) => crate::pathguard::contain_within_canonical(workdir, dir)?,
279        None => workdir.to_path_buf(),
280    };
281    let mode = args
282        .get("mode")
283        .and_then(|value| value.as_str())
284        .unwrap_or("wait");
285    let mut cmd = Command::new("sh");
286    cmd.arg("-c").arg(command).current_dir(&effective_dir);
287    scrub_secret_env(&mut cmd);
288    if mode == "background" {
289        // New process group so the detached child (and anything it forks) can be
290        // signalled/reaped as a unit rather than leaking grandchildren (#24).
291        #[cfg(unix)]
292        {
293            use std::os::unix::process::CommandExt;
294            cmd.process_group(0);
295        }
296        let child = cmd
297            .spawn()
298            .with_context(|| format!("failed to replay background command `{command}`"))?;
299        return Ok(format!(
300            "replayed execute_command in background with pid {}",
301            child.id()
302        ));
303    }
304    let output = cmd
305        .output()
306        .with_context(|| format!("failed to replay command `{command}`"))?;
307    anyhow::ensure!(
308        output.status.success(),
309        "replayed command failed with {}: {}",
310        output.status,
311        String::from_utf8_lossy(&output.stderr)
312    );
313    Ok(format!(
314        "replayed execute_command successfully ({} stdout bytes)",
315        output.stdout.len()
316    ))
317}
318
319/// Re-apply one parsed patch hunk beneath `root` for approval replay, using the
320/// SAME confined pathguard helpers as the live tool so a symlinked leaf inside
321/// the root can neither leak an outside file nor redirect a write (#F5).
322fn replay_apply_hunk(root: &Path, hunk: &crate::apply_patch::Hunk) -> Result<()> {
323    use crate::apply_patch::Hunk;
324    match hunk {
325        Hunk::AddFile { path, contents } => {
326            let rel = crate::pathguard::relative_within(root, &path.to_string_lossy())?;
327            replay_ensure_parent(root, &rel)?;
328            crate::pathguard::write_atomic_beneath(root, &rel, contents.as_bytes())?;
329        },
330        Hunk::DeleteFile { path } => {
331            let rel = crate::pathguard::relative_within(root, &path.to_string_lossy())?;
332            crate::pathguard::remove_file_beneath(root, &rel)?;
333        },
334        Hunk::UpdateFile {
335            path,
336            move_path,
337            chunks,
338        } => {
339            let src_rel = crate::pathguard::relative_within(root, &path.to_string_lossy())?;
340            let original = replay_read_beneath(root, &src_rel)?;
341            let applied = crate::apply_patch::derive_new_contents(&original, chunks)
342                .map_err(|e| anyhow::anyhow!("apply_patch replay for {}: {e}", path.display()))?;
343            let dst_rel = match move_path {
344                Some(mv) => crate::pathguard::relative_within(root, &mv.to_string_lossy())?,
345                None => src_rel.clone(),
346            };
347            replay_ensure_parent(root, &dst_rel)?;
348            crate::pathguard::write_atomic_beneath(
349                root,
350                &dst_rel,
351                applied.new_contents.as_bytes(),
352            )?;
353            if src_rel != dst_rel {
354                crate::pathguard::remove_file_beneath(root, &src_rel)?;
355            }
356        },
357    }
358    Ok(())
359}
360
361fn replay_ensure_parent(root: &Path, rel: &Path) -> Result<()> {
362    if let Some(parent) = rel.parent()
363        && !parent.as_os_str().is_empty()
364    {
365        crate::pathguard::create_dir_all_beneath(root, parent)?;
366    }
367    Ok(())
368}
369
370fn replay_read_beneath(root: &Path, rel: &Path) -> Result<String> {
371    use std::io::Read;
372    let mut file = crate::pathguard::open_beneath(root, rel, crate::pathguard::OpenIntent::Read)?;
373    let mut s = String::new();
374    file.read_to_string(&mut s)?;
375    Ok(s)
376}
377
378fn string_arg<'a>(args: &'a serde_json::Value, name: &str) -> Result<&'a str> {
379    args.get(name)
380        .and_then(|value| value.as_str())
381        .with_context(|| format!("pending action missing string arg `{name}`"))
382}
383
384#[cfg(test)]
385mod tests {
386    use super::*;
387
388    #[test]
389    fn replay_path_rejects_parent_escape() {
390        let root = std::env::temp_dir().join("mermaid_replay_root");
391        assert!(crate::pathguard::relative_within(&root, "../escape").is_err());
392    }
393
394    /// RC-B: a write replayed through an in-repo symlink that escapes the root
395    /// must be refused, and nothing may be written outside. The live tool path
396    /// already had this guarantee via the `*_beneath` helpers; the replay path
397    /// now shares it instead of following the symlink with by-path `std::fs`.
398    #[cfg(target_os = "linux")]
399    #[test]
400    fn replay_write_refuses_escaping_symlink() {
401        let root =
402            std::env::temp_dir().join(format!("mermaid_replay_symlink_{}", std::process::id()));
403        let outside =
404            std::env::temp_dir().join(format!("mermaid_replay_outside_{}", std::process::id()));
405        let _ = std::fs::remove_dir_all(&root);
406        let _ = std::fs::remove_dir_all(&outside);
407        std::fs::create_dir_all(&root).unwrap();
408        std::fs::create_dir_all(&outside).unwrap();
409        // Plant a symlink inside the root that redirects outside it.
410        std::os::unix::fs::symlink(&outside, root.join("escape")).unwrap();
411
412        let action = serde_json::json!({
413            "tool": "write_file",
414            "workdir": root,
415            "args": {"path": "escape/evil.txt", "content": "pwned"}
416        });
417        assert!(
418            replay_pending_action(&action).is_err(),
419            "a write through an escaping symlink must be refused"
420        );
421        assert!(
422            !outside.join("evil.txt").exists(),
423            "nothing may be written outside the root"
424        );
425        let _ = std::fs::remove_dir_all(&root);
426        let _ = std::fs::remove_dir_all(&outside);
427    }
428
429    /// RC-B/#F7: a tampered pending record whose command is destructive must be
430    /// refused on replay rather than executed verbatim.
431    #[test]
432    fn replay_execute_command_refuses_destructive() {
433        let root =
434            std::env::temp_dir().join(format!("mermaid_replay_destr_{}", std::process::id()));
435        let _ = std::fs::remove_dir_all(&root);
436        std::fs::create_dir_all(&root).unwrap();
437        let action = serde_json::json!({
438            "tool": "execute_command",
439            "workdir": root,
440            "args": {"command": "rm -rf /"}
441        });
442        assert!(
443            replay_pending_action(&action).is_err(),
444            "a destructive stored command must be refused on replay"
445        );
446        let _ = std::fs::remove_dir_all(&root);
447    }
448
449    #[test]
450    fn replay_execute_command_rejects_escaping_working_dir() {
451        let root = std::env::temp_dir().join(format!("mermaid_replay_exec_{}", std::process::id()));
452        let action = serde_json::json!({
453            "tool": "execute_command",
454            "workdir": root,
455            "args": {"command": "true", "working_dir": "../escape"}
456        });
457        // Containment fails before any shell is spawned, so this is portable.
458        assert!(
459            replay_pending_action(&action).is_err(),
460            "an escaping working_dir must be rejected before exec"
461        );
462    }
463
464    #[test]
465    fn replay_write_file_creates_parent() {
466        let root =
467            std::env::temp_dir().join(format!("mermaid_replay_write_{}", std::process::id()));
468        let _ = std::fs::remove_dir_all(&root);
469        std::fs::create_dir_all(&root).unwrap();
470        let action = serde_json::json!({
471            "tool": "write_file",
472            "workdir": root,
473            "args": {"path": "a/b.txt", "content": "ok"}
474        });
475        let summary = replay_pending_action(&action).unwrap();
476        assert!(summary.contains("write_file"));
477    }
478
479    fn temp_store(name: &str) -> RuntimeStore {
480        let dir = std::env::temp_dir().join(format!("mermaid_approval_{name}"));
481        let _ = std::fs::remove_dir_all(&dir);
482        std::fs::create_dir_all(&dir).expect("create temp dir");
483        let path = dir.join("runtime.sqlite3");
484        RuntimeStore::open(&path).expect("open store")
485    }
486
487    fn pending_approval(store: &RuntimeStore, action: &serde_json::Value) -> String {
488        store
489            .approvals()
490            .create(crate::NewApproval {
491                task_id: None,
492                proposed_action: "test".to_string(),
493                risk_classification: "low".to_string(),
494                policy_decision: "ask".to_string(),
495                args_summary: None,
496                checkpoint_id: None,
497                pending_action_json: Some(action.to_string()),
498            })
499            .expect("create approval")
500            .id
501    }
502
503    fn temp_workdir(name: &str) -> PathBuf {
504        let root =
505            std::env::temp_dir().join(format!("mermaid_approve_{name}_{}", std::process::id()));
506        let _ = std::fs::remove_dir_all(&root);
507        std::fs::create_dir_all(&root).unwrap();
508        root
509    }
510
511    #[test]
512    fn approve_replay_marks_approved_only_after_effect() {
513        let store = temp_store("ok");
514        let root = temp_workdir("ok");
515        let action = serde_json::json!({
516            "tool": "write_file",
517            "workdir": root,
518            "args": {"path": "out.txt", "content": "hello"}
519        });
520        let id = pending_approval(&store, &action);
521
522        let result = approve_and_replay_with(&store, &id).expect("replay should succeed");
523        assert!(result.replayed);
524        assert!(
525            root.join("out.txt").exists(),
526            "the file effect must have run"
527        );
528        let decided = store.approvals().get(&id).unwrap().unwrap();
529        assert_eq!(decided.user_decision.as_deref(), Some("approved"));
530    }
531
532    #[test]
533    fn failed_replay_leaves_approval_pending() {
534        let store = temp_store("fail");
535        let root = temp_workdir("fail");
536        // delete_file of a path that doesn't exist → replay errors *after* the
537        // pending pre-check but *before* `decide`, so the approval must stay
538        // undecided (re-runnable), never "approved but never applied" (#62).
539        let action = serde_json::json!({
540            "tool": "delete_file",
541            "workdir": root,
542            "args": {"path": "nope.txt"}
543        });
544        let id = pending_approval(&store, &action);
545
546        assert!(approve_and_replay_with(&store, &id).is_err());
547        let still = store.approvals().get(&id).unwrap().unwrap();
548        assert!(
549            still.user_decision.is_none(),
550            "a failed replay must not mark the approval approved"
551        );
552    }
553
554    #[test]
555    fn second_approve_is_single_shot() {
556        let store = temp_store("twice");
557        let root = temp_workdir("twice");
558        let action = serde_json::json!({
559            "tool": "create_directory",
560            "workdir": root,
561            "args": {"path": "sub"}
562        });
563        let id = pending_approval(&store, &action);
564
565        approve_and_replay_with(&store, &id).expect("first approve succeeds");
566        assert!(
567            approve_and_replay_with(&store, &id).is_err(),
568            "a second approve must be rejected (already decided)"
569        );
570        let decided = store.approvals().get(&id).unwrap().unwrap();
571        assert_eq!(decided.user_decision.as_deref(), Some("approved"));
572    }
573}