Skip to main content

sysknife_daemon/
preview.rs

1//! Preview-time presentation profile for every action the daemon recognises.
2//!
3//! The dispatcher calls [`preview_action`] *before* execution to produce a
4//! [`PreviewEnvelope`] the shell shows the operator.
5//!
6//! ## Risk, reboot, and rollback are a single source of truth
7//!
8//! Risk, `reboot_required`, and `rollback_available` are **not** declared here —
9//! they are owned by each action's `ActionSpec` (`crate::actions`) and derived
10//! via [`crate::actions::spec_meta`] / [`gate_risk`], so the approval gate and
11//! the displayed reboot/rollback flags can never disagree with the catalogue in
12//! `docs/action-reference.md`. This module supplies only the preview-specific
13//! presentation: expected side effects and warnings. Every catalogued action
14//! must have a profile arm below; `tests/action_consistency.rs` fails the build
15//! if one falls through to the `_` "unclassified" default.
16//!
17//! ## Invariant: spec ↔ preview ↔ policy consistency
18//!
19//! `policy::min_role_for_action` mirrors the spec risk via
20//! `policy::role_for_risk_level`, except a documented, *monotonic* exception
21//! list (an exception may only raise the role above the risk floor, never lower
22//! it). The cross-module tests in `tests/action_consistency.rs` pin
23//! `preview.risk == spec.risk` and `role == role_for_risk_level(risk)` (± the
24//! exception list) for every action, so these tables cannot silently drift.
25//!
26//! [`CallerRole`]: sysknife_types::CallerRole
27
28use 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
37/// The two standard approval-gate warning phrases, threaded through nearly
38/// every mutating action's profile below. Named so every arm spells the same
39/// wording instead of re-typing the literal ~50 times; a copy-paste typo here
40/// would only be a cosmetic wording drift (`tests/action_consistency.rs` pins
41/// risk/role/reboot/rollback, not warning text), but a shared constant makes
42/// that drift impossible rather than merely unlikely.
43const 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 profile = preview_profile(&request.action_name);
52    // Risk, reboot, and rollback are OWNED by the action's `ActionSpec` (the
53    // single source of truth, `crate::actions`) and derived here so the approval
54    // gate and the displayed reboot/rollback flags can never disagree with the
55    // catalogue. Only presentation text (side effects, warnings) comes from the
56    // per-action profile. Actions with no spec (only the dispatcher-internal
57    // `ListJobHistory`) fall back to a conservative risk and no reboot/rollback.
58    let risk_level = gate_risk(&request.action_name);
59    let (reboot_required, rollback_available) = crate::actions::spec_meta(&request.action_name)
60        .map(|m| (m.reboot_required, m.rollback_available))
61        .unwrap_or_else(|| {
62            // Unlike risk (which falls back to High), reboot/rollback default to
63            // the *reassuring* (false, false). That is accurate only for the one
64            // spec-less action that legitimately reaches preview, the read-only
65            // `ListJobHistory`. For anything else a missing spec is a bug (a
66            // catalogued action must have one) or an unvalidated name off the
67            // wire — log it, since silently claiming "no reboot, no rollback" for
68            // a real mutating action would mislead the operator.
69            if request.action_name != "ListJobHistory" {
70                eprintln!(
71                    "[sysknife-daemon] preview_action: no ActionSpec for {:?}; defaulting \
72                     reboot_required=false, rollback_available=false (risk falls back to High)",
73                    request.action_name
74                );
75            }
76            (false, false)
77        });
78
79    PreviewEnvelope {
80        summary: preview_summary(&request.action_name, &risk_level),
81        risk_level,
82        current_state,
83        proposed_change,
84        expected_side_effects: profile.expected_side_effects,
85        reboot_required,
86        rollback_available,
87        warnings: profile.warnings,
88        request_hash: request.request_hash.clone(),
89    }
90}
91
92/// Conservative risk for the rare action that reaches preview without an
93/// `ActionSpec`. Only `ListJobHistory` (dispatcher-internal, read-only) does so;
94/// anything else unknown is High, so a missing spec can never silently downgrade
95/// the approval gate.
96fn fallback_risk(action_name: &str) -> RiskLevel {
97    match action_name {
98        "ListJobHistory" => RiskLevel::Low,
99        _ => RiskLevel::High,
100    }
101}
102
103/// The approval-gate risk for `action_name`: the risk declared on its
104/// `ActionSpec` (the single source of truth), or a conservative fallback for the
105/// rare action with no spec. This is the one function every risk-gated decision
106/// derives from — the preview envelope above and the CLI's auto-approval gate
107/// (`sysknife-cli`) both call it, so neither can size its friction off a stale
108/// or model-guessed risk.
109pub fn gate_risk(action_name: &str) -> RiskLevel {
110    crate::actions::spec_meta(action_name)
111        .map(|m| m.risk_level)
112        .unwrap_or_else(|| fallback_risk(action_name))
113}
114
115fn preview_profile(action_name: &str) -> PreviewProfile {
116    match action_name {
117        "GetSystemState"
118        | "CollectDiagnostics"
119        | "GetDeploymentHistory"
120        | "ListDeployments"
121        | "GetKernelArguments"
122        | "GetPendingUpdates"
123        | "SearchFlatpakApps"
124        | "ListFlatpakRemotes"
125        | "ListInstalledFlatpaks"
126        | "GetFlatpakAppInfo"
127        | "ListToolboxes"
128        | "ListServices"
129        | "GetServiceLogs"
130        | "GetServiceStatus"
131        | "GetServiceResourceLimits"
132        | "ListTimers"
133        | "GetFirewallState"
134        | "ListUsers"
135        | "ListGroups"
136        | "ListPackageRepositories"
137        | "ListContainers"
138        | "GetContainerInfo"
139        | "GetLayeredPackages"
140        | "GetDiskUsage"
141        | "ListProcesses"
142        | "GetMemoryInfo"
143        | "GetNetworkStatus"
144        | "GetListeningPorts"
145        | "GetJournalLog"
146        | "GetLvmReport"
147        | "GetSysctl"
148        | "GetMounts"
149        | "GetSudoGrants"
150        | "GetLogrotateStatus"
151        | "GetPasswordAging"
152        | "GetAuditRules"
153        | "GetCertificates"
154        | "GetAuthorizedKeys"
155        | "GetDateTime"
156        | "ListJobHistory"
157        // Ubuntu apt read-only
158        | "AptSearch"
159        | "AptListInstalled"
160        | "AptShow"
161        | "GetAptPins"
162        // Ubuntu snap read-only
163        | "SnapList"
164        | "SnapInfo"
165        // Ubuntu ufw read-only
166        | "UfwStatus"
167        // Ubuntu distrobox read-only
168        | "DistroboxList"
169        // Ubuntu netplan read-only
170        | "NetplanGetConfig"
171        // Ubuntu Pro / Livepatch / Multipass read-only (Tier 3)
172        | "ProStatus"
173        | "LivepatchStatus"
174        | "MultipassList"
175        // Cross-distro + Ubuntu read-only queries
176        | "AptListUpgradable"
177        | "AptHistoryList"
178        | "GrubGetKargs"
179        | "CheckPendingReboot"
180        | "ResolvectlStatus"
181        | "AppArmorStatus"
182        | "CloudInitStatus"
183        | "UbuntuListFlatpaks"
184        | "Fail2banStatus" => PreviewProfile {
185            expected_side_effects: Vec::new(),
186            warnings: Vec::new(),
187        },
188        // ── Ubuntu apt: package-state changes ─────────────────────────────
189        //
190        // These ops change installed packages — reversible but not atomic, and
191        // may trigger service restarts via needrestart. (Risk is owned by each
192        // action's ActionSpec; this arm only supplies preview side effects.)
193        "AptInstall" | "AptRemove" | "AptPurge" | "AptHold" | "AptUnhold" => PreviewProfile {
194            expected_side_effects: vec![
195                "package state will change".to_string(),
196                "services may be restarted by needrestart".to_string(),
197            ],
198            warnings: vec![APPROVAL_REQUIRED.to_string()],
199        },
200
201        // ── Ubuntu snap medium-risk ───────────────────────────────────────
202        "SnapInstall" | "SnapRemove" | "SnapRefresh" | "SnapHold" | "SnapUnhold" => {
203            PreviewProfile {
204                expected_side_effects: vec!["snap state will change".to_string()],
205                warnings: vec![
206                    APPROVAL_REQUIRED.to_string(),
207                    "snap auto-refresh: install is paired with --hold by default".to_string(),
208                ],
209            }
210        }
211
212        // ── Ubuntu distrobox medium-risk ──────────────────────────────────
213        "DistroboxCreate" | "DistroboxRemove" => PreviewProfile {
214            expected_side_effects: vec!["container lifecycle will change".to_string()],
215            warnings: vec![APPROVAL_REQUIRED.to_string()],
216        },
217
218        // ── Ubuntu apt high-risk ──────────────────────────────────────────
219        //
220        // AptUpgrade uses dist-upgrade which can remove packages to resolve
221        // dependency conflicts, and triggers needrestart service restarts.
222        "AptUpgrade" => PreviewProfile {
223            expected_side_effects: vec![
224                "all packages will be upgraded".to_string(),
225                "packages may be removed to resolve dependency conflicts".to_string(),
226                "services may be restarted by needrestart".to_string(),
227            ],
228            warnings: vec![
229                "dist-upgrade may remove packages".to_string(),
230                EXACT_APPROVAL_REQUIRED.to_string(),
231            ],
232        },
233
234        // ── Ubuntu ufw high-risk ──────────────────────────────────────────
235        "UfwEnable" | "UfwDisable" | "UfwAllow" | "UfwDeny" | "UfwReset" => PreviewProfile {
236            expected_side_effects: vec![
237                "firewall rules will change".to_string(),
238                "network access may be immediately affected".to_string(),
239            ],
240            warnings: vec![
241                "misconfigured rules can lock out SSH access".to_string(),
242                EXACT_APPROVAL_REQUIRED.to_string(),
243            ],
244        },
245
246        // ── Ubuntu netplan high-risk ──────────────────────────────────────
247        "NetplanApply" => PreviewProfile {
248            expected_side_effects: vec![
249                "network interfaces will be reconfigured immediately".to_string(),
250                "SSH session may be disconnected".to_string(),
251            ],
252            warnings: vec![
253                "can disconnect the current SSH session if config is wrong".to_string(),
254                EXACT_APPROVAL_REQUIRED.to_string(),
255            ],
256        },
257
258        // ── Ubuntu netplan medium-risk (Tier 3) ───────────────────────────
259        "NetplanGenerate" => PreviewProfile {
260            expected_side_effects: vec![
261                "backend network config files will be regenerated".to_string(),
262            ],
263            warnings: vec![APPROVAL_REQUIRED.to_string()],
264        },
265
266        // ── Ubuntu netplan high-risk (Tier 3) ─────────────────────────────
267        "NetplanSet" => PreviewProfile {
268            expected_side_effects: vec![
269                "netplan configuration will be modified".to_string(),
270                "network may be affected when NetplanApply is run".to_string(),
271            ],
272            warnings: vec![
273                "run NetplanApply to activate the change".to_string(),
274                EXACT_APPROVAL_REQUIRED.to_string(),
275            ],
276        },
277
278        // ── Ubuntu ufw Tier 3 high-risk ───────────────────────────────────
279        "UfwDeleteRule" | "UfwLimit" => PreviewProfile {
280            expected_side_effects: vec![
281                "firewall rules will change".to_string(),
282                "network access may be immediately affected".to_string(),
283            ],
284            warnings: vec![
285                "misconfigured rules can lock out SSH access".to_string(),
286                EXACT_APPROVAL_REQUIRED.to_string(),
287            ],
288        },
289
290        // ── Ubuntu Pro attach (Tier 3 high-risk; carries token) ──────────
291        "ProAttach" => PreviewProfile {
292            expected_side_effects: vec![
293                "Ubuntu Pro subscription will be attached".to_string(),
294                "Pro services (ESM, Livepatch, FIPS) may be enabled".to_string(),
295            ],
296            warnings: vec![
297                EXACT_APPROVAL_REQUIRED.to_string(),
298                "token is redacted from the preview, audit log, and diagnostic output".to_string(),
299            ],
300        },
301
302        // ── Ubuntu Pro detach (Tier 3 high-risk; no credential param) ───
303        "ProDetach" => PreviewProfile {
304            expected_side_effects: vec![
305                "Ubuntu Pro subscription will be released".to_string(),
306                "ESM, Livepatch, and FIPS services will be disabled".to_string(),
307            ],
308            warnings: vec![
309                EXACT_APPROVAL_REQUIRED.to_string(),
310                "after detach, this machine no longer receives Pro security patches".to_string(),
311            ],
312        },
313
314        // ── auditd file-watch rules ─────────────────────────────────────
315        "AddAuditRule" | "RemoveAuditRule" => PreviewProfile {
316            expected_side_effects: vec![
317                "a persistent audit rule will be written/removed and reloaded".to_string(),
318            ],
319            warnings: vec![
320                "requires the auditd package installed to take effect".to_string(),
321                EXACT_APPROVAL_REQUIRED.to_string(),
322            ],
323        },
324
325        // ── certbot / ACME ──────────────────────────────────────────────
326        "ObtainCertificate" => PreviewProfile {
327            expected_side_effects: vec![
328                "certbot will obtain a TLS certificate (ACME challenge)".to_string(),
329                "TLS material will be written under /etc/letsencrypt".to_string(),
330            ],
331            warnings: vec![
332                "contacts a public ACME CA over the network".to_string(),
333                "requires certbot installed + a reachable DNS/HTTP challenge".to_string(),
334                EXACT_APPROVAL_REQUIRED.to_string(),
335            ],
336        },
337        "RenewCertificates" => PreviewProfile {
338            expected_side_effects: vec!["certbot will renew due certificates".to_string()],
339            warnings: vec![
340                "contacts a public ACME CA over the network".to_string(),
341                EXACT_APPROVAL_REQUIRED.to_string(),
342            ],
343        },
344
345        // ── fail2ban jail config ────────────────────────────────────────
346        "ConfigureFail2banJail" => PreviewProfile {
347            expected_side_effects: vec![
348                "a fail2ban jail override will be written and the daemon reloaded".to_string(),
349            ],
350            warnings: vec![
351                "changes who gets banned (too strict can lock out real users)".to_string(),
352                "requires the fail2ban package installed to take effect".to_string(),
353                EXACT_APPROVAL_REQUIRED.to_string(),
354            ],
355        },
356
357        // ── Ubuntu Pro service toggles ──────────────────────────────────
358        "EnableProService" | "DisableProService" => PreviewProfile {
359            expected_side_effects: vec![
360                "a single Ubuntu Pro service will be enabled/disabled".to_string(),
361            ],
362            warnings: vec![
363                EXACT_APPROVAL_REQUIRED.to_string(),
364                "requires an attached Pro subscription + network to take effect".to_string(),
365            ],
366        },
367
368        // ── Ubuntu release upgrade Tier 3 ────────────────────────────────
369        "UbuntuReleaseUpgrade" => PreviewProfile {
370            expected_side_effects: vec![
371                "entire OS will be upgraded to the next Ubuntu release".to_string(),
372                "takes 20–45 minutes; system will be rebooted to complete".to_string(),
373                "third-party PPAs may be disabled or break during upgrade".to_string(),
374            ],
375            warnings: vec![
376                "reboot required to complete the upgrade".to_string(),
377                "long-running operation — configure timeout >= 3600 seconds".to_string(),
378                EXACT_APPROVAL_REQUIRED.to_string(),
379            ],
380        },
381
382        "ReloadService" => PreviewProfile {
383            expected_side_effects: vec!["service config will be reloaded".to_string()],
384            warnings: vec![
385                APPROVAL_REQUIRED.to_string(),
386                "requires ExecReload= to be defined in the unit file; \
387                 if not defined, use RestartService instead"
388                    .to_string(),
389            ],
390        },
391        "RestartService"
392        | "ReloadDaemon"
393        | "SetServiceEnabled"
394        | "StartService"
395        | "StopService"
396        | "CreateToolbox"
397        | "RemoveToolbox"
398        | "InstallFlatpak"
399        | "RemoveFlatpak"
400        | "UpdateFlatpak"
401        | "AddFlatpakRemote"
402        | "RemoveFlatpakRemote"
403        | "UnmaskService"
404        | "SetHostname"
405        | "SetTimezone"
406        | "SetLocale"
407        | "SetNtp" => PreviewProfile {
408            expected_side_effects: vec!["service interruption".to_string()],
409            warnings: vec![APPROVAL_REQUIRED.to_string()],
410        },
411
412        // ── Wi-Fi association (High) ───────────────────────────────────────
413        "ConfigureWifi" => PreviewProfile {
414            expected_side_effects: vec![
415                "the host's Wi-Fi connection will change".to_string(),
416                "current network connectivity may be interrupted while reconnecting".to_string(),
417            ],
418            warnings: vec![
419                "a wrong SSID or password can disconnect the host from network access"
420                    .to_string(),
421                EXACT_APPROVAL_REQUIRED.to_string(),
422            ],
423        },
424
425        // ── DNS redirection (High) — same primitive as ResolvectlSetDns ────
426        "SetDnsServers" | "ResolvectlSetDns" => PreviewProfile {
427            expected_side_effects: vec!["DNS servers for the interface will change".to_string()],
428            warnings: vec![
429                "redirecting DNS can enable interception of name resolution".to_string(),
430                EXACT_APPROVAL_REQUIRED.to_string(),
431            ],
432        },
433
434        // ── firewalld rule + reload (High) ─────────────────────────────────
435        "ConfigureFirewall" => PreviewProfile {
436            expected_side_effects: vec![
437                "a firewalld rule will change and reload immediately".to_string(),
438                "network access may be immediately affected".to_string(),
439            ],
440            warnings: vec![
441                "misconfigured rules can lock out SSH or other remote access".to_string(),
442                EXACT_APPROVAL_REQUIRED.to_string(),
443            ],
444        },
445
446        // ── mask a unit so it cannot be started (High) ─────────────────────
447        "MaskService" => PreviewProfile {
448            expected_side_effects: vec![
449                "the unit will be masked (symlinked to /dev/null)".to_string(),
450                "the unit cannot be started, even manually, until UnmaskService is run"
451                    .to_string(),
452            ],
453            warnings: vec![
454                "masking a unit other services depend on can cascade into unrelated failures"
455                    .to_string(),
456                EXACT_APPROVAL_REQUIRED.to_string(),
457            ],
458        },
459
460        // ── create a new local account (High) ──────────────────────────────
461        "CreateUser" => PreviewProfile {
462            expected_side_effects: vec![
463                "a new local user account will be created".to_string(),
464                "a home directory may be created".to_string(),
465            ],
466            warnings: vec![
467                "grants a new account with login access — review shell/home/group defaults"
468                    .to_string(),
469                EXACT_APPROVAL_REQUIRED.to_string(),
470            ],
471        },
472
473        // ── Observability / journald maintenance ─────────────────────────
474        "VacuumJournal" => PreviewProfile {
475            expected_side_effects: vec![
476                "old journal entries will be permanently deleted".to_string(),
477                "disk space will be reclaimed".to_string(),
478            ],
479            warnings: vec!["deleted log history cannot be recovered".to_string()],
480        },
481
482        // ── Storage / LVM mutations ───────────────────────────────────────
483        "ExtendLogicalVolume" => PreviewProfile {
484            expected_side_effects: vec![
485                "the logical volume and its filesystem will be grown".to_string(),
486            ],
487            warnings: vec![
488                "resizes a live filesystem; a wrong volume target risks data".to_string(),
489                EXACT_APPROVAL_REQUIRED.to_string(),
490            ],
491        },
492        "CreateLogicalVolume" | "CreateLvSnapshot" => PreviewProfile {
493            expected_side_effects: vec![
494                "volume-group free space will be consumed".to_string(),
495            ],
496            warnings: vec![
497                "consumes VG capacity; snapshots fill as the origin changes".to_string(),
498                EXACT_APPROVAL_REQUIRED.to_string(),
499            ],
500        },
501
502        // ── systemd resource limits ───────────────────────────────────────
503        "SetServiceResourceLimits" => PreviewProfile {
504            expected_side_effects: vec![
505                "the service's memory/CPU/task limits will change immediately".to_string(),
506                "a persistent drop-in will be written (undo: systemctl revert)".to_string(),
507            ],
508            warnings: vec![
509                "too tight a MemoryMax can OOM-kill the service".to_string(),
510                EXACT_APPROVAL_REQUIRED.to_string(),
511            ],
512        },
513
514        // ── Log management ────────────────────────────────────────────────
515        "ConfigureLogRotation" | "RemoveLogRotation" => PreviewProfile {
516            expected_side_effects: vec![
517                "a logrotate drop-in will be written/removed".to_string(),
518            ],
519            warnings: vec![APPROVAL_REQUIRED.to_string()],
520        },
521        "ConfigureRemoteSyslog" | "RemoveRemoteSyslog" => PreviewProfile {
522            expected_side_effects: vec![
523                "rsyslog will start/stop forwarding all logs to a remote host".to_string(),
524                "rsyslog will be restarted".to_string(),
525            ],
526            warnings: vec![
527                "forwarding sends log data off-host (exfiltration surface)".to_string(),
528                EXACT_APPROVAL_REQUIRED.to_string(),
529            ],
530        },
531
532        // ── PAM password policy ───────────────────────────────────────────
533        "SetPasswordAging" => PreviewProfile {
534            expected_side_effects: vec![
535                "the target account's password-aging limits will change".to_string(),
536            ],
537            warnings: vec![EXACT_APPROVAL_REQUIRED.to_string()],
538        },
539        "SetPasswordPolicy" | "SetAccountLockout" => PreviewProfile {
540            expected_side_effects: vec![
541                "a system-wide PAM policy file will be written".to_string(),
542            ],
543            warnings: vec![
544                "affects password/lockout rules for all accounts".to_string(),
545                "takes effect only if the PAM module is enabled in the auth stack".to_string(),
546                EXACT_APPROVAL_REQUIRED.to_string(),
547            ],
548        },
549
550        // ── Ubuntu apt pinning ────────────────────────────────────────────
551        "SetAptPin" | "RemoveAptPin" => PreviewProfile {
552            expected_side_effects: vec![
553                "apt version/origin preferences will change".to_string(),
554                "a /etc/apt/preferences.d drop-in will be written/removed".to_string(),
555            ],
556            warnings: vec![APPROVAL_REQUIRED.to_string()],
557        },
558
559        // ── Scoped sudoers.d ──────────────────────────────────────────────
560        "GrantSudoAccess" | "RevokeSudoAccess" => PreviewProfile {
561            expected_side_effects: vec![
562                "a sudoers.d drop-in will be created/removed".to_string(),
563                "the target user's sudo privileges will change".to_string(),
564            ],
565            warnings: vec![
566                "this configures privilege escalation — review the rule carefully".to_string(),
567                EXACT_APPROVAL_REQUIRED.to_string(),
568            ],
569        },
570
571        // ── Filesystem mounts / swap ──────────────────────────────────────
572        "AddMount" | "RemoveMount" => PreviewProfile {
573            expected_side_effects: vec![
574                "a filesystem will be (un)mounted".to_string(),
575                "/etc/fstab will be updated (managed entries carry nofail)".to_string(),
576            ],
577            warnings: vec![
578                "a wrong device or mountpoint risks data or availability".to_string(),
579                EXACT_APPROVAL_REQUIRED.to_string(),
580            ],
581        },
582        "AddSwap" | "RemoveSwap" => PreviewProfile {
583            expected_side_effects: vec![
584                "swap will be enabled/disabled".to_string(),
585                "a swap file will be created/removed and /etc/fstab updated".to_string(),
586            ],
587            warnings: vec![EXACT_APPROVAL_REQUIRED.to_string()],
588        },
589
590        // ── Kernel / sysctl mutation ──────────────────────────────────────
591        "SetSysctl" => PreviewProfile {
592            expected_side_effects: vec![
593                "a kernel parameter will change immediately".to_string(),
594                "the value will persist across reboots (/etc/sysctl.d)".to_string(),
595            ],
596            warnings: vec![
597                "a wrong net.*/vm.*/kernel.* value can degrade or lock the host".to_string(),
598                EXACT_APPROVAL_REQUIRED.to_string(),
599            ],
600        },
601
602        "SignalProcess" => PreviewProfile {
603            expected_side_effects: vec!["the target process will be terminated".to_string()],
604            warnings: vec![
605                "the process and its in-flight work will stop".to_string(),
606                EXACT_APPROVAL_REQUIRED.to_string(),
607            ],
608        },
609        "ConfigureUnattendedUpgrades" => PreviewProfile {
610            expected_side_effects: vec![
611                "the automatic security-update policy will change".to_string()
612            ],
613            warnings: vec![APPROVAL_REQUIRED.to_string()],
614        },
615        "CreateScheduledJob" => PreviewProfile {
616            expected_side_effects: vec![
617                "a systemd timer will run the command on a recurring schedule".to_string(),
618            ],
619            warnings: vec![
620                "persistent root-scheduled execution".to_string(),
621                EXACT_APPROVAL_REQUIRED.to_string(),
622            ],
623        },
624        "AddUserToGroup"
625        | "RemoveUserFromGroup"
626        | "CreateGroup"
627        | "DeleteGroup"
628        | "LockUserAccount"
629        | "UnlockUserAccount"
630        | "SetSshdOption"
631        | "DeleteUser"
632        | "AddAuthorizedKey"
633        | "RemoveAuthorizedKey" => PreviewProfile {
634            // High risk: access-control changes — group membership, account
635            // deletion, and SSH key modifications require Admin authorization.
636            expected_side_effects: vec!["access control will change".to_string()],
637            warnings: vec![
638                "privilege change".to_string(),
639                EXACT_APPROVAL_REQUIRED.to_string(),
640            ],
641        },
642        "AddPackageRepository"
643        | "RemovePackageRepository"
644        | "EnablePackageRepository"
645        | "DisablePackageRepository" => PreviewProfile {
646            expected_side_effects: vec!["package repository configuration will change".to_string()],
647            warnings: vec![APPROVAL_REQUIRED.to_string()],
648        },
649        "CreateContainer" | "StartContainer" | "StopContainer" | "RemoveContainer" => {
650            PreviewProfile {
651                expected_side_effects: vec!["container lifecycle will change".to_string()],
652                warnings: vec![APPROVAL_REQUIRED.to_string()],
653            }
654        }
655        "UpdateSystem"
656        | "InstallPackages"
657        | "RemovePackages"
658        | "RebaseSystem"
659        | "RollbackDeployment"
660        | "AddLayeredPackage"
661        | "RemoveLayeredPackage"
662        | "ReplaceLayeredPackage"
663        | "ResetLayeredPackageOverride"
664        | "RemoveBasePackage" => PreviewProfile {
665            expected_side_effects: vec![
666                "system deployment will change".to_string(),
667                "reboot may be required".to_string(),
668            ],
669            warnings: vec![
670                "reboot required".to_string(),
671                EXACT_APPROVAL_REQUIRED.to_string(),
672            ],
673        },
674        "SetKernelArguments" => PreviewProfile {
675            expected_side_effects: vec!["boot arguments will change".to_string()],
676            warnings: vec![
677                "reboot required".to_string(),
678                EXACT_APPROVAL_REQUIRED.to_string(),
679            ],
680        },
681        "RebootSystem" => PreviewProfile {
682            expected_side_effects: vec!["system reboot will interrupt running work".to_string()],
683            warnings: vec![
684                "reboot required".to_string(),
685                EXACT_APPROVAL_REQUIRED.to_string(),
686            ],
687        },
688        "CleanupDeployments" => PreviewProfile {
689            expected_side_effects: vec!["old deployments may be removed".to_string()],
690            warnings: vec![EXACT_APPROVAL_REQUIRED.to_string()],
691        },
692        "PinDeployment" | "UnpinDeployment" => PreviewProfile {
693            expected_side_effects: vec!["deployment pin state will change".to_string()],
694            warnings: vec![EXACT_APPROVAL_REQUIRED.to_string()],
695        },
696        // ── Ubuntu apt: index refresh + autoremove ───────────────────────
697        "AptUpdate" => PreviewProfile {
698            expected_side_effects: vec!["the apt package index will be refreshed".to_string()],
699            warnings: Vec::new(),
700        },
701        "AptAutoremove" => PreviewProfile {
702            expected_side_effects: vec![
703                "automatically-installed packages no longer needed will be removed".to_string(),
704            ],
705            warnings: vec![APPROVAL_REQUIRED.to_string()],
706        },
707
708        // ── Launchpad PPAs ────────────────────────────────────────────────
709        "AddPpa" => PreviewProfile {
710            expected_side_effects: vec![
711                "a Launchpad PPA and its signing key will be added".to_string(),
712                "the trusted software supply chain will expand".to_string(),
713            ],
714            warnings: vec![
715                "a PPA is a third-party package source outside the distro archive".to_string(),
716                EXACT_APPROVAL_REQUIRED.to_string(),
717            ],
718        },
719        "RemovePpa" => PreviewProfile {
720            expected_side_effects: vec!["a Launchpad PPA will be removed".to_string()],
721            warnings: vec![APPROVAL_REQUIRED.to_string()],
722        },
723
724        // ── snap revert / classic install ─────────────────────────────────
725        "SnapRevert" => PreviewProfile {
726            expected_side_effects: vec![
727                "the snap will be reverted to its previous revision".to_string(),
728            ],
729            warnings: vec![APPROVAL_REQUIRED.to_string()],
730        },
731        "SnapClassicInstall" => PreviewProfile {
732            expected_side_effects: vec![
733                "a snap will be installed with classic confinement".to_string(),
734            ],
735            warnings: vec![
736                "classic confinement grants the snap full, unsandboxed system access".to_string(),
737                APPROVAL_REQUIRED.to_string(),
738            ],
739        },
740
741        // ── GRUB kernel arguments ─────────────────────────────────────────
742        "GrubSetKargs" => PreviewProfile {
743            expected_side_effects: vec![
744                "GRUB kernel arguments will change".to_string(),
745                "update-grub will regenerate the boot configuration".to_string(),
746            ],
747            warnings: vec![
748                "takes effect on the next reboot".to_string(),
749                "a bad kernel argument can prevent the system from booting".to_string(),
750                EXACT_APPROVAL_REQUIRED.to_string(),
751            ],
752        },
753
754        // ── AppArmor profile modes ────────────────────────────────────────
755        "AppArmorEnforce" => PreviewProfile {
756            expected_side_effects: vec![
757                "an AppArmor profile will be switched to enforce mode".to_string(),
758            ],
759            warnings: vec![
760                "an over-tight profile can block a legitimate service".to_string(),
761                EXACT_APPROVAL_REQUIRED.to_string(),
762            ],
763        },
764        "AppArmorComplain" => PreviewProfile {
765            expected_side_effects: vec![
766                "an AppArmor profile will be switched to complain mode".to_string(),
767            ],
768            warnings: vec![
769                "complain mode disables MAC enforcement for the profile".to_string(),
770                EXACT_APPROVAL_REQUIRED.to_string(),
771            ],
772        },
773
774        // ── fail2ban ban / unban ──────────────────────────────────────────
775        "Fail2banBanIp" => PreviewProfile {
776            expected_side_effects: vec!["an IP address will be banned in a fail2ban jail".to_string()],
777            warnings: vec![
778                "banning can lock out legitimate access from that address".to_string(),
779                EXACT_APPROVAL_REQUIRED.to_string(),
780            ],
781        },
782        "Fail2banUnbanIp" => PreviewProfile {
783            expected_side_effects: vec![
784                "an IP address will be unbanned in a fail2ban jail".to_string(),
785            ],
786            warnings: vec![APPROVAL_REQUIRED.to_string()],
787        },
788
789        // ── Ubuntu Flatpak (per-user) ─────────────────────────────────────
790        "UbuntuInstallFlatpak" | "UbuntuRemoveFlatpak" | "UbuntuUpdateFlatpak" => PreviewProfile {
791            expected_side_effects: vec!["a user's Flatpak apps will change".to_string()],
792            warnings: vec![APPROVAL_REQUIRED.to_string()],
793        },
794
795        _ => PreviewProfile {
796            expected_side_effects: vec!["unclassified action".to_string()],
797            warnings: vec!["action profile not recognized".to_string()],
798        },
799    }
800}
801
802fn preview_summary(action_name: &str, risk_level: &RiskLevel) -> String {
803    let risk = match risk_level {
804        RiskLevel::Low => "low-risk",
805        RiskLevel::Medium => "medium-risk",
806        RiskLevel::High => "high-risk",
807    };
808
809    format!("{action_name} preview ({risk})")
810}
811
812#[cfg(test)]
813mod tests {
814    use super::*;
815    use sysknife_types::{CallerRole, RiskLevel};
816
817    fn req(action: &str) -> RequestEnvelope {
818        RequestEnvelope {
819            action_name: action.to_string(),
820            request_id: "test-req".to_string(),
821            params: serde_json::Value::Null,
822            caller_role: CallerRole::Dev,
823            request_hash: sysknife_types::RequestHash::new("hash".to_string()),
824        }
825    }
826
827    #[test]
828    fn all_low_risk_actions() {
829        let actions = [
830            "GetSystemState",
831            "CollectDiagnostics",
832            "GetDeploymentHistory",
833            "ListDeployments",
834            "GetKernelArguments",
835            "GetPendingUpdates",
836            "SearchFlatpakApps",
837            "ListFlatpakRemotes",
838            "ListInstalledFlatpaks",
839            "GetFlatpakAppInfo",
840            "ListToolboxes",
841            "ListServices",
842            "GetServiceLogs",
843            "GetServiceStatus",
844            "ListTimers",
845            "GetFirewallState",
846            "ListUsers",
847            "ListGroups",
848            "ListPackageRepositories",
849            "ListContainers",
850            "GetContainerInfo",
851            "GetLayeredPackages",
852            "GetDiskUsage",
853            "ListProcesses",
854            "GetMemoryInfo",
855            "GetNetworkStatus",
856            "GetAuthorizedKeys",
857            "GetDateTime",
858            "ListJobHistory",
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::Low,
870                "{action} should be Low risk"
871            );
872            assert!(
873                !envelope.reboot_required,
874                "{action} should not require reboot"
875            );
876            assert!(
877                !envelope.rollback_available,
878                "{action} should not have rollback available"
879            );
880        }
881    }
882
883    #[test]
884    fn reload_service_is_medium_risk() {
885        let envelope = preview_action(
886            &req("ReloadService"),
887            serde_json::Value::Null,
888            serde_json::Value::Null,
889        );
890        assert_eq!(envelope.risk_level, RiskLevel::Medium);
891        assert_eq!(envelope.warnings.len(), 2);
892        assert!(!envelope.reboot_required);
893        assert!(!envelope.rollback_available);
894    }
895
896    #[test]
897    fn medium_risk_actions() {
898        let actions = [
899            "RestartService",
900            "StopService",
901            "StartService",
902            "SetHostname",
903            "SetTimezone",
904            "SetLocale",
905            "SetNtp",
906            "InstallFlatpak",
907            "RemoveFlatpak",
908            "CreateToolbox",
909            "RemoveToolbox",
910            "CreateContainer",
911            "StartContainer",
912            "StopContainer",
913            "RemoveContainer",
914            "RemovePackageRepository",
915        ];
916
917        for action in &actions {
918            let envelope = preview_action(
919                &req(action),
920                serde_json::Value::Null,
921                serde_json::Value::Null,
922            );
923            assert_eq!(
924                envelope.risk_level,
925                RiskLevel::Medium,
926                "{action} should be Medium risk"
927            );
928        }
929    }
930
931    #[test]
932    fn high_risk_access_control_actions() {
933        let actions = [
934            "AddUserToGroup",
935            "RemoveUserFromGroup",
936            "DeleteUser",
937            "AddAuthorizedKey",
938            "RemoveAuthorizedKey",
939        ];
940
941        for action in &actions {
942            let envelope = preview_action(
943                &req(action),
944                serde_json::Value::Null,
945                serde_json::Value::Null,
946            );
947            assert_eq!(
948                envelope.risk_level,
949                RiskLevel::High,
950                "{action} should be High risk"
951            );
952            assert!(
953                envelope
954                    .expected_side_effects
955                    .iter()
956                    .any(|e| e.contains("access control will change")),
957                "{action} should have 'access control will change' in expected_side_effects"
958            );
959        }
960    }
961
962    /// `ConfigureWifi`, `SetDnsServers`, `ConfigureFirewall`, `MaskService`, and
963    /// `CreateUser` are all High risk. Before this test they rendered through
964    /// the generic Medium-styled "service interruption" arm, which only ever
965    /// carried the soft `APPROVAL_REQUIRED` warning — a High-risk action must
966    /// use `EXACT_APPROVAL_REQUIRED` like every other High-risk action.
967    #[test]
968    fn high_risk_actions_previously_misclassified_use_exact_approval() {
969        let actions = [
970            "ConfigureWifi",
971            "SetDnsServers",
972            "ConfigureFirewall",
973            "MaskService",
974            "CreateUser",
975        ];
976
977        for action in &actions {
978            let envelope = preview_action(
979                &req(action),
980                serde_json::Value::Null,
981                serde_json::Value::Null,
982            );
983            assert_eq!(
984                envelope.risk_level,
985                RiskLevel::High,
986                "{action} should be High risk"
987            );
988            assert!(
989                envelope
990                    .warnings
991                    .iter()
992                    .any(|w| w == EXACT_APPROVAL_REQUIRED),
993                "{action} should carry EXACT_APPROVAL_REQUIRED, got: {:?}",
994                envelope.warnings
995            );
996            assert!(
997                !envelope
998                    .expected_side_effects
999                    .iter()
1000                    .any(|e| e == "service interruption"),
1001                "{action} should have an accurate side-effect description, not the generic \
1002                 'service interruption' placeholder"
1003            );
1004        }
1005
1006        // CreateUser specifically must not claim "service interruption" — it's
1007        // useradd, not a service restart.
1008        let create_user = preview_action(&req("CreateUser"), Value::Null, Value::Null);
1009        assert!(
1010            !create_user
1011                .expected_side_effects
1012                .iter()
1013                .any(|e| e.contains("service interruption")),
1014            "CreateUser must not claim service interruption: {:?}",
1015            create_user.expected_side_effects
1016        );
1017    }
1018
1019    #[test]
1020    fn high_risk_system_reboot_actions() {
1021        // All are High and reboot-required. Every deployment/layering mutation is
1022        // rollback-capable EXCEPT RollbackDeployment itself — rolling back a
1023        // rollback is not offered as an automatic undo (reboot/rollback are now
1024        // derived from the ActionSpec, which the executor's rollback test pins).
1025        let actions = [
1026            "UpdateSystem",
1027            "InstallPackages",
1028            "RemovePackages",
1029            "RebaseSystem",
1030            "RollbackDeployment",
1031            "AddLayeredPackage",
1032            "RemoveLayeredPackage",
1033            "SetKernelArguments",
1034        ];
1035
1036        for action in &actions {
1037            let envelope = preview_action(
1038                &req(action),
1039                serde_json::Value::Null,
1040                serde_json::Value::Null,
1041            );
1042            assert_eq!(
1043                envelope.risk_level,
1044                RiskLevel::High,
1045                "{action} should be High risk"
1046            );
1047            assert!(envelope.reboot_required, "{action} should require reboot");
1048            let expect_rollback = *action != "RollbackDeployment";
1049            assert_eq!(
1050                envelope.rollback_available, expect_rollback,
1051                "{action} rollback_available should be {expect_rollback}"
1052            );
1053        }
1054    }
1055
1056    #[test]
1057    fn reboot_system_no_rollback() {
1058        let envelope = preview_action(
1059            &req("RebootSystem"),
1060            serde_json::Value::Null,
1061            serde_json::Value::Null,
1062        );
1063        assert_eq!(envelope.risk_level, RiskLevel::High);
1064        assert!(envelope.reboot_required);
1065        assert!(!envelope.rollback_available);
1066    }
1067
1068    #[test]
1069    fn cleanup_deployments_is_high_no_reboot() {
1070        let envelope = preview_action(
1071            &req("CleanupDeployments"),
1072            serde_json::Value::Null,
1073            serde_json::Value::Null,
1074        );
1075        assert_eq!(envelope.risk_level, RiskLevel::High);
1076        assert!(!envelope.reboot_required);
1077    }
1078
1079    #[test]
1080    fn pin_unpin_deployment_are_high() {
1081        for action in &["PinDeployment", "UnpinDeployment"] {
1082            let envelope = preview_action(
1083                &req(action),
1084                serde_json::Value::Null,
1085                serde_json::Value::Null,
1086            );
1087            assert_eq!(
1088                envelope.risk_level,
1089                RiskLevel::High,
1090                "{action} should be High risk"
1091            );
1092        }
1093    }
1094
1095    #[test]
1096    fn unknown_action_defaults_to_high() {
1097        let envelope = preview_action(
1098            &req("DefinitelyNotRealAction"),
1099            serde_json::Value::Null,
1100            serde_json::Value::Null,
1101        );
1102        assert_eq!(envelope.risk_level, RiskLevel::High);
1103        assert!(
1104            envelope
1105                .warnings
1106                .iter()
1107                .any(|w| w.contains("action profile not recognized")),
1108            "warnings should contain 'action profile not recognized'"
1109        );
1110        assert!(
1111            envelope
1112                .expected_side_effects
1113                .iter()
1114                .any(|e| e.contains("unclassified action")),
1115            "expected_side_effects should contain 'unclassified action'"
1116        );
1117    }
1118
1119    // The completeness guard proves every action has *a* profile, but not that
1120    // the right one is wired to the right name. These pin the two easiest-to-swap
1121    // pairs so a transposition (which would tell the operator the opposite of the
1122    // real effect) fails the build.
1123    #[test]
1124    fn apparmor_enforce_and_complain_profiles_are_not_swapped() {
1125        let enforce = preview_action(
1126            &req("AppArmorEnforce"),
1127            serde_json::Value::Null,
1128            serde_json::Value::Null,
1129        );
1130        let complain = preview_action(
1131            &req("AppArmorComplain"),
1132            serde_json::Value::Null,
1133            serde_json::Value::Null,
1134        );
1135        // Check the distinguishing side-effect phrase only ("enforce mode" vs
1136        // "complain mode"); the complain warning legitimately says "enforcement",
1137        // which would trip a bare "enforce" substring match.
1138        let se = |e: &sysknife_types::PreviewEnvelope| e.expected_side_effects.join(" ");
1139        assert!(se(&enforce).contains("enforce mode") && !se(&enforce).contains("complain"));
1140        assert!(se(&complain).contains("complain mode") && !se(&complain).contains("enforce mode"));
1141    }
1142
1143    #[test]
1144    fn fail2ban_ban_and_unban_profiles_are_not_swapped() {
1145        let ban = preview_action(
1146            &req("Fail2banBanIp"),
1147            serde_json::Value::Null,
1148            serde_json::Value::Null,
1149        );
1150        let unban = preview_action(
1151            &req("Fail2banUnbanIp"),
1152            serde_json::Value::Null,
1153            serde_json::Value::Null,
1154        );
1155        assert!(ban
1156            .expected_side_effects
1157            .iter()
1158            .any(|e| e.contains("will be banned")));
1159        assert!(unban
1160            .expected_side_effects
1161            .iter()
1162            .any(|e| e.contains("will be unbanned")));
1163    }
1164
1165    #[test]
1166    fn preview_action_summary_format() {
1167        let envelope = preview_action(
1168            &req("GetDiskUsage"),
1169            serde_json::Value::Null,
1170            serde_json::Value::Null,
1171        );
1172        assert_eq!(envelope.summary, "GetDiskUsage preview (low-risk)");
1173    }
1174
1175    #[test]
1176    fn preview_action_passes_current_and_proposed_state() {
1177        let current = serde_json::json!({"disk": "80%"});
1178        let proposed = serde_json::json!({"action": "cleanup"});
1179        let envelope = preview_action(
1180            &req("CleanupDeployments"),
1181            current.clone(),
1182            proposed.clone(),
1183        );
1184        assert_eq!(envelope.current_state, current);
1185        assert_eq!(envelope.proposed_change, proposed);
1186    }
1187
1188    #[test]
1189    fn preview_action_preserves_request_hash() {
1190        let mut r = req("GetDiskUsage");
1191        r.request_hash = sysknife_types::RequestHash::new("deadbeef".to_string());
1192        let envelope = preview_action(&r, serde_json::Value::Null, serde_json::Value::Null);
1193        assert_eq!(envelope.request_hash.as_str(), "deadbeef");
1194    }
1195}