Skip to main content

sysknife_daemon/
preview.rs

1//! Static safety profile for every action the daemon recognises.
2//!
3//! The dispatcher calls [`preview_action`] *before* execution to produce a
4//! [`PreviewEnvelope`] that the shell shows the operator. The envelope's
5//! risk level, side-effect list, reboot flag, and rollback flag come from
6//! the per-action `PreviewProfile` table in this module — **not** from
7//! the live system or from anything the planner suggested.
8//!
9//! ## Invariant: profile ↔ policy ↔ rollback consistency
10//!
11//! Three independent tables describe each action and they must stay in sync:
12//!
13//! 1. `PreviewProfile` (here) — risk + side effects shown to the operator.
14//! 2. `policy::min_role_for_action` — the minimum [`CallerRole`] that may
15//!    invoke the action.
16//! 3. The executor's per-action rollback spec.
17//!
18//! If `PreviewProfile` says `risk_level = Low` but `min_role_for_action`
19//! says `Admin`, an operator gets a misleadingly soft preview before being
20//! denied — or, worse, the dispatcher could allow a Dev caller through
21//! because the preview "looked safe". Likewise, `rollback_available = true`
22//! must imply that the executor records a rollback ref the daemon can
23//! later use; a true value with no executor support produces an undoable
24//! "undo" button in the GUI. The cross-module test
25//! `every_spec_action_has_a_policy_entry` (in `tests/action_consistency.rs`)
26//! pins these tables together.
27//!
28//! [`CallerRole`]: sysknife_types::CallerRole
29
30use serde_json::Value;
31use sysknife_types::{PreviewEnvelope, RequestEnvelope, RiskLevel};
32
33#[derive(Clone, Debug, PartialEq, Eq)]
34struct PreviewProfile {
35    risk_level: RiskLevel,
36    expected_side_effects: Vec<String>,
37    reboot_required: bool,
38    rollback_available: bool,
39    warnings: Vec<String>,
40}
41
42pub fn preview_action(
43    request: &RequestEnvelope,
44    current_state: Value,
45    proposed_change: Value,
46) -> PreviewEnvelope {
47    let profile = preview_profile(&request.action_name);
48
49    PreviewEnvelope {
50        summary: preview_summary(&request.action_name, &profile.risk_level),
51        risk_level: profile.risk_level,
52        current_state,
53        proposed_change,
54        expected_side_effects: profile.expected_side_effects,
55        reboot_required: profile.reboot_required,
56        rollback_available: profile.rollback_available,
57        warnings: profile.warnings,
58        request_hash: request.request_hash.clone(),
59    }
60}
61
62fn preview_profile(action_name: &str) -> PreviewProfile {
63    match action_name {
64        "GetSystemState"
65        | "CollectDiagnostics"
66        | "GetDeploymentHistory"
67        | "ListDeployments"
68        | "GetKernelArguments"
69        | "GetPendingUpdates"
70        | "SearchFlatpakApps"
71        | "ListFlatpakRemotes"
72        | "ListInstalledFlatpaks"
73        | "GetFlatpakAppInfo"
74        | "ListToolboxes"
75        | "ListServices"
76        | "GetServiceLogs"
77        | "GetServiceStatus"
78        | "GetServiceResourceLimits"
79        | "ListTimers"
80        | "GetFirewallState"
81        | "ListUsers"
82        | "ListGroups"
83        | "ListPackageRepositories"
84        | "ListContainers"
85        | "GetContainerInfo"
86        | "GetLayeredPackages"
87        | "GetDiskUsage"
88        | "ListProcesses"
89        | "GetMemoryInfo"
90        | "GetNetworkStatus"
91        | "GetListeningPorts"
92        | "GetJournalLog"
93        | "GetLvmReport"
94        | "GetSysctl"
95        | "GetMounts"
96        | "GetSudoGrants"
97        | "GetLogrotateStatus"
98        | "GetPasswordAging"
99        | "GetAuditRules"
100        | "GetCertificates"
101        | "GetAuthorizedKeys"
102        | "GetDateTime"
103        | "ListJobHistory"
104        // Ubuntu apt read-only
105        | "AptSearch"
106        | "AptListInstalled"
107        | "AptShow"
108        | "GetAptPins"
109        // Ubuntu snap read-only
110        | "SnapList"
111        | "SnapInfo"
112        // Ubuntu ufw read-only
113        | "UfwStatus"
114        // Ubuntu distrobox read-only
115        | "DistroboxList"
116        // Ubuntu netplan read-only
117        | "NetplanGetConfig"
118        // Ubuntu Pro / Livepatch / Multipass read-only (Tier 3)
119        | "ProStatus"
120        | "LivepatchStatus"
121        | "MultipassList" => PreviewProfile {
122            risk_level: RiskLevel::Low,
123            expected_side_effects: Vec::new(),
124            reboot_required: false,
125            rollback_available: false,
126            warnings: Vec::new(),
127        },
128        // ── Ubuntu apt medium-risk ────────────────────────────────────────
129        //
130        // AptUpdate / AptAutoremove are low-risk (listed in the read-only arm).
131        // The following ops change installed packages — reversible but not
132        // atomic, and may trigger service restarts via needrestart.
133        "AptInstall" | "AptRemove" | "AptPurge" | "AptHold" | "AptUnhold" => PreviewProfile {
134            risk_level: RiskLevel::Medium,
135            expected_side_effects: vec![
136                "package state will change".to_string(),
137                "services may be restarted by needrestart".to_string(),
138            ],
139            reboot_required: false,
140            rollback_available: false,
141            warnings: vec!["approval required".to_string()],
142        },
143
144        // ── Ubuntu snap medium-risk ───────────────────────────────────────
145        "SnapInstall" | "SnapRemove" | "SnapRefresh" | "SnapHold" | "SnapUnhold" => {
146            PreviewProfile {
147                risk_level: RiskLevel::Medium,
148                expected_side_effects: vec!["snap state will change".to_string()],
149                reboot_required: false,
150                rollback_available: false,
151                warnings: vec![
152                    "approval required".to_string(),
153                    "snap auto-refresh: install is paired with --hold by default".to_string(),
154                ],
155            }
156        }
157
158        // ── Ubuntu distrobox medium-risk ──────────────────────────────────
159        "DistroboxCreate" | "DistroboxRemove" => PreviewProfile {
160            risk_level: RiskLevel::Medium,
161            expected_side_effects: vec!["container lifecycle will change".to_string()],
162            reboot_required: false,
163            rollback_available: false,
164            warnings: vec!["approval required".to_string()],
165        },
166
167        // ── Ubuntu apt high-risk ──────────────────────────────────────────
168        //
169        // AptUpgrade uses dist-upgrade which can remove packages to resolve
170        // dependency conflicts, and triggers needrestart service restarts.
171        "AptUpgrade" => PreviewProfile {
172            risk_level: RiskLevel::High,
173            expected_side_effects: vec![
174                "all packages will be upgraded".to_string(),
175                "packages may be removed to resolve dependency conflicts".to_string(),
176                "services may be restarted by needrestart".to_string(),
177            ],
178            reboot_required: false,
179            rollback_available: false,
180            warnings: vec![
181                "dist-upgrade may remove packages".to_string(),
182                "exact approval required".to_string(),
183            ],
184        },
185
186        // ── Ubuntu ufw high-risk ──────────────────────────────────────────
187        "UfwEnable" | "UfwDisable" | "UfwAllow" | "UfwDeny" | "UfwReset" => PreviewProfile {
188            risk_level: RiskLevel::High,
189            expected_side_effects: vec![
190                "firewall rules will change".to_string(),
191                "network access may be immediately affected".to_string(),
192            ],
193            reboot_required: false,
194            rollback_available: false,
195            warnings: vec![
196                "misconfigured rules can lock out SSH access".to_string(),
197                "exact approval required".to_string(),
198            ],
199        },
200
201        // ── Ubuntu netplan high-risk ──────────────────────────────────────
202        "NetplanApply" => PreviewProfile {
203            risk_level: RiskLevel::High,
204            expected_side_effects: vec![
205                "network interfaces will be reconfigured immediately".to_string(),
206                "SSH session may be disconnected".to_string(),
207            ],
208            reboot_required: false,
209            rollback_available: false,
210            warnings: vec![
211                "can disconnect the current SSH session if config is wrong".to_string(),
212                "exact approval required".to_string(),
213            ],
214        },
215
216        // ── Ubuntu netplan medium-risk (Tier 3) ───────────────────────────
217        "NetplanGenerate" => PreviewProfile {
218            risk_level: RiskLevel::Medium,
219            expected_side_effects: vec![
220                "backend network config files will be regenerated".to_string(),
221            ],
222            reboot_required: false,
223            rollback_available: false,
224            warnings: vec!["approval required".to_string()],
225        },
226
227        // ── Ubuntu netplan high-risk (Tier 3) ─────────────────────────────
228        "NetplanSet" => PreviewProfile {
229            risk_level: RiskLevel::High,
230            expected_side_effects: vec![
231                "netplan configuration will be modified".to_string(),
232                "network may be affected when NetplanApply is run".to_string(),
233            ],
234            reboot_required: false,
235            rollback_available: true,
236            warnings: vec![
237                "run NetplanApply to activate the change".to_string(),
238                "exact approval required".to_string(),
239            ],
240        },
241
242        // ── Ubuntu ufw Tier 3 high-risk ───────────────────────────────────
243        "UfwDeleteRule" | "UfwLimit" => PreviewProfile {
244            risk_level: RiskLevel::High,
245            expected_side_effects: vec![
246                "firewall rules will change".to_string(),
247                "network access may be immediately affected".to_string(),
248            ],
249            reboot_required: false,
250            rollback_available: false,
251            warnings: vec![
252                "misconfigured rules can lock out SSH access".to_string(),
253                "exact approval required".to_string(),
254            ],
255        },
256
257        // ── Ubuntu Pro attach (Tier 3 high-risk; carries token) ──────────
258        "ProAttach" => PreviewProfile {
259            risk_level: RiskLevel::High,
260            expected_side_effects: vec![
261                "Ubuntu Pro subscription will be attached".to_string(),
262                "Pro services (ESM, Livepatch, FIPS) may be enabled".to_string(),
263            ],
264            reboot_required: false,
265            rollback_available: true,
266            warnings: vec![
267                "exact approval required".to_string(),
268                "token is redacted from the preview, audit log, and diagnostic output".to_string(),
269            ],
270        },
271
272        // ── Ubuntu Pro detach (Tier 3 high-risk; no credential param) ───
273        "ProDetach" => PreviewProfile {
274            risk_level: RiskLevel::High,
275            expected_side_effects: vec![
276                "Ubuntu Pro subscription will be released".to_string(),
277                "ESM, Livepatch, and FIPS services will be disabled".to_string(),
278            ],
279            reboot_required: false,
280            rollback_available: true,
281            warnings: vec![
282                "exact approval required".to_string(),
283                "after detach, this machine no longer receives Pro security patches".to_string(),
284            ],
285        },
286
287        // ── auditd file-watch rules ─────────────────────────────────────
288        "AddAuditRule" | "RemoveAuditRule" => PreviewProfile {
289            risk_level: RiskLevel::High,
290            expected_side_effects: vec![
291                "a persistent audit rule will be written/removed and reloaded".to_string(),
292            ],
293            reboot_required: false,
294            rollback_available: false,
295            warnings: vec![
296                "requires the auditd package installed to take effect".to_string(),
297                "exact approval required".to_string(),
298            ],
299        },
300
301        // ── certbot / ACME ──────────────────────────────────────────────
302        "ObtainCertificate" => PreviewProfile {
303            risk_level: RiskLevel::High,
304            expected_side_effects: vec![
305                "certbot will obtain a TLS certificate (ACME challenge)".to_string(),
306                "TLS material will be written under /etc/letsencrypt".to_string(),
307            ],
308            reboot_required: false,
309            rollback_available: false,
310            warnings: vec![
311                "contacts a public ACME CA over the network".to_string(),
312                "requires certbot installed + a reachable DNS/HTTP challenge".to_string(),
313                "exact approval required".to_string(),
314            ],
315        },
316        "RenewCertificates" => PreviewProfile {
317            risk_level: RiskLevel::High,
318            expected_side_effects: vec!["certbot will renew due certificates".to_string()],
319            reboot_required: false,
320            rollback_available: false,
321            warnings: vec![
322                "contacts a public ACME CA over the network".to_string(),
323                "exact approval required".to_string(),
324            ],
325        },
326
327        // ── fail2ban jail config ────────────────────────────────────────
328        "ConfigureFail2banJail" => PreviewProfile {
329            risk_level: RiskLevel::High,
330            expected_side_effects: vec![
331                "a fail2ban jail override will be written and the daemon reloaded".to_string(),
332            ],
333            reboot_required: false,
334            rollback_available: false,
335            warnings: vec![
336                "changes who gets banned (too strict can lock out real users)".to_string(),
337                "requires the fail2ban package installed to take effect".to_string(),
338                "exact approval required".to_string(),
339            ],
340        },
341
342        // ── Ubuntu Pro service toggles ──────────────────────────────────
343        "EnableProService" | "DisableProService" => PreviewProfile {
344            risk_level: RiskLevel::High,
345            expected_side_effects: vec![
346                "a single Ubuntu Pro service will be enabled/disabled".to_string(),
347            ],
348            reboot_required: false,
349            rollback_available: false,
350            warnings: vec![
351                "exact approval required".to_string(),
352                "requires an attached Pro subscription + network to take effect".to_string(),
353            ],
354        },
355
356        // ── Ubuntu release upgrade Tier 3 ────────────────────────────────
357        "UbuntuReleaseUpgrade" => PreviewProfile {
358            risk_level: RiskLevel::High,
359            expected_side_effects: vec![
360                "entire OS will be upgraded to the next Ubuntu release".to_string(),
361                "takes 20–45 minutes; system will be rebooted to complete".to_string(),
362                "third-party PPAs may be disabled or break during upgrade".to_string(),
363            ],
364            reboot_required: true,
365            rollback_available: false,
366            warnings: vec![
367                "reboot required to complete the upgrade".to_string(),
368                "long-running operation — configure timeout >= 3600 seconds".to_string(),
369                "exact approval required".to_string(),
370            ],
371        },
372
373        "ReloadService" => PreviewProfile {
374            risk_level: RiskLevel::Medium,
375            expected_side_effects: vec!["service config will be reloaded".to_string()],
376            reboot_required: false,
377            rollback_available: false,
378            warnings: vec![
379                "approval required".to_string(),
380                "requires ExecReload= to be defined in the unit file; \
381                 if not defined, use RestartService instead"
382                    .to_string(),
383            ],
384        },
385        "RestartService"
386        | "ReloadDaemon"
387        | "SetServiceEnabled"
388        | "StartService"
389        | "StopService"
390        | "ConfigureWifi"
391        | "SetDnsServers"
392        | "ConfigureFirewall"
393        | "CreateToolbox"
394        | "RemoveToolbox"
395        | "InstallFlatpak"
396        | "RemoveFlatpak"
397        | "UpdateFlatpak"
398        | "AddFlatpakRemote"
399        | "RemoveFlatpakRemote"
400        | "MaskService"
401        | "UnmaskService"
402        | "SetHostname"
403        | "SetTimezone"
404        | "SetLocale"
405        | "SetNtp"
406        | "CreateUser" => PreviewProfile {
407            risk_level: RiskLevel::Medium,
408            expected_side_effects: vec!["service interruption".to_string()],
409            reboot_required: false,
410            rollback_available: false,
411            warnings: vec!["approval required".to_string()],
412        },
413        // ── Observability / journald maintenance ─────────────────────────
414        "VacuumJournal" => PreviewProfile {
415            risk_level: RiskLevel::Medium,
416            expected_side_effects: vec![
417                "old journal entries will be permanently deleted".to_string(),
418                "disk space will be reclaimed".to_string(),
419            ],
420            reboot_required: false,
421            rollback_available: false,
422            warnings: vec!["deleted log history cannot be recovered".to_string()],
423        },
424
425        // ── Storage / LVM mutations ───────────────────────────────────────
426        "ExtendLogicalVolume" => PreviewProfile {
427            risk_level: RiskLevel::High,
428            expected_side_effects: vec![
429                "the logical volume and its filesystem will be grown".to_string(),
430            ],
431            reboot_required: false,
432            rollback_available: false,
433            warnings: vec![
434                "resizes a live filesystem; a wrong volume target risks data".to_string(),
435                "exact approval required".to_string(),
436            ],
437        },
438        "CreateLogicalVolume" | "CreateLvSnapshot" => PreviewProfile {
439            risk_level: RiskLevel::High,
440            expected_side_effects: vec![
441                "volume-group free space will be consumed".to_string(),
442            ],
443            reboot_required: false,
444            rollback_available: false,
445            warnings: vec![
446                "consumes VG capacity; snapshots fill as the origin changes".to_string(),
447                "exact approval required".to_string(),
448            ],
449        },
450
451        // ── systemd resource limits ───────────────────────────────────────
452        "SetServiceResourceLimits" => PreviewProfile {
453            risk_level: RiskLevel::High,
454            expected_side_effects: vec![
455                "the service's memory/CPU/task limits will change immediately".to_string(),
456                "a persistent drop-in will be written (undo: systemctl revert)".to_string(),
457            ],
458            reboot_required: false,
459            rollback_available: false,
460            warnings: vec![
461                "too tight a MemoryMax can OOM-kill the service".to_string(),
462                "exact approval required".to_string(),
463            ],
464        },
465
466        // ── Log management ────────────────────────────────────────────────
467        "ConfigureLogRotation" | "RemoveLogRotation" => PreviewProfile {
468            risk_level: RiskLevel::Medium,
469            expected_side_effects: vec![
470                "a logrotate drop-in will be written/removed".to_string(),
471            ],
472            reboot_required: false,
473            rollback_available: false,
474            warnings: vec!["approval required".to_string()],
475        },
476        "ConfigureRemoteSyslog" | "RemoveRemoteSyslog" => PreviewProfile {
477            risk_level: RiskLevel::High,
478            expected_side_effects: vec![
479                "rsyslog will start/stop forwarding all logs to a remote host".to_string(),
480                "rsyslog will be restarted".to_string(),
481            ],
482            reboot_required: false,
483            rollback_available: false,
484            warnings: vec![
485                "forwarding sends log data off-host (exfiltration surface)".to_string(),
486                "exact approval required".to_string(),
487            ],
488        },
489
490        // ── PAM password policy ───────────────────────────────────────────
491        "SetPasswordAging" => PreviewProfile {
492            risk_level: RiskLevel::High,
493            expected_side_effects: vec![
494                "the target account's password-aging limits will change".to_string(),
495            ],
496            reboot_required: false,
497            rollback_available: false,
498            warnings: vec!["exact approval required".to_string()],
499        },
500        "SetPasswordPolicy" | "SetAccountLockout" => PreviewProfile {
501            risk_level: RiskLevel::High,
502            expected_side_effects: vec![
503                "a system-wide PAM policy file will be written".to_string(),
504            ],
505            reboot_required: false,
506            rollback_available: false,
507            warnings: vec![
508                "affects password/lockout rules for all accounts".to_string(),
509                "takes effect only if the PAM module is enabled in the auth stack".to_string(),
510                "exact approval required".to_string(),
511            ],
512        },
513
514        // ── Ubuntu apt pinning ────────────────────────────────────────────
515        "SetAptPin" | "RemoveAptPin" => PreviewProfile {
516            risk_level: RiskLevel::Medium,
517            expected_side_effects: vec![
518                "apt version/origin preferences will change".to_string(),
519                "a /etc/apt/preferences.d drop-in will be written/removed".to_string(),
520            ],
521            reboot_required: false,
522            rollback_available: false,
523            warnings: vec!["approval required".to_string()],
524        },
525
526        // ── Scoped sudoers.d ──────────────────────────────────────────────
527        "GrantSudoAccess" | "RevokeSudoAccess" => PreviewProfile {
528            risk_level: RiskLevel::High,
529            expected_side_effects: vec![
530                "a sudoers.d drop-in will be created/removed".to_string(),
531                "the target user's sudo privileges will change".to_string(),
532            ],
533            reboot_required: false,
534            rollback_available: false,
535            warnings: vec![
536                "this configures privilege escalation — review the rule carefully".to_string(),
537                "exact approval required".to_string(),
538            ],
539        },
540
541        // ── Filesystem mounts / swap ──────────────────────────────────────
542        "AddMount" | "RemoveMount" => PreviewProfile {
543            risk_level: RiskLevel::High,
544            expected_side_effects: vec![
545                "a filesystem will be (un)mounted".to_string(),
546                "/etc/fstab will be updated (managed entries carry nofail)".to_string(),
547            ],
548            reboot_required: false,
549            rollback_available: false,
550            warnings: vec![
551                "a wrong device or mountpoint risks data or availability".to_string(),
552                "exact approval required".to_string(),
553            ],
554        },
555        "AddSwap" | "RemoveSwap" => PreviewProfile {
556            risk_level: RiskLevel::High,
557            expected_side_effects: vec![
558                "swap will be enabled/disabled".to_string(),
559                "a swap file will be created/removed and /etc/fstab updated".to_string(),
560            ],
561            reboot_required: false,
562            rollback_available: false,
563            warnings: vec!["exact approval required".to_string()],
564        },
565
566        // ── Kernel / sysctl mutation ──────────────────────────────────────
567        "SetSysctl" => PreviewProfile {
568            risk_level: RiskLevel::High,
569            expected_side_effects: vec![
570                "a kernel parameter will change immediately".to_string(),
571                "the value will persist across reboots (/etc/sysctl.d)".to_string(),
572            ],
573            reboot_required: false,
574            rollback_available: false,
575            warnings: vec![
576                "a wrong net.*/vm.*/kernel.* value can degrade or lock the host".to_string(),
577                "exact approval required".to_string(),
578            ],
579        },
580
581        "SignalProcess" => PreviewProfile {
582            risk_level: RiskLevel::High,
583            expected_side_effects: vec!["the target process will be terminated".to_string()],
584            reboot_required: false,
585            rollback_available: false,
586            warnings: vec![
587                "the process and its in-flight work will stop".to_string(),
588                "exact approval required".to_string(),
589            ],
590        },
591        "ConfigureUnattendedUpgrades" => PreviewProfile {
592            risk_level: RiskLevel::High,
593            expected_side_effects: vec![
594                "the automatic security-update policy will change".to_string()
595            ],
596            reboot_required: false,
597            rollback_available: false,
598            warnings: vec!["approval required".to_string()],
599        },
600        "CreateScheduledJob" => PreviewProfile {
601            risk_level: RiskLevel::High,
602            expected_side_effects: vec![
603                "a systemd timer will run the command on a recurring schedule".to_string(),
604            ],
605            reboot_required: false,
606            rollback_available: false,
607            warnings: vec![
608                "persistent root-scheduled execution".to_string(),
609                "exact approval required".to_string(),
610            ],
611        },
612        "AddUserToGroup"
613        | "RemoveUserFromGroup"
614        | "CreateGroup"
615        | "DeleteGroup"
616        | "LockUserAccount"
617        | "UnlockUserAccount"
618        | "SetSshdOption"
619        | "DeleteUser"
620        | "AddAuthorizedKey"
621        | "RemoveAuthorizedKey" => PreviewProfile {
622            // High risk: access-control changes — group membership, account
623            // deletion, and SSH key modifications require Admin authorization.
624            risk_level: RiskLevel::High,
625            expected_side_effects: vec!["access control will change".to_string()],
626            reboot_required: false,
627            rollback_available: false,
628            warnings: vec![
629                "privilege change".to_string(),
630                "exact approval required".to_string(),
631            ],
632        },
633        "AddPackageRepository"
634        | "RemovePackageRepository"
635        | "EnablePackageRepository"
636        | "DisablePackageRepository" => PreviewProfile {
637            risk_level: RiskLevel::Medium,
638            expected_side_effects: vec!["package repository configuration will change".to_string()],
639            reboot_required: false,
640            rollback_available: false,
641            warnings: vec!["approval required".to_string()],
642        },
643        "CreateContainer" | "StartContainer" | "StopContainer" | "RemoveContainer" => {
644            PreviewProfile {
645                risk_level: RiskLevel::Medium,
646                expected_side_effects: vec!["container lifecycle will change".to_string()],
647                reboot_required: false,
648                rollback_available: false,
649                warnings: vec!["approval required".to_string()],
650            }
651        }
652        "UpdateSystem"
653        | "InstallPackages"
654        | "RemovePackages"
655        | "RebaseSystem"
656        | "RollbackDeployment"
657        | "AddLayeredPackage"
658        | "RemoveLayeredPackage"
659        | "ReplaceLayeredPackage"
660        | "ResetLayeredPackageOverride"
661        | "RemoveBasePackage" => PreviewProfile {
662            risk_level: RiskLevel::High,
663            expected_side_effects: vec![
664                "system deployment will change".to_string(),
665                "reboot may be required".to_string(),
666            ],
667            reboot_required: true,
668            rollback_available: true,
669            warnings: vec![
670                "reboot required".to_string(),
671                "exact approval required".to_string(),
672            ],
673        },
674        "SetKernelArguments" => PreviewProfile {
675            risk_level: RiskLevel::High,
676            expected_side_effects: vec!["boot arguments will change".to_string()],
677            reboot_required: true,
678            rollback_available: true,
679            warnings: vec![
680                "reboot required".to_string(),
681                "exact approval required".to_string(),
682            ],
683        },
684        "RebootSystem" => PreviewProfile {
685            risk_level: RiskLevel::High,
686            expected_side_effects: vec!["system reboot will interrupt running work".to_string()],
687            reboot_required: true,
688            rollback_available: false,
689            warnings: vec![
690                "reboot required".to_string(),
691                "exact approval required".to_string(),
692            ],
693        },
694        "CleanupDeployments" => PreviewProfile {
695            risk_level: RiskLevel::High,
696            expected_side_effects: vec!["old deployments may be removed".to_string()],
697            reboot_required: false,
698            rollback_available: false,
699            warnings: vec!["exact approval required".to_string()],
700        },
701        "PinDeployment" | "UnpinDeployment" => PreviewProfile {
702            risk_level: RiskLevel::High,
703            expected_side_effects: vec!["deployment pin state will change".to_string()],
704            reboot_required: false,
705            rollback_available: false,
706            warnings: vec!["exact approval required".to_string()],
707        },
708        _ => PreviewProfile {
709            risk_level: RiskLevel::High,
710            expected_side_effects: vec!["unclassified action".to_string()],
711            reboot_required: false,
712            rollback_available: false,
713            warnings: vec!["action profile not recognized".to_string()],
714        },
715    }
716}
717
718fn preview_summary(action_name: &str, risk_level: &RiskLevel) -> String {
719    let risk = match risk_level {
720        RiskLevel::Low => "low-risk",
721        RiskLevel::Medium => "medium-risk",
722        RiskLevel::High => "high-risk",
723    };
724
725    format!("{action_name} preview ({risk})")
726}
727
728#[cfg(test)]
729mod tests {
730    use super::*;
731    use sysknife_types::{CallerRole, RiskLevel};
732
733    fn req(action: &str) -> RequestEnvelope {
734        RequestEnvelope {
735            action_name: action.to_string(),
736            request_id: "test-req".to_string(),
737            params: serde_json::Value::Null,
738            caller_role: CallerRole::Dev,
739            request_hash: sysknife_types::RequestHash::new("hash".to_string()),
740        }
741    }
742
743    #[test]
744    fn all_low_risk_actions() {
745        let actions = [
746            "GetSystemState",
747            "CollectDiagnostics",
748            "GetDeploymentHistory",
749            "ListDeployments",
750            "GetKernelArguments",
751            "GetPendingUpdates",
752            "SearchFlatpakApps",
753            "ListFlatpakRemotes",
754            "ListInstalledFlatpaks",
755            "GetFlatpakAppInfo",
756            "ListToolboxes",
757            "ListServices",
758            "GetServiceLogs",
759            "GetServiceStatus",
760            "ListTimers",
761            "GetFirewallState",
762            "ListUsers",
763            "ListGroups",
764            "ListPackageRepositories",
765            "ListContainers",
766            "GetContainerInfo",
767            "GetLayeredPackages",
768            "GetDiskUsage",
769            "ListProcesses",
770            "GetMemoryInfo",
771            "GetNetworkStatus",
772            "GetAuthorizedKeys",
773            "GetDateTime",
774            "ListJobHistory",
775        ];
776
777        for action in &actions {
778            let envelope = preview_action(
779                &req(action),
780                serde_json::Value::Null,
781                serde_json::Value::Null,
782            );
783            assert_eq!(
784                envelope.risk_level,
785                RiskLevel::Low,
786                "{action} should be Low risk"
787            );
788            assert!(
789                !envelope.reboot_required,
790                "{action} should not require reboot"
791            );
792            assert!(
793                !envelope.rollback_available,
794                "{action} should not have rollback available"
795            );
796        }
797    }
798
799    #[test]
800    fn reload_service_is_medium_risk() {
801        let envelope = preview_action(
802            &req("ReloadService"),
803            serde_json::Value::Null,
804            serde_json::Value::Null,
805        );
806        assert_eq!(envelope.risk_level, RiskLevel::Medium);
807        assert_eq!(envelope.warnings.len(), 2);
808        assert!(!envelope.reboot_required);
809        assert!(!envelope.rollback_available);
810    }
811
812    #[test]
813    fn medium_risk_actions() {
814        let actions = [
815            "RestartService",
816            "StopService",
817            "StartService",
818            "MaskService",
819            "SetHostname",
820            "SetTimezone",
821            "SetLocale",
822            "SetNtp",
823            "ConfigureFirewall",
824            "InstallFlatpak",
825            "RemoveFlatpak",
826            "CreateToolbox",
827            "RemoveToolbox",
828            "CreateUser",
829            "CreateContainer",
830            "StartContainer",
831            "StopContainer",
832            "RemoveContainer",
833            "AddPackageRepository",
834            "RemovePackageRepository",
835        ];
836
837        for action in &actions {
838            let envelope = preview_action(
839                &req(action),
840                serde_json::Value::Null,
841                serde_json::Value::Null,
842            );
843            assert_eq!(
844                envelope.risk_level,
845                RiskLevel::Medium,
846                "{action} should be Medium risk"
847            );
848        }
849    }
850
851    #[test]
852    fn high_risk_access_control_actions() {
853        let actions = [
854            "AddUserToGroup",
855            "RemoveUserFromGroup",
856            "DeleteUser",
857            "AddAuthorizedKey",
858            "RemoveAuthorizedKey",
859        ];
860
861        for action in &actions {
862            let envelope = preview_action(
863                &req(action),
864                serde_json::Value::Null,
865                serde_json::Value::Null,
866            );
867            assert_eq!(
868                envelope.risk_level,
869                RiskLevel::High,
870                "{action} should be High risk"
871            );
872            assert!(
873                envelope
874                    .expected_side_effects
875                    .iter()
876                    .any(|e| e.contains("access control will change")),
877                "{action} should have 'access control will change' in expected_side_effects"
878            );
879        }
880    }
881
882    #[test]
883    fn high_risk_system_reboot_actions() {
884        let actions = [
885            "UpdateSystem",
886            "InstallPackages",
887            "RemovePackages",
888            "RebaseSystem",
889            "RollbackDeployment",
890            "AddLayeredPackage",
891            "RemoveLayeredPackage",
892            "SetKernelArguments",
893        ];
894
895        for action in &actions {
896            let envelope = preview_action(
897                &req(action),
898                serde_json::Value::Null,
899                serde_json::Value::Null,
900            );
901            assert_eq!(
902                envelope.risk_level,
903                RiskLevel::High,
904                "{action} should be High risk"
905            );
906            assert!(envelope.reboot_required, "{action} should require reboot");
907            assert!(
908                envelope.rollback_available,
909                "{action} should have rollback available"
910            );
911        }
912    }
913
914    #[test]
915    fn reboot_system_no_rollback() {
916        let envelope = preview_action(
917            &req("RebootSystem"),
918            serde_json::Value::Null,
919            serde_json::Value::Null,
920        );
921        assert_eq!(envelope.risk_level, RiskLevel::High);
922        assert!(envelope.reboot_required);
923        assert!(!envelope.rollback_available);
924    }
925
926    #[test]
927    fn cleanup_deployments_is_high_no_reboot() {
928        let envelope = preview_action(
929            &req("CleanupDeployments"),
930            serde_json::Value::Null,
931            serde_json::Value::Null,
932        );
933        assert_eq!(envelope.risk_level, RiskLevel::High);
934        assert!(!envelope.reboot_required);
935    }
936
937    #[test]
938    fn pin_unpin_deployment_are_high() {
939        for action in &["PinDeployment", "UnpinDeployment"] {
940            let envelope = preview_action(
941                &req(action),
942                serde_json::Value::Null,
943                serde_json::Value::Null,
944            );
945            assert_eq!(
946                envelope.risk_level,
947                RiskLevel::High,
948                "{action} should be High risk"
949            );
950        }
951    }
952
953    #[test]
954    fn unknown_action_defaults_to_high() {
955        let envelope = preview_action(
956            &req("DefinitelyNotRealAction"),
957            serde_json::Value::Null,
958            serde_json::Value::Null,
959        );
960        assert_eq!(envelope.risk_level, RiskLevel::High);
961        assert!(
962            envelope
963                .warnings
964                .iter()
965                .any(|w| w.contains("action profile not recognized")),
966            "warnings should contain 'action profile not recognized'"
967        );
968        assert!(
969            envelope
970                .expected_side_effects
971                .iter()
972                .any(|e| e.contains("unclassified action")),
973            "expected_side_effects should contain 'unclassified action'"
974        );
975    }
976
977    #[test]
978    fn preview_action_summary_format() {
979        let envelope = preview_action(
980            &req("GetDiskUsage"),
981            serde_json::Value::Null,
982            serde_json::Value::Null,
983        );
984        assert_eq!(envelope.summary, "GetDiskUsage preview (low-risk)");
985    }
986
987    #[test]
988    fn preview_action_passes_current_and_proposed_state() {
989        let current = serde_json::json!({"disk": "80%"});
990        let proposed = serde_json::json!({"action": "cleanup"});
991        let envelope = preview_action(
992            &req("CleanupDeployments"),
993            current.clone(),
994            proposed.clone(),
995        );
996        assert_eq!(envelope.current_state, current);
997        assert_eq!(envelope.proposed_change, proposed);
998    }
999
1000    #[test]
1001    fn preview_action_preserves_request_hash() {
1002        let mut r = req("GetDiskUsage");
1003        r.request_hash = sysknife_types::RequestHash::new("deadbeef".to_string());
1004        let envelope = preview_action(&r, serde_json::Value::Null, serde_json::Value::Null);
1005        assert_eq!(envelope.request_hash.as_str(), "deadbeef");
1006    }
1007}