Skip to main content

a3s_code_core/
read_only_verifier.rs

1//! Optional read-only producer of a LOOP-1 verification report.
2//!
3//! Off by default. The editor role cannot mark the report passed. A verifier
4//! `Failed` or `NeedsReview` remains a non-pass under the completion gate.
5
6use crate::verification::{VerificationCheck, VerificationReport, VerificationStatus};
7
8const MUTATING_TOOLS: &[&str] = &["write", "edit", "patch", "download"];
9/// Nested loops do not inherit the verifier flag, so they can write after this
10/// turn has already been labeled read-only.
11const NESTED_WRITER_TOOLS: &[&str] = &["skill", "task", "batch"];
12
13#[derive(Debug, Clone, Copy, PartialEq, Eq)]
14pub enum ReportAuthor {
15    Verifier,
16    Editor,
17    Host,
18}
19
20pub const VERIFIER_TURN_INPUT: &str = "Verification role for this turn only. Presented tools exclude write, edit, patch, download, Skill, task, and batch. A sentence does not close the completion gate.";
21
22pub fn should_invoke(enabled: bool, mutated: bool) -> bool {
23    enabled && mutated
24}
25
26pub fn presentable(tool: &str) -> bool {
27    let tool = tool.to_ascii_lowercase();
28    !MUTATING_TOOLS.contains(&tool.as_str()) && !NESTED_WRITER_TOOLS.contains(&tool.as_str())
29}
30
31pub fn tool_allowed(tool: &str, args: &serde_json::Value) -> bool {
32    let tool = tool.to_ascii_lowercase();
33    if MUTATING_TOOLS.contains(&tool.as_str()) || NESTED_WRITER_TOOLS.contains(&tool.as_str()) {
34        return false;
35    }
36    if tool == "bash" {
37        return !looks_mutating(args);
38    }
39    if tool == "git" {
40        return !git_command_mutates(args);
41    }
42    true
43}
44
45/// Nested `program` calls do not re-enter the verifier tool list. A check
46/// producer may run; anything else must have declared itself read-only.
47pub fn nested_call_allowed(tool: &str, args: &serde_json::Value, declared_read_only: bool) -> bool {
48    if !tool_allowed(tool, args) {
49        return false;
50    }
51    matches!(
52        tool.to_ascii_lowercase().as_str(),
53        "bash" | "git" | "program"
54    ) || declared_read_only
55}
56
57pub fn accept_report(
58    author: ReportAuthor,
59    report: VerificationReport,
60) -> Option<VerificationReport> {
61    match author {
62        ReportAuthor::Editor => None,
63        ReportAuthor::Verifier | ReportAuthor::Host => Some(report),
64    }
65}
66
67fn git_command_mutates(args: &serde_json::Value) -> bool {
68    let command = args
69        .get("command")
70        .and_then(serde_json::Value::as_str)
71        .unwrap_or("");
72    match command {
73        "checkout" => true,
74        "branch" => args
75            .get("name")
76            .and_then(serde_json::Value::as_str)
77            .is_some(),
78        "stash" => {
79            args.get("message")
80                .and_then(serde_json::Value::as_str)
81                .is_some()
82                || args
83                    .get("include_untracked")
84                    .and_then(serde_json::Value::as_bool)
85                    .unwrap_or(false)
86        }
87        "worktree" => matches!(
88            args.get("subcommand").and_then(serde_json::Value::as_str),
89            Some("create" | "remove")
90        ),
91        _ => false,
92    }
93}
94
95fn looks_mutating(args: &serde_json::Value) -> bool {
96    let command = args
97        .get("command")
98        .and_then(serde_json::Value::as_str)
99        .unwrap_or("");
100    command.contains('>')
101        || command.contains("rm ")
102        || command.contains("mv ")
103        || command.contains("tee ")
104        || args.get("changed_paths").is_some()
105}
106
107pub fn failed_report(digest: &str) -> VerificationReport {
108    VerificationReport::new(
109        "verifier",
110        vec![
111            VerificationCheck::required("review", "verifier", "rejected")
112                .with_status(VerificationStatus::Failed),
113        ],
114    )
115    .with_effect_digest(digest)
116}
117
118#[cfg(test)]
119mod tests {
120    use super::*;
121    use crate::harness_loop::{decide_completion, CompletionGate, MutationLedger};
122
123    #[test]
124    fn presented_tools_exclude_mutations() {
125        assert!(!tool_allowed("write", &serde_json::json!({})));
126        assert!(!tool_allowed("edit", &serde_json::json!({})));
127        assert!(!tool_allowed("patch", &serde_json::json!({})));
128        assert!(!tool_allowed(
129            "bash",
130            &serde_json::json!({"command": "echo hi > file"})
131        ));
132        assert!(tool_allowed(
133            "read",
134            &serde_json::json!({"file_path": "a.rs"})
135        ));
136        assert!(tool_allowed(
137            "bash",
138            &serde_json::json!({"command": "cargo test"})
139        ));
140        assert!(!tool_allowed(
141            "git",
142            &serde_json::json!({"command": "checkout", "ref": "main"})
143        ));
144        assert!(!tool_allowed(
145            "git",
146            &serde_json::json!({"command": "stash", "message": "wip"})
147        ));
148        assert!(!tool_allowed(
149            "git",
150            &serde_json::json!({"command": "worktree", "subcommand": "create"})
151        ));
152        assert!(!tool_allowed(
153            "git",
154            &serde_json::json!({"command": "branch", "name": "feature"})
155        ));
156        assert!(tool_allowed("git", &serde_json::json!({"command": "diff"})));
157        assert!(tool_allowed(
158            "git",
159            &serde_json::json!({"command": "stash"})
160        ));
161        assert!(!tool_allowed(
162            "Skill",
163            &serde_json::json!({"skill_name": "review"})
164        ));
165        assert!(!tool_allowed(
166            "task",
167            &serde_json::json!({"prompt": "edit the file"})
168        ));
169        assert!(!tool_allowed("batch", &serde_json::json!({})));
170        assert!(!presentable("Skill"));
171        assert!(!presentable("task"));
172        assert!(!presentable("batch"));
173        assert!(!presentable("write"));
174        assert!(!presentable("edit"));
175        assert!(!presentable("patch"));
176        assert!(!presentable("download"));
177        assert!(presentable("read"));
178        assert!(presentable("bash"));
179        assert!(!should_invoke(false, true));
180    }
181
182    #[test]
183    fn failed_verifier_report_blocks_loop_1_success() {
184        let mut ledger = MutationLedger::default();
185        ledger.observe_tool(
186            "write",
187            0,
188            Some(&serde_json::json!({"file_path": "a.rs", "after": "x"})),
189        );
190        let report = accept_report(ReportAuthor::Verifier, failed_report(ledger.digest())).unwrap();
191        assert!(matches!(
192            decide_completion(&ledger, &[report], &[], false),
193            CompletionGate::Incomplete { .. }
194        ));
195    }
196
197    #[test]
198    fn editor_prose_cannot_flip_the_report_to_passed() {
199        let report = VerificationReport::new(
200            "editor",
201            vec![
202                VerificationCheck::required("review", "prose", "tests passed")
203                    .with_status(VerificationStatus::Passed),
204            ],
205        );
206        assert!(accept_report(ReportAuthor::Editor, report).is_none());
207    }
208
209    #[test]
210    fn default_session_does_not_invoke_a_verifier() {
211        assert!(!should_invoke(false, true));
212        assert!(!should_invoke(true, false));
213        assert!(should_invoke(true, true));
214    }
215}