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};
37
38const MAX_GRAINS: usize = 2000;
41
42pub struct CommandAnalyzer {
44 argv: Vec<String>,
45 manifest: AnalyzerManifest,
46}
47
48impl CommandAnalyzer {
49 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, trust_class: TrustClass::Command,
84 params: vec![],
85 default_on: true, };
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 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 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
137fn 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
148fn 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") };
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
181fn 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 evalset_hash: None,
217 judged_by: None,
218 })
219}
220
221fn parse_severity(s: &str) -> Severity {
222 match s.trim().to_ascii_lowercase().as_str() {
223 "high" => Severity::High,
224 "medium" | "med" => Severity::Medium,
225 "info" => Severity::Info,
226 _ => Severity::Low,
227 }
228}
229
230#[derive(Serialize)]
233struct AnalyzeRequest<'a> {
234 loop_analyzer: u8,
235 op: &'a str,
236 now_ms: i64,
237 #[serde(skip_serializing_if = "Option::is_none")]
238 watermark_ms: Option<i64>,
239 grains: &'a [GrainRecord],
240}
241
242#[derive(Deserialize, Default)]
243#[serde(default)]
244struct ProbeReply {
245 id: String,
246 title: String,
247 description: String,
248}
249
250#[derive(Deserialize, Default)]
251#[serde(default)]
252struct AnalyzeReply {
253 findings: Vec<ExternalFinding>,
254}
255
256#[derive(Deserialize, Default)]
257#[serde(default)]
258struct ExternalFinding {
259 target: String,
260 summary: String,
261 severity: String,
262 evidence: Vec<String>,
263 guidance: String,
264 confidence: f64,
265 importance: f64,
266}
267
268#[cfg(test)]
269mod tests {
270 use super::*;
271
272 #[test]
273 fn normalize_id_shapes() {
274 assert_eq!(normalize_id("acme.pii/2", "x"), "acme.pii/2");
275 assert_eq!(normalize_id("acme.pii", "x"), "acme.pii/1");
276 assert_eq!(normalize_id("", "/usr/local/bin/pii-check.sh"), "command.pii_check/1");
277 assert_eq!(normalize_id(" ", "weird!!name"), "command.weird__name/1");
278 }
279
280 #[test]
281 fn to_draft_requires_target_and_summary() {
282 assert!(to_draft(ExternalFinding::default()).is_none());
283 let ok = to_draft(ExternalFinding {
284 target: "entity:caller/acme".into(),
285 summary: "looks off".into(),
286 severity: "high".into(),
287 confidence: 0.0,
288 ..Default::default()
289 })
290 .unwrap();
291 assert_eq!(ok.severity, Severity::High);
292 assert_eq!(ok.confidence, 0.5); assert_eq!(ok.summary.render(), "looks off");
294 }
295}