1use 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
40const MAX_GRAINS: usize = 2000;
43
44pub struct CommandAnalyzer {
46 argv: Vec<String>,
47 manifest: AnalyzerManifest,
48}
49
50impl CommandAnalyzer {
51 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, trust_class: TrustClass::Command,
86 params: vec![],
87 default_on: true, };
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 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 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
139fn 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
164fn 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") };
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
197fn 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 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#[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); assert_eq!(ok.summary.render(), "looks off");
309 }
310}