1use serde_json::Value;
29use sysknife_types::{PreviewEnvelope, RequestEnvelope, RiskLevel};
30
31#[derive(Clone, Debug, PartialEq, Eq)]
32struct PreviewProfile {
33 expected_side_effects: Vec<String>,
34 warnings: Vec<String>,
35}
36
37const APPROVAL_REQUIRED: &str = "approval required";
44const EXACT_APPROVAL_REQUIRED: &str = "exact approval required";
45
46pub fn preview_action(
47 request: &RequestEnvelope,
48 current_state: Value,
49 proposed_change: Value,
50) -> PreviewEnvelope {
51 let mut profile = preview_profile(&request.action_name);
52 if request.action_name == "CreateScheduledJob" {
53 if let Some(name) = request.params.get("name").and_then(Value::as_str) {
54 if let Some((service_path, timer_path)) =
55 crate::actions::services::scheduled_job_unit_paths(name)
56 {
57 profile.expected_side_effects.push(format!(
58 "unit files {service_path} and {timer_path} will be created; existing paths are refused, not overwritten"
59 ));
60 }
61 }
62 }
63 if request.action_name == "GrantSudoAccess" {
71 if let Some(commands) = request.params.get("commands").and_then(Value::as_str) {
72 if let Some(offender) =
73 crate::actions::validate::shell_equivalent_sudo_command(commands)
74 {
75 let user = request
76 .params
77 .get("user")
78 .and_then(Value::as_str)
79 .unwrap_or("the target user");
80 profile.warnings.insert(
81 0,
82 format!(
83 "{offender} runs an arbitrary command, so this grant gives {user} \
84 the same authority as `commands = \"ALL\"` however narrow the list \
85 looks"
86 ),
87 );
88 }
89 }
90 }
91 let risk_level = gate_risk(&request.action_name);
98 let (reboot_required, rollback_available) = crate::actions::spec_meta(&request.action_name)
99 .map(|m| (m.reboot_required, m.rollback_available))
100 .unwrap_or_else(|| {
101 if request.action_name != "ListJobHistory" {
109 eprintln!(
110 "[sysknife-daemon] preview_action: no ActionSpec for {:?}; defaulting \
111 reboot_required=false, rollback_available=false (risk falls back to High)",
112 request.action_name
113 );
114 }
115 (false, false)
116 });
117
118 PreviewEnvelope {
119 summary: preview_summary(&request.action_name, &risk_level),
120 risk_level,
121 current_state,
122 proposed_change,
123 expected_side_effects: profile.expected_side_effects,
124 reboot_required,
125 rollback_available,
126 warnings: profile.warnings,
127 request_hash: request.request_hash.clone(),
128 }
129}
130
131fn fallback_risk(action_name: &str) -> RiskLevel {
136 match action_name {
137 "ListJobHistory" => RiskLevel::Low,
138 _ => RiskLevel::High,
139 }
140}
141
142pub fn gate_risk(action_name: &str) -> RiskLevel {
149 crate::actions::spec_meta(action_name)
150 .map(|m| m.risk_level)
151 .unwrap_or_else(|| fallback_risk(action_name))
152}
153
154fn preview_profile(action_name: &str) -> PreviewProfile {
155 match action_name {
156 "GetSystemState"
157 | "GetHostState"
158 | "CollectDiagnostics"
159 | "GetDeploymentHistory"
160 | "ListDeployments"
161 | "GetKernelArguments"
162 | "GetPendingUpdates"
163 | "SearchFlatpakApps"
164 | "ListFlatpakRemotes"
165 | "ListInstalledFlatpaks"
166 | "GetFlatpakAppInfo"
167 | "ListToolboxes"
168 | "ListServices"
169 | "GetServiceLogs"
170 | "GetServiceStatus"
171 | "GetServiceResourceLimits"
172 | "ListTimers"
173 | "GetFirewallState"
174 | "ListUsers"
175 | "ListGroups"
176 | "ListPackageRepositories"
177 | "ListContainers"
178 | "GetContainerInfo"
179 | "GetLayeredPackages"
180 | "GetDiskUsage"
181 | "ListProcesses"
182 | "GetMemoryInfo"
183 | "GetNetworkStatus"
184 | "GetListeningPorts"
185 | "GetNftablesRuleset"
186 | "GetFirewallBackendState"
187 | "GetJournalLog"
188 | "GetLvmReport"
189 | "GetSysctl"
190 | "GetMounts"
191 | "GetSudoGrants"
192 | "GetLogrotateStatus"
193 | "GetPasswordAging"
194 | "GetAuditRules"
195 | "GetCertificates"
196 | "GetAuthorizedKeys"
197 | "GetDateTime"
198 | "ListJobHistory"
199 | "AptSearch"
201 | "AptListInstalled"
202 | "AptShow"
203 | "GetAptPins"
204 | "SnapList"
206 | "SnapInfo"
207 | "UfwStatus"
209 | "DistroboxList"
211 | "NetplanGetConfig"
213 | "ProStatus"
215 | "LivepatchStatus"
216 | "MultipassList"
217 | "AptListUpgradable"
219 | "AptHistoryList"
220 | "GrubGetKargs"
221 | "CheckPendingReboot"
222 | "ResolvectlStatus"
223 | "AppArmorStatus"
224 | "CloudInitStatus"
225 | "UbuntuListFlatpaks"
226 | "Fail2banStatus" => PreviewProfile {
227 expected_side_effects: Vec::new(),
228 warnings: Vec::new(),
229 },
230 "AptInstall" | "AptRemove" | "AptPurge" | "AptHold" | "AptUnhold" => PreviewProfile {
236 expected_side_effects: vec![
237 "package state will change".to_string(),
238 "services may be restarted by needrestart".to_string(),
239 ],
240 warnings: vec![APPROVAL_REQUIRED.to_string()],
241 },
242
243 "SnapInstall" | "SnapRemove" | "SnapRefresh" | "SnapHold" | "SnapUnhold" => {
245 PreviewProfile {
246 expected_side_effects: vec!["snap state will change".to_string()],
247 warnings: vec![
248 APPROVAL_REQUIRED.to_string(),
249 "snap auto-refresh: install is paired with --hold by default".to_string(),
250 ],
251 }
252 }
253
254 "DistroboxCreate" | "DistroboxRemove" => PreviewProfile {
256 expected_side_effects: vec!["container lifecycle will change".to_string()],
257 warnings: vec![APPROVAL_REQUIRED.to_string()],
258 },
259
260 "AptUpgrade" => PreviewProfile {
265 expected_side_effects: vec![
266 "all packages will be upgraded".to_string(),
267 "packages may be removed to resolve dependency conflicts".to_string(),
268 "services may be restarted by needrestart".to_string(),
269 ],
270 warnings: vec![
271 "dist-upgrade may remove packages".to_string(),
272 EXACT_APPROVAL_REQUIRED.to_string(),
273 ],
274 },
275
276 "UfwEnable" | "UfwDisable" | "UfwAllow" | "UfwDeny" | "UfwReset" => PreviewProfile {
278 expected_side_effects: vec![
279 "firewall rules will change".to_string(),
280 "network access may be immediately affected".to_string(),
281 ],
282 warnings: vec![
283 "misconfigured rules can lock out SSH access".to_string(),
284 EXACT_APPROVAL_REQUIRED.to_string(),
285 ],
286 },
287
288 "NetplanApply" => PreviewProfile {
290 expected_side_effects: vec![
291 "network interfaces will be reconfigured immediately".to_string(),
292 "SSH session may be disconnected".to_string(),
293 ],
294 warnings: vec![
295 "can disconnect the current SSH session if config is wrong".to_string(),
296 EXACT_APPROVAL_REQUIRED.to_string(),
297 ],
298 },
299
300 "NetplanGenerate" => PreviewProfile {
302 expected_side_effects: vec![
303 "backend network config files will be regenerated".to_string(),
304 ],
305 warnings: vec![APPROVAL_REQUIRED.to_string()],
306 },
307
308 "NetplanSet" => PreviewProfile {
310 expected_side_effects: vec![
311 "netplan configuration will be modified".to_string(),
312 "network may be affected when NetplanApply is run".to_string(),
313 ],
314 warnings: vec![
315 "run NetplanApply to activate the change".to_string(),
316 EXACT_APPROVAL_REQUIRED.to_string(),
317 ],
318 },
319
320 "UfwDeleteRule" | "UfwLimit" => PreviewProfile {
322 expected_side_effects: vec![
323 "firewall rules will change".to_string(),
324 "network access may be immediately affected".to_string(),
325 ],
326 warnings: vec![
327 "misconfigured rules can lock out SSH access".to_string(),
328 EXACT_APPROVAL_REQUIRED.to_string(),
329 ],
330 },
331
332 "ProAttach" => PreviewProfile {
334 expected_side_effects: vec![
335 "Ubuntu Pro subscription will be attached".to_string(),
336 "Pro services (ESM, Livepatch, FIPS) may be enabled".to_string(),
337 ],
338 warnings: vec![
339 EXACT_APPROVAL_REQUIRED.to_string(),
340 "token is redacted from the preview, audit log, and diagnostic output".to_string(),
341 ],
342 },
343
344 "ProDetach" => PreviewProfile {
346 expected_side_effects: vec![
347 "Ubuntu Pro subscription will be released".to_string(),
348 "ESM, Livepatch, and FIPS services will be disabled".to_string(),
349 ],
350 warnings: vec![
351 EXACT_APPROVAL_REQUIRED.to_string(),
352 "after detach, this machine no longer receives Pro security patches".to_string(),
353 ],
354 },
355
356 "AddAuditRule" | "RemoveAuditRule" => PreviewProfile {
358 expected_side_effects: vec![
359 "a persistent audit rule will be written/removed and reloaded".to_string(),
360 ],
361 warnings: vec![
362 "requires the auditd package installed to take effect".to_string(),
363 EXACT_APPROVAL_REQUIRED.to_string(),
364 ],
365 },
366
367 "ObtainCertificate" => PreviewProfile {
369 expected_side_effects: vec![
370 "certbot will obtain a TLS certificate (ACME challenge)".to_string(),
371 "TLS material will be written under /etc/letsencrypt".to_string(),
372 ],
373 warnings: vec![
374 "contacts a public ACME CA over the network".to_string(),
375 "requires certbot installed + a reachable DNS/HTTP challenge".to_string(),
376 EXACT_APPROVAL_REQUIRED.to_string(),
377 ],
378 },
379 "RenewCertificates" => PreviewProfile {
380 expected_side_effects: vec!["certbot will renew due certificates".to_string()],
381 warnings: vec![
382 "contacts a public ACME CA over the network".to_string(),
383 EXACT_APPROVAL_REQUIRED.to_string(),
384 ],
385 },
386
387 "ConfigureFail2banJail" => PreviewProfile {
389 expected_side_effects: vec![
390 "a fail2ban jail override will be written and the daemon reloaded".to_string(),
391 ],
392 warnings: vec![
393 "changes who gets banned (too strict can lock out real users)".to_string(),
394 "requires the fail2ban package installed to take effect".to_string(),
395 EXACT_APPROVAL_REQUIRED.to_string(),
396 ],
397 },
398
399 "EnableProService" | "DisableProService" => PreviewProfile {
401 expected_side_effects: vec![
402 "a single Ubuntu Pro service will be enabled/disabled".to_string(),
403 ],
404 warnings: vec![
405 EXACT_APPROVAL_REQUIRED.to_string(),
406 "requires an attached Pro subscription + network to take effect".to_string(),
407 ],
408 },
409
410 "UbuntuReleaseUpgrade" => PreviewProfile {
412 expected_side_effects: vec![
413 "entire OS will be upgraded to the next Ubuntu release".to_string(),
414 "takes 20–45 minutes; system will be rebooted to complete".to_string(),
415 "third-party PPAs may be disabled or break during upgrade".to_string(),
416 ],
417 warnings: vec![
418 "reboot required to complete the upgrade".to_string(),
419 "long-running operation — configure timeout >= 3600 seconds".to_string(),
420 EXACT_APPROVAL_REQUIRED.to_string(),
421 ],
422 },
423
424 "ReloadService" => PreviewProfile {
425 expected_side_effects: vec!["service config will be reloaded".to_string()],
426 warnings: vec![
427 APPROVAL_REQUIRED.to_string(),
428 "requires ExecReload= to be defined in the unit file; \
429 if not defined, use RestartService instead"
430 .to_string(),
431 ],
432 },
433 "RestartService"
434 | "ReloadDaemon"
435 | "SetServiceEnabled"
436 | "StartService"
437 | "StopService"
438 | "CreateToolbox"
439 | "RemoveToolbox"
440 | "InstallFlatpak"
441 | "RemoveFlatpak"
442 | "UpdateFlatpak"
443 | "AddFlatpakRemote"
444 | "RemoveFlatpakRemote"
445 | "UnmaskService"
446 | "SetHostname"
447 | "SetTimezone"
448 | "SetLocale"
449 | "SetNtp" => PreviewProfile {
450 expected_side_effects: vec!["service interruption".to_string()],
451 warnings: vec![APPROVAL_REQUIRED.to_string()],
452 },
453
454 "ConfigureWifi" => PreviewProfile {
456 expected_side_effects: vec![
457 "the host's Wi-Fi connection will change".to_string(),
458 "current network connectivity may be interrupted while reconnecting".to_string(),
459 ],
460 warnings: vec![
461 "a wrong SSID or password can disconnect the host from network access"
462 .to_string(),
463 EXACT_APPROVAL_REQUIRED.to_string(),
464 ],
465 },
466
467 "SetDnsServers" | "ResolvectlSetDns" => PreviewProfile {
469 expected_side_effects: vec!["DNS servers for the interface will change".to_string()],
470 warnings: vec![
471 "redirecting DNS can enable interception of name resolution".to_string(),
472 EXACT_APPROVAL_REQUIRED.to_string(),
473 ],
474 },
475
476 "ConfigureFirewall" => PreviewProfile {
478 expected_side_effects: vec![
479 "a firewalld rule will change and reload immediately".to_string(),
480 "network access may be immediately affected".to_string(),
481 ],
482 warnings: vec![
483 "misconfigured rules can lock out SSH or other remote access".to_string(),
484 EXACT_APPROVAL_REQUIRED.to_string(),
485 ],
486 },
487
488 "MaskService" => PreviewProfile {
490 expected_side_effects: vec![
491 "the unit will be masked (symlinked to /dev/null)".to_string(),
492 "the unit cannot be started, even manually, until UnmaskService is run"
493 .to_string(),
494 ],
495 warnings: vec![
496 "masking a unit other services depend on can cascade into unrelated failures"
497 .to_string(),
498 EXACT_APPROVAL_REQUIRED.to_string(),
499 ],
500 },
501
502 "CreateUser" => PreviewProfile {
504 expected_side_effects: vec![
505 "a new local user account will be created".to_string(),
506 "a home directory may be created".to_string(),
507 ],
508 warnings: vec![
509 "grants a new account with login access — review shell/home/group defaults"
510 .to_string(),
511 EXACT_APPROVAL_REQUIRED.to_string(),
512 ],
513 },
514
515 "VacuumJournal" => PreviewProfile {
517 expected_side_effects: vec![
518 "old journal entries will be permanently deleted".to_string(),
519 "disk space will be reclaimed".to_string(),
520 ],
521 warnings: vec!["deleted log history cannot be recovered".to_string()],
522 },
523
524 "ExtendLogicalVolume" => PreviewProfile {
526 expected_side_effects: vec![
527 "the logical volume and its filesystem will be grown".to_string(),
528 ],
529 warnings: vec![
530 "resizes a live filesystem; a wrong volume target risks data".to_string(),
531 EXACT_APPROVAL_REQUIRED.to_string(),
532 ],
533 },
534 "CreateLogicalVolume" | "CreateLvSnapshot" => PreviewProfile {
535 expected_side_effects: vec![
536 "volume-group free space will be consumed".to_string(),
537 ],
538 warnings: vec![
539 "consumes VG capacity; snapshots fill as the origin changes".to_string(),
540 EXACT_APPROVAL_REQUIRED.to_string(),
541 ],
542 },
543
544 "SetServiceResourceLimits" => PreviewProfile {
546 expected_side_effects: vec![
547 "the service's memory/CPU/task limits will change immediately".to_string(),
548 "a persistent drop-in will be written (undo: systemctl revert)".to_string(),
549 ],
550 warnings: vec![
551 "too tight a MemoryMax can OOM-kill the service".to_string(),
552 EXACT_APPROVAL_REQUIRED.to_string(),
553 ],
554 },
555
556 "ConfigureLogRotation" | "RemoveLogRotation" => PreviewProfile {
558 expected_side_effects: vec![
559 "a logrotate drop-in will be written/removed".to_string(),
560 ],
561 warnings: vec![APPROVAL_REQUIRED.to_string()],
562 },
563 "ConfigureRemoteSyslog" | "RemoveRemoteSyslog" => PreviewProfile {
564 expected_side_effects: vec![
565 "rsyslog will start/stop forwarding all logs to a remote host".to_string(),
566 "rsyslog will be restarted".to_string(),
567 ],
568 warnings: vec![
569 "forwarding sends log data off-host (exfiltration surface)".to_string(),
570 EXACT_APPROVAL_REQUIRED.to_string(),
571 ],
572 },
573
574 "SetPasswordAging" => PreviewProfile {
576 expected_side_effects: vec![
577 "the target account's password-aging limits will change".to_string(),
578 ],
579 warnings: vec![EXACT_APPROVAL_REQUIRED.to_string()],
580 },
581 "SetPasswordPolicy" | "SetAccountLockout" => PreviewProfile {
582 expected_side_effects: vec![
583 "a system-wide PAM policy file will be written".to_string(),
584 ],
585 warnings: vec![
586 "affects password/lockout rules for all accounts".to_string(),
587 "takes effect only if the PAM module is enabled in the auth stack".to_string(),
588 EXACT_APPROVAL_REQUIRED.to_string(),
589 ],
590 },
591
592 "SetAptPin" | "RemoveAptPin" => PreviewProfile {
594 expected_side_effects: vec![
595 "apt version/origin preferences will change".to_string(),
596 "a /etc/apt/preferences.d drop-in will be written/removed".to_string(),
597 ],
598 warnings: vec![APPROVAL_REQUIRED.to_string()],
599 },
600
601 "GrantSudoAccess" | "RevokeSudoAccess" => PreviewProfile {
603 expected_side_effects: vec![
604 "a sudoers.d drop-in will be created/removed".to_string(),
605 "the target user's sudo privileges will change".to_string(),
606 ],
607 warnings: vec![
608 "this configures privilege escalation — review the rule carefully".to_string(),
609 EXACT_APPROVAL_REQUIRED.to_string(),
610 ],
611 },
612
613 "AddMount" | "RemoveMount" => PreviewProfile {
615 expected_side_effects: vec![
616 "a filesystem will be (un)mounted".to_string(),
617 "/etc/fstab will be updated (managed entries carry nofail)".to_string(),
618 ],
619 warnings: vec![
620 "a wrong device or mountpoint risks data or availability".to_string(),
621 EXACT_APPROVAL_REQUIRED.to_string(),
622 ],
623 },
624 "AddSwap" | "RemoveSwap" => PreviewProfile {
625 expected_side_effects: vec![
626 "swap will be enabled/disabled".to_string(),
627 "a swap file will be created/removed and /etc/fstab updated".to_string(),
628 ],
629 warnings: vec![EXACT_APPROVAL_REQUIRED.to_string()],
630 },
631
632 "SetSysctl" => PreviewProfile {
634 expected_side_effects: vec![
635 "a kernel parameter will change immediately".to_string(),
636 "the value will persist across reboots (/etc/sysctl.d)".to_string(),
637 ],
638 warnings: vec![
639 "a wrong net.*/vm.*/kernel.* value can degrade or lock the host".to_string(),
640 EXACT_APPROVAL_REQUIRED.to_string(),
641 ],
642 },
643
644 "SignalProcess" => PreviewProfile {
645 expected_side_effects: vec!["the target process will be terminated".to_string()],
646 warnings: vec![
647 "the process and its in-flight work will stop".to_string(),
648 EXACT_APPROVAL_REQUIRED.to_string(),
649 ],
650 },
651 "ConfigureUnattendedUpgrades" => PreviewProfile {
652 expected_side_effects: vec![
653 "the automatic security-update policy will change".to_string()
654 ],
655 warnings: vec![APPROVAL_REQUIRED.to_string()],
656 },
657 "CreateScheduledJob" => PreviewProfile {
658 expected_side_effects: vec![
659 "a systemd timer will run the command on a recurring schedule".to_string(),
660 ],
661 warnings: vec![
662 "persistent root-scheduled execution".to_string(),
663 EXACT_APPROVAL_REQUIRED.to_string(),
664 ],
665 },
666 "AddUserToGroup"
667 | "RemoveUserFromGroup"
668 | "CreateGroup"
669 | "DeleteGroup"
670 | "LockUserAccount"
671 | "UnlockUserAccount"
672 | "SetSshdOption"
673 | "DeleteUser"
674 | "AddAuthorizedKey"
675 | "RemoveAuthorizedKey" => PreviewProfile {
676 expected_side_effects: vec!["access control will change".to_string()],
679 warnings: vec![
680 "privilege change".to_string(),
681 EXACT_APPROVAL_REQUIRED.to_string(),
682 ],
683 },
684 "AddPackageRepository"
685 | "RemovePackageRepository"
686 | "EnablePackageRepository"
687 | "DisablePackageRepository" => PreviewProfile {
688 expected_side_effects: vec!["package repository configuration will change".to_string()],
689 warnings: vec![APPROVAL_REQUIRED.to_string()],
690 },
691 "CreateContainer" | "StartContainer" | "StopContainer" | "RemoveContainer" => {
692 PreviewProfile {
693 expected_side_effects: vec!["container lifecycle will change".to_string()],
694 warnings: vec![APPROVAL_REQUIRED.to_string()],
695 }
696 }
697 "UpdateSystem"
698 | "InstallPackages"
699 | "RemovePackages"
700 | "RebaseSystem"
701 | "RollbackDeployment"
702 | "AddLayeredPackage"
703 | "RemoveLayeredPackage"
704 | "ReplaceLayeredPackage"
705 | "ResetLayeredPackageOverride"
706 | "RemoveBasePackage" => PreviewProfile {
707 expected_side_effects: vec![
708 "system deployment will change".to_string(),
709 "reboot may be required".to_string(),
710 ],
711 warnings: vec![
712 "reboot required".to_string(),
713 EXACT_APPROVAL_REQUIRED.to_string(),
714 ],
715 },
716 "SetKernelArguments" => PreviewProfile {
717 expected_side_effects: vec!["boot arguments will change".to_string()],
718 warnings: vec![
719 "reboot required".to_string(),
720 EXACT_APPROVAL_REQUIRED.to_string(),
721 ],
722 },
723 "RebootSystem" => PreviewProfile {
724 expected_side_effects: vec!["system reboot will interrupt running work".to_string()],
725 warnings: vec![
726 "reboot required".to_string(),
727 EXACT_APPROVAL_REQUIRED.to_string(),
728 ],
729 },
730 "CleanupDeployments" => PreviewProfile {
731 expected_side_effects: vec!["old deployments may be removed".to_string()],
732 warnings: vec![EXACT_APPROVAL_REQUIRED.to_string()],
733 },
734 "PinDeployment" | "UnpinDeployment" => PreviewProfile {
735 expected_side_effects: vec!["deployment pin state will change".to_string()],
736 warnings: vec![EXACT_APPROVAL_REQUIRED.to_string()],
737 },
738 "AptUpdate" => PreviewProfile {
740 expected_side_effects: vec!["the apt package index will be refreshed".to_string()],
741 warnings: Vec::new(),
742 },
743 "AptAutoremove" => PreviewProfile {
744 expected_side_effects: vec![
745 "automatically-installed packages no longer needed will be removed".to_string(),
746 ],
747 warnings: vec![APPROVAL_REQUIRED.to_string()],
748 },
749
750 "AddPpa" => PreviewProfile {
752 expected_side_effects: vec![
753 "a Launchpad PPA and its signing key will be added".to_string(),
754 "the trusted software supply chain will expand".to_string(),
755 ],
756 warnings: vec![
757 "a PPA is a third-party package source outside the distro archive".to_string(),
758 EXACT_APPROVAL_REQUIRED.to_string(),
759 ],
760 },
761 "RemovePpa" => PreviewProfile {
762 expected_side_effects: vec!["a Launchpad PPA will be removed".to_string()],
763 warnings: vec![APPROVAL_REQUIRED.to_string()],
764 },
765
766 "SnapRevert" => PreviewProfile {
768 expected_side_effects: vec![
769 "the snap will be reverted to its previous revision".to_string(),
770 ],
771 warnings: vec![APPROVAL_REQUIRED.to_string()],
772 },
773 "SnapClassicInstall" => PreviewProfile {
774 expected_side_effects: vec![
775 "a snap will be installed with classic confinement".to_string(),
776 ],
777 warnings: vec![
778 "classic confinement grants the snap full, unsandboxed system access".to_string(),
779 APPROVAL_REQUIRED.to_string(),
780 ],
781 },
782
783 "GrubSetKargs" => PreviewProfile {
785 expected_side_effects: vec![
786 "GRUB kernel arguments will change".to_string(),
787 "update-grub will regenerate the boot configuration".to_string(),
788 ],
789 warnings: vec![
790 "takes effect on the next reboot".to_string(),
791 "a bad kernel argument can prevent the system from booting".to_string(),
792 EXACT_APPROVAL_REQUIRED.to_string(),
793 ],
794 },
795
796 "AppArmorEnforce" => PreviewProfile {
798 expected_side_effects: vec![
799 "an AppArmor profile will be switched to enforce mode".to_string(),
800 ],
801 warnings: vec![
802 "an over-tight profile can block a legitimate service".to_string(),
803 EXACT_APPROVAL_REQUIRED.to_string(),
804 ],
805 },
806 "AppArmorComplain" => PreviewProfile {
807 expected_side_effects: vec![
808 "an AppArmor profile will be switched to complain mode".to_string(),
809 ],
810 warnings: vec![
811 "complain mode disables MAC enforcement for the profile".to_string(),
812 EXACT_APPROVAL_REQUIRED.to_string(),
813 ],
814 },
815
816 "Fail2banBanIp" => PreviewProfile {
818 expected_side_effects: vec!["an IP address will be banned in a fail2ban jail".to_string()],
819 warnings: vec![
820 "banning can lock out legitimate access from that address".to_string(),
821 EXACT_APPROVAL_REQUIRED.to_string(),
822 ],
823 },
824 "Fail2banUnbanIp" => PreviewProfile {
825 expected_side_effects: vec![
826 "an IP address will be unbanned in a fail2ban jail".to_string(),
827 ],
828 warnings: vec![APPROVAL_REQUIRED.to_string()],
829 },
830
831 "UbuntuInstallFlatpak" | "UbuntuRemoveFlatpak" | "UbuntuUpdateFlatpak" => PreviewProfile {
833 expected_side_effects: vec!["a user's Flatpak apps will change".to_string()],
834 warnings: vec![APPROVAL_REQUIRED.to_string()],
835 },
836
837 _ => PreviewProfile {
838 expected_side_effects: vec!["unclassified action".to_string()],
839 warnings: vec!["action profile not recognized".to_string()],
840 },
841 }
842}
843
844fn preview_summary(action_name: &str, risk_level: &RiskLevel) -> String {
845 let risk = match risk_level {
846 RiskLevel::Low => "low-risk",
847 RiskLevel::Medium => "medium-risk",
848 RiskLevel::High => "high-risk",
849 };
850
851 format!("{action_name} preview ({risk})")
852}
853
854#[cfg(test)]
855mod tests {
856 use super::*;
857 use sysknife_types::{CallerRole, RiskLevel};
858
859 fn req(action: &str) -> RequestEnvelope {
860 RequestEnvelope {
861 action_name: action.to_string(),
862 request_id: "test-req".to_string(),
863 params: serde_json::Value::Null,
864 caller_role: CallerRole::Dev,
865 request_hash: sysknife_types::RequestHash::new("hash".to_string()),
866 }
867 }
868
869 #[test]
870 fn all_low_risk_actions() {
871 let actions = [
872 "GetSystemState",
873 "CollectDiagnostics",
874 "GetDeploymentHistory",
875 "ListDeployments",
876 "GetKernelArguments",
877 "GetPendingUpdates",
878 "SearchFlatpakApps",
879 "ListFlatpakRemotes",
880 "ListInstalledFlatpaks",
881 "GetFlatpakAppInfo",
882 "ListToolboxes",
883 "ListServices",
884 "GetServiceLogs",
885 "GetServiceStatus",
886 "ListTimers",
887 "GetFirewallState",
888 "ListUsers",
889 "ListGroups",
890 "ListPackageRepositories",
891 "ListContainers",
892 "GetContainerInfo",
893 "GetLayeredPackages",
894 "GetDiskUsage",
895 "ListProcesses",
896 "GetMemoryInfo",
897 "GetNetworkStatus",
898 "GetAuthorizedKeys",
899 "GetDateTime",
900 "ListJobHistory",
901 ];
902
903 for action in &actions {
904 let envelope = preview_action(
905 &req(action),
906 serde_json::Value::Null,
907 serde_json::Value::Null,
908 );
909 assert_eq!(
910 envelope.risk_level,
911 RiskLevel::Low,
912 "{action} should be Low risk"
913 );
914 assert!(
915 !envelope.reboot_required,
916 "{action} should not require reboot"
917 );
918 assert!(
919 !envelope.rollback_available,
920 "{action} should not have rollback available"
921 );
922 }
923 }
924
925 #[test]
926 fn create_scheduled_job_preview_names_exact_unit_paths() {
927 let mut request = req("CreateScheduledJob");
928 request.params = serde_json::json!({"name": "nightly"});
929
930 let envelope = preview_action(&request, serde_json::Value::Null, serde_json::Value::Null);
931
932 let paths = envelope.expected_side_effects.join("\n");
933 assert!(paths.contains("/etc/systemd/system/sysknife-nightly.service"));
934 assert!(paths.contains("/etc/systemd/system/sysknife-nightly.timer"));
935 assert!(paths.contains("refused, not overwritten"));
936 }
937
938 #[test]
939 fn reload_service_is_medium_risk() {
940 let envelope = preview_action(
941 &req("ReloadService"),
942 serde_json::Value::Null,
943 serde_json::Value::Null,
944 );
945 assert_eq!(envelope.risk_level, RiskLevel::Medium);
946 assert_eq!(envelope.warnings.len(), 2);
947 assert!(!envelope.reboot_required);
948 assert!(!envelope.rollback_available);
949 }
950
951 #[test]
952 fn medium_risk_actions() {
953 let actions = [
954 "RestartService",
955 "StopService",
956 "StartService",
957 "SetHostname",
958 "SetTimezone",
959 "SetLocale",
960 "SetNtp",
961 "InstallFlatpak",
962 "RemoveFlatpak",
963 "CreateToolbox",
964 "RemoveToolbox",
965 "CreateContainer",
966 "StartContainer",
967 "StopContainer",
968 "RemoveContainer",
969 "RemovePackageRepository",
970 ];
971
972 for action in &actions {
973 let envelope = preview_action(
974 &req(action),
975 serde_json::Value::Null,
976 serde_json::Value::Null,
977 );
978 assert_eq!(
979 envelope.risk_level,
980 RiskLevel::Medium,
981 "{action} should be Medium risk"
982 );
983 }
984 }
985
986 #[test]
987 fn high_risk_access_control_actions() {
988 let actions = [
989 "AddUserToGroup",
990 "RemoveUserFromGroup",
991 "DeleteUser",
992 "AddAuthorizedKey",
993 "RemoveAuthorizedKey",
994 ];
995
996 for action in &actions {
997 let envelope = preview_action(
998 &req(action),
999 serde_json::Value::Null,
1000 serde_json::Value::Null,
1001 );
1002 assert_eq!(
1003 envelope.risk_level,
1004 RiskLevel::High,
1005 "{action} should be High risk"
1006 );
1007 assert!(
1008 envelope
1009 .expected_side_effects
1010 .iter()
1011 .any(|e| e.contains("access control will change")),
1012 "{action} should have 'access control will change' in expected_side_effects"
1013 );
1014 }
1015 }
1016
1017 #[test]
1023 fn high_risk_actions_previously_misclassified_use_exact_approval() {
1024 let actions = [
1025 "ConfigureWifi",
1026 "SetDnsServers",
1027 "ConfigureFirewall",
1028 "MaskService",
1029 "CreateUser",
1030 ];
1031
1032 for action in &actions {
1033 let envelope = preview_action(
1034 &req(action),
1035 serde_json::Value::Null,
1036 serde_json::Value::Null,
1037 );
1038 assert_eq!(
1039 envelope.risk_level,
1040 RiskLevel::High,
1041 "{action} should be High risk"
1042 );
1043 assert!(
1044 envelope
1045 .warnings
1046 .iter()
1047 .any(|w| w == EXACT_APPROVAL_REQUIRED),
1048 "{action} should carry EXACT_APPROVAL_REQUIRED, got: {:?}",
1049 envelope.warnings
1050 );
1051 assert!(
1052 !envelope
1053 .expected_side_effects
1054 .iter()
1055 .any(|e| e == "service interruption"),
1056 "{action} should have an accurate side-effect description, not the generic \
1057 'service interruption' placeholder"
1058 );
1059 }
1060
1061 let create_user = preview_action(&req("CreateUser"), Value::Null, Value::Null);
1064 assert!(
1065 !create_user
1066 .expected_side_effects
1067 .iter()
1068 .any(|e| e.contains("service interruption")),
1069 "CreateUser must not claim service interruption: {:?}",
1070 create_user.expected_side_effects
1071 );
1072 }
1073
1074 #[test]
1075 fn high_risk_system_reboot_actions() {
1076 let actions = [
1081 "UpdateSystem",
1082 "InstallPackages",
1083 "RemovePackages",
1084 "RebaseSystem",
1085 "RollbackDeployment",
1086 "AddLayeredPackage",
1087 "RemoveLayeredPackage",
1088 "SetKernelArguments",
1089 ];
1090
1091 for action in &actions {
1092 let envelope = preview_action(
1093 &req(action),
1094 serde_json::Value::Null,
1095 serde_json::Value::Null,
1096 );
1097 assert_eq!(
1098 envelope.risk_level,
1099 RiskLevel::High,
1100 "{action} should be High risk"
1101 );
1102 assert!(envelope.reboot_required, "{action} should require reboot");
1103 let expect_rollback = *action != "RollbackDeployment";
1104 assert_eq!(
1105 envelope.rollback_available, expect_rollback,
1106 "{action} rollback_available should be {expect_rollback}"
1107 );
1108 }
1109 }
1110
1111 #[test]
1112 fn reboot_system_no_rollback() {
1113 let envelope = preview_action(
1114 &req("RebootSystem"),
1115 serde_json::Value::Null,
1116 serde_json::Value::Null,
1117 );
1118 assert_eq!(envelope.risk_level, RiskLevel::High);
1119 assert!(envelope.reboot_required);
1120 assert!(!envelope.rollback_available);
1121 }
1122
1123 #[test]
1124 fn cleanup_deployments_is_high_no_reboot() {
1125 let envelope = preview_action(
1126 &req("CleanupDeployments"),
1127 serde_json::Value::Null,
1128 serde_json::Value::Null,
1129 );
1130 assert_eq!(envelope.risk_level, RiskLevel::High);
1131 assert!(!envelope.reboot_required);
1132 }
1133
1134 #[test]
1135 fn pin_unpin_deployment_are_high() {
1136 for action in &["PinDeployment", "UnpinDeployment"] {
1137 let envelope = preview_action(
1138 &req(action),
1139 serde_json::Value::Null,
1140 serde_json::Value::Null,
1141 );
1142 assert_eq!(
1143 envelope.risk_level,
1144 RiskLevel::High,
1145 "{action} should be High risk"
1146 );
1147 }
1148 }
1149
1150 #[test]
1151 fn unknown_action_defaults_to_high() {
1152 let envelope = preview_action(
1153 &req("DefinitelyNotRealAction"),
1154 serde_json::Value::Null,
1155 serde_json::Value::Null,
1156 );
1157 assert_eq!(envelope.risk_level, RiskLevel::High);
1158 assert!(
1159 envelope
1160 .warnings
1161 .iter()
1162 .any(|w| w.contains("action profile not recognized")),
1163 "warnings should contain 'action profile not recognized'"
1164 );
1165 assert!(
1166 envelope
1167 .expected_side_effects
1168 .iter()
1169 .any(|e| e.contains("unclassified action")),
1170 "expected_side_effects should contain 'unclassified action'"
1171 );
1172 }
1173
1174 #[test]
1179 fn apparmor_enforce_and_complain_profiles_are_not_swapped() {
1180 let enforce = preview_action(
1181 &req("AppArmorEnforce"),
1182 serde_json::Value::Null,
1183 serde_json::Value::Null,
1184 );
1185 let complain = preview_action(
1186 &req("AppArmorComplain"),
1187 serde_json::Value::Null,
1188 serde_json::Value::Null,
1189 );
1190 let se = |e: &sysknife_types::PreviewEnvelope| e.expected_side_effects.join(" ");
1194 assert!(se(&enforce).contains("enforce mode") && !se(&enforce).contains("complain"));
1195 assert!(se(&complain).contains("complain mode") && !se(&complain).contains("enforce mode"));
1196 }
1197
1198 #[test]
1199 fn fail2ban_ban_and_unban_profiles_are_not_swapped() {
1200 let ban = preview_action(
1201 &req("Fail2banBanIp"),
1202 serde_json::Value::Null,
1203 serde_json::Value::Null,
1204 );
1205 let unban = preview_action(
1206 &req("Fail2banUnbanIp"),
1207 serde_json::Value::Null,
1208 serde_json::Value::Null,
1209 );
1210 assert!(ban
1211 .expected_side_effects
1212 .iter()
1213 .any(|e| e.contains("will be banned")));
1214 assert!(unban
1215 .expected_side_effects
1216 .iter()
1217 .any(|e| e.contains("will be unbanned")));
1218 }
1219
1220 #[test]
1221 fn preview_action_summary_format() {
1222 let envelope = preview_action(
1223 &req("GetDiskUsage"),
1224 serde_json::Value::Null,
1225 serde_json::Value::Null,
1226 );
1227 assert_eq!(envelope.summary, "GetDiskUsage preview (low-risk)");
1228 }
1229
1230 #[test]
1231 fn preview_action_passes_current_and_proposed_state() {
1232 let current = serde_json::json!({"disk": "80%"});
1233 let proposed = serde_json::json!({"action": "cleanup"});
1234 let envelope = preview_action(
1235 &req("CleanupDeployments"),
1236 current.clone(),
1237 proposed.clone(),
1238 );
1239 assert_eq!(envelope.current_state, current);
1240 assert_eq!(envelope.proposed_change, proposed);
1241 }
1242
1243 #[test]
1244 fn preview_action_preserves_request_hash() {
1245 let mut r = req("GetDiskUsage");
1246 r.request_hash = sysknife_types::RequestHash::new("deadbeef".to_string());
1247 let envelope = preview_action(&r, serde_json::Value::Null, serde_json::Value::Null);
1248 assert_eq!(envelope.request_hash.as_str(), "deadbeef");
1249 }
1250}