Skip to main content

areev_loop/
external.rs

1//! External command analyzers — the `--analyzer-cmd` seam (SDK §11).
2//!
3//! A subprocess that receives a live-grain snapshot on stdin and returns
4//! advisory findings on stdout. It runs at trust class [`Command`] with
5//! auto-apply [`Never`]: a domain-specific subprocess can *surface* an issue a
6//! human then reviews, but can never mutate memory. Any failure — cannot spawn,
7//! non-zero exit, garbled output — *skips the analyzer for the run*; it never
8//! crashes the pass or the sibling analyzers (the engine already treats an
9//! `analyze` error as a per-analyzer skip).
10//!
11//! ## Protocol (one JSON object on stdin, one on stdout)
12//!
13//! - **Probe** (at construction): `{"loop_analyzer":1,"op":"probe"}` →
14//!   `{"id":"acme.pii/1","title":"PII scan","description":"…"}` — every field
15//!   optional; a missing/garbled probe just falls back to an id derived from the
16//!   command name.
17//! - **Analyze** (per run): `{"loop_analyzer":1,"op":"analyze","now_ms":…,
18//!   "watermark_ms":…,"grains":[<grain>…]}` → `{"findings":[{"target":
19//!   "entity:ns/subject","summary":"…","severity":"low","evidence":["<hash>"],
20//!   "confidence":0.8}]}`. Each `<grain>` is a `{hash,grain_type,namespace,
21//!   created_at_ms,fields}` record; a finding must name a `target` and a
22//!   `summary` (others are dropped).
23//!
24//! [`Command`]: crate::manifest::TrustClass::Command
25//! [`Never`]: crate::manifest::AutoApplyClass::Never
26
27use crate::analyzer::{AnalyzeCtx, Analyzer};
28use crate::error::{Error, Result};
29use crate::manifest::{
30    AnalyzerManifest, AutoApplyClass, CadenceClass, TargetClass, Tier, TrustClass,
31};
32use crate::model::{grain_type, ActionKind, GrainRecord, Severity};
33use crate::recommendation::{Proposal, RecDraft, Summary};
34use crate::substrate::ReadOpts;
35use serde::{Deserialize, Serialize};
36use serde_json::{Map, Value};
37use std::io::Write;
38use std::process::{Command, Stdio};
39
40/// Live grains handed to the subprocess per run — a pipe-size backstop, not a
41/// correctness bound (the snapshot is best-effort context).
42const MAX_GRAINS: usize = 2000;
43
44/// An [`Analyzer`] backed by an external command (`--analyzer-cmd`).
45pub struct CommandAnalyzer {
46    argv: Vec<String>,
47    manifest: AnalyzerManifest,
48}
49
50impl CommandAnalyzer {
51    /// Construct and probe. The probe lets the command self-describe; a probe
52    /// that fails to *spawn* errors here (at construction, not mid-run), while a
53    /// probe that merely returns nothing usable falls back to an id generated
54    /// from the command name. The resulting manifest is always trust class
55    /// `Command` / auto-apply `Never` — advisory only, regardless of what the
56    /// command claims.
57    pub fn new(cmd: &str) -> Result<Self> {
58        let argv: Vec<String> = cmd.split_whitespace().map(str::to_string).collect();
59        if argv.is_empty() {
60            return Err(Error::AnalyzerFailed {
61                id: "command".into(),
62                message: "--analyzer-cmd is empty".into(),
63            });
64        }
65        let probe = run(&argv, r#"{"loop_analyzer":1,"op":"probe"}"#).map_err(|m| {
66            Error::AnalyzerFailed {
67                id: "command".into(),
68                message: m,
69            }
70        })?;
71        let reply: ProbeReply = serde_json::from_str(probe.trim()).unwrap_or_default();
72        let id = normalize_id(&reply.id, &argv[0]);
73        let title = non_empty(reply.title).unwrap_or_else(|| id.clone());
74        let description =
75            non_empty(reply.description).unwrap_or_else(|| format!("External analyzer: {cmd}"));
76        let manifest = AnalyzerManifest {
77            id,
78            title,
79            description,
80            tier: Tier::T0,
81            cadence: CadenceClass::Batch,
82            requires: vec![],
83            target_classes: vec![TargetClass::Memory],
84            auto_apply: AutoApplyClass::Never, // advisory only — never trust a subprocess to mutate
85            trust_class: TrustClass::Command,
86            params: vec![],
87            default_on: true, // registered explicitly → on
88        };
89        Ok(CommandAnalyzer { argv, manifest })
90    }
91}
92
93impl Analyzer for CommandAnalyzer {
94    fn manifest(&self) -> &AnalyzerManifest {
95        &self.manifest
96    }
97
98    fn analyze(&self, ctx: &AnalyzeCtx) -> Result<Vec<RecDraft>> {
99        // Snapshot the common user grain types (live, capped) as context.
100        let mut grains: Vec<GrainRecord> = Vec::new();
101        for gt in [
102            grain_type::FACT,
103            grain_type::OBSERVATION,
104            grain_type::TOOL,
105            grain_type::SKILL,
106            grain_type::GOAL,
107        ] {
108            if grains.len() >= MAX_GRAINS {
109                break;
110            }
111            if let Ok(mut g) = ctx.grains_of_type(gt, ReadOpts::default()) {
112                grains.append(&mut g);
113            }
114        }
115        grains.truncate(MAX_GRAINS);
116
117        let req = AnalyzeRequest {
118            loop_analyzer: 1,
119            op: "analyze",
120            now_ms: ctx.now_ms(),
121            watermark_ms: ctx.watermark_ms(),
122            grains: &grains,
123        };
124        let body = serde_json::to_string(&req).map_err(|e| Error::AnalyzerFailed {
125            id: self.manifest.id.clone(),
126            message: format!("serialize request: {e}"),
127        })?;
128        let raw = run(&self.argv, &body).map_err(|m| Error::AnalyzerFailed {
129            id: self.manifest.id.clone(),
130            message: m,
131        })?;
132        // Garbled output yields no findings, never an error (the model/command
133        // can't crash the run) — same fail-soft posture as the LLM parsers.
134        let reply: AnalyzeReply = serde_json::from_str(raw.trim()).unwrap_or_default();
135        Ok(reply.findings.into_iter().filter_map(to_draft).collect())
136    }
137}
138
139/// Spawn the command, write `request` to stdin, return stdout. Plain-string
140/// errors so callers can wrap them with the right analyzer id.
141fn run(argv: &[String], request: &str) -> std::result::Result<String, String> {
142    let mut child = Command::new(&argv[0])
143        .args(&argv[1..])
144        .stdin(Stdio::piped())
145        .stdout(Stdio::piped())
146        .stderr(Stdio::inherit())
147        .spawn()
148        .map_err(|e| format!("spawn --analyzer-cmd {:?}: {e}", argv[0]))?;
149    {
150        let mut stdin = child.stdin.take().expect("stdin piped");
151        stdin
152            .write_all(request.as_bytes())
153            .map_err(|e| format!("write to --analyzer-cmd: {e}"))?;
154    }
155    let out = child
156        .wait_with_output()
157        .map_err(|e| format!("--analyzer-cmd wait: {e}"))?;
158    if !out.status.success() {
159        return Err(format!("--analyzer-cmd exited with {}", out.status));
160    }
161    String::from_utf8(out.stdout).map_err(|e| format!("--analyzer-cmd stdout not UTF-8: {e}"))
162}
163
164/// Coerce the probe-reported id into `publisher.name/major` shape, or generate
165/// one from the command's basename — a stable, non-empty dedup family either way.
166fn normalize_id(reported: &str, argv0: &str) -> String {
167    let r = reported.trim();
168    if !r.is_empty() {
169        return if r.contains('/') {
170            r.to_string()
171        } else {
172            format!("{r}/1") // no major → append one so family() = the whole id
173        };
174    }
175    let base = argv0
176        .rsplit(['/', '\\'])
177        .next()
178        .unwrap_or(argv0)
179        .trim_end_matches(".sh")
180        .trim_end_matches(".py");
181    let sanitized: String = base
182        .chars()
183        .map(|c| if c.is_ascii_alphanumeric() { c } else { '_' })
184        .collect();
185    let name = sanitized.trim_matches('_');
186    format!("command.{}/1", if name.is_empty() { "external" } else { name })
187}
188
189fn non_empty(s: String) -> Option<String> {
190    if s.trim().is_empty() {
191        None
192    } else {
193        Some(s)
194    }
195}
196
197/// Map an external finding to an advisory [`RecDraft`]. Drops findings that name
198/// neither a target nor a summary. The engine stamps origin/dedup afterward and
199/// re-validates the target ref (a bad ref drops just that draft).
200fn to_draft(f: ExternalFinding) -> Option<RecDraft> {
201    if f.target.trim().is_empty() || f.summary.trim().is_empty() {
202        return None;
203    }
204    let mut args = Map::new();
205    args.insert("text".into(), Value::from(f.summary));
206    let mut data = Map::new();
207    data.insert("source".into(), Value::from("command"));
208    if let Some(g) = non_empty(f.guidance) {
209        data.insert("guidance".into(), Value::from(g));
210    }
211    Some(RecDraft {
212        target_ref: f.target,
213        action_kind: ActionKind::Flag,
214        summary: Summary::new("command.finding", args),
215        severity: parse_severity(&f.severity),
216        proposal: Proposal::Data { data },
217        evidence: f.evidence,
218        evidence_query: None,
219        metric: None,
220        confidence: if f.confidence > 0.0 {
221            f.confidence.clamp(0.0, 1.0)
222        } else {
223            0.5
224        },
225        importance: if f.importance > 0.0 {
226            f.importance.clamp(0.0, 1.0)
227        } else {
228            0.3
229        },
230        // External analyzers cannot pin evalsets: code_revision drafts from
231        // a subprocess would fail Rule E1 at stamp anyway (advisory only).
232        evalset_hash: None,
233    })
234}
235
236fn parse_severity(s: &str) -> Severity {
237    match s.trim().to_ascii_lowercase().as_str() {
238        "high" => Severity::High,
239        "medium" | "med" => Severity::Medium,
240        "info" => Severity::Info,
241        _ => Severity::Low,
242    }
243}
244
245// ---- wire schema -----------------------------------------------------------
246
247#[derive(Serialize)]
248struct AnalyzeRequest<'a> {
249    loop_analyzer: u8,
250    op: &'a str,
251    now_ms: i64,
252    #[serde(skip_serializing_if = "Option::is_none")]
253    watermark_ms: Option<i64>,
254    grains: &'a [GrainRecord],
255}
256
257#[derive(Deserialize, Default)]
258#[serde(default)]
259struct ProbeReply {
260    id: String,
261    title: String,
262    description: String,
263}
264
265#[derive(Deserialize, Default)]
266#[serde(default)]
267struct AnalyzeReply {
268    findings: Vec<ExternalFinding>,
269}
270
271#[derive(Deserialize, Default)]
272#[serde(default)]
273struct ExternalFinding {
274    target: String,
275    summary: String,
276    severity: String,
277    evidence: Vec<String>,
278    guidance: String,
279    confidence: f64,
280    importance: f64,
281}
282
283#[cfg(test)]
284mod tests {
285    use super::*;
286
287    #[test]
288    fn normalize_id_shapes() {
289        assert_eq!(normalize_id("acme.pii/2", "x"), "acme.pii/2");
290        assert_eq!(normalize_id("acme.pii", "x"), "acme.pii/1");
291        assert_eq!(normalize_id("", "/usr/local/bin/pii-check.sh"), "command.pii_check/1");
292        assert_eq!(normalize_id("  ", "weird!!name"), "command.weird__name/1");
293    }
294
295    #[test]
296    fn to_draft_requires_target_and_summary() {
297        assert!(to_draft(ExternalFinding::default()).is_none());
298        let ok = to_draft(ExternalFinding {
299            target: "entity:caller/acme".into(),
300            summary: "looks off".into(),
301            severity: "high".into(),
302            confidence: 0.0,
303            ..Default::default()
304        })
305        .unwrap();
306        assert_eq!(ok.severity, Severity::High);
307        assert_eq!(ok.confidence, 0.5); // 0.0 → default floor
308        assert_eq!(ok.summary.render(), "looks off");
309    }
310}