Skip to main content

microclaw_engine/
completion_contract.rs

1//! Completion contracts: evidence-based verification that a sub-agent's task
2//! is actually done, instead of trusting its self-report.
3//!
4//! A contract is a small list of machine-checkable exit criteria declared at
5//! spawn time (`exit_criteria` on `sessions_spawn`). When the sub-agent
6//! finishes, the runtime executes every criterion and annotates the result
7//! with per-criterion evidence — "done" then means *verified done* (v0.4.0
8//! item C+E in `docs/roadmap/competitive-intel-update-2026-07.md`).
9//!
10//! Safety model:
11//! - File criteria only accept RELATIVE paths and resolve inside the chat's
12//!   working directory — a contract cannot be used to probe `~/.ssh` or other
13//!   host paths, and evidence excerpts are capped.
14//! - Command criteria run through the sub-agent's own `bash` tool via
15//!   [`CommandRunner`], so the sandbox router, dangerous-pattern checks, and
16//!   `tool_policy` all apply exactly as if the agent had run the command.
17
18use async_trait::async_trait;
19use serde::{Deserialize, Serialize};
20use std::path::{Path, PathBuf};
21
22/// Hard cap on criteria per contract — a contract is a checklist, not a test
23/// suite; anything larger belongs in an actual test the agent runs itself.
24pub const MAX_CRITERIA: usize = 8;
25
26/// Cap on evidence excerpts embedded in the verification report.
27const MAX_EVIDENCE_CHARS: usize = 300;
28
29#[derive(Debug, Clone, PartialEq, Deserialize, Serialize)]
30#[serde(tag = "type", rename_all = "snake_case", deny_unknown_fields)]
31pub enum ExitCriterion {
32    /// A file exists (relative to the chat working dir).
33    FileExists { path: String },
34    /// A file exists and contains `needle` (substring match).
35    FileContains { path: String, needle: String },
36    /// A file exists and is at least `min_bytes` long.
37    FileMinBytes { path: String, min_bytes: u64 },
38    /// The sub-agent's final result text contains `needle`.
39    ResultContains { needle: String },
40    /// A shell command succeeds (exit 0), optionally also requiring its
41    /// output to contain `expect_contains`. Runs through the sub-agent's
42    /// bash tool — sandbox and policy guards apply.
43    Command {
44        run: String,
45        #[serde(default, skip_serializing_if = "Option::is_none")]
46        expect_contains: Option<String>,
47    },
48}
49
50impl ExitCriterion {
51    /// One-line human-readable form used in prompts and reports.
52    pub fn describe(&self) -> String {
53        match self {
54            ExitCriterion::FileExists { path } => format!("file `{path}` exists"),
55            ExitCriterion::FileContains { path, needle } => {
56                format!("file `{path}` contains {needle:?}")
57            }
58            ExitCriterion::FileMinBytes { path, min_bytes } => {
59                format!("file `{path}` is at least {min_bytes} bytes")
60            }
61            ExitCriterion::ResultContains { needle } => {
62                format!("result text contains {needle:?}")
63            }
64            ExitCriterion::Command {
65                run,
66                expect_contains,
67            } => match expect_contains {
68                Some(needle) => format!("command `{run}` succeeds and prints {needle:?}"),
69                None => format!("command `{run}` succeeds"),
70            },
71        }
72    }
73}
74
75/// Outcome of checking one criterion.
76#[derive(Debug, Clone)]
77pub struct CriterionOutcome {
78    pub description: String,
79    pub passed: bool,
80    pub evidence: String,
81}
82
83/// Executes `Command` criteria. Production wires this to the sub-agent's
84/// bash tool; tests use a stub.
85#[async_trait]
86pub trait CommandRunner: Send + Sync {
87    /// Returns (success, output-or-error-text).
88    async fn run(&self, command: &str) -> (bool, String);
89}
90
91/// Parse the `exit_criteria` tool-input value. `None` when absent/null;
92/// errors on malformed entries so bad contracts fail loudly at spawn time
93/// instead of silently verifying nothing.
94pub fn parse_exit_criteria(
95    value: Option<&serde_json::Value>,
96) -> Result<Vec<ExitCriterion>, String> {
97    let Some(value) = value else {
98        return Ok(Vec::new());
99    };
100    if value.is_null() {
101        return Ok(Vec::new());
102    }
103    let arr = value
104        .as_array()
105        .ok_or_else(|| "exit_criteria must be an array".to_string())?;
106    if arr.len() > MAX_CRITERIA {
107        return Err(format!(
108            "exit_criteria has {} entries (max {MAX_CRITERIA})",
109            arr.len()
110        ));
111    }
112    let mut out = Vec::with_capacity(arr.len());
113    for (i, entry) in arr.iter().enumerate() {
114        let criterion: ExitCriterion = serde_json::from_value(entry.clone()).map_err(|e| {
115            format!(
116                "exit_criteria[{i}] is invalid: {e}. Expected one of: \
117                 {{type: file_exists, path}}, {{type: file_contains, path, needle}}, \
118                 {{type: file_min_bytes, path, min_bytes}}, {{type: result_contains, needle}}, \
119                 {{type: command, run, expect_contains?}}"
120            )
121        })?;
122        if let ExitCriterion::FileExists { path }
123        | ExitCriterion::FileContains { path, .. }
124        | ExitCriterion::FileMinBytes { path, .. } = &criterion
125        {
126            validate_contract_path(path).map_err(|e| format!("exit_criteria[{i}]: {e}"))?;
127        }
128        out.push(criterion);
129    }
130    Ok(out)
131}
132
133/// File-criterion paths must stay inside the chat working dir: relative,
134/// no parent traversal, no absolute/rooted/drive-prefixed forms.
135///
136/// `has_root()` matters beyond `is_absolute()`: on Windows, `/etc/passwd`
137/// is NOT absolute (no drive) but IS rooted — and `Path::join` REPLACES the
138/// base entirely for any rooted path, which would escape the jail.
139/// `Component::Prefix` rejects drive/UNC forms (`C:foo`, `\\server\share`).
140fn validate_contract_path(path: &str) -> Result<(), String> {
141    let p = Path::new(path);
142    if p.is_absolute() || p.has_root() || path.starts_with('~') {
143        return Err(format!(
144            "path `{path}` must be relative to the chat working directory"
145        ));
146    }
147    if p.components().any(|c| {
148        matches!(
149            c,
150            std::path::Component::ParentDir | std::path::Component::Prefix(_)
151        )
152    }) {
153        return Err(format!(
154            "path `{path}` must not contain `..` or a drive/UNC prefix"
155        ));
156    }
157    Ok(())
158}
159
160fn truncate_evidence(s: &str) -> String {
161    let trimmed = s.trim();
162    if trimmed.len() <= MAX_EVIDENCE_CHARS {
163        return trimmed.to_string();
164    }
165    let end = microclaw_core::text::floor_char_boundary(trimmed, MAX_EVIDENCE_CHARS);
166    format!("{}…", &trimmed[..end])
167}
168
169/// Check every criterion and collect evidence. Never panics; unreadable
170/// files or runner errors surface as failed criteria with the error as
171/// evidence (fail closed).
172pub async fn verify_criteria(
173    criteria: &[ExitCriterion],
174    result_text: &str,
175    working_dir: &Path,
176    runner: Option<&dyn CommandRunner>,
177) -> Vec<CriterionOutcome> {
178    let mut outcomes = Vec::with_capacity(criteria.len());
179    for criterion in criteria {
180        let description = criterion.describe();
181        let (passed, evidence) = match criterion {
182            ExitCriterion::FileExists { path } => {
183                let full = resolve(working_dir, path);
184                if full.is_file() {
185                    (true, format!("{} exists", full.display()))
186                } else {
187                    (false, format!("{} not found", full.display()))
188                }
189            }
190            ExitCriterion::FileContains { path, needle } => {
191                let full = resolve(working_dir, path);
192                match std::fs::read_to_string(&full) {
193                    Ok(content) if content.contains(needle.as_str()) => {
194                        (true, format!("{needle:?} found in {path}"))
195                    }
196                    Ok(_) => (false, format!("{needle:?} not found in {path}")),
197                    Err(e) => (false, format!("cannot read {path}: {e}")),
198                }
199            }
200            ExitCriterion::FileMinBytes { path, min_bytes } => {
201                let full = resolve(working_dir, path);
202                match std::fs::metadata(&full) {
203                    Ok(meta) if meta.len() >= *min_bytes => {
204                        (true, format!("{path} is {} bytes", meta.len()))
205                    }
206                    Ok(meta) => (
207                        false,
208                        format!("{path} is {} bytes (need >= {min_bytes})", meta.len()),
209                    ),
210                    Err(e) => (false, format!("cannot stat {path}: {e}")),
211                }
212            }
213            ExitCriterion::ResultContains { needle } => {
214                if result_text.contains(needle.as_str()) {
215                    (true, format!("{needle:?} present in result"))
216                } else {
217                    (false, format!("{needle:?} missing from result"))
218                }
219            }
220            ExitCriterion::Command {
221                run,
222                expect_contains,
223            } => match runner {
224                Some(runner) => {
225                    let (success, output) = runner.run(run).await;
226                    match (success, expect_contains) {
227                        (false, _) => (false, truncate_evidence(&output)),
228                        (true, Some(needle)) if !output.contains(needle.as_str()) => (
229                            false,
230                            format!(
231                                "command succeeded but output lacks {needle:?}: {}",
232                                truncate_evidence(&output)
233                            ),
234                        ),
235                        (true, _) => (true, truncate_evidence(&output)),
236                    }
237                }
238                None => (
239                    false,
240                    "command criteria are not available in this runtime".to_string(),
241                ),
242            },
243        };
244        outcomes.push(CriterionOutcome {
245            description,
246            passed,
247            evidence: truncate_evidence(&evidence),
248        });
249    }
250    outcomes
251}
252
253fn resolve(working_dir: &Path, rel: &str) -> PathBuf {
254    working_dir.join(rel)
255}
256
257/// Render the verification block prepended to the sub-agent's result text.
258pub fn render_report(outcomes: &[CriterionOutcome]) -> String {
259    let passed = outcomes.iter().filter(|o| o.passed).count();
260    let total = outcomes.len();
261    let mut out = if passed == total {
262        format!("[completion contract] VERIFIED — {passed}/{total} criteria passed\n")
263    } else {
264        format!(
265            "[completion contract] FAILED — {passed}/{total} criteria passed. \
266             Do NOT treat this task as done; the failed criteria below are the \
267             remaining work.\n"
268        )
269    };
270    for o in outcomes {
271        let mark = if o.passed { "✓" } else { "✗" };
272        out.push_str(&format!("{mark} {} — {}\n", o.description, o.evidence));
273    }
274    out
275}
276
277/// Render the contract for the sub-agent's task prompt so it knows exactly
278/// what will be checked when it declares completion.
279pub fn render_for_prompt(criteria: &[ExitCriterion]) -> String {
280    let mut out = String::from(
281        "\n\nCompletion contract: after you finish, the runtime will VERIFY these \
282         criteria with real checks (file reads / commands). Your result is only \
283         accepted as done if all pass — make each one true before submitting:\n",
284    );
285    for c in criteria {
286        out.push_str(&format!("- {}\n", c.describe()));
287    }
288    out
289}
290
291#[cfg(test)]
292mod tests {
293    use super::*;
294    use serde_json::json;
295
296    struct StubRunner {
297        success: bool,
298        output: &'static str,
299    }
300
301    #[async_trait]
302    impl CommandRunner for StubRunner {
303        async fn run(&self, _command: &str) -> (bool, String) {
304            (self.success, self.output.to_string())
305        }
306    }
307
308    fn temp_dir(tag: &str) -> PathBuf {
309        let dir = std::env::temp_dir().join(format!(
310            "microclaw-contract-{tag}-{}-{}",
311            std::process::id(),
312            std::time::SystemTime::now()
313                .duration_since(std::time::UNIX_EPOCH)
314                .unwrap()
315                .as_nanos()
316        ));
317        std::fs::create_dir_all(&dir).unwrap();
318        dir
319    }
320
321    #[test]
322    fn parse_accepts_valid_and_rejects_invalid() {
323        let ok = parse_exit_criteria(Some(&json!([
324            {"type": "file_exists", "path": "report.md"},
325            {"type": "file_contains", "path": "report.md", "needle": "## Summary"},
326            {"type": "file_min_bytes", "path": "out.pdf", "min_bytes": 10240},
327            {"type": "result_contains", "needle": "done"},
328            {"type": "command", "run": "cargo test -q"},
329            {"type": "command", "run": "wc -l x", "expect_contains": "30"}
330        ])))
331        .unwrap();
332        assert_eq!(ok.len(), 6);
333
334        assert!(parse_exit_criteria(None).unwrap().is_empty());
335        assert!(parse_exit_criteria(Some(&json!(null))).unwrap().is_empty());
336        assert!(parse_exit_criteria(Some(&json!("nope"))).is_err());
337        assert!(parse_exit_criteria(Some(&json!([{"type": "bogus"}]))).is_err());
338        // Unknown extra fields rejected (deny_unknown_fields).
339        assert!(
340            parse_exit_criteria(Some(&json!([{"type": "file_exists", "path": "a", "x": 1}])))
341                .is_err()
342        );
343    }
344
345    #[test]
346    fn parse_rejects_unsafe_paths_and_oversize() {
347        assert!(parse_exit_criteria(Some(&json!([
348            {"type": "file_exists", "path": "/etc/passwd"}
349        ])))
350        .is_err());
351        assert!(parse_exit_criteria(Some(&json!([
352            {"type": "file_exists", "path": "../secrets"}
353        ])))
354        .is_err());
355        assert!(parse_exit_criteria(Some(&json!([
356            {"type": "file_contains", "path": "~/.ssh/id_rsa", "needle": "x"}
357        ])))
358        .is_err());
359        let too_many: Vec<_> = (0..9)
360            .map(|i| json!({"type": "file_exists", "path": format!("f{i}")}))
361            .collect();
362        assert!(parse_exit_criteria(Some(&json!(too_many))).is_err());
363    }
364
365    #[tokio::test]
366    async fn file_criteria_verify_against_working_dir() {
367        let dir = temp_dir("files");
368        std::fs::write(dir.join("report.md"), "# Report\n## Summary\nAll good.").unwrap();
369
370        let criteria = vec![
371            ExitCriterion::FileExists {
372                path: "report.md".into(),
373            },
374            ExitCriterion::FileContains {
375                path: "report.md".into(),
376                needle: "## Summary".into(),
377            },
378            ExitCriterion::FileMinBytes {
379                path: "report.md".into(),
380                min_bytes: 10,
381            },
382            ExitCriterion::FileExists {
383                path: "missing.md".into(),
384            },
385        ];
386        let outcomes = verify_criteria(&criteria, "", &dir, None).await;
387        assert_eq!(
388            outcomes.iter().map(|o| o.passed).collect::<Vec<_>>(),
389            vec![true, true, true, false]
390        );
391
392        std::fs::remove_dir_all(&dir).ok();
393    }
394
395    #[tokio::test]
396    async fn result_and_command_criteria() {
397        let dir = temp_dir("cmd");
398        let criteria = vec![
399            ExitCriterion::ResultContains {
400                needle: "30 files updated".into(),
401            },
402            ExitCriterion::Command {
403                run: "true".into(),
404                expect_contains: None,
405            },
406        ];
407
408        let ok_runner = StubRunner {
409            success: true,
410            output: "ok",
411        };
412        let outcomes =
413            verify_criteria(&criteria, "Done: 30 files updated.", &dir, Some(&ok_runner)).await;
414        assert!(outcomes.iter().all(|o| o.passed));
415
416        // Failing command + expect_contains mismatch + no runner.
417        let fail_runner = StubRunner {
418            success: true,
419            output: "27 matches",
420        };
421        let criteria = vec![ExitCriterion::Command {
422            run: "grep -c x".into(),
423            expect_contains: Some("30".into()),
424        }];
425        let outcomes = verify_criteria(&criteria, "", &dir, Some(&fail_runner)).await;
426        assert!(!outcomes[0].passed);
427        assert!(outcomes[0].evidence.contains("lacks"));
428
429        let outcomes = verify_criteria(&criteria, "", &dir, None).await;
430        assert!(!outcomes[0].passed);
431
432        std::fs::remove_dir_all(&dir).ok();
433    }
434
435    #[test]
436    fn report_renders_verified_and_failed() {
437        let verified = render_report(&[CriterionOutcome {
438            description: "file `x` exists".into(),
439            passed: true,
440            evidence: "x exists".into(),
441        }]);
442        assert!(verified.contains("VERIFIED — 1/1"));
443
444        let failed = render_report(&[
445            CriterionOutcome {
446                description: "a".into(),
447                passed: true,
448                evidence: "e1".into(),
449            },
450            CriterionOutcome {
451                description: "b".into(),
452                passed: false,
453                evidence: "e2".into(),
454            },
455        ]);
456        assert!(failed.contains("FAILED — 1/2"));
457        assert!(failed.contains("✗ b — e2"));
458    }
459
460    #[test]
461    fn prompt_rendering_lists_criteria() {
462        let text = render_for_prompt(&[ExitCriterion::FileExists {
463            path: "out.pdf".into(),
464        }]);
465        assert!(text.contains("Completion contract"));
466        assert!(text.contains("file `out.pdf` exists"));
467    }
468}