1use std::time::Duration;
10
11use chrono::{DateTime, Utc};
12use chrono_tz::Tz;
13use talon_core::schedule;
14use talon_core::time::{parse_iso, to_iso};
15use talon_core::types::{ContextMode, ScheduleType, ScheduledTask, TaskStatus};
16use talon_core::{Error, Result};
17use talon_db::Investigation;
18
19use crate::store::HttpStore;
20
21pub const DIGEST_TASK_ID: &str = "copilot-alert-digest-15m";
23pub const WEBHOOK_JID: &str = "webhook:copilot";
25pub const DIGEST_CRON: &str = "*/15 * * * *";
27pub const STATUS_QUEUED: &str = "queued";
29pub const STATUS_RUNNING: &str = "running";
31pub const STATUS_FAILED: &str = "failed";
33
34#[derive(Debug, Clone, PartialEq, Eq)]
36pub struct InvestigationRequest {
37 pub alert_id: u64,
39 pub customer_code: String,
41 pub triggered_by: String,
43 pub template_override: Option<String>,
45}
46
47pub fn job_id(alert_id: u64, millis: i64) -> String {
49 format!("copilot-inv-{alert_id}-{millis}")
50}
51
52pub fn is_duplicate(
57 existing: &Investigation,
58 req: &InvestigationRequest,
59 window: Duration,
60 now: DateTime<Utc>,
61) -> bool {
62 if window.is_zero()
63 || existing.status == STATUS_FAILED
64 || existing.alert_id != req.alert_id.to_string()
65 || existing.customer_code != req.customer_code
66 || existing.template != req.template_override
67 {
68 return false;
69 }
70 let Some(created) = parse_iso(&existing.created_at) else {
71 return false;
72 };
73 let Ok(window) = chrono::Duration::from_std(window) else {
74 return true;
75 };
76 created > now - window && created <= now + chrono::Duration::minutes(5)
77}
78
79pub fn investigation_task(
81 id: &str,
82 group_folder: &str,
83 chat_jid: &str,
84 prompt: String,
85 tz: Tz,
86 now: DateTime<Utc>,
87) -> Result<ScheduledTask> {
88 let local = now
89 .with_timezone(&tz)
90 .format("%Y-%m-%dT%H:%M:%S")
91 .to_string();
92 let next_run = schedule::first_run(ScheduleType::Once, &local, tz, now)?;
93 Ok(ScheduledTask {
94 id: id.to_string(),
95 group_folder: group_folder.to_string(),
96 chat_jid: chat_jid.to_string(),
97 prompt,
98 script: None,
99 schedule_type: ScheduleType::Once,
100 schedule_value: local,
101 context_mode: ContextMode::Isolated,
102 next_run: Some(next_run),
103 last_run: None,
104 last_result: None,
105 status: TaskStatus::Active,
106 created_at: to_iso(now),
107 })
108}
109
110pub fn investigation_record(
112 id: &str,
113 req: &InvestigationRequest,
114 now: DateTime<Utc>,
115) -> Investigation {
116 let ts = to_iso(now);
117 Investigation {
118 id: id.to_string(),
119 alert_id: req.alert_id.to_string(),
120 customer_code: req.customer_code.clone(),
121 triggered_by: req.triggered_by.clone(),
122 status: STATUS_QUEUED.to_string(),
123 template: req.template_override.clone(),
124 created_at: ts.clone(),
125 updated_at: ts,
126 verdict: None,
127 error: None,
128 }
129}
130
131pub fn investigation_prompt(req: &InvestigationRequest, job_id: &str) -> String {
134 let alert_id = req.alert_id;
135 let customer_code = &req.customer_code;
136 let triggered_by = &req.triggered_by;
137 let override_line = req
138 .template_override
139 .as_deref()
140 .map(|t| format!("\n- template_override: {t}"))
141 .unwrap_or_default();
142 let step_override_instruction = req
143 .template_override
144 .as_deref()
145 .map(|t| {
146 format!(
147 "3a. TEMPLATE OVERRIDE — the caller selected template \"{t}\". Skip Step 2.5 template detection entirely. Read /workspace/group/prompts/{t} directly and follow it. Record selection_method=\"override\" when writing the eval JSON.\n"
148 )
149 })
150 .unwrap_or_default();
151
152 format!(
153 r#"You are running a targeted SOC investigation triggered by CoPilot. Follow the full investigation workflow from AGENT.md.
154
155Alert to investigate:
156- alert_id: {alert_id}
157- customer_code: {customer_code}
158- job_id: {job_id}
159- triggered_by: {triggered_by}{override_line}
160
161Steps:
1621. Skip Step 0 deduplication — Talahon already deduplicated this request and assigned the job id. Register the job in CoPilot and mark it running, in this order:
163 a. mcp__copilot__CreateAiAnalystJobTool(id="{job_id}", alert_id={alert_id}, customer_code="{customer_code}", triggered_by="{triggered_by}")
164 b. mcp__copilot__UpdateAiAnalystJobTool(job_id="{job_id}", status="running")
1652. Resolve the alert source and target document via MySQL before any OpenSearch query. The alert_id passed here is CoPilot's internal DB counter and is NOT a field on raw SIEM documents — searching OpenSearch for it returns nothing, and falling back to a time-window query will pick up unrelated noise (typically the agent's own container-startup events) and produce a report about the wrong event. Run exactly this query via mcp__mysql__mysql_query:
166
167 SELECT a.id, a.alert_name, a.source, a.alert_creation_time,
168 a.customer_code, ast.asset_name, ast.agent_id,
169 ast.index_name, ast.index_id
170 FROM incident_management_alert a
171 LEFT JOIN incident_management_asset ast ON ast.alert_linked = a.id
172 WHERE a.id = {alert_id} AND a.customer_code = "{customer_code}";
173
174 Treat the returned (index_name, index_id) as the authoritative pointer to the raw event for every subsequent SIEM lookup. The 'source' column ("wazuh", "office365", "misp", …) tells you which OpenSearch index family the event lives in. If the row is missing, ast.index_name is empty, or the source is unrecognised, mark the job 'failed' with a descriptive error_message rather than guessing from timestamps.
1753. Follow Step 0.5 (MemPalace prior context for the asset, customer and IOCs) and Steps 2–6 from AGENT.md exactly (fetch raw event + index mapping, detect alert type, load template, extract IOCs, threat intel, SIEM correlation).
176{step_override_instruction}3b. Complete Step 5.5 from AGENT.md: call score_alert, verify every claim against the evidence you collected, then call submit_triage_report with report_id="{job_id}". Use the severity_assessment label and the Markdown it returns for step 4a. Talahon records this structured verdict on the investigation.
1774. Write back to CoPilot — call these three tools in order. The four body fields on SubmitAiAnalystReportTool (severity_assessment, summary, report_markdown, recommended_actions) carry the entire human-readable investigation output; a call missing any of them persists a blank row that surfaces as an empty report in CoPilot. Pass all of them every time, even on trivial false positives.
178
179 a. mcp__copilot__SubmitAiAnalystReportTool — returns report_id:
180 job_id="{job_id}"
181 alert_id={alert_id}
182 customer_code="{customer_code}"
183 severity_assessment=<Critical|High|Medium|Low|Informational>
184 summary=<1-2 sentence tl;dr>
185 report_markdown=<full structured markdown report>
186 recommended_actions=<action list>
187
188 b. mcp__copilot__SubmitAiAnalystIocsTool(report_id=<from 4a>, alert_id={alert_id}, customer_code="{customer_code}", iocs=[...])
189
190 c. Only after 4a succeeded: mcp__copilot__UpdateAiAnalystJobTool(job_id="{job_id}", status="completed", template_used=<template key or null>). If 4a failed, set status="failed" with error_message instead: a job marked completed without a report is never investigated again.
191
1925. Write the eval JSON to /home/agent/.talon/evals/{alert_id}-{job_id}.json as instructed in AGENT.md Step 6 (create the directory if needed). That directory is private to this investigation lane; never write evals under /workspace/group.
1936. Send the full investigation report via send_message."#
194 )
195}
196
197pub fn digest_task(group_folder: &str, tz: Tz, now: DateTime<Utc>) -> Result<ScheduledTask> {
199 let next_run = schedule::first_run(ScheduleType::Cron, DIGEST_CRON, tz, now)?;
200 Ok(ScheduledTask {
201 id: DIGEST_TASK_ID.to_string(),
202 group_folder: group_folder.to_string(),
203 chat_jid: WEBHOOK_JID.to_string(),
204 prompt: DIGEST_PROMPT.to_string(),
205 script: None,
206 schedule_type: ScheduleType::Cron,
207 schedule_value: DIGEST_CRON.to_string(),
208 context_mode: ContextMode::Isolated,
211 next_run: Some(next_run),
212 last_run: None,
213 last_result: None,
214 status: TaskStatus::Paused,
215 created_at: to_iso(now),
216 })
217}
218
219pub fn seed_digest_task(
223 store: &dyn HttpStore,
224 group_folder: &str,
225 tz: Tz,
226 now: DateTime<Utc>,
227) -> Result<bool> {
228 if store.get_task_by_id(DIGEST_TASK_ID)?.is_some() {
229 return Ok(false);
230 }
231 let task = digest_task(group_folder, tz, now)?;
232 store.create_task(&task)?;
233 Ok(true)
234}
235
236pub(crate) fn persist_investigation(
239 store: &dyn HttpStore,
240 task: &ScheduledTask,
241 inv: &Investigation,
242) -> Result<()> {
243 if store.get_task_by_id(&task.id)?.is_some() {
244 return Err(Error::invalid(format!("task {} already exists", task.id)));
245 }
246 store.create_task(task)?;
247 if let Err(e) = store.upsert_investigation(inv) {
248 if let Err(del) = store.delete_task(&task.id) {
249 tracing::error!(task = %task.id, err = %del, "failed to roll back investigation task");
250 }
251 return Err(e);
252 }
253 Ok(())
254}
255
256pub const DIGEST_PROMPT: &str = r#"You are running as a scheduled SOC monitor. Follow these steps exactly for each alert.
258
2591. Query MySQL for OPEN alerts of the last 24 hours that have no AI
260 investigation yet. A completed job, or a pending/running one younger
261 than 2 hours (an older one is a dead run), counts as an investigation;
262 failed jobs do not:
263
264 SELECT a.id, a.alert_name, a.source, a.alert_creation_time,
265 a.customer_code, ast.asset_name, ast.agent_id,
266 ast.index_name, ast.index_id
267 FROM incident_management_alert a
268 JOIN incident_management_asset ast ON ast.alert_linked = a.id
269 WHERE a.status = 'OPEN'
270 AND a.alert_creation_time >= DATE_SUB(NOW(), INTERVAL 24 HOUR)
271 AND ast.index_name != ''
272 AND ast.index_id != ''
273 AND NOT EXISTS (
274 SELECT 1 FROM ai_analyst_job j
275 WHERE j.alert_id = a.id
276 AND (j.status = 'completed'
277 OR (j.status IN ('pending', 'running')
278 AND j.created_at >= DATE_SUB(NOW(), INTERVAL 2 HOUR)))
279 )
280 ORDER BY a.alert_creation_time DESC
281 LIMIT 20;
282
2832. If no rows are returned, stop here. Do not send any message.
284
2853. For each alert:
286
287 a. DEDUPLICATION — call ListAiAnalystJobsByAlertTool(alert_id=<id>)
288 right before you start (a real-time investigation may have begun
289 since the query). Skip this alert if a job is "completed", or
290 "pending"/"running" and created less than 2 hours ago.
291
292 b. REGISTER JOB — call CreateAiAnalystJobTool:
293 id="copilot-inv-<alert_id>-<unix_timestamp>"
294 alert_id=<id>, customer_code=<code>, triggered_by="scheduled"
295 Then immediately call UpdateAiAnalystJobTool(job_id=<id>, status="running").
296
297 c. FETCH INDEX MAPPING + RAW EVENT in parallel:
298 - get_document(index_name, index_id) → full raw alert event
299 - get_index(index_name) → field mapping (confirms field names and types)
300 Use the mapping before writing any OpenSearch query.
301
302 d. DETECT ALERT TYPE from the raw event:
303 - List available templates: ls /workspace/group/prompts/
304 - Compare the template filenames with the alert summary
305 (rule_description, rule_groups, data_win_system_eventID, agent_name)
306 and pick the best matching filename, or none if nothing fits
307 (selection_method="hints")
308 - Fallback: match rule_groups values or data_win_system_eventID
309 against the available filenames (selection_method="fallback")
310
311 NOTE: Graylog flattens all fields with underscores — use
312 rule_groups, rule_description, data_win_system_eventID, agent_name.
313 Never use dot notation for raw document fields.
314
315 e. LOAD TEMPLATE — Read /workspace/group/prompts/<filename>.
316 If found, follow its steps substituting:
317 {{ alert }} → raw OpenSearch event JSON
318 {{ event_id }} → numeric event ID
319 {{ pipeline | default('wazuh') }} → wazuh
320 {{ virustotal_results }} → VT results after threat intel
321 If no template, use the default steps below.
322
323 f. DEFAULT INVESTIGATION (when no template matches):
324 - Extract IOCs: IPs (data_win_eventdata_destinationIp, data_srcip, data_dstip),
325 domains (data_win_eventdata_queryName, data_win_eventdata_destinationHostname),
326 hashes (data_win_eventdata_hashes), processes (data_win_eventdata_image,
327 data_win_eventdata_parentImage), commands (data_win_eventdata_commandLine)
328 - For each external IP/domain: WebSearch "<value>" site:virustotal.com
329 - For each SHA256 hash: WebFetch https://www.virustotal.com/gui/file/<hash>
330 - Check rule_level, rule_description, rule_mitre_tactic
331
332 g. WRITE BACK TO COPILOT — call in order:
333 1. SubmitAiAnalystReportTool(
334 job_id=<id>, alert_id=<id>, customer_code=<code>,
335 severity_assessment=<Critical|High|Medium|Low|Informational>,
336 summary=<1-2 sentence tl;dr>,
337 report_markdown=<full markdown report>,
338 recommended_actions=<action list>)
339 → note the returned report_id
340 2. SubmitAiAnalystIocsTool(
341 report_id=<from step 1>, alert_id=<id>, customer_code=<code>,
342 iocs=[{ ioc_value, ioc_type, vt_verdict, vt_score, details }, ...])
343 ioc_type: ip|domain|hash|process|url|user|command
344 vt_verdict: malicious|suspicious|clean|unknown
345 3. Only after step 1 succeeded: UpdateAiAnalystJobTool(job_id=<id>,
346 status="completed", template_used=<alert_type or null>).
347 If step 1 failed: status="failed" with error_message.
348
349 h. SEND REPORT via send_message:
350 🔍 **SOC Investigation** — <alert_name>
351 Customer: <customer_code> | Asset: <asset_name> | Created: <alert_creation_time>
352 **Severity**: <assessment> | **Template**: <alert_type or "default">
353
354 **Alert Summary**: rule description, MITRE tactic/technique
355 **IOC Analysis**: table of IOCs with type and VT verdict
356 **Severity Assessment**: reasoning
357 **Recommended Actions**: specific actionable steps
358
359 On any error during write-back, call UpdateAiAnalystJobTool with
360 status="failed" and error_message=<exception details>."#;
361
362#[cfg(test)]
363mod tests {
364 use super::*;
365
366 fn req(template: Option<&str>) -> InvestigationRequest {
367 InvestigationRequest {
368 alert_id: 1234,
369 customer_code: "acme".into(),
370 triggered_by: "webhook".into(),
371 template_override: template.map(str::to_string),
372 }
373 }
374
375 fn now() -> DateTime<Utc> {
376 parse_iso("2026-10-14T12:00:00.000Z").unwrap()
377 }
378
379 #[test]
380 fn job_ids_follow_the_ts_format() {
381 assert_eq!(
382 job_id(1234, 1714000000000),
383 "copilot-inv-1234-1714000000000"
384 );
385 }
386
387 #[test]
388 fn prompt_carries_the_job_and_alert() {
389 let p = investigation_prompt(&req(None), "copilot-inv-1234-1");
390 assert!(p.contains("- alert_id: 1234\n- customer_code: acme\n- job_id: copilot-inv-1234-1\n- triggered_by: webhook\n\nSteps:"));
391 assert!(p.contains(r#"WHERE a.id = 1234 AND a.customer_code = "acme";"#));
392 assert!(p.contains(
393 "5. Write the eval JSON to /home/agent/.talon/evals/1234-copilot-inv-1234-1.json as instructed"
394 ));
395 assert!(!p.contains("/workspace/group/evals"));
396 assert!(!p.contains("TEMPLATE OVERRIDE"));
397 assert!(p.contains("SIEM correlation).\n3b. Complete Step 5.5"));
398 assert!(p.contains(r#"submit_triage_report with report_id="copilot-inv-1234-1""#));
399 assert!(p.contains("for step 4a. Talahon records this structured verdict on the investigation.\n4. Write back"));
400 assert!(p.contains("3. Follow Step 0.5 (MemPalace"));
402 let report = p
404 .find("a. mcp__copilot__SubmitAiAnalystReportTool")
405 .unwrap();
406 let completed = p
407 .find(r#"UpdateAiAnalystJobTool(job_id="copilot-inv-1234-1", status="completed""#)
408 .unwrap();
409 assert!(report < completed);
410 let create = p
413 .find(r#"CreateAiAnalystJobTool(id="copilot-inv-1234-1", alert_id=1234, customer_code="acme", triggered_by="webhook")"#)
414 .unwrap();
415 let running = p
416 .find(r#"UpdateAiAnalystJobTool(job_id="copilot-inv-1234-1", status="running")"#)
417 .unwrap();
418 assert!(create < running);
419 }
420
421 #[test]
422 fn prompt_includes_the_template_override() {
423 let p = investigation_prompt(&req(Some("sysmon_event_1.txt")), "j");
424 assert!(p.contains("- triggered_by: webhook\n- template_override: sysmon_event_1.txt\n"));
425 assert!(p.contains(
426 "3a. TEMPLATE OVERRIDE — the caller selected template \"sysmon_event_1.txt\"."
427 ));
428 assert!(p.contains("Read /workspace/group/prompts/sysmon_event_1.txt directly"));
429 assert!(p.contains(
430 "selection_method=\"override\" when writing the eval JSON.\n3b. Complete Step 5.5"
431 ));
432 }
433
434 #[test]
435 fn once_task_is_due_now_on_the_investigation_lane() {
436 let t = investigation_task(
437 "copilot-inv-1-5",
438 "copilot",
439 "http:copilot:investigate",
440 "p".into(),
441 chrono_tz::Europe::Berlin,
442 now(),
443 )
444 .unwrap();
445 assert_eq!(t.schedule_type, ScheduleType::Once);
446 assert_eq!(t.schedule_value, "2026-10-14T14:00:00");
447 assert_eq!(t.next_run.as_deref(), Some("2026-10-14T12:00:00.000Z"));
448 assert_eq!(t.context_mode, ContextMode::Isolated);
449 assert_eq!(t.status, TaskStatus::Active);
450 assert_eq!(t.chat_jid, "http:copilot:investigate");
451 schedule::validate(
452 t.schedule_type,
453 &t.schedule_value,
454 chrono_tz::Europe::Berlin,
455 )
456 .unwrap();
457 }
458
459 #[test]
460 fn dedup_rules() {
461 let window = Duration::from_secs(600);
462 let mut existing = investigation_record("j1", &req(None), now());
463 let later = now() + chrono::Duration::minutes(5);
464 assert!(is_duplicate(&existing, &req(None), window, later));
465 assert!(!is_duplicate(
467 &existing,
468 &req(None),
469 window,
470 now() + chrono::Duration::minutes(11)
471 ));
472 assert!(!is_duplicate(&existing, &req(None), Duration::ZERO, later));
474 assert!(!is_duplicate(&existing, &req(Some("x.txt")), window, later));
476 let mut other = req(None);
478 other.customer_code = "beta".into();
479 assert!(!is_duplicate(&existing, &other, window, later));
480 existing.status = STATUS_FAILED.into();
482 assert!(!is_duplicate(&existing, &req(None), window, later));
483 existing.status = "completed".into();
485 assert!(is_duplicate(&existing, &req(None), window, later));
486 existing.created_at = "yesterday".into();
488 assert!(!is_duplicate(&existing, &req(None), window, later));
489 }
490
491 #[test]
492 fn digest_task_is_paused_and_scheduled() {
493 let t = digest_task("copilot", chrono_tz::UTC, now()).unwrap();
494 assert_eq!(t.id, DIGEST_TASK_ID);
495 assert_eq!(t.status, TaskStatus::Paused);
496 assert_eq!(t.chat_jid, WEBHOOK_JID);
497 assert_eq!(t.context_mode, ContextMode::Isolated);
498 assert_eq!(t.schedule_value, "*/15 * * * *");
499 assert_eq!(t.next_run.as_deref(), Some("2026-10-14T12:15:00.000Z"));
500 assert!(
501 t.prompt
502 .starts_with("You are running as a scheduled SOC monitor.")
503 );
504 assert!(t.prompt.contains("{{ alert }} → raw OpenSearch event JSON"));
505 assert!(t.prompt.contains("NOT EXISTS ("));
508 assert!(t.prompt.contains("INTERVAL 24 HOUR"));
509 assert!(!t.prompt.contains("INTERVAL 15 MINUTE"));
510 let report = t.prompt.find("1. SubmitAiAnalystReportTool(").unwrap();
511 let completed = t
512 .prompt
513 .find("UpdateAiAnalystJobTool(job_id=<id>,\n status=\"completed\"")
514 .unwrap();
515 assert!(report < completed);
516 assert!(t.prompt.ends_with("error_message=<exception details>."));
517 }
518}