Skip to main content

firstpass_proxy/
subprocess.rs

1//! Subprocess gate plugin contract (SPEC §8.1) — the language-agnostic moat mechanism.
2//!
3//! A gate can be *any* executable: a 10-line Python script, a compiled binary, `bash -c '…'`.
4//! The proxy speaks to it over a tiny, stable contract:
5//! - **stdin** (JSON): `{ "gate_id", "candidate", "request": { model, system, messages } }`.
6//!   The model output is passed as **data on stdin, never as a command-line argument**, so a
7//!   malicious candidate can't be interpreted as flags or shell (§8.3.2 injection resistance).
8//! - **stdout** (JSON): `{ "verdict": "pass|fail|abstain", "score"?: 0.0-1.0, "reason"?, "evidence"? }`.
9//! - **exit ≠ 0** → gate error → `abstain` (reason `gate_crash`, stderr captured).
10//! - **timeout** → the child is killed → `abstain` (reason `timeout`).
11//!
12//! A gate never gets the API keys or anything beyond the candidate + request metadata it needs.
13
14use 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/// A gate backed by an external process.
26#[derive(Debug, Clone)]
27pub struct SubprocessGate {
28    id: String,
29    program: String,
30    args: Vec<String>,
31    timeout: Duration,
32}
33
34impl SubprocessGate {
35    /// Build a subprocess gate. `program`/`args` are operator-configured (trusted); the
36    /// untrusted candidate only ever travels on stdin.
37    #[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/// What the proxy writes to the gate's stdin.
54#[derive(Serialize)]
55struct GateInput<'a> {
56    gate_id: &'a str,
57    candidate: &'a str,
58    request: GateRequestView<'a>,
59}
60
61/// The request metadata a gate may need — never keys, never more than this.
62#[derive(Serialize)]
63struct GateRequestView<'a> {
64    model: &'a str,
65    system: Option<&'a str>,
66    messages: &'a [crate::provider::ChatMessage],
67}
68
69/// What the proxy reads from the gate's stdout.
70#[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
120/// Internal run errors, mapped to abstain reasons by the caller.
121enum GateRunError {
122    Timeout,
123    Spawn(std::io::Error),
124    NonZero { code: i32, stderr: String },
125}
126
127impl SubprocessGate {
128    /// Spawn the child, write `payload` to stdin, enforce the timeout, and return stdout bytes.
129    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            // Ignore a write error here: the child may have exited early; wait_with_output below
141            // surfaces the real failure (non-zero exit / empty stdout).
142            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), // kill_on_drop reaps the child
150        };
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    /// Parse the gate's stdout JSON into a [`GateResult`], defaulting sanely on malformed output.
163    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    /// A gate that always passes (ignores input).
239    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        // The gate echoes back a fail iff it can read the candidate from stdin JSON — proving the
261        // candidate travels as stdin data (jq extracts .candidate), not as an argument.
262        let g = SubprocessGate::new(
263            "reads-stdin",
264            "sh",
265            vec![
266                "-c".into(),
267                // If .candidate == "SECRET", fail; else pass. Reads stdin, never argv.
268                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}