use crate::analyzer::{AnalyzeCtx, Analyzer};
use crate::error::{Error, Result};
use crate::manifest::{
AnalyzerManifest, AutoApplyClass, CadenceClass, TargetClass, Tier, TrustClass,
};
use crate::model::{grain_type, ActionKind, GrainRecord, Severity};
use crate::recommendation::{Proposal, RecDraft, Summary};
use crate::substrate::ReadOpts;
use serde::{Deserialize, Serialize};
use serde_json::{Map, Value};
use std::io::Write;
use std::process::{Command, Stdio};
const MAX_GRAINS: usize = 2000;
pub struct CommandAnalyzer {
argv: Vec<String>,
manifest: AnalyzerManifest,
}
impl CommandAnalyzer {
pub fn new(cmd: &str) -> Result<Self> {
let argv: Vec<String> = cmd.split_whitespace().map(str::to_string).collect();
if argv.is_empty() {
return Err(Error::AnalyzerFailed {
id: "command".into(),
message: "--analyzer-cmd is empty".into(),
});
}
let probe = run(&argv, r#"{"loop_analyzer":1,"op":"probe"}"#).map_err(|m| {
Error::AnalyzerFailed {
id: "command".into(),
message: m,
}
})?;
let reply: ProbeReply = serde_json::from_str(probe.trim()).unwrap_or_default();
let id = normalize_id(&reply.id, &argv[0]);
let title = non_empty(reply.title).unwrap_or_else(|| id.clone());
let description =
non_empty(reply.description).unwrap_or_else(|| format!("External analyzer: {cmd}"));
let manifest = AnalyzerManifest {
id,
title,
description,
tier: Tier::T0,
cadence: CadenceClass::Batch,
requires: vec![],
target_classes: vec![TargetClass::Memory],
auto_apply: AutoApplyClass::Never, trust_class: TrustClass::Command,
params: vec![],
default_on: true, };
Ok(CommandAnalyzer { argv, manifest })
}
}
impl Analyzer for CommandAnalyzer {
fn manifest(&self) -> &AnalyzerManifest {
&self.manifest
}
fn analyze(&self, ctx: &AnalyzeCtx) -> Result<Vec<RecDraft>> {
let mut grains: Vec<GrainRecord> = Vec::new();
for gt in [
grain_type::FACT,
grain_type::OBSERVATION,
grain_type::TOOL,
grain_type::SKILL,
grain_type::GOAL,
] {
if grains.len() >= MAX_GRAINS {
break;
}
if let Ok(mut g) = ctx.grains_of_type(gt, ReadOpts::default()) {
grains.append(&mut g);
}
}
grains.truncate(MAX_GRAINS);
let req = AnalyzeRequest {
loop_analyzer: 1,
op: "analyze",
now_ms: ctx.now_ms(),
watermark_ms: ctx.watermark_ms(),
grains: &grains,
};
let body = serde_json::to_string(&req).map_err(|e| Error::AnalyzerFailed {
id: self.manifest.id.clone(),
message: format!("serialize request: {e}"),
})?;
let raw = run(&self.argv, &body).map_err(|m| Error::AnalyzerFailed {
id: self.manifest.id.clone(),
message: m,
})?;
let reply: AnalyzeReply = serde_json::from_str(raw.trim()).unwrap_or_default();
Ok(reply.findings.into_iter().filter_map(to_draft).collect())
}
}
fn run(argv: &[String], request: &str) -> std::result::Result<String, String> {
let mut child = Command::new(&argv[0])
.args(&argv[1..])
.stdin(Stdio::piped())
.stdout(Stdio::piped())
.stderr(Stdio::inherit())
.spawn()
.map_err(|e| format!("spawn --analyzer-cmd {:?}: {e}", argv[0]))?;
{
let mut stdin = child.stdin.take().expect("stdin piped");
stdin
.write_all(request.as_bytes())
.map_err(|e| format!("write to --analyzer-cmd: {e}"))?;
}
let out = child
.wait_with_output()
.map_err(|e| format!("--analyzer-cmd wait: {e}"))?;
if !out.status.success() {
return Err(format!("--analyzer-cmd exited with {}", out.status));
}
String::from_utf8(out.stdout).map_err(|e| format!("--analyzer-cmd stdout not UTF-8: {e}"))
}
fn normalize_id(reported: &str, argv0: &str) -> String {
let r = reported.trim();
if !r.is_empty() {
return if r.contains('/') {
r.to_string()
} else {
format!("{r}/1") };
}
let base = argv0
.rsplit(['/', '\\'])
.next()
.unwrap_or(argv0)
.trim_end_matches(".sh")
.trim_end_matches(".py");
let sanitized: String = base
.chars()
.map(|c| if c.is_ascii_alphanumeric() { c } else { '_' })
.collect();
let name = sanitized.trim_matches('_');
format!("command.{}/1", if name.is_empty() { "external" } else { name })
}
fn non_empty(s: String) -> Option<String> {
if s.trim().is_empty() {
None
} else {
Some(s)
}
}
fn to_draft(f: ExternalFinding) -> Option<RecDraft> {
if f.target.trim().is_empty() || f.summary.trim().is_empty() {
return None;
}
let mut args = Map::new();
args.insert("text".into(), Value::from(f.summary));
let mut data = Map::new();
data.insert("source".into(), Value::from("command"));
if let Some(g) = non_empty(f.guidance) {
data.insert("guidance".into(), Value::from(g));
}
Some(RecDraft {
target_ref: f.target,
action_kind: ActionKind::Flag,
summary: Summary::new("command.finding", args),
severity: parse_severity(&f.severity),
proposal: Proposal::Data { data },
evidence: f.evidence,
evidence_query: None,
metric: None,
confidence: if f.confidence > 0.0 {
f.confidence.clamp(0.0, 1.0)
} else {
0.5
},
importance: if f.importance > 0.0 {
f.importance.clamp(0.0, 1.0)
} else {
0.3
},
evalset_hash: None,
})
}
fn parse_severity(s: &str) -> Severity {
match s.trim().to_ascii_lowercase().as_str() {
"high" => Severity::High,
"medium" | "med" => Severity::Medium,
"info" => Severity::Info,
_ => Severity::Low,
}
}
#[derive(Serialize)]
struct AnalyzeRequest<'a> {
loop_analyzer: u8,
op: &'a str,
now_ms: i64,
#[serde(skip_serializing_if = "Option::is_none")]
watermark_ms: Option<i64>,
grains: &'a [GrainRecord],
}
#[derive(Deserialize, Default)]
#[serde(default)]
struct ProbeReply {
id: String,
title: String,
description: String,
}
#[derive(Deserialize, Default)]
#[serde(default)]
struct AnalyzeReply {
findings: Vec<ExternalFinding>,
}
#[derive(Deserialize, Default)]
#[serde(default)]
struct ExternalFinding {
target: String,
summary: String,
severity: String,
evidence: Vec<String>,
guidance: String,
confidence: f64,
importance: f64,
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn normalize_id_shapes() {
assert_eq!(normalize_id("acme.pii/2", "x"), "acme.pii/2");
assert_eq!(normalize_id("acme.pii", "x"), "acme.pii/1");
assert_eq!(normalize_id("", "/usr/local/bin/pii-check.sh"), "command.pii_check/1");
assert_eq!(normalize_id(" ", "weird!!name"), "command.weird__name/1");
}
#[test]
fn to_draft_requires_target_and_summary() {
assert!(to_draft(ExternalFinding::default()).is_none());
let ok = to_draft(ExternalFinding {
target: "entity:caller/acme".into(),
summary: "looks off".into(),
severity: "high".into(),
confidence: 0.0,
..Default::default()
})
.unwrap();
assert_eq!(ok.severity, Severity::High);
assert_eq!(ok.confidence, 0.5); assert_eq!(ok.summary.render(), "looks off");
}
}