a3s_code_core/
read_only_verifier.rs1use crate::verification::{VerificationCheck, VerificationReport, VerificationStatus};
7
8const MUTATING_TOOLS: &[&str] = &["write", "edit", "patch", "download"];
9const 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
45pub 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}