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