Skip to main content

construct/cron/
mod.rs

1use crate::config::Config;
2use crate::security::SecurityPolicy;
3use anyhow::{Result, anyhow, bail};
4
5mod schedule;
6mod store;
7mod types;
8
9pub mod scheduler;
10
11#[allow(unused_imports)]
12pub use schedule::{
13    next_run_for_schedule, normalize_expression, schedule_cron_expression, validate_schedule,
14};
15#[allow(unused_imports)]
16pub use store::{
17    add_agent_job, all_overdue_jobs, due_jobs, get_job, list_jobs, list_runs, record_last_run,
18    record_run, remove_job, remove_workflow_cron_jobs, reschedule_after_run, sync_declarative_jobs,
19    sync_workflow_cron_jobs, update_job,
20};
21pub use types::{
22    CronJob, CronJobPatch, CronRun, DeliveryConfig, JobType, Schedule, SessionTarget,
23    deserialize_maybe_stringified,
24};
25
26/// Validate a shell command against the full security policy (allowlist + risk gate).
27///
28/// Returns `Ok(())` if the command passes all checks, or an error describing
29/// why it was blocked.
30pub fn validate_shell_command(config: &Config, command: &str, approved: bool) -> Result<()> {
31    let security = SecurityPolicy::from_config(&config.autonomy, &config.workspace_dir);
32    validate_shell_command_with_security(&security, command, approved)
33}
34
35/// Validate a shell command using an existing `SecurityPolicy` instance.
36///
37/// Preferred when the caller already holds a `SecurityPolicy` (e.g. scheduler).
38pub(crate) fn validate_shell_command_with_security(
39    security: &SecurityPolicy,
40    command: &str,
41    approved: bool,
42) -> Result<()> {
43    security
44        .validate_command_execution(command, approved)
45        .map(|_| ())
46        .map_err(|reason| anyhow!("blocked by security policy: {reason}"))
47}
48
49pub(crate) fn validate_delivery_config(delivery: Option<&DeliveryConfig>) -> Result<()> {
50    let Some(delivery) = delivery else {
51        return Ok(());
52    };
53
54    if delivery.mode.eq_ignore_ascii_case("none") {
55        return Ok(());
56    }
57    if !delivery.mode.eq_ignore_ascii_case("announce") {
58        bail!("unsupported delivery mode: {}", delivery.mode);
59    }
60
61    let channel = delivery.channel.as_deref().map(str::trim);
62    let Some(channel) = channel.filter(|value| !value.is_empty()) else {
63        bail!("delivery.channel is required for announce mode");
64    };
65    match channel.to_ascii_lowercase().as_str() {
66        "telegram" | "discord" | "slack" | "mattermost" | "signal" | "matrix" | "qq" => {}
67        other => bail!("unsupported delivery channel: {other}"),
68    }
69
70    let has_target = delivery
71        .to
72        .as_deref()
73        .map(str::trim)
74        .is_some_and(|value| !value.is_empty());
75    if !has_target {
76        bail!("delivery.to is required for announce mode");
77    }
78
79    Ok(())
80}
81
82/// Create a validated shell job, enforcing security policy before persistence.
83///
84/// All entrypoints that create shell cron jobs should route through this
85/// function to guarantee consistent policy enforcement.
86pub fn add_shell_job_with_approval(
87    config: &Config,
88    name: Option<String>,
89    schedule: Schedule,
90    command: &str,
91    delivery: Option<DeliveryConfig>,
92    approved: bool,
93) -> Result<CronJob> {
94    validate_shell_command(config, command, approved)?;
95    validate_delivery_config(delivery.as_ref())?;
96    store::add_shell_job(config, name, schedule, command, delivery)
97}
98
99/// Update a shell job's command with security validation.
100///
101/// Validates the new command (if changed) before persisting.
102pub fn update_shell_job_with_approval(
103    config: &Config,
104    job_id: &str,
105    patch: CronJobPatch,
106    approved: bool,
107) -> Result<CronJob> {
108    if let Some(command) = patch.command.as_deref() {
109        validate_shell_command(config, command, approved)?;
110    }
111    update_job(config, job_id, patch)
112}
113
114/// Create a one-shot validated shell job from a delay string (e.g. "30m").
115pub fn add_once_validated(
116    config: &Config,
117    delay: &str,
118    command: &str,
119    approved: bool,
120) -> Result<CronJob> {
121    let duration = parse_delay(delay)?;
122    let at = chrono::Utc::now() + duration;
123    add_once_at_validated(config, at, command, approved)
124}
125
126/// Create a one-shot validated shell job at an absolute timestamp.
127pub fn add_once_at_validated(
128    config: &Config,
129    at: chrono::DateTime<chrono::Utc>,
130    command: &str,
131    approved: bool,
132) -> Result<CronJob> {
133    let schedule = Schedule::At { at };
134    add_shell_job_with_approval(config, None, schedule, command, None, approved)
135}
136
137// Convenience wrappers for CLI paths (default approved=false).
138
139pub(crate) fn add_shell_job(
140    config: &Config,
141    name: Option<String>,
142    schedule: Schedule,
143    command: &str,
144) -> Result<CronJob> {
145    add_shell_job_with_approval(config, name, schedule, command, None, false)
146}
147
148pub(crate) fn add_job(config: &Config, expression: &str, command: &str) -> Result<CronJob> {
149    let schedule = Schedule::Cron {
150        expr: expression.to_string(),
151        tz: None,
152    };
153    add_shell_job(config, None, schedule, command)
154}
155
156#[allow(clippy::needless_pass_by_value)]
157pub fn handle_command(command: crate::CronCommands, config: &Config) -> Result<()> {
158    match command {
159        crate::CronCommands::List => {
160            let jobs = list_jobs(config)?;
161            if jobs.is_empty() {
162                println!("No scheduled tasks yet.");
163                println!("\nUsage:");
164                println!("  construct cron add '0 9 * * *' 'agent -m \"Good morning!\"'");
165                return Ok(());
166            }
167
168            println!("πŸ•’ Scheduled jobs ({}):", jobs.len());
169            for job in jobs {
170                let last_run = job
171                    .last_run
172                    .map_or_else(|| "never".into(), |d| d.to_rfc3339());
173                let last_status = job.last_status.unwrap_or_else(|| "n/a".into());
174                println!(
175                    "- {} | {:?} | next={} | last={} ({})",
176                    job.id,
177                    job.schedule,
178                    job.next_run.to_rfc3339(),
179                    last_run,
180                    last_status,
181                );
182                if !job.command.is_empty() {
183                    println!("    cmd: {}", job.command);
184                }
185                if let Some(prompt) = &job.prompt {
186                    println!("    prompt: {prompt}");
187                }
188            }
189            Ok(())
190        }
191        crate::CronCommands::Add {
192            expression,
193            tz,
194            agent,
195            allowed_tools,
196            command,
197        } => {
198            let schedule = Schedule::Cron {
199                expr: expression,
200                tz,
201            };
202            if agent {
203                let job = add_agent_job(
204                    config,
205                    None,
206                    schedule,
207                    &command,
208                    SessionTarget::Isolated,
209                    None,
210                    None,
211                    false,
212                    if allowed_tools.is_empty() {
213                        None
214                    } else {
215                        Some(allowed_tools)
216                    },
217                )?;
218                println!("βœ… Added agent cron job {}", job.id);
219                println!("  Expr  : {}", job.expression);
220                println!("  Next  : {}", job.next_run.to_rfc3339());
221                println!("  Prompt: {}", job.prompt.as_deref().unwrap_or_default());
222            } else {
223                if !allowed_tools.is_empty() {
224                    bail!("--allowed-tool is only supported with --agent cron jobs");
225                }
226                let job = add_shell_job(config, None, schedule, &command)?;
227                println!("βœ… Added cron job {}", job.id);
228                println!("  Expr: {}", job.expression);
229                println!("  Next: {}", job.next_run.to_rfc3339());
230                println!("  Cmd : {}", job.command);
231            }
232            Ok(())
233        }
234        crate::CronCommands::AddAt {
235            at,
236            agent,
237            allowed_tools,
238            command,
239        } => {
240            let at = chrono::DateTime::parse_from_rfc3339(&at)
241                .map_err(|e| anyhow::anyhow!("Invalid RFC3339 timestamp for --at: {e}"))?
242                .with_timezone(&chrono::Utc);
243            let schedule = Schedule::At { at };
244            if agent {
245                let job = add_agent_job(
246                    config,
247                    None,
248                    schedule,
249                    &command,
250                    SessionTarget::Isolated,
251                    None,
252                    None,
253                    true,
254                    if allowed_tools.is_empty() {
255                        None
256                    } else {
257                        Some(allowed_tools)
258                    },
259                )?;
260                println!("βœ… Added one-shot agent cron job {}", job.id);
261                println!("  At    : {}", job.next_run.to_rfc3339());
262                println!("  Prompt: {}", job.prompt.as_deref().unwrap_or_default());
263            } else {
264                if !allowed_tools.is_empty() {
265                    bail!("--allowed-tool is only supported with --agent cron jobs");
266                }
267                let job = add_shell_job(config, None, schedule, &command)?;
268                println!("βœ… Added one-shot cron job {}", job.id);
269                println!("  At  : {}", job.next_run.to_rfc3339());
270                println!("  Cmd : {}", job.command);
271            }
272            Ok(())
273        }
274        crate::CronCommands::AddEvery {
275            every_ms,
276            agent,
277            allowed_tools,
278            command,
279        } => {
280            let schedule = Schedule::Every { every_ms };
281            if agent {
282                let job = add_agent_job(
283                    config,
284                    None,
285                    schedule,
286                    &command,
287                    SessionTarget::Isolated,
288                    None,
289                    None,
290                    false,
291                    if allowed_tools.is_empty() {
292                        None
293                    } else {
294                        Some(allowed_tools)
295                    },
296                )?;
297                println!("βœ… Added interval agent cron job {}", job.id);
298                println!("  Every(ms): {every_ms}");
299                println!("  Next     : {}", job.next_run.to_rfc3339());
300                println!("  Prompt   : {}", job.prompt.as_deref().unwrap_or_default());
301            } else {
302                if !allowed_tools.is_empty() {
303                    bail!("--allowed-tool is only supported with --agent cron jobs");
304                }
305                let job = add_shell_job(config, None, schedule, &command)?;
306                println!("βœ… Added interval cron job {}", job.id);
307                println!("  Every(ms): {every_ms}");
308                println!("  Next     : {}", job.next_run.to_rfc3339());
309                println!("  Cmd      : {}", job.command);
310            }
311            Ok(())
312        }
313        crate::CronCommands::Once {
314            delay,
315            agent,
316            allowed_tools,
317            command,
318        } => {
319            if agent {
320                let duration = parse_delay(&delay)?;
321                let at = chrono::Utc::now() + duration;
322                let schedule = Schedule::At { at };
323                let job = add_agent_job(
324                    config,
325                    None,
326                    schedule,
327                    &command,
328                    SessionTarget::Isolated,
329                    None,
330                    None,
331                    true,
332                    if allowed_tools.is_empty() {
333                        None
334                    } else {
335                        Some(allowed_tools)
336                    },
337                )?;
338                println!("βœ… Added one-shot agent cron job {}", job.id);
339                println!("  At    : {}", job.next_run.to_rfc3339());
340                println!("  Prompt: {}", job.prompt.as_deref().unwrap_or_default());
341            } else {
342                if !allowed_tools.is_empty() {
343                    bail!("--allowed-tool is only supported with --agent cron jobs");
344                }
345                let job = add_once(config, &delay, &command)?;
346                println!("βœ… Added one-shot cron job {}", job.id);
347                println!("  At  : {}", job.next_run.to_rfc3339());
348                println!("  Cmd : {}", job.command);
349            }
350            Ok(())
351        }
352        crate::CronCommands::Update {
353            id,
354            expression,
355            tz,
356            command,
357            name,
358            allowed_tools,
359        } => {
360            if expression.is_none()
361                && tz.is_none()
362                && command.is_none()
363                && name.is_none()
364                && allowed_tools.is_empty()
365            {
366                bail!(
367                    "At least one of --expression, --tz, --command, --name, or --allowed-tool must be provided"
368                );
369            }
370
371            let existing = if expression.is_some() || tz.is_some() || !allowed_tools.is_empty() {
372                Some(get_job(config, &id)?)
373            } else {
374                None
375            };
376
377            // Merge expression/tz with the existing schedule so that
378            // --tz alone updates the timezone and --expression alone
379            // preserves the existing timezone.
380            let schedule = if expression.is_some() || tz.is_some() {
381                let existing = existing
382                    .as_ref()
383                    .expect("existing job must be loaded when updating schedule");
384                let (existing_expr, existing_tz) = match &existing.schedule {
385                    Schedule::Cron {
386                        expr,
387                        tz: existing_tz,
388                    } => (expr.clone(), existing_tz.clone()),
389                    _ => bail!("Cannot update expression/tz on a non-cron schedule"),
390                };
391                Some(Schedule::Cron {
392                    expr: expression.unwrap_or(existing_expr),
393                    tz: tz.or(existing_tz),
394                })
395            } else {
396                None
397            };
398
399            if !allowed_tools.is_empty() {
400                let existing = existing
401                    .as_ref()
402                    .expect("existing job must be loaded when updating allowed tools");
403                if existing.job_type != JobType::Agent {
404                    bail!("--allowed-tool is only supported for agent cron jobs");
405                }
406            }
407
408            let patch = CronJobPatch {
409                schedule,
410                command,
411                name,
412                allowed_tools: if allowed_tools.is_empty() {
413                    None
414                } else {
415                    Some(allowed_tools)
416                },
417                ..CronJobPatch::default()
418            };
419
420            let job = update_shell_job_with_approval(config, &id, patch, false)?;
421            println!("\u{2705} Updated cron job {}", job.id);
422            println!("  Expr: {}", job.expression);
423            println!("  Next: {}", job.next_run.to_rfc3339());
424            println!("  Cmd : {}", job.command);
425            Ok(())
426        }
427        crate::CronCommands::Remove { id } => remove_job(config, &id),
428        crate::CronCommands::Pause { id } => {
429            pause_job(config, &id)?;
430            println!("⏸️  Paused cron job {id}");
431            Ok(())
432        }
433        crate::CronCommands::Resume { id } => {
434            resume_job(config, &id)?;
435            println!("▢️  Resumed cron job {id}");
436            Ok(())
437        }
438    }
439}
440
441pub(crate) fn add_once(config: &Config, delay: &str, command: &str) -> Result<CronJob> {
442    add_once_validated(config, delay, command, false)
443}
444
445pub(crate) fn add_once_at(
446    config: &Config,
447    at: chrono::DateTime<chrono::Utc>,
448    command: &str,
449) -> Result<CronJob> {
450    add_once_at_validated(config, at, command, false)
451}
452
453pub fn pause_job(config: &Config, id: &str) -> Result<CronJob> {
454    update_job(
455        config,
456        id,
457        CronJobPatch {
458            enabled: Some(false),
459            ..CronJobPatch::default()
460        },
461    )
462}
463
464pub fn resume_job(config: &Config, id: &str) -> Result<CronJob> {
465    update_job(
466        config,
467        id,
468        CronJobPatch {
469            enabled: Some(true),
470            ..CronJobPatch::default()
471        },
472    )
473}
474
475fn parse_delay(input: &str) -> Result<chrono::Duration> {
476    let input = input.trim();
477    if input.is_empty() {
478        anyhow::bail!("delay must not be empty");
479    }
480    let split = input
481        .find(|c: char| !c.is_ascii_digit())
482        .unwrap_or(input.len());
483    let (num, unit) = input.split_at(split);
484    let amount: i64 = num.parse()?;
485    let unit = if unit.is_empty() { "m" } else { unit };
486    let duration = match unit {
487        "s" => chrono::Duration::seconds(amount),
488        "m" => chrono::Duration::minutes(amount),
489        "h" => chrono::Duration::hours(amount),
490        "d" => chrono::Duration::days(amount),
491        _ => anyhow::bail!("unsupported delay unit '{unit}', use s/m/h/d"),
492    };
493    Ok(duration)
494}
495
496#[cfg(test)]
497mod tests {
498    use super::*;
499    use tempfile::TempDir;
500
501    fn test_config(tmp: &TempDir) -> Config {
502        let config = Config {
503            workspace_dir: tmp.path().join("workspace"),
504            config_path: tmp.path().join("config.toml"),
505            ..Config::default()
506        };
507        std::fs::create_dir_all(&config.workspace_dir).unwrap();
508        config
509    }
510
511    fn make_job(config: &Config, expr: &str, tz: Option<&str>, cmd: &str) -> CronJob {
512        add_shell_job(
513            config,
514            None,
515            Schedule::Cron {
516                expr: expr.into(),
517                tz: tz.map(Into::into),
518            },
519            cmd,
520        )
521        .unwrap()
522    }
523
524    fn run_update(
525        config: &Config,
526        id: &str,
527        expression: Option<&str>,
528        tz: Option<&str>,
529        command: Option<&str>,
530        name: Option<&str>,
531    ) -> Result<()> {
532        handle_command(
533            crate::CronCommands::Update {
534                id: id.into(),
535                expression: expression.map(Into::into),
536                tz: tz.map(Into::into),
537                command: command.map(Into::into),
538                name: name.map(Into::into),
539                allowed_tools: vec![],
540            },
541            config,
542        )
543    }
544
545    #[test]
546    fn update_changes_command_via_handler() {
547        let tmp = TempDir::new().unwrap();
548        let config = test_config(&tmp);
549        let job = make_job(&config, "*/5 * * * *", None, "echo original");
550
551        run_update(&config, &job.id, None, None, Some("echo updated"), None).unwrap();
552
553        let updated = get_job(&config, &job.id).unwrap();
554        assert_eq!(updated.command, "echo updated");
555        assert_eq!(updated.id, job.id);
556    }
557
558    #[test]
559    fn update_changes_expression_via_handler() {
560        let tmp = TempDir::new().unwrap();
561        let config = test_config(&tmp);
562        let job = make_job(&config, "*/5 * * * *", None, "echo test");
563
564        run_update(&config, &job.id, Some("0 9 * * *"), None, None, None).unwrap();
565
566        let updated = get_job(&config, &job.id).unwrap();
567        assert_eq!(updated.expression, "0 9 * * *");
568    }
569
570    #[test]
571    fn update_changes_name_via_handler() {
572        let tmp = TempDir::new().unwrap();
573        let config = test_config(&tmp);
574        let job = make_job(&config, "*/5 * * * *", None, "echo test");
575
576        run_update(&config, &job.id, None, None, None, Some("new-name")).unwrap();
577
578        let updated = get_job(&config, &job.id).unwrap();
579        assert_eq!(updated.name.as_deref(), Some("new-name"));
580    }
581
582    #[test]
583    fn update_tz_alone_sets_timezone() {
584        let tmp = TempDir::new().unwrap();
585        let config = test_config(&tmp);
586        let job = make_job(&config, "*/5 * * * *", None, "echo test");
587
588        run_update(
589            &config,
590            &job.id,
591            None,
592            Some("America/Los_Angeles"),
593            None,
594            None,
595        )
596        .unwrap();
597
598        let updated = get_job(&config, &job.id).unwrap();
599        assert_eq!(
600            updated.schedule,
601            Schedule::Cron {
602                expr: "*/5 * * * *".into(),
603                tz: Some("America/Los_Angeles".into()),
604            }
605        );
606    }
607
608    #[test]
609    fn update_expression_preserves_existing_tz() {
610        let tmp = TempDir::new().unwrap();
611        let config = test_config(&tmp);
612        let job = make_job(
613            &config,
614            "*/5 * * * *",
615            Some("America/Los_Angeles"),
616            "echo test",
617        );
618
619        run_update(&config, &job.id, Some("0 9 * * *"), None, None, None).unwrap();
620
621        let updated = get_job(&config, &job.id).unwrap();
622        assert_eq!(
623            updated.schedule,
624            Schedule::Cron {
625                expr: "0 9 * * *".into(),
626                tz: Some("America/Los_Angeles".into()),
627            }
628        );
629    }
630
631    #[test]
632    fn update_preserves_unchanged_fields() {
633        let tmp = TempDir::new().unwrap();
634        let config = test_config(&tmp);
635        let job = add_shell_job(
636            &config,
637            Some("original-name".into()),
638            Schedule::Cron {
639                expr: "*/5 * * * *".into(),
640                tz: None,
641            },
642            "echo original",
643        )
644        .unwrap();
645
646        run_update(&config, &job.id, None, None, Some("echo changed"), None).unwrap();
647
648        let updated = get_job(&config, &job.id).unwrap();
649        assert_eq!(updated.command, "echo changed");
650        assert_eq!(updated.name.as_deref(), Some("original-name"));
651        assert_eq!(updated.expression, "*/5 * * * *");
652    }
653
654    #[test]
655    fn update_no_flags_fails() {
656        let tmp = TempDir::new().unwrap();
657        let config = test_config(&tmp);
658        let job = make_job(&config, "*/5 * * * *", None, "echo test");
659
660        let result = run_update(&config, &job.id, None, None, None, None);
661        assert!(result.is_err());
662        assert!(result.unwrap_err().to_string().contains("At least one of"));
663    }
664
665    #[test]
666    fn update_nonexistent_job_fails() {
667        let tmp = TempDir::new().unwrap();
668        let config = test_config(&tmp);
669
670        let result = run_update(
671            &config,
672            "nonexistent-id",
673            None,
674            None,
675            Some("echo test"),
676            None,
677        );
678        assert!(result.is_err());
679    }
680
681    #[test]
682    fn update_security_allows_safe_command() {
683        let tmp = TempDir::new().unwrap();
684        let config = test_config(&tmp);
685
686        let security = SecurityPolicy::from_config(&config.autonomy, &config.workspace_dir);
687        assert!(security.is_command_allowed("echo safe"));
688    }
689
690    #[test]
691    fn add_shell_job_requires_explicit_approval_for_medium_risk() {
692        let tmp = TempDir::new().unwrap();
693        let mut config = test_config(&tmp);
694        config.autonomy.allowed_commands = vec!["echo".into(), "touch".into()];
695
696        let denied = add_shell_job(
697            &config,
698            None,
699            Schedule::Cron {
700                expr: "*/5 * * * *".into(),
701                tz: None,
702            },
703            "touch cron-medium-risk",
704        );
705        assert!(denied.is_err());
706        assert!(
707            denied
708                .unwrap_err()
709                .to_string()
710                .contains("explicit approval")
711        );
712
713        let approved = add_shell_job_with_approval(
714            &config,
715            None,
716            Schedule::Cron {
717                expr: "*/5 * * * *".into(),
718                tz: None,
719            },
720            "touch cron-medium-risk",
721            None,
722            true,
723        );
724        assert!(approved.is_ok(), "{approved:?}");
725    }
726
727    #[test]
728    fn update_requires_explicit_approval_for_medium_risk() {
729        let tmp = TempDir::new().unwrap();
730        let mut config = test_config(&tmp);
731        config.autonomy.allowed_commands = vec!["echo".into(), "touch".into()];
732        let job = make_job(&config, "*/5 * * * *", None, "echo original");
733
734        let denied = update_shell_job_with_approval(
735            &config,
736            &job.id,
737            CronJobPatch {
738                command: Some("touch cron-medium-risk-update".into()),
739                ..CronJobPatch::default()
740            },
741            false,
742        );
743        assert!(denied.is_err());
744        assert!(
745            denied
746                .unwrap_err()
747                .to_string()
748                .contains("explicit approval")
749        );
750
751        let approved = update_shell_job_with_approval(
752            &config,
753            &job.id,
754            CronJobPatch {
755                command: Some("touch cron-medium-risk-update".into()),
756                ..CronJobPatch::default()
757            },
758            true,
759        )
760        .unwrap();
761        assert_eq!(approved.command, "touch cron-medium-risk-update");
762    }
763
764    #[test]
765    fn cli_update_requires_explicit_approval_for_medium_risk() {
766        let tmp = TempDir::new().unwrap();
767        let mut config = test_config(&tmp);
768        config.autonomy.allowed_commands = vec!["echo".into(), "touch".into()];
769        let job = make_job(&config, "*/5 * * * *", None, "echo original");
770
771        let result = run_update(
772            &config,
773            &job.id,
774            None,
775            None,
776            Some("touch cron-cli-medium-risk"),
777            None,
778        );
779        assert!(result.is_err());
780        assert!(
781            result
782                .unwrap_err()
783                .to_string()
784                .contains("explicit approval")
785        );
786    }
787
788    #[test]
789    fn add_once_validated_creates_one_shot_job() {
790        let tmp = TempDir::new().unwrap();
791        let config = test_config(&tmp);
792
793        let job = add_once_validated(&config, "1h", "echo one-shot", false).unwrap();
794        assert_eq!(job.command, "echo one-shot");
795        assert!(matches!(job.schedule, Schedule::At { .. }));
796    }
797
798    #[test]
799    fn add_once_validated_blocks_disallowed_command() {
800        let tmp = TempDir::new().unwrap();
801        let mut config = test_config(&tmp);
802        config.autonomy.allowed_commands = vec!["echo".into()];
803        config.autonomy.level = crate::security::AutonomyLevel::Supervised;
804
805        let result = add_once_validated(&config, "1h", "curl https://example.com", false);
806        assert!(result.is_err());
807        assert!(
808            result
809                .unwrap_err()
810                .to_string()
811                .contains("blocked by security policy")
812        );
813    }
814
815    #[test]
816    fn add_once_at_validated_creates_one_shot_job() {
817        let tmp = TempDir::new().unwrap();
818        let config = test_config(&tmp);
819        let at = chrono::Utc::now() + chrono::Duration::hours(1);
820
821        let job = add_once_at_validated(&config, at, "echo at-shot", false).unwrap();
822        assert_eq!(job.command, "echo at-shot");
823        assert!(matches!(job.schedule, Schedule::At { .. }));
824    }
825
826    #[test]
827    fn add_once_at_validated_blocks_medium_risk_without_approval() {
828        let tmp = TempDir::new().unwrap();
829        let mut config = test_config(&tmp);
830        config.autonomy.allowed_commands = vec!["echo".into(), "touch".into()];
831        let at = chrono::Utc::now() + chrono::Duration::hours(1);
832
833        let denied = add_once_at_validated(&config, at, "touch at-medium", false);
834        assert!(denied.is_err());
835        assert!(
836            denied
837                .unwrap_err()
838                .to_string()
839                .contains("explicit approval")
840        );
841
842        let approved = add_once_at_validated(&config, at, "touch at-medium", true);
843        assert!(approved.is_ok(), "{approved:?}");
844    }
845
846    #[test]
847    fn gateway_api_path_validates_shell_command() {
848        let tmp = TempDir::new().unwrap();
849        let mut config = test_config(&tmp);
850        config.autonomy.allowed_commands = vec!["echo".into()];
851        config.autonomy.level = crate::security::AutonomyLevel::Supervised;
852
853        // Simulate gateway API path: add_shell_job_with_approval(approved=false)
854        let result = add_shell_job_with_approval(
855            &config,
856            None,
857            Schedule::Cron {
858                expr: "*/5 * * * *".into(),
859                tz: None,
860            },
861            "curl https://example.com",
862            None,
863            false,
864        );
865        assert!(result.is_err());
866        assert!(
867            result
868                .unwrap_err()
869                .to_string()
870                .contains("blocked by security policy")
871        );
872    }
873
874    #[test]
875    fn scheduler_path_validates_shell_command() {
876        let tmp = TempDir::new().unwrap();
877        let mut config = test_config(&tmp);
878        config.autonomy.allowed_commands = vec!["echo".into()];
879        config.autonomy.level = crate::security::AutonomyLevel::Supervised;
880
881        let security = SecurityPolicy::from_config(&config.autonomy, &config.workspace_dir);
882        // Simulate scheduler validation path
883        let result =
884            validate_shell_command_with_security(&security, "curl https://example.com", false);
885        assert!(result.is_err());
886        assert!(
887            result
888                .unwrap_err()
889                .to_string()
890                .contains("blocked by security policy")
891        );
892    }
893
894    #[test]
895    fn cli_agent_flag_creates_agent_job() {
896        let tmp = TempDir::new().unwrap();
897        let config = test_config(&tmp);
898
899        handle_command(
900            crate::CronCommands::Add {
901                expression: "*/15 * * * *".into(),
902                tz: None,
903                agent: true,
904                allowed_tools: vec![],
905                command: "Check server health: disk space, memory, CPU load".into(),
906            },
907            &config,
908        )
909        .unwrap();
910
911        let jobs = list_jobs(&config).unwrap();
912        assert_eq!(jobs.len(), 1);
913        assert_eq!(jobs[0].job_type, JobType::Agent);
914        assert_eq!(
915            jobs[0].prompt.as_deref(),
916            Some("Check server health: disk space, memory, CPU load")
917        );
918    }
919
920    #[test]
921    fn cli_agent_flag_bypasses_shell_security_validation() {
922        let tmp = TempDir::new().unwrap();
923        let mut config = test_config(&tmp);
924        config.autonomy.allowed_commands = vec!["echo".into()];
925        config.autonomy.level = crate::security::AutonomyLevel::Supervised;
926
927        // Without --agent, a natural language string would be blocked by shell
928        // security policy. With --agent, it routes to agent job and skips
929        // shell validation entirely.
930        let result = handle_command(
931            crate::CronCommands::Add {
932                expression: "*/15 * * * *".into(),
933                tz: None,
934                agent: true,
935                allowed_tools: vec![],
936                command: "Check server health: disk space, memory, CPU load".into(),
937            },
938            &config,
939        );
940        assert!(result.is_ok());
941
942        let jobs = list_jobs(&config).unwrap();
943        assert_eq!(jobs.len(), 1);
944        assert_eq!(jobs[0].job_type, JobType::Agent);
945    }
946
947    #[test]
948    fn cli_agent_allowed_tools_persist() {
949        let tmp = TempDir::new().unwrap();
950        let config = test_config(&tmp);
951
952        handle_command(
953            crate::CronCommands::Add {
954                expression: "*/15 * * * *".into(),
955                tz: None,
956                agent: true,
957                allowed_tools: vec!["file_read".into(), "web_search".into()],
958                command: "Check server health".into(),
959            },
960            &config,
961        )
962        .unwrap();
963
964        let jobs = list_jobs(&config).unwrap();
965        assert_eq!(jobs.len(), 1);
966        assert_eq!(
967            jobs[0].allowed_tools,
968            Some(vec!["file_read".into(), "web_search".into()])
969        );
970    }
971
972    #[test]
973    fn cli_update_agent_allowed_tools_persist() {
974        let tmp = TempDir::new().unwrap();
975        let config = test_config(&tmp);
976        let job = add_agent_job(
977            &config,
978            Some("agent".into()),
979            Schedule::Cron {
980                expr: "*/5 * * * *".into(),
981                tz: None,
982            },
983            "original prompt",
984            SessionTarget::Isolated,
985            None,
986            None,
987            false,
988            None,
989        )
990        .unwrap();
991
992        handle_command(
993            crate::CronCommands::Update {
994                id: job.id.clone(),
995                expression: None,
996                tz: None,
997                command: None,
998                name: None,
999                allowed_tools: vec!["shell".into()],
1000            },
1001            &config,
1002        )
1003        .unwrap();
1004
1005        let updated = get_job(&config, &job.id).unwrap();
1006        assert_eq!(updated.allowed_tools, Some(vec!["shell".into()]));
1007    }
1008
1009    #[test]
1010    fn cli_without_agent_flag_defaults_to_shell_job() {
1011        let tmp = TempDir::new().unwrap();
1012        let config = test_config(&tmp);
1013
1014        handle_command(
1015            crate::CronCommands::Add {
1016                expression: "*/5 * * * *".into(),
1017                tz: None,
1018                agent: false,
1019                allowed_tools: vec![],
1020                command: "echo ok".into(),
1021            },
1022            &config,
1023        )
1024        .unwrap();
1025
1026        let jobs = list_jobs(&config).unwrap();
1027        assert_eq!(jobs.len(), 1);
1028        assert_eq!(jobs[0].job_type, JobType::Shell);
1029        assert_eq!(jobs[0].command, "echo ok");
1030    }
1031}