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 raw: Value::Null,
227 }
228 }
229 fn resp(text: &str) -> ModelResponse {
230 ModelResponse {
231 model: "m".to_owned(),
232 text: text.to_owned(),
233 in_tokens: 1,
234 out_tokens: 1,
235 raw: Value::Null,
236 }
237 }
238
239 fn echo_pass() -> SubprocessGate {
241 SubprocessGate::new(
242 "always-pass",
243 "sh",
244 vec![
245 "-c".into(),
246 r#"echo '{"verdict":"pass","score":1.0}'"#.into(),
247 ],
248 Duration::from_secs(5),
249 )
250 }
251
252 #[tokio::test]
253 async fn passing_subprocess_gate() {
254 let r = echo_pass().evaluate(&req(), &resp("x")).await;
255 assert_eq!(r.verdict, Verdict::Pass);
256 assert_eq!(r.score.map(firstpass_core::Score::value), Some(1.0));
257 }
258
259 #[tokio::test]
260 async fn gate_reads_candidate_from_stdin_as_data() {
261 let g = SubprocessGate::new(
264 "reads-stdin",
265 "sh",
266 vec![
267 "-c".into(),
268 r#"c=$(cat); case "$c" in *SECRET*) echo '{"verdict":"fail"}';; *) echo '{"verdict":"pass"}';; esac"#.into(),
270 ],
271 Duration::from_secs(5),
272 );
273 assert_eq!(
274 g.evaluate(&req(), &resp("SECRET")).await.verdict,
275 Verdict::Fail
276 );
277 assert_eq!(
278 g.evaluate(&req(), &resp("benign")).await.verdict,
279 Verdict::Pass
280 );
281 }
282
283 #[tokio::test]
284 async fn nonzero_exit_becomes_abstain() {
285 let g = SubprocessGate::new(
286 "crasher",
287 "sh",
288 vec!["-c".into(), "echo oops >&2; exit 3".into()],
289 Duration::from_secs(5),
290 );
291 let r = g.evaluate(&req(), &resp("x")).await;
292 assert_eq!(r.verdict, Verdict::Abstain);
293 assert_eq!(r.reason.as_deref(), Some(reason::GATE_CRASH));
294 }
295
296 #[tokio::test]
297 async fn timeout_becomes_abstain() {
298 let g = SubprocessGate::new(
299 "slow",
300 "sh",
301 vec!["-c".into(), "sleep 10".into()],
302 Duration::from_millis(150),
303 );
304 let r = g.evaluate(&req(), &resp("x")).await;
305 assert_eq!(r.verdict, Verdict::Abstain);
306 assert_eq!(r.reason.as_deref(), Some(reason::TIMEOUT));
307 }
308
309 #[tokio::test]
310 async fn malformed_stdout_becomes_abstain() {
311 let g = SubprocessGate::new(
312 "garbage",
313 "sh",
314 vec!["-c".into(), "echo not-json".into()],
315 Duration::from_secs(5),
316 );
317 assert_eq!(
318 g.evaluate(&req(), &resp("x")).await.verdict,
319 Verdict::Abstain
320 );
321 }
322
323 #[tokio::test]
324 async fn missing_program_becomes_abstain() {
325 let g = SubprocessGate::new(
326 "nope",
327 "this-binary-does-not-exist-firstpass",
328 vec![],
329 Duration::from_secs(5),
330 );
331 assert_eq!(
332 g.evaluate(&req(), &resp("x")).await.verdict,
333 Verdict::Abstain
334 );
335 }
336}