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