1use crate::gate::Gate;
15use crate::provider::{ModelRequest, ModelResponse};
16use async_trait::async_trait;
17use firstpass_core::verdict::reason;
18use firstpass_core::{GateResult, Score, Verdict};
19use serde::{Deserialize, Serialize};
20use std::process::Stdio;
21use std::time::{Duration, Instant};
22use tokio::io::AsyncWriteExt;
23use tokio::process::Command;
24
25#[derive(Debug, Clone)]
27pub struct SubprocessGate {
28 id: String,
29 program: String,
30 args: Vec<String>,
31 timeout: Duration,
32}
33
34impl SubprocessGate {
35 #[must_use]
38 pub fn new(
39 id: impl Into<String>,
40 program: impl Into<String>,
41 args: Vec<String>,
42 timeout: Duration,
43 ) -> Self {
44 Self {
45 id: id.into(),
46 program: program.into(),
47 args,
48 timeout,
49 }
50 }
51}
52
53#[derive(Serialize)]
55struct GateInput<'a> {
56 gate_id: &'a str,
57 candidate: &'a str,
58 request: GateRequestView<'a>,
59}
60
61#[derive(Serialize)]
63struct GateRequestView<'a> {
64 model: &'a str,
65 system: Option<&'a str>,
66 messages: &'a [crate::provider::ChatMessage],
67}
68
69#[derive(Deserialize)]
71struct GateOutput {
72 verdict: String,
73 #[serde(default)]
74 score: Option<f64>,
75 #[serde(default)]
76 reason: Option<String>,
77 #[serde(default)]
78 evidence: Option<String>,
79}
80
81#[async_trait]
82impl Gate for SubprocessGate {
83 fn id(&self) -> &str {
84 &self.id
85 }
86
87 async fn evaluate(&self, req: &ModelRequest, resp: &ModelResponse) -> GateResult {
88 let start = Instant::now();
89 let input = GateInput {
90 gate_id: &self.id,
91 candidate: &resp.text,
92 request: GateRequestView {
93 model: &req.model,
94 system: req.system.as_deref(),
95 messages: &req.messages,
96 },
97 };
98 let payload = match serde_json::to_vec(&input) {
99 Ok(p) => p,
100 Err(e) => {
101 return self.abstain(reason::GATE_CRASH, &format!("serialize input: {e}"), start);
102 }
103 };
104
105 match self.run(&payload).await {
106 Ok(out) => self.parse_output(&out, start),
107 Err(GateRunError::Timeout) => self.abstain(reason::TIMEOUT, "gate timed out", start),
108 Err(GateRunError::Spawn(e)) => {
109 self.abstain(reason::GATE_CRASH, &format!("spawn: {e}"), start)
110 }
111 Err(GateRunError::NonZero { code, stderr }) => self.abstain(
112 reason::GATE_CRASH,
113 &format!("exit {code}: {}", truncate(&stderr, 200)),
114 start,
115 ),
116 }
117 }
118}
119
120enum GateRunError {
122 Timeout,
123 Spawn(std::io::Error),
124 NonZero { code: i32, stderr: String },
125}
126
127impl SubprocessGate {
128 async fn run(&self, payload: &[u8]) -> Result<Vec<u8>, GateRunError> {
130 let mut child = Command::new(&self.program)
131 .args(&self.args)
132 .stdin(Stdio::piped())
133 .stdout(Stdio::piped())
134 .stderr(Stdio::piped())
135 .kill_on_drop(true)
136 .spawn()
137 .map_err(GateRunError::Spawn)?;
138
139 if let Some(mut stdin) = child.stdin.take() {
140 let _ = stdin.write_all(payload).await;
143 let _ = stdin.shutdown().await;
144 }
145
146 let output = match tokio::time::timeout(self.timeout, child.wait_with_output()).await {
147 Ok(Ok(o)) => o,
148 Ok(Err(e)) => return Err(GateRunError::Spawn(e)),
149 Err(_elapsed) => return Err(GateRunError::Timeout), };
151
152 if output.status.success() {
153 Ok(output.stdout)
154 } else {
155 Err(GateRunError::NonZero {
156 code: output.status.code().unwrap_or(-1),
157 stderr: String::from_utf8_lossy(&output.stderr).into_owned(),
158 })
159 }
160 }
161
162 fn parse_output(&self, stdout: &[u8], start: Instant) -> GateResult {
164 let ms = elapsed_ms(start);
165 let out: GateOutput = match serde_json::from_slice(stdout) {
166 Ok(o) => o,
167 Err(e) => {
168 return self.abstain(reason::GATE_CRASH, &format!("bad stdout json: {e}"), start);
169 }
170 };
171 let verdict = match out.verdict.as_str() {
172 "pass" => Verdict::Pass,
173 "fail" => Verdict::Fail,
174 "abstain" => Verdict::Abstain,
175 other => {
176 return self.abstain(
177 reason::GATE_CRASH,
178 &format!("unknown verdict {other:?}"),
179 start,
180 );
181 }
182 };
183 GateResult {
184 gate_id: self.id.clone(),
185 verdict,
186 score: out.score.and_then(|s| Score::new(s).ok()),
187 cost_usd: 0.0,
188 ms,
189 reason: out.reason,
190 evidence_ref: out.evidence,
191 }
192 }
193
194 fn abstain(&self, reason: &str, detail: &str, start: Instant) -> GateResult {
195 tracing::warn!(gate = %self.id, %reason, %detail, "subprocess gate abstained");
196 let mut r = GateResult::abstain(&self.id, reason, elapsed_ms(start));
197 r.evidence_ref = Some(detail.to_owned());
198 r
199 }
200}
201
202fn elapsed_ms(start: Instant) -> u64 {
203 u64::try_from(start.elapsed().as_millis()).unwrap_or(u64::MAX)
204}
205
206fn truncate(s: &str, max: usize) -> String {
207 if s.len() <= max {
208 s.to_owned()
209 } else {
210 format!("{}…", &s[..max])
211 }
212}
213
214#[cfg(test)]
215mod tests {
216 use super::*;
217 use serde_json::Value;
218
219 fn req() -> ModelRequest {
220 ModelRequest {
221 model: "anthropic/claude-haiku-4-5".to_owned(),
222 system: None,
223 messages: vec![],
224 max_tokens: 16,
225 tools: Value::Null,
226 }
227 }
228 fn resp(text: &str) -> ModelResponse {
229 ModelResponse {
230 model: "m".to_owned(),
231 text: text.to_owned(),
232 in_tokens: 1,
233 out_tokens: 1,
234 raw: Value::Null,
235 }
236 }
237
238 fn echo_pass() -> SubprocessGate {
240 SubprocessGate::new(
241 "always-pass",
242 "sh",
243 vec![
244 "-c".into(),
245 r#"echo '{"verdict":"pass","score":1.0}'"#.into(),
246 ],
247 Duration::from_secs(5),
248 )
249 }
250
251 #[tokio::test]
252 async fn passing_subprocess_gate() {
253 let r = echo_pass().evaluate(&req(), &resp("x")).await;
254 assert_eq!(r.verdict, Verdict::Pass);
255 assert_eq!(r.score.map(firstpass_core::Score::value), Some(1.0));
256 }
257
258 #[tokio::test]
259 async fn gate_reads_candidate_from_stdin_as_data() {
260 let g = SubprocessGate::new(
263 "reads-stdin",
264 "sh",
265 vec![
266 "-c".into(),
267 r#"c=$(cat); case "$c" in *SECRET*) echo '{"verdict":"fail"}';; *) echo '{"verdict":"pass"}';; esac"#.into(),
269 ],
270 Duration::from_secs(5),
271 );
272 assert_eq!(
273 g.evaluate(&req(), &resp("SECRET")).await.verdict,
274 Verdict::Fail
275 );
276 assert_eq!(
277 g.evaluate(&req(), &resp("benign")).await.verdict,
278 Verdict::Pass
279 );
280 }
281
282 #[tokio::test]
283 async fn nonzero_exit_becomes_abstain() {
284 let g = SubprocessGate::new(
285 "crasher",
286 "sh",
287 vec!["-c".into(), "echo oops >&2; exit 3".into()],
288 Duration::from_secs(5),
289 );
290 let r = g.evaluate(&req(), &resp("x")).await;
291 assert_eq!(r.verdict, Verdict::Abstain);
292 assert_eq!(r.reason.as_deref(), Some(reason::GATE_CRASH));
293 }
294
295 #[tokio::test]
296 async fn timeout_becomes_abstain() {
297 let g = SubprocessGate::new(
298 "slow",
299 "sh",
300 vec!["-c".into(), "sleep 10".into()],
301 Duration::from_millis(150),
302 );
303 let r = g.evaluate(&req(), &resp("x")).await;
304 assert_eq!(r.verdict, Verdict::Abstain);
305 assert_eq!(r.reason.as_deref(), Some(reason::TIMEOUT));
306 }
307
308 #[tokio::test]
309 async fn malformed_stdout_becomes_abstain() {
310 let g = SubprocessGate::new(
311 "garbage",
312 "sh",
313 vec!["-c".into(), "echo not-json".into()],
314 Duration::from_secs(5),
315 );
316 assert_eq!(
317 g.evaluate(&req(), &resp("x")).await.verdict,
318 Verdict::Abstain
319 );
320 }
321
322 #[tokio::test]
323 async fn missing_program_becomes_abstain() {
324 let g = SubprocessGate::new(
325 "nope",
326 "this-binary-does-not-exist-firstpass",
327 vec![],
328 Duration::from_secs(5),
329 );
330 assert_eq!(
331 g.evaluate(&req(), &resp("x")).await.verdict,
332 Verdict::Abstain
333 );
334 }
335}