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