use crate::gate::Gate;
use crate::provider::{ModelRequest, ModelResponse};
use async_trait::async_trait;
use firstpass_core::verdict::reason;
use firstpass_core::{GateResult, Score, Verdict};
use serde::{Deserialize, Serialize};
use std::process::Stdio;
use std::time::{Duration, Instant};
use tokio::io::AsyncWriteExt;
use tokio::process::Command;
#[derive(Debug, Clone)]
pub struct SubprocessGate {
id: String,
program: String,
args: Vec<String>,
timeout: Duration,
}
impl SubprocessGate {
#[must_use]
pub fn new(
id: impl Into<String>,
program: impl Into<String>,
args: Vec<String>,
timeout: Duration,
) -> Self {
Self {
id: id.into(),
program: program.into(),
args,
timeout,
}
}
}
#[derive(Serialize)]
struct GateInput<'a> {
gate_id: &'a str,
candidate: &'a str,
request: GateRequestView<'a>,
}
#[derive(Serialize)]
struct GateRequestView<'a> {
model: &'a str,
system: Option<&'a str>,
messages: &'a [crate::provider::ChatMessage],
}
#[derive(Deserialize)]
struct GateOutput {
verdict: String,
#[serde(default)]
score: Option<f64>,
#[serde(default)]
reason: Option<String>,
#[serde(default)]
evidence: Option<String>,
}
#[async_trait]
impl Gate for SubprocessGate {
fn id(&self) -> &str {
&self.id
}
async fn evaluate(&self, req: &ModelRequest, resp: &ModelResponse) -> GateResult {
let start = Instant::now();
let input = GateInput {
gate_id: &self.id,
candidate: &resp.text,
request: GateRequestView {
model: &req.model,
system: req.system.as_deref(),
messages: &req.messages,
},
};
let payload = match serde_json::to_vec(&input) {
Ok(p) => p,
Err(e) => {
return self.abstain(reason::GATE_CRASH, &format!("serialize input: {e}"), start);
}
};
match self.run(&payload).await {
Ok(out) => self.parse_output(&out, start),
Err(GateRunError::Timeout) => self.abstain(reason::TIMEOUT, "gate timed out", start),
Err(GateRunError::Spawn(e)) => {
self.abstain(reason::GATE_CRASH, &format!("spawn: {e}"), start)
}
Err(GateRunError::NonZero { code, stderr }) => self.abstain(
reason::GATE_CRASH,
&format!("exit {code}: {}", truncate(&stderr, 200)),
start,
),
}
}
}
enum GateRunError {
Timeout,
Spawn(std::io::Error),
NonZero { code: i32, stderr: String },
}
impl SubprocessGate {
async fn run(&self, payload: &[u8]) -> Result<Vec<u8>, GateRunError> {
let mut child = Command::new(&self.program)
.args(&self.args)
.stdin(Stdio::piped())
.stdout(Stdio::piped())
.stderr(Stdio::piped())
.kill_on_drop(true)
.spawn()
.map_err(GateRunError::Spawn)?;
if let Some(mut stdin) = child.stdin.take() {
let _ = stdin.write_all(payload).await;
let _ = stdin.shutdown().await;
}
let output = match tokio::time::timeout(self.timeout, child.wait_with_output()).await {
Ok(Ok(o)) => o,
Ok(Err(e)) => return Err(GateRunError::Spawn(e)),
Err(_elapsed) => return Err(GateRunError::Timeout), };
if output.status.success() {
Ok(output.stdout)
} else {
Err(GateRunError::NonZero {
code: output.status.code().unwrap_or(-1),
stderr: String::from_utf8_lossy(&output.stderr).into_owned(),
})
}
}
fn parse_output(&self, stdout: &[u8], start: Instant) -> GateResult {
let ms = elapsed_ms(start);
let out: GateOutput = match serde_json::from_slice(stdout) {
Ok(o) => o,
Err(e) => {
return self.abstain(reason::GATE_CRASH, &format!("bad stdout json: {e}"), start);
}
};
let verdict = match out.verdict.as_str() {
"pass" => Verdict::Pass,
"fail" => Verdict::Fail,
"abstain" => Verdict::Abstain,
other => {
return self.abstain(
reason::GATE_CRASH,
&format!("unknown verdict {other:?}"),
start,
);
}
};
GateResult {
gate_id: self.id.clone(),
verdict,
score: out.score.and_then(|s| Score::new(s).ok()),
cost_usd: 0.0,
ms,
reason: out.reason,
evidence_ref: out.evidence,
}
}
fn abstain(&self, reason: &str, detail: &str, start: Instant) -> GateResult {
tracing::warn!(gate = %self.id, %reason, %detail, "subprocess gate abstained");
let mut r = GateResult::abstain(&self.id, reason, elapsed_ms(start));
r.evidence_ref = Some(detail.to_owned());
r
}
}
fn elapsed_ms(start: Instant) -> u64 {
u64::try_from(start.elapsed().as_millis()).unwrap_or(u64::MAX)
}
fn truncate(s: &str, max: usize) -> String {
if s.len() <= max {
s.to_owned()
} else {
format!("{}…", &s[..max])
}
}
#[cfg(test)]
mod tests {
use super::*;
use serde_json::Value;
fn req() -> ModelRequest {
ModelRequest {
model: "anthropic/claude-haiku-4-5".to_owned(),
system: None,
messages: vec![],
max_tokens: 16,
tools: Value::Null,
}
}
fn resp(text: &str) -> ModelResponse {
ModelResponse {
model: "m".to_owned(),
text: text.to_owned(),
in_tokens: 1,
out_tokens: 1,
raw: Value::Null,
}
}
fn echo_pass() -> SubprocessGate {
SubprocessGate::new(
"always-pass",
"sh",
vec![
"-c".into(),
r#"echo '{"verdict":"pass","score":1.0}'"#.into(),
],
Duration::from_secs(5),
)
}
#[tokio::test]
async fn passing_subprocess_gate() {
let r = echo_pass().evaluate(&req(), &resp("x")).await;
assert_eq!(r.verdict, Verdict::Pass);
assert_eq!(r.score.map(firstpass_core::Score::value), Some(1.0));
}
#[tokio::test]
async fn gate_reads_candidate_from_stdin_as_data() {
let g = SubprocessGate::new(
"reads-stdin",
"sh",
vec![
"-c".into(),
r#"c=$(cat); case "$c" in *SECRET*) echo '{"verdict":"fail"}';; *) echo '{"verdict":"pass"}';; esac"#.into(),
],
Duration::from_secs(5),
);
assert_eq!(
g.evaluate(&req(), &resp("SECRET")).await.verdict,
Verdict::Fail
);
assert_eq!(
g.evaluate(&req(), &resp("benign")).await.verdict,
Verdict::Pass
);
}
#[tokio::test]
async fn nonzero_exit_becomes_abstain() {
let g = SubprocessGate::new(
"crasher",
"sh",
vec!["-c".into(), "echo oops >&2; exit 3".into()],
Duration::from_secs(5),
);
let r = g.evaluate(&req(), &resp("x")).await;
assert_eq!(r.verdict, Verdict::Abstain);
assert_eq!(r.reason.as_deref(), Some(reason::GATE_CRASH));
}
#[tokio::test]
async fn timeout_becomes_abstain() {
let g = SubprocessGate::new(
"slow",
"sh",
vec!["-c".into(), "sleep 10".into()],
Duration::from_millis(150),
);
let r = g.evaluate(&req(), &resp("x")).await;
assert_eq!(r.verdict, Verdict::Abstain);
assert_eq!(r.reason.as_deref(), Some(reason::TIMEOUT));
}
#[tokio::test]
async fn malformed_stdout_becomes_abstain() {
let g = SubprocessGate::new(
"garbage",
"sh",
vec!["-c".into(), "echo not-json".into()],
Duration::from_secs(5),
);
assert_eq!(
g.evaluate(&req(), &resp("x")).await.verdict,
Verdict::Abstain
);
}
#[tokio::test]
async fn missing_program_becomes_abstain() {
let g = SubprocessGate::new(
"nope",
"this-binary-does-not-exist-firstpass",
vec![],
Duration::from_secs(5),
);
assert_eq!(
g.evaluate(&req(), &resp("x")).await.verdict,
Verdict::Abstain
);
}
}