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