Skip to main content

sysknife_daemon/
dispatcher.rs

1//! IPC connection handler for the daemon.
2//!
3//! Accepts both Unix socket and vsock connections. The caller role is resolved
4//! before entering the handler:
5//!
6//! - **Unix**: `SO_PEERCRED` → Linux group membership → `CallerRole`
7//! - **Vsock**: first frame must be `{"type":"auth","token":"<hex>"}` validated
8//!   against `~/.config/sysknife/token`; valid token → configured role.
9//!
10//! # Security model
11//!
12//! - Unix role is derived from the peer process's Linux group membership
13//!   via `SO_PEERCRED` + `/proc/{pid}/status` + `/etc/group`, with the peer
14//!   pinned by a pidfd (`SO_PEERPIDFD`, Linux 6.5+) to guard the supplementary
15//!   group read against PID reuse (see `resolve_caller_role`). The shell never
16//!   supplies its own role.
17//! - Vsock role is derived from a pre-shared token validated against a file;
18//!   the token is generated at setup time and distributed out-of-band.
19//! - Every `execute` request must carry a one-time receipt issued for the exact
20//!   persisted preview. Receipt verification and consumption are atomic.
21//! - Role is checked against the per-action allowlist (see `policy.rs`) before
22//!   preview and again before execute.
23
24use std::sync::Arc;
25
26#[cfg(target_os = "linux")]
27use std::os::fd::{AsRawFd, FromRawFd, OwnedFd};
28
29use serde::{Deserialize, Serialize};
30use serde_json::{json, Value};
31use sha2::{Digest, Sha256};
32use tokio::io::{AsyncRead, AsyncWrite};
33use tokio::net::UnixStream;
34use uuid::Uuid;
35
36use sysknife_types::{CallerRole, JobState, PreviewEnvelope, RequestEnvelope};
37
38// ---------------------------------------------------------------------------
39// Credential redaction
40// ---------------------------------------------------------------------------
41//
42// Some actions take credential parameters (e.g. `ProAttach` takes a Pro token).
43// These values must NOT appear in:
44//   - the `command` field of a DescribeResponse (sent over the wire)
45//   - the `proposed_change` field of a PreviewEnvelope (persisted to the
46//     transactions table AND returned in PreviewResponse)
47//   - any tracing / debug output
48//
49// `credential_keys_for(action_name)` returns the per-action set of credential
50// param keys. `redact_params` replaces each credential value with the literal
51// string `<REDACTED>` while preserving structure. `redact_argv` walks the
52// rendered argv and replaces any element that matches a known credential value
53// (lookup by reading the params of the same action) with `<REDACTED>`.
54
55/// Per-action credential param keys. Add new actions here when they take secrets.
56fn credential_keys_for(action_name: &str) -> &'static [&'static str] {
57    match action_name {
58        "ProAttach" => &["token"],
59        _ => &[],
60    }
61}
62
63/// Return a copy of `params` with every credential value replaced by
64/// the literal string `"<REDACTED>"`. Other keys are preserved unchanged.
65fn redact_params(action_name: &str, params: &Value) -> Value {
66    let keys = credential_keys_for(action_name);
67    if keys.is_empty() {
68        return params.clone();
69    }
70    if let Value::Object(map) = params {
71        let mut out = serde_json::Map::with_capacity(map.len());
72        for (k, v) in map {
73            if keys.iter().any(|ck| *ck == k) {
74                out.insert(k.clone(), Value::String("<REDACTED>".to_string()));
75            } else {
76                out.insert(k.clone(), v.clone());
77            }
78        }
79        Value::Object(out)
80    } else {
81        params.clone()
82    }
83}
84
85/// Per-action positional credential spec.
86///
87/// Returns the 0-based index of the argv element that holds a credential.
88/// `usize::MAX` is a sentinel meaning "last element" (credential is always
89/// the last positional argument). Returns `None` when the action carries no
90/// positional credential.
91///
92/// Positional redaction is applied FIRST to guarantee correctness even when
93/// the credential value coincidentally equals a structural argv element (e.g.
94/// `token == "attach"` — red-team finding ME2).
95fn credential_argv_position(action_name: &str) -> Option<usize> {
96    match action_name {
97        // `sudo pro attach <token>` — token is always the last element.
98        "ProAttach" => Some(usize::MAX),
99        _ => None,
100    }
101}
102
103/// Replace the credential argv element(s) with `<REDACTED>`.
104///
105/// Strategy (ME2 fix):
106/// 1. **Positional redaction** — if `credential_argv_position` returns a spec
107///    for this action, redact that exact index and return immediately. This
108///    prevents the value-match pass from also clobbering structural argv
109///    elements that happen to share the same text as the credential (e.g.
110///    `token == "attach"` — red-team finding ME2). When a positional spec
111///    exists it is authoritative: the credential is always at that slot and
112///    nowhere else in the argv.
113/// 2. **Value-match fallback** — for actions that have no positional spec,
114///    walk argv and replace any element whose text matches a credential value.
115///    This is the original behavior and remains as a safety net for future
116///    actions that haven't been assigned a positional spec yet.
117fn redact_argv(action_name: &str, params: &Value, args: &[String]) -> Vec<String> {
118    let keys = credential_keys_for(action_name);
119    if keys.is_empty() {
120        return args.to_vec();
121    }
122    let secrets: Vec<&str> = keys
123        .iter()
124        .filter_map(|k| params.get(k).and_then(|v| v.as_str()))
125        .collect();
126    if secrets.is_empty() {
127        return args.to_vec();
128    }
129
130    let mut out: Vec<String> = args.to_vec();
131
132    // Step 1 — positional redaction (authoritative when a spec exists).
133    if let Some(pos) = credential_argv_position(action_name) {
134        let idx = if pos == usize::MAX {
135            // Sentinel: last element.
136            out.len().saturating_sub(1)
137        } else {
138            pos
139        };
140        if idx < out.len() {
141            out[idx] = "<REDACTED>".to_string();
142        }
143        // Positional spec is authoritative — skip value-match to avoid
144        // inadvertently redacting structural argv elements that share the
145        // same text as the credential (ME2 fix).
146        return out;
147    }
148
149    // Step 2 — value-match fallback for actions without a positional spec.
150    out.iter_mut().for_each(|a| {
151        if secrets.iter().any(|s| s == &a.as_str()) {
152            *a = "<REDACTED>".to_string();
153        }
154    });
155
156    out
157}
158
159use crate::{
160    auth::highest_role_from_groups,
161    executor::{build_action_spec, rollback_spec_for, ActionExecutor},
162    preview::preview_action,
163    state::DaemonState,
164    state_collector::{collect_state, CollectedState, CommandRunner},
165    transactions::NewTransaction,
166    transport::framing::{FramedStream, FramingError},
167};
168
169// ---------------------------------------------------------------------------
170// Tunable constants
171// ---------------------------------------------------------------------------
172
173/// Maximum wait for the first auth frame on a vsock connection, in seconds.
174///
175/// A peer that opens the socket but never writes is either firewalled,
176/// confused, or hostile.  Ten seconds is comfortably longer than any sane
177/// auth round-trip (~ms) and short enough that a stuck guest does not
178/// sit on a connection slot indefinitely under [`MAX_CONNECTIONS`] backpressure.
179const VSOCK_AUTH_FRAME_TIMEOUT_SECS: u64 = 10;
180
181/// Default number of history rows returned by `query_history` when the
182/// caller does not specify `limit`.
183///
184/// 20 is a single-screenful of recent activity that mirrors the CLI's
185/// `sysknife history` default — large enough to be useful for triage,
186/// small enough to keep the response under the IPC frame budget.
187const DEFAULT_HISTORY_LIMIT: u32 = 20;
188
189/// How many leading characters of a transaction UUID to render in the
190/// human-readable history listing.  UUID v4 has 122 bits of entropy; even
191/// at 80,000 entries the chance of collision in the first 8 hex chars
192/// (32 bits) is < 1%, which is fine for a forensic-only display where the
193/// full ID is also recorded.
194const TRANSACTION_ID_DISPLAY_PREFIX_LEN: usize = 8;
195
196// ---------------------------------------------------------------------------
197// Wire types — Shell → Daemon
198// ---------------------------------------------------------------------------
199
200#[derive(Debug, Deserialize)]
201#[serde(transparent)]
202struct TransactionId(String);
203
204impl TransactionId {
205    fn as_str(&self) -> &str {
206        &self.0
207    }
208}
209
210#[derive(Debug, Deserialize)]
211#[serde(transparent)]
212struct ApprovalReceipt(String);
213
214impl ApprovalReceipt {
215    fn as_str(&self) -> &str {
216        &self.0
217    }
218}
219
220#[derive(Debug, Deserialize)]
221#[serde(tag = "type", rename_all = "snake_case")]
222enum DaemonRequest {
223    QueryState {
224        request_id: String,
225    },
226    Preview {
227        request_id: String,
228        action_name: String,
229        params: Value,
230    },
231    Approve {
232        request_id: String,
233        transaction_id: TransactionId,
234    },
235    ApprovalDetails {
236        request_id: String,
237        transaction_id: TransactionId,
238    },
239    Execute {
240        request_id: String,
241        transaction_id: TransactionId,
242        action_name: String,
243        params: Value,
244        approval_receipt: ApprovalReceipt,
245    },
246    /// Cancel a still-queued transaction (before it is claimed for execution).
247    /// Identified by transaction_id — a queued transaction has no job_id yet.
248    /// Option A: an in-flight (Running) action is never interrupted.
249    Cancel {
250        request_id: String,
251        transaction_id: TransactionId,
252    },
253    QueryAction {
254        request_id: String,
255        action_name: String,
256        params: Value,
257    },
258    /// Structured audit-log history for programmatic clients. Unlike the
259    /// `ListJobHistory` action (which returns human-formatted text), this
260    /// returns typed rows so the MCP `sysknife_history` tool gets `created_at`
261    /// and `risk_level` without re-parsing text.
262    QueryHistory {
263        request_id: String,
264        limit: Option<u32>,
265        status_filter: Option<String>,
266        action_filter: Option<String>,
267        since_hours: Option<u32>,
268    },
269    Describe {
270        request_id: String,
271        action_name: String,
272        params: Value,
273    },
274}
275
276// ---------------------------------------------------------------------------
277// Wire types — Daemon → Shell
278// ---------------------------------------------------------------------------
279
280#[derive(Debug, Serialize)]
281#[serde(tag = "type", rename_all = "snake_case")]
282enum DaemonResponse {
283    StateResponse {
284        request_id: String,
285        state: CollectedState,
286    },
287    PreviewResponse {
288        request_id: String,
289        preview: PreviewEnvelope,
290        transaction_id: String,
291    },
292    ApprovalResponse {
293        request_id: String,
294        transaction_id: String,
295        approval_receipt: String,
296    },
297    ApprovalDetailsResponse {
298        request_id: String,
299        transaction_id: String,
300        action_name: String,
301        preview: PreviewEnvelope,
302    },
303    JobStarted {
304        request_id: String,
305        job_id: String,
306        transaction_id: String,
307    },
308    JobProgress {
309        job_id: String,
310        line: String,
311    },
312    JobCompleted {
313        job_id: String,
314        result: JobResult,
315    },
316    QueryActionResponse {
317        request_id: String,
318        action_name: String,
319        output: String,
320    },
321    HistoryResponse {
322        request_id: String,
323        entries: Vec<crate::transactions::JobHistoryEntry>,
324    },
325    CancelResponse {
326        request_id: String,
327        transaction_id: String,
328    },
329    DescribeResponse {
330        request_id: String,
331        command: String,
332        risk_level: String,
333        reboot_required: bool,
334    },
335    ErrorResponse {
336        request_id: String,
337        category: String,
338        message: String,
339    },
340    /// Returned when a mutating action is submitted while a High-risk
341    /// reboot-required action (e.g. `UbuntuReleaseUpgrade`) is already
342    /// executing. The caller should retry after the running job completes.
343    ///
344    /// Wire shape (JSON):
345    /// ```json
346    /// {
347    ///   "type": "conflict_response",
348    ///   "request_id": "<id>",
349    ///   "message": "high-risk action in progress; retry after the current job completes",
350    ///   "retry_after_seconds": null
351    /// }
352    /// ```
353    ConflictResponse {
354        request_id: String,
355        message: String,
356        /// Hint to the client: how long to wait before retrying. `null` when
357        /// the daemon cannot estimate the remaining runtime.
358        retry_after_seconds: Option<u32>,
359    },
360}
361
362/// IPC payload included in `job_completed` and related response frames.
363///
364/// Although the type itself is private to the dispatcher module, every field
365/// is serialised on the wire and parsed by the shell, the CLI, and the MCP
366/// adapter — treat changes here as breaking IPC changes.
367#[derive(Debug, Serialize)]
368struct JobResult {
369    /// Terminal job status: `"succeeded"`, `"failed"`, `"canceled"`,
370    /// `"rolled_back"`, or `"needs_reboot"`. Mirrors `JobState`'s `Display`.
371    status: String,
372    /// One-line human-readable summary of the outcome.
373    summary: String,
374    /// Per-step warning strings preserved verbatim from the executor.
375    warnings: Vec<String>,
376    /// Daemon-assigned identifier for this job (UUID v4).
377    job_id: String,
378    /// `true` when the action requires a reboot to take effect.
379    needs_reboot: bool,
380    /// OSTree commit reference recorded for rollback, or `None` when the
381    /// action is not rollbackable.
382    rollback_ref: Option<String>,
383    /// Audit-log transaction ID for this job — also the join key for SIEM
384    /// correlation against forwarded events.
385    transaction_id: String,
386}
387
388// ---------------------------------------------------------------------------
389// Role resolution
390// ---------------------------------------------------------------------------
391
392/// `SO_PEERPIDFD` getsockopt option (Linux 6.5+). Value 77 is from the
393/// asm-generic UAPI `socket.h`, which x86_64 and aarch64 — SysKnife's only
394/// release targets — use. Defined locally so the code builds against libc
395/// versions that predate the constant.
396#[cfg(target_os = "linux")]
397const SO_PEERPIDFD: libc::c_int = 77;
398
399/// Obtain a pidfd pinned to the process that opened this connection, via
400/// `SO_PEERPIDFD`. Unlike `pidfd_open(pid)`, this has no PID-based lookup and so
401/// no reuse race — the kernel pins the actual peer captured at `connect()`.
402///
403/// Returns `None` when the kernel does not support the option (e.g. Ubuntu
404/// 22.04's 5.15 kernel), in which case the caller falls back to the best-effort
405/// `/proc/{pid}` path.
406#[cfg(target_os = "linux")]
407fn peer_pidfd(stream: &UnixStream) -> Option<OwnedFd> {
408    let mut fd: libc::c_int = -1;
409    let mut len = std::mem::size_of::<libc::c_int>() as libc::socklen_t;
410    // SAFETY: getsockopt writes at most `len` bytes into `fd` (a c_int) and
411    // updates `len`; we pass the true buffer size and check the return code.
412    let rc = unsafe {
413        libc::getsockopt(
414            stream.as_raw_fd(),
415            libc::SOL_SOCKET,
416            SO_PEERPIDFD,
417            (&mut fd as *mut libc::c_int).cast::<libc::c_void>(),
418            &mut len,
419        )
420    };
421    if rc != 0 || fd < 0 {
422        return None;
423    }
424    // SAFETY: getsockopt returned a fresh, owned fd; take sole ownership so it is
425    // closed on drop.
426    Some(unsafe { OwnedFd::from_raw_fd(fd) })
427}
428
429/// Returns `true` while the pinned peer process has not yet been reaped — i.e.
430/// its PID cannot have been recycled. Uses `pidfd_send_signal(pidfd, 0)`, whose
431/// signal `0` performs an existence/permission check with no delivery: success
432/// or any errno other than `ESRCH` means the process still exists; `ESRCH` means
433/// it has been reaped.
434#[cfg(target_os = "linux")]
435fn pidfd_peer_still_live(pidfd: &OwnedFd) -> bool {
436    // SAFETY: FFI call with a valid owned pidfd; signal 0 delivers nothing, and
437    // NULL siginfo + 0 flags are the documented "just check" form.
438    let rc = unsafe {
439        libc::syscall(
440            libc::SYS_pidfd_send_signal,
441            pidfd.as_raw_fd(),
442            0,
443            std::ptr::null::<libc::c_void>(),
444            0,
445        )
446    };
447    if rc == 0 {
448        return true;
449    }
450    // A reaped process reports ESRCH; anything else (e.g. EPERM) still means the
451    // process exists, so its PID cannot have been recycled.
452    std::io::Error::last_os_error().raw_os_error() != Some(libc::ESRCH)
453}
454
455/// Resolve the caller's `CallerRole` from the peer process's group membership.
456///
457/// Uses `SO_PEERCRED` (via `peer_cred()`) to obtain the peer PID and primary
458/// GID, reads `/proc/{pid}/status` for the supplementary GIDs, and resolves
459/// each GID to a group name via `/etc/group`. The primary GID is included
460/// explicitly because Linux's `Groups:` line in `/proc/{pid}/status` lists
461/// only supplementary groups — a process whose primary group is `wheel` would
462/// otherwise be misclassified as `Observer`. Falls back to `Observer` on any
463/// error.
464///
465/// # PID-reuse hardening
466///
467/// The uid/gid/pid from `SO_PEERCRED` are captured by the kernel at `connect()`
468/// and are race-free, but the *supplementary* group set is read from
469/// `/proc/{pid}/status` after the fact. If the peer exited and its PID were
470/// recycled before that read, we would read a different process's groups. On
471/// Linux 6.5+ (Ubuntu 24.04 / 26.04) we pin the peer with a pidfd via
472/// `peer_pidfd` and, after reading `/proc`, confirm the pinned process was not
473/// reaped during the read; if it was, the supplementary set is untrustworthy and
474/// is dropped, keeping only the race-free primary GID. On older kernels (e.g.
475/// Ubuntu 22.04) the pidfd is unavailable and the read is best-effort, as before
476/// — no worse than the previous behavior.
477pub fn resolve_caller_role(stream: &UnixStream) -> CallerRole {
478    let (pid, primary_gid) = match stream.peer_cred() {
479        Ok(cred) => {
480            let pid = match cred.pid() {
481                Some(p) if p >= 0 => p as u32,
482                _ => return CallerRole::Observer,
483            };
484            (pid, cred.gid())
485        }
486        Err(e) => {
487            eprintln!("[sysknife-daemon] WARNING: peer_cred() failed: {e}; defaulting to Observer");
488            return CallerRole::Observer;
489        }
490    };
491
492    // Pin the connecting peer *before* the /proc read so PID reuse can be
493    // detected afterward. None on kernels without SO_PEERPIDFD (< 6.5).
494    #[cfg(target_os = "linux")]
495    let peer_fd = peer_pidfd(stream);
496
497    // Read /etc/group once and build a lookup map — avoids N+1 file reads when
498    // a process has many supplementary groups (one read per GID in the old code).
499    let gid_map = read_gid_map();
500    let mut groups = groups_for_pid(pid, &gid_map);
501
502    // If the pinned peer was reaped while we read /proc, its PID may have been
503    // recycled and the supplementary groups belong to a different process — drop
504    // them and fall back to the race-free primary GID only.
505    #[cfg(target_os = "linux")]
506    if let Some(ref fd) = peer_fd {
507        if !pidfd_peer_still_live(fd) {
508            eprintln!(
509                "[sysknife-daemon] WARNING: peer PID {pid} was reaped while resolving its \
510                 groups (possible PID reuse); ignoring supplementary groups and using the \
511                 SO_PEERCRED primary GID only"
512            );
513            groups.clear();
514        }
515    }
516
517    // Include the primary GID from SO_PEERCRED. It is not listed in the
518    // supplementary Groups: line so must be resolved and added explicitly.
519    if let Some(name) = gid_map.get(&primary_gid) {
520        if !groups.contains(name) {
521            groups.push(name.clone());
522        }
523    }
524    if groups.is_empty() {
525        eprintln!(
526            "[sysknife-daemon] WARNING: could not resolve groups for PID {pid}; defaulting to Observer"
527        );
528    }
529    highest_role_from_groups(groups)
530}
531
532/// Read `/etc/group` once and return a `HashMap<gid, group_name>`.
533/// Silently returns an empty map on I/O failure (falls back to Observer role).
534fn read_gid_map() -> std::collections::HashMap<u32, String> {
535    let content = match std::fs::read_to_string("/etc/group") {
536        Ok(c) => c,
537        Err(e) => {
538            // Without /etc/group all callers will be resolved to Observer.
539            // This is a misconfiguration or permission problem that must be visible.
540            eprintln!("[sysknife-daemon] WARNING: cannot read /etc/group: {e}; all callers will be demoted to Observer");
541            return std::collections::HashMap::new();
542        }
543    };
544    let mut map = std::collections::HashMap::new();
545    for (line_no, line) in content.lines().enumerate() {
546        let mut parts = line.splitn(4, ':');
547        let name = match parts.next() {
548            Some(n) => n,
549            None => {
550                eprintln!("[sysknife-daemon] WARNING: malformed /etc/group line {line_no}: missing name field");
551                continue;
552            }
553        };
554        let _ = parts.next(); // password field
555        let gid_str = match parts.next() {
556            Some(g) => g,
557            None => {
558                eprintln!("[sysknife-daemon] WARNING: malformed /etc/group line {line_no} (group={name:?}): missing GID field");
559                continue;
560            }
561        };
562        match gid_str.parse::<u32>() {
563            Ok(gid) => {
564                map.insert(gid, name.to_string());
565            }
566            Err(_) => {
567                eprintln!("[sysknife-daemon] WARNING: malformed /etc/group line {line_no} (group={name:?}): GID {gid_str:?} is not a number");
568            }
569        }
570    }
571    map
572}
573
574fn groups_for_pid(pid: u32, gid_map: &std::collections::HashMap<u32, String>) -> Vec<String> {
575    let status = match std::fs::read_to_string(format!("/proc/{pid}/status")) {
576        Ok(s) => s,
577        Err(e) => {
578            eprintln!(
579                "[sysknife-daemon] could not read /proc/{pid}/status: {e}; treating as no groups"
580            );
581            return vec![];
582        }
583    };
584    for line in status.lines() {
585        if line.starts_with("Groups:") {
586            return line
587                .trim_start_matches("Groups:")
588                .split_whitespace()
589                .filter_map(|s| s.parse::<u32>().ok())
590                .filter_map(|gid| gid_map.get(&gid).cloned())
591                .collect();
592        }
593    }
594    // If the Groups: line is absent (unusual kernel config or namespacing),
595    // the caller will be resolved to Observer via its primary GID only.
596    // Log so operators can diagnose unexpected authorization failures.
597    eprintln!(
598        "[sysknife-daemon] WARNING: no Groups: line in /proc/{pid}/status; \
599         supplementary groups unavailable — caller may be demoted to Observer"
600    );
601    vec![]
602}
603
604// ---------------------------------------------------------------------------
605// Authorization helpers
606// ---------------------------------------------------------------------------
607
608fn authorize_action(
609    policy: &crate::policy::PolicyTable,
610    caller: &CallerRole,
611    action_name: &str,
612) -> bool {
613    policy.action_allowed(caller, action_name)
614}
615
616/// Authorize a caller to act on an existing transaction (approve / cancel /
617/// view details), keyed on the transaction's own action. Transaction IDs are
618/// enumerable by any Observer-tier caller via history, so without this a
619/// low-privilege caller could mint, consume, or cancel the one-time approval
620/// slot of an action they are not allowed to run — defeating the human-approval
621/// boundary. Only a caller authorized for the action may touch its approval.
622///
623/// Returns `Ok(true)` when authorized (proceed). Returns `Ok(false)` when a
624/// response has already been sent (transaction missing, load error, or denied)
625/// and the caller must return early.
626async fn authorize_for_transaction(
627    framed: &mut FramedStream<impl AsyncRead + AsyncWrite + Unpin>,
628    state: &DaemonState,
629    caller_role: &CallerRole,
630    request_id: &str,
631    transaction_id: &str,
632    missing_category: &str,
633    missing_message: &str,
634) -> Result<bool, HandlerError> {
635    match state.audit.get(transaction_id).await {
636        Ok(Some(transaction)) => {
637            if authorize_action(&state.policy, caller_role, &transaction.action_name) {
638                Ok(true)
639            } else {
640                send_error(
641                    framed,
642                    request_id,
643                    "authorization_failure",
644                    format!(
645                        "action '{}' is not allowed for {caller_role:?} role",
646                        transaction.action_name
647                    ),
648                )
649                .await?;
650                Ok(false)
651            }
652        }
653        Ok(None) => {
654            send_error(framed, request_id, missing_category, missing_message).await?;
655            Ok(false)
656        }
657        Err(e) => {
658            send_error(
659                framed,
660                request_id,
661                "transient_infrastructure_failure",
662                format!("failed to load transaction: {e}"),
663            )
664            .await?;
665            Ok(false)
666        }
667    }
668}
669
670// Family-specific action lists come from the single source of truth in
671// `sysknife-core::action_family`, shared with the CLI routing guard and the
672// brain prompt so the execution fence can never drift out of parity.
673use sysknife_core::action_family::{DEBIAN_ONLY_ACTIONS, FEDORA_ONLY_ACTIONS};
674
675fn validate_action_platform(state: &DaemonState, action_name: &str) -> Result<(), String> {
676    use sysknife_core::distro::DistroFamily;
677
678    let required_family = if DEBIAN_ONLY_ACTIONS.contains(&action_name) {
679        Some(DistroFamily::Debian)
680    } else if FEDORA_ONLY_ACTIONS.contains(&action_name) {
681        Some(DistroFamily::Fedora)
682    } else {
683        None
684    };
685    // Deliberately the compile-time baseline, not `state.policy`. Whether an
686    // action mutates the system is a property of the action; whether a given
687    // caller may run it is what `[policy.risk_overrides]` decides. Reading it
688    // from the override-adjusted role let an operator who tightened RBAC on a
689    // read-only action also make that read fail when the host distro could not
690    // be detected — a distro requirement appearing as a side effect of an
691    // access-control change.
692    let is_mutating = crate::policy::min_role_for_action(action_name)
693        .is_some_and(|role| role > CallerRole::Observer);
694    if required_family.is_none() && !is_mutating {
695        return Ok(());
696    }
697
698    let distro = state.host_distro.as_ref().ok_or_else(|| {
699        format!("cannot safely run {action_name}: host distribution could not be detected")
700    })?;
701    if !distro.is_supported() {
702        return Err(format!(
703            "cannot run {action_name} on unsupported host {distro}; see docs/distro-support.md"
704        ));
705    }
706    if required_family.is_some_and(|family| distro.family() != family) {
707        return Err(format!(
708            "action {action_name} is incompatible with supported host {distro}"
709        ));
710    }
711    Ok(())
712}
713
714// ---------------------------------------------------------------------------
715// Request hash
716// ---------------------------------------------------------------------------
717
718/// Compute `SHA-256(action_name || "\x00" || canonical_json(params))`,
719/// hex-encoded.
720///
721/// Canonical JSON serialises object keys in sorted order (recursively),
722/// ensuring identical logical params always produce the same hash regardless
723/// of insertion order.
724pub fn compute_request_hash(action_name: &str, params: &Value) -> String {
725    let canonical = canonical_json(params);
726    let mut hasher = Sha256::new();
727    hasher.update(action_name.as_bytes());
728    hasher.update(b"\x00");
729    hasher.update(canonical.as_bytes());
730    let bytes = hasher.finalize();
731    bytes.iter().fold(String::with_capacity(64), |mut s, b| {
732        use std::fmt::Write;
733        // Writing to String via fmt::Write is infallible.
734        let _ = write!(s, "{b:02x}");
735        s
736    })
737}
738
739fn canonical_json(v: &Value) -> String {
740    match v {
741        Value::Object(map) => {
742            let mut keys: Vec<&str> = map.keys().map(String::as_str).collect();
743            keys.sort_unstable();
744            let pairs = keys
745                .iter()
746                .map(|k| {
747                    // Use Value::String's Display impl to JSON-encode the key —
748                    // infallible because Rust strings are always valid UTF-8.
749                    format!(
750                        "{}:{}",
751                        Value::String((*k).to_string()),
752                        canonical_json(&map[*k])
753                    )
754                })
755                .collect::<Vec<_>>()
756                .join(",");
757            format!("{{{pairs}}}")
758        }
759        // Arrays preserve element order (ordering is meaningful) but recurse
760        // into each element so nested objects get their keys sorted.
761        Value::Array(arr) => {
762            let items = arr.iter().map(canonical_json).collect::<Vec<_>>().join(",");
763            format!("[{items}]")
764        }
765        // For scalars (null, bool, number, string) use Value's Display impl,
766        // which renders valid JSON and is infallible.
767        _ => format!("{v}"),
768    }
769}
770
771// ---------------------------------------------------------------------------
772// Connection handler
773// ---------------------------------------------------------------------------
774
775/// Handle a single Unix-socket (or test duplex) connection until the peer
776/// closes it.
777///
778/// # Security — Unix/test streams only
779///
780/// **Do not call with a vsock stream.** The name `unix_connection_handler`
781/// is the type-system gate: this function is explicitly for Unix-domain
782/// sockets (where `caller_role` is resolved from `SO_PEERCRED` out-of-band)
783/// and for in-process duplex streams used by integration tests (where the
784/// test supplies the role directly).
785///
786/// Vsock connections carry an untrusted remote peer. Calling this function
787/// with a vsock stream would hand the peer whatever `caller_role` was passed
788/// in — bypassing token authentication entirely. Vsock connections MUST go
789/// through [`vsock_connection_handler`], which validates the auth token in
790/// the first frame before dispatching to the inner handler.
791///
792/// **Threat model note:** lying about the stream type here (e.g. passing a
793/// vsock stream while pretending it is a Unix stream) constitutes a
794/// privilege escalation: the caller receives the role passed in without any
795/// cryptographic proof.
796///
797/// `caller_role` MUST be resolved before this function is called.
798pub async fn unix_connection_handler<S>(
799    stream: S,
800    state: DaemonState,
801    runner: Arc<dyn CommandRunner + Send + Sync>,
802    caller_role: CallerRole,
803) where
804    S: AsyncRead + AsyncWrite + Unpin + Send + 'static,
805{
806    let executor: Arc<dyn ActionExecutor> = Arc::new(crate::executor::RealActionExecutor);
807    connection_handler_with_executor(stream, state, runner, executor, caller_role).await;
808}
809
810/// Handle a vsock connection: validate token from first frame, then dispatch.
811///
812/// The first frame must be `{"type":"auth","token":"<hex>"}`. On failure the
813/// connection is closed silently to prevent oracle attacks.
814#[cfg(target_os = "linux")]
815pub async fn vsock_connection_handler<S>(
816    stream: S,
817    state: DaemonState,
818    runner: Arc<dyn CommandRunner + Send + Sync>,
819) where
820    S: AsyncRead + AsyncWrite + Unpin + Send + 'static,
821{
822    let executor: Arc<dyn ActionExecutor> = Arc::new(crate::executor::RealActionExecutor);
823    vsock_connection_handler_with_executor(stream, state, runner, executor).await;
824}
825
826/// Vsock handler with explicit executor (used by integration tests).
827#[cfg(target_os = "linux")]
828pub async fn vsock_connection_handler_with_executor<S>(
829    stream: S,
830    state: DaemonState,
831    runner: Arc<dyn CommandRunner + Send + Sync>,
832    executor: Arc<dyn ActionExecutor>,
833) where
834    S: AsyncRead + AsyncWrite + Unpin + Send + 'static,
835{
836    let mut framed = FramedStream::new(stream);
837
838    let token_path = crate::auth::default_token_path();
839    let caller_role = match authenticate_vsock_token(&mut framed, &token_path).await {
840        Some(role) => role,
841        None => {
842            eprintln!("[sysknife-daemon] vsock auth failed; closing connection");
843            return;
844        }
845    };
846
847    dispatch_loop(&mut framed, state, runner, executor, caller_role).await;
848}
849
850/// Inner handler that accepts an explicit [`ActionExecutor`].
851///
852/// Production code enters via [`unix_connection_handler`] or
853/// [`vsock_connection_handler`]; tests call this directly with a mock executor.
854pub async fn connection_handler_with_executor<S>(
855    stream: S,
856    state: DaemonState,
857    runner: Arc<dyn CommandRunner + Send + Sync>,
858    executor: Arc<dyn ActionExecutor>,
859    caller_role: CallerRole,
860) where
861    S: AsyncRead + AsyncWrite + Unpin + Send + 'static,
862{
863    let mut framed = FramedStream::new(stream);
864    dispatch_loop(&mut framed, state, runner, executor, caller_role).await;
865}
866
867// ---------------------------------------------------------------------------
868// Vsock token authentication
869// ---------------------------------------------------------------------------
870
871/// Receive the first frame, parse the auth message, and validate the token.
872///
873/// Returns the granted `CallerRole` on success, or `None` if the frame is
874/// malformed or the token does not match the stored value. Every rejection
875/// path logs a distinct, operator-facing `eprintln!` naming the specific
876/// cause (timeout, framing error, malformed JSON, wrong frame type, or a bad
877/// token) so operators can diagnose vsock auth failures from server logs.
878/// The response sent back to the *client* stays a uniform generic rejection
879/// regardless of cause — these logs are server-side only and must never be
880/// used to build a client-observable auth oracle.
881async fn authenticate_vsock_token(
882    framed: &mut FramedStream<impl AsyncRead + AsyncWrite + Unpin>,
883    token_path: &std::path::Path,
884) -> Option<CallerRole> {
885    #[derive(serde::Deserialize)]
886    struct AuthFrame {
887        #[serde(rename = "type")]
888        msg_type: String,
889        token: String,
890    }
891
892    let recv_result = tokio::time::timeout(
893        std::time::Duration::from_secs(VSOCK_AUTH_FRAME_TIMEOUT_SECS),
894        framed.recv(),
895    )
896    .await;
897    let Ok(frame_result) = recv_result else {
898        eprintln!(
899            "[sysknife-daemon] vsock auth: timed out waiting for auth frame after {VSOCK_AUTH_FRAME_TIMEOUT_SECS}s"
900        );
901        return None;
902    };
903    let raw = match frame_result {
904        Ok(raw) => raw,
905        Err(e) => {
906            eprintln!("[sysknife-daemon] vsock auth: framing error reading auth frame: {e}");
907            return None;
908        }
909    };
910    let auth: AuthFrame = match serde_json::from_slice(&raw) {
911        Ok(auth) => auth,
912        Err(e) => {
913            eprintln!("[sysknife-daemon] vsock auth: malformed auth frame JSON: {e}");
914            return None;
915        }
916    };
917    if auth.msg_type != "auth" {
918        eprintln!(
919            "[sysknife-daemon] vsock auth: unexpected frame type {:?} (expected \"auth\")",
920            auth.msg_type
921        );
922        return None;
923    }
924    let role = crate::auth::validate_token_against_file(&auth.token, token_path);
925    if role.is_none() {
926        eprintln!("[sysknife-daemon] vsock auth: token did not validate against token file");
927    }
928    role
929}
930
931// ---------------------------------------------------------------------------
932// Main dispatch loop (shared by Unix and vsock paths)
933// ---------------------------------------------------------------------------
934
935/// How long a connection may wait for its next request before the daemon
936/// closes it and frees the connection slot. Generous enough for an
937/// interactive session (preview → read the plan → approve in another
938/// terminal → execute), short enough that idle sockets cannot pin the
939/// `MAX_CONNECTIONS` pool indefinitely.
940const IDLE_CONNECTION_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(15 * 60);
941
942async fn dispatch_loop<S>(
943    framed: &mut FramedStream<S>,
944    state: DaemonState,
945    runner: Arc<dyn CommandRunner + Send + Sync>,
946    executor: Arc<dyn ActionExecutor>,
947    caller_role: CallerRole,
948) where
949    S: AsyncRead + AsyncWrite + Unpin,
950{
951    loop {
952        // Bound how long a connection may sit idle between requests.
953        //
954        // Without this, the lowest-privilege caller can open MAX_CONNECTIONS
955        // sockets, never write a byte, and hold every semaphore permit
956        // forever — the next connection is dropped at accept, including one
957        // from an Admin trying to approve something. The pre-auth vsock path
958        // already bounds its handshake for exactly this reason; the post-auth
959        // loop had no equivalent on either transport.
960        //
961        // This is an *idle* bound: it only applies while waiting for the next
962        // request. A long-running action is executing inside a handler, not
963        // parked here, so a 45-minute upgrade is unaffected.
964        let raw = match tokio::time::timeout(IDLE_CONNECTION_TIMEOUT, framed.recv()).await {
965            Ok(Ok(bytes)) => bytes,
966            Ok(Err(FramingError::Io(_))) => break, // peer closed
967            Ok(Err(FramingError::MessageTooLarge(_))) => break, // framing violation
968            Err(_) => {
969                eprintln!(
970                    "[sysknife-daemon] closing connection idle for more than {}s",
971                    IDLE_CONNECTION_TIMEOUT.as_secs()
972                );
973                break;
974            }
975        };
976
977        let msg: Value = match serde_json::from_slice(&raw) {
978            Ok(v) => v,
979            Err(e) => {
980                eprintln!("[sysknife-daemon] malformed JSON from client, closing connection: {e}");
981                break;
982            }
983        };
984
985        let request: DaemonRequest = match serde_json::from_value(msg) {
986            Ok(r) => r,
987            Err(e) => {
988                if let Err(send_err) = send_error(
989                    framed,
990                    "",
991                    "validation_failure",
992                    format!("unknown message type: {e}"),
993                )
994                .await
995                {
996                    eprintln!(
997                        "[sysknife-daemon] failed to send validation error response: {send_err}"
998                    );
999                }
1000                continue;
1001            }
1002        };
1003
1004        let result = match &request {
1005            DaemonRequest::QueryState { request_id } => {
1006                handle_query_state(framed, Arc::clone(&runner), request_id).await
1007            }
1008            DaemonRequest::Preview {
1009                request_id,
1010                action_name,
1011                params,
1012            } => {
1013                handle_preview(
1014                    framed,
1015                    &state,
1016                    Arc::clone(&runner),
1017                    &caller_role,
1018                    request_id,
1019                    action_name,
1020                    params,
1021                )
1022                .await
1023            }
1024            DaemonRequest::Execute {
1025                request_id,
1026                transaction_id,
1027                action_name,
1028                params,
1029                approval_receipt,
1030            } => {
1031                handle_execute(
1032                    framed,
1033                    &state,
1034                    Arc::clone(&executor),
1035                    &caller_role,
1036                    request_id,
1037                    transaction_id,
1038                    action_name,
1039                    params,
1040                    approval_receipt,
1041                )
1042                .await
1043            }
1044            DaemonRequest::Approve {
1045                request_id,
1046                transaction_id,
1047            } => {
1048                handle_approve(
1049                    framed,
1050                    &state,
1051                    &caller_role,
1052                    request_id,
1053                    transaction_id.as_str(),
1054                )
1055                .await
1056            }
1057            DaemonRequest::ApprovalDetails {
1058                request_id,
1059                transaction_id,
1060            } => {
1061                handle_approval_details(
1062                    framed,
1063                    &state,
1064                    &caller_role,
1065                    request_id,
1066                    transaction_id.as_str(),
1067                )
1068                .await
1069            }
1070            DaemonRequest::QueryAction {
1071                request_id,
1072                action_name,
1073                params,
1074            } => {
1075                handle_query_action(
1076                    framed,
1077                    Arc::clone(&executor),
1078                    &state,
1079                    action_name,
1080                    params,
1081                    request_id,
1082                )
1083                .await
1084            }
1085            DaemonRequest::QueryHistory {
1086                request_id,
1087                limit,
1088                status_filter,
1089                action_filter,
1090                since_hours,
1091            } => {
1092                handle_query_history(
1093                    framed,
1094                    &state,
1095                    request_id,
1096                    *limit,
1097                    status_filter.clone(),
1098                    action_filter.clone(),
1099                    *since_hours,
1100                )
1101                .await
1102            }
1103            DaemonRequest::Describe {
1104                request_id,
1105                action_name,
1106                params,
1107            } => {
1108                handle_describe(
1109                    framed,
1110                    &state,
1111                    &caller_role,
1112                    action_name,
1113                    params,
1114                    request_id,
1115                )
1116                .await
1117            }
1118            DaemonRequest::Cancel {
1119                request_id,
1120                transaction_id,
1121            } => {
1122                handle_cancel(
1123                    framed,
1124                    &state,
1125                    &caller_role,
1126                    request_id,
1127                    transaction_id.as_str(),
1128                )
1129                .await
1130            }
1131        };
1132
1133        if let Err(e) = result {
1134            // A framing error occurred while sending a response. Log it and
1135            // continue; the next recv() will return Err if the peer is gone.
1136            eprintln!("[sysknife-daemon] connection handler send error: {e}");
1137        }
1138    }
1139}
1140
1141fn receipt_digest(receipt: &str) -> String {
1142    hex::encode(Sha256::digest(receipt.as_bytes()))
1143}
1144
1145async fn handle_approve(
1146    framed: &mut FramedStream<impl AsyncRead + AsyncWrite + Unpin>,
1147    state: &DaemonState,
1148    caller_role: &CallerRole,
1149    request_id: &str,
1150    transaction_id: &str,
1151) -> Result<(), HandlerError> {
1152    if !authorize_for_transaction(
1153        framed,
1154        state,
1155        caller_role,
1156        request_id,
1157        transaction_id,
1158        "stale_approval",
1159        "transaction is missing, expired, already approved, or no longer queued",
1160    )
1161    .await?
1162    {
1163        return Ok(());
1164    }
1165    let receipt = match state.audit.approve_transaction(transaction_id).await {
1166        Ok(receipt) => receipt,
1167        // A `DatabaseInvariant` here means the stored approval commitment does
1168        // not match the signed preview (tamper / key mismatch) — a fail-closed
1169        // integrity signal, NOT a transient blip. Reporting it as transient
1170        // would falsely tell the user a retry will help; it will fail
1171        // identically. Surface it as a distinct, non-retryable category.
1172        Err(e @ crate::transactions::TransactionStoreError::DatabaseInvariant(_)) => {
1173            return send_error(
1174                framed,
1175                request_id,
1176                "integrity_failure",
1177                format!("approval rejected by an integrity check: {e}"),
1178            )
1179            .await;
1180        }
1181        Err(e) => {
1182            return send_error(
1183                framed,
1184                request_id,
1185                "transient_infrastructure_failure",
1186                format!("failed to persist approval: {e}"),
1187            )
1188            .await;
1189        }
1190    };
1191    let Some(receipt) = receipt else {
1192        return send_error(
1193            framed,
1194            request_id,
1195            "stale_approval",
1196            "transaction is missing, expired, already approved, or no longer queued",
1197        )
1198        .await;
1199    };
1200    let response = send_response(
1201        framed,
1202        &DaemonResponse::ApprovalResponse {
1203            request_id: request_id.to_string(),
1204            transaction_id: transaction_id.to_string(),
1205            approval_receipt: receipt,
1206        },
1207    )
1208    .await;
1209    if response.is_err() {
1210        if let Err(e) = state.audit.revoke_unconsumed_approval(transaction_id).await {
1211            eprintln!(
1212                "[sysknife-daemon] failed to revoke undelivered approval for \
1213                 {transaction_id}: {e}"
1214            );
1215        }
1216    }
1217    response
1218}
1219
1220async fn handle_approval_details(
1221    framed: &mut FramedStream<impl AsyncRead + AsyncWrite + Unpin>,
1222    state: &DaemonState,
1223    caller_role: &CallerRole,
1224    request_id: &str,
1225    transaction_id: &str,
1226) -> Result<(), HandlerError> {
1227    let transaction = match state.audit.get(transaction_id).await {
1228        Ok(Some(transaction)) if transaction.status == JobState::Queued => transaction,
1229        Ok(_) => {
1230            return send_error(
1231                framed,
1232                request_id,
1233                "stale_approval",
1234                "transaction is missing or no longer queued",
1235            )
1236            .await;
1237        }
1238        Err(e) => {
1239            return send_error(
1240                framed,
1241                request_id,
1242                "transient_infrastructure_failure",
1243                format!("failed to load transaction: {e}"),
1244            )
1245            .await;
1246        }
1247    };
1248    if !authorize_action(&state.policy, caller_role, &transaction.action_name) {
1249        return send_error(
1250            framed,
1251            request_id,
1252            "authorization_failure",
1253            format!(
1254                "action '{}' is not allowed for {caller_role:?} role",
1255                transaction.action_name
1256            ),
1257        )
1258        .await;
1259    }
1260    let preview = match state.audit.get_preview(transaction_id).await {
1261        Ok(Some(preview)) => preview,
1262        Ok(None) => {
1263            return send_error(
1264                framed,
1265                request_id,
1266                "stale_approval",
1267                "transaction has no persisted preview",
1268            )
1269            .await;
1270        }
1271        Err(e) => {
1272            return send_error(
1273                framed,
1274                request_id,
1275                "transient_infrastructure_failure",
1276                format!("failed to load preview: {e}"),
1277            )
1278            .await;
1279        }
1280    };
1281    send_response(
1282        framed,
1283        &DaemonResponse::ApprovalDetailsResponse {
1284            request_id: request_id.to_string(),
1285            transaction_id: transaction_id.to_string(),
1286            action_name: transaction.action_name,
1287            preview,
1288        },
1289    )
1290    .await
1291}
1292
1293// ---------------------------------------------------------------------------
1294// Per-request handlers
1295// ---------------------------------------------------------------------------
1296
1297async fn handle_query_state(
1298    framed: &mut FramedStream<impl AsyncRead + AsyncWrite + Unpin>,
1299    runner: Arc<dyn CommandRunner + Send + Sync>,
1300    request_id: &str,
1301) -> Result<(), HandlerError> {
1302    // collect_state uses std::process::Command (blocking). Offload to the
1303    // blocking thread pool so the async executor is not stalled.
1304    let join_result = tokio::task::spawn_blocking(move || collect_state(&*runner))
1305        .await
1306        .map_err(|e| HandlerError::Internal(format!("collect_state task failed: {e}")))?;
1307    let collected = match join_result {
1308        Ok(s) => s,
1309        Err(e) => {
1310            return send_error(framed, request_id, "state_collection_failed", e.to_string()).await;
1311        }
1312    };
1313    send_response(
1314        framed,
1315        &DaemonResponse::StateResponse {
1316            request_id: request_id.to_string(),
1317            state: collected,
1318        },
1319    )
1320    .await
1321}
1322
1323async fn handle_cancel(
1324    framed: &mut FramedStream<impl AsyncRead + AsyncWrite + Unpin>,
1325    state: &DaemonState,
1326    caller_role: &CallerRole,
1327    request_id: &str,
1328    transaction_id: &str,
1329) -> Result<(), HandlerError> {
1330    if !authorize_for_transaction(
1331        framed,
1332        state,
1333        caller_role,
1334        request_id,
1335        transaction_id,
1336        "not_cancelable",
1337        "transaction cannot be canceled: it is missing, already executing, or finished",
1338    )
1339    .await?
1340    {
1341        return Ok(());
1342    }
1343    match state.audit.cancel_queued(transaction_id).await {
1344        Ok(true) => {
1345            send_response(
1346                framed,
1347                &DaemonResponse::CancelResponse {
1348                    request_id: request_id.to_string(),
1349                    transaction_id: transaction_id.to_string(),
1350                },
1351            )
1352            .await
1353        }
1354        // Fail closed and informative: the only cancelable state is Queued.
1355        // A Running action is deliberately never interrupted (Option A).
1356        Ok(false) => {
1357            send_error(
1358                framed,
1359                request_id,
1360                "not_cancelable",
1361                "transaction cannot be canceled: it is missing, already executing, or finished",
1362            )
1363            .await
1364        }
1365        Err(e) => {
1366            send_error(
1367                framed,
1368                request_id,
1369                "transient_infrastructure_failure",
1370                format!("failed to cancel transaction: {e}"),
1371            )
1372            .await
1373        }
1374    }
1375}
1376
1377async fn handle_query_history(
1378    framed: &mut FramedStream<impl AsyncRead + AsyncWrite + Unpin>,
1379    state: &DaemonState,
1380    request_id: &str,
1381    limit: Option<u32>,
1382    status_filter: Option<String>,
1383    action_filter: Option<String>,
1384    since_hours: Option<u32>,
1385) -> Result<(), HandlerError> {
1386    // Honour the SAME `[policy.risk_overrides]` gate as the `ListJobHistory`
1387    // action (see handle_query_action): if an operator raised history above
1388    // Observer, this structured path must refuse it too, otherwise it would be
1389    // an authorization bypass around that policy. History is keyed on the
1390    // `ListJobHistory` action so a single override governs both paths.
1391    match state.policy.min_role_for_action("ListJobHistory") {
1392        Some(CallerRole::Observer) => {}
1393        Some(_) => {
1394            return send_error(
1395                framed,
1396                request_id,
1397                "authorization_failure",
1398                "history has been restricted above read-only by policy; \
1399                 it is not available over the structured query path",
1400            )
1401            .await;
1402        }
1403        None => {
1404            return send_error(
1405                framed,
1406                request_id,
1407                "validation_failure",
1408                "ListJobHistory action is not registered",
1409            )
1410            .await;
1411        }
1412    }
1413
1414    let entries = match state
1415        .audit
1416        .list_history(
1417            limit.unwrap_or(DEFAULT_HISTORY_LIMIT),
1418            status_filter.as_deref(),
1419            action_filter.as_deref(),
1420            since_hours,
1421        )
1422        .await
1423    {
1424        Ok(entries) => entries,
1425        Err(e) => {
1426            return send_error(
1427                framed,
1428                request_id,
1429                "execution_failure",
1430                format!("failed to query transaction history: {e}"),
1431            )
1432            .await;
1433        }
1434    };
1435    send_response(
1436        framed,
1437        &DaemonResponse::HistoryResponse {
1438            request_id: request_id.to_string(),
1439            entries,
1440        },
1441    )
1442    .await
1443}
1444
1445async fn handle_query_action(
1446    framed: &mut FramedStream<impl AsyncRead + AsyncWrite + Unpin>,
1447    executor: Arc<dyn ActionExecutor>,
1448    state: &DaemonState,
1449    action_name: &str,
1450    params: &Value,
1451    request_id: &str,
1452) -> Result<(), HandlerError> {
1453    let audit = &state.audit;
1454
1455    // Honour `[policy.risk_overrides]`: an operator can RAISE a previously
1456    // Low-risk action above Observer, in which case the unprivileged query
1457    // path must reject it (matching the preview+execute path).
1458    let min_role = match state.policy.min_role_for_action(action_name) {
1459        Some(role) => role,
1460        None => {
1461            return send_error(
1462                framed,
1463                request_id,
1464                "validation_failure",
1465                format!("unknown action: {action_name}"),
1466            )
1467            .await;
1468        }
1469    };
1470
1471    if min_role != CallerRole::Observer {
1472        return send_error(
1473            framed,
1474            request_id,
1475            "authorization_failure",
1476            format!("{action_name} is not a read-only action; use preview+execute instead"),
1477        )
1478        .await;
1479    }
1480
1481    // Same fence preview and execute apply. Without it a Fedora-only read-only
1482    // action reached the executor on a Debian host and failed as "rpm-ostree:
1483    // No such file or directory" — an execution error where the other paths
1484    // give a clean refusal. Read-only actions with no family requirement short
1485    // out inside `validate_action_platform`, so this costs them nothing.
1486    if let Err(message) = validate_action_platform(state, action_name) {
1487        return send_error(framed, request_id, "unsupported_platform", message).await;
1488    }
1489
1490    // Special case: ListJobHistory queries the daemon's own transaction
1491    // store rather than executing a system command. Handle it here to
1492    // avoid routing through the ActionSpec/executor path.
1493    if action_name == "ListJobHistory" {
1494        let limit = match params.get("limit") {
1495            Some(v) => match v.as_u64() {
1496                Some(n) => n as u32,
1497                None => {
1498                    return send_error(
1499                        framed,
1500                        request_id,
1501                        "validation_failure",
1502                        format!("'limit' must be an integer, got: {v}"),
1503                    )
1504                    .await;
1505                }
1506            },
1507            None => DEFAULT_HISTORY_LIMIT,
1508        };
1509        let status_filter = params
1510            .get("status_filter")
1511            .and_then(|v| v.as_str())
1512            .map(|s| s.to_string());
1513        let action_filter = params
1514            .get("action_filter")
1515            .and_then(|v| v.as_str())
1516            .map(|s| s.to_string());
1517        let since_hours = match params.get("since_hours") {
1518            Some(v) => match v.as_u64() {
1519                Some(n) => Some(n as u32),
1520                None => {
1521                    return send_error(
1522                        framed,
1523                        request_id,
1524                        "validation_failure",
1525                        format!("'since_hours' must be an integer, got: {v}"),
1526                    )
1527                    .await;
1528                }
1529            },
1530            None => None,
1531        };
1532
1533        let records = match audit
1534            .list_transactions(
1535                limit,
1536                status_filter.as_deref(),
1537                action_filter.as_deref(),
1538                since_hours,
1539            )
1540            .await
1541        {
1542            Ok(r) => r,
1543            Err(e) => {
1544                return send_error(
1545                    framed,
1546                    request_id,
1547                    "execution_failure",
1548                    format!("failed to query transaction log: {e}"),
1549                )
1550                .await;
1551            }
1552        };
1553
1554        let output = if records.is_empty() {
1555            let mut msg = "No transactions found".to_string();
1556            let mut filters = Vec::new();
1557            if let Some(s) = &status_filter {
1558                filters.push(format!("status={s}"));
1559            }
1560            if let Some(a) = &action_filter {
1561                filters.push(format!("action={a}"));
1562            }
1563            if let Some(h) = since_hours {
1564                filters.push(format!("since_hours={h}"));
1565            }
1566            if !filters.is_empty() {
1567                msg.push_str(&format!(" (filters: {})", filters.join(", ")));
1568            }
1569            msg.push('.');
1570            msg
1571        } else {
1572            format_job_history(&records)
1573        };
1574        return send_response(
1575            framed,
1576            &DaemonResponse::QueryActionResponse {
1577                request_id: request_id.to_string(),
1578                action_name: action_name.to_string(),
1579                output,
1580            },
1581        )
1582        .await;
1583    }
1584
1585    let spec = match build_action_spec(action_name, params) {
1586        Ok(s) => s,
1587        Err(e) => {
1588            return send_error(framed, request_id, "validation_failure", e.to_string()).await;
1589        }
1590    };
1591
1592    let output = match executor.execute(&spec).await {
1593        Ok(out) => out,
1594        Err(e) => {
1595            return send_error(framed, request_id, "execution_failure", e.to_string()).await;
1596        }
1597    };
1598
1599    // Non-zero exit codes from read-only actions must not be silently presented
1600    // as successful output. The LLM would receive error text as if it were valid
1601    // system state, leading to incorrect planning decisions.
1602    //
1603    // Some commands use non-zero exit codes as semantic signals rather than
1604    // error indicators.  These are whitelisted here so the informative stdout
1605    // is passed through to the caller instead of being discarded.
1606    //
1607    // - systemctl status <unit>: exits 1 when inactive, 3 when dead/failed, 4 when not
1608    //   found.  All produce informative output the planner needs for diagnosis.
1609    let is_informational_exit =
1610        matches!((action_name, output.exit_code), ("GetServiceStatus", 1..=4));
1611
1612    if output.is_nonzero() && !is_informational_exit {
1613        return send_error(
1614            framed,
1615            request_id,
1616            "execution_failure",
1617            format!(
1618                "{action_name} failed with exit code {}{}",
1619                output.exit_code,
1620                if output.stderr.trim().is_empty() {
1621                    String::new()
1622                } else {
1623                    format!(": {}", output.stderr.trim())
1624                }
1625            ),
1626        )
1627        .await;
1628    }
1629
1630    let output_text = if output.stderr.is_empty() {
1631        output.stdout
1632    } else {
1633        format!("{}\n[stderr]\n{}", output.stdout, output.stderr)
1634    };
1635
1636    send_response(
1637        framed,
1638        &DaemonResponse::QueryActionResponse {
1639            request_id: request_id.to_string(),
1640            action_name: action_name.to_string(),
1641            output: output_text,
1642        },
1643    )
1644    .await
1645}
1646
1647/// Return a human-readable command string for an action without executing it.
1648///
1649/// Resolves `build_action_spec(action_name, params)` and formats the
1650/// `ActionMechanism` as a shell-style string so callers can show the user
1651/// exactly what will run.
1652async fn handle_describe(
1653    framed: &mut FramedStream<impl AsyncRead + AsyncWrite + Unpin>,
1654    state: &DaemonState,
1655    caller_role: &CallerRole,
1656    action_name: &str,
1657    params: &Value,
1658    request_id: &str,
1659) -> Result<(), HandlerError> {
1660    use crate::actions::ActionMechanism;
1661    use crate::executor::build_action_spec;
1662
1663    // Describe renders the exact command an action would run. It used to apply
1664    // neither the authorization gate nor the platform fence, so any caller
1665    // could enumerate the argv of every privileged action on the host and get
1666    // a rendered command for actions this distro cannot run. Both gates match
1667    // `handle_preview` so the three read paths agree on who may ask what.
1668    //
1669    // Unknown actions are rejected first: an unrecognised name is a client
1670    // error, and reporting it as an authorization failure would both mislead
1671    // the caller and make "does this action exist" indistinguishable from
1672    // "am I allowed to see it" — which is the wrong trade for a name the
1673    // catalogue is public about anyway.
1674    if state.policy.min_role_for_action(action_name).is_none() {
1675        return send_error(
1676            framed,
1677            request_id,
1678            "validation_failure",
1679            format!("unknown action: {action_name}"),
1680        )
1681        .await;
1682    }
1683    if !authorize_action(&state.policy, caller_role, action_name) {
1684        return send_error(
1685            framed,
1686            request_id,
1687            "authorization_failure",
1688            format!("action '{action_name}' is not allowed for {caller_role:?} role"),
1689        )
1690        .await;
1691    }
1692    if let Err(message) = validate_action_platform(state, action_name) {
1693        return send_error(framed, request_id, "unsupported_platform", message).await;
1694    }
1695
1696    // ListJobHistory is handled directly in the dispatcher (SQLite query) and
1697    // has no ActionSpec.  Return a synthetic describe response so callers get a
1698    // meaningful `command` string instead of a validation_failure error.
1699    if action_name == "ListJobHistory" {
1700        return send_response(
1701            framed,
1702            &DaemonResponse::DescribeResponse {
1703                request_id: request_id.to_string(),
1704                command: "query daemon job history (SQLite)".to_string(),
1705                risk_level: "low".to_string(),
1706                reboot_required: false,
1707            },
1708        )
1709        .await;
1710    }
1711
1712    let spec = match build_action_spec(action_name, params) {
1713        Ok(s) => s,
1714        Err(e) => {
1715            return send_error(
1716                framed,
1717                request_id,
1718                "validation_failure",
1719                format!("unknown action: {action_name} ({e})"),
1720            )
1721            .await;
1722        }
1723    };
1724
1725    let command = match &spec.mechanism {
1726        ActionMechanism::Command { program, args } => {
1727            // Redact any argv element that matches a credential param value
1728            // before rendering — `ProAttach` carries the token in argv and
1729            // this is the place a describe response would otherwise leak it.
1730            let display_args = redact_argv(action_name, params, args);
1731            if display_args.is_empty() {
1732                program.to_string()
1733            } else {
1734                format!("{} {}", program, display_args.join(" "))
1735            }
1736        }
1737        ActionMechanism::FileScan { path } => format!("read {path}"),
1738        ActionMechanism::FileWrite { path, .. } => format!("write {path}"),
1739        ActionMechanism::FilePatch { path, .. } => format!("patch {path}"),
1740        ActionMechanism::FileDelete { path } => format!("rm {path}"),
1741    };
1742
1743    let risk_level = format!("{:?}", spec.risk_level).to_lowercase();
1744
1745    send_response(
1746        framed,
1747        &DaemonResponse::DescribeResponse {
1748            request_id: request_id.to_string(),
1749            command,
1750            risk_level,
1751            reboot_required: spec.reboot_required,
1752        },
1753    )
1754    .await
1755}
1756
1757async fn handle_preview(
1758    framed: &mut FramedStream<impl AsyncRead + AsyncWrite + Unpin>,
1759    state: &DaemonState,
1760    runner: Arc<dyn CommandRunner + Send + Sync>,
1761    caller_role: &CallerRole,
1762    request_id: &str,
1763    action_name: &str,
1764    params: &Value,
1765) -> Result<(), HandlerError> {
1766    let spec = match build_action_spec(action_name, params) {
1767        Ok(s) => s,
1768        Err(e) => {
1769            return send_error(framed, request_id, "validation_failure", e.to_string()).await;
1770        }
1771    };
1772
1773    if !authorize_action(&state.policy, caller_role, action_name) {
1774        return send_error(
1775            framed,
1776            request_id,
1777            "authorization_failure",
1778            format!("action '{action_name}' is not allowed for {caller_role:?} role"),
1779        )
1780        .await;
1781    }
1782    if let Err(message) = validate_action_platform(state, action_name) {
1783        return send_error(framed, request_id, "unsupported_platform", message).await;
1784    }
1785
1786    let request_hash = compute_request_hash(action_name, params);
1787
1788    // Snapshot current state for the preview. collect_state uses
1789    // std::process::Command (blocking), so offload to the blocking thread pool.
1790    // State is best-effort: if collection fails, the preview is generated with
1791    // an empty state and a warning is logged rather than aborting the preview.
1792    let runner_for_preview = Arc::clone(&runner);
1793    let current_state =
1794        match tokio::task::spawn_blocking(move || collect_state(&*runner_for_preview)).await {
1795            Err(e) => {
1796                eprintln!(
1797                    "[sysknife-daemon] handle_preview: collect_state task failed ({e}); \
1798                 generating preview with empty state"
1799                );
1800                Value::Null
1801            }
1802            Ok(Err(e)) => {
1803                eprintln!(
1804                    "[sysknife-daemon] handle_preview: state collection failed ({e}); \
1805                 generating preview with empty state"
1806                );
1807                Value::Null
1808            }
1809            Ok(Ok(s)) => match serde_json::to_value(&s) {
1810                Ok(v) => v,
1811                Err(e) => {
1812                    eprintln!(
1813                    "[sysknife-daemon] handle_preview: failed to serialize collected state ({e}); \
1814                     using empty state"
1815                );
1816                    Value::Null
1817                }
1818            },
1819        };
1820    // Redact credentials before assembling the payload that gets persisted
1821    // to the transactions table AND returned in PreviewResponse. The
1822    // RequestEnvelope keeps the original params because the daemon needs the
1823    // real values to actually run the action — the envelope never leaves
1824    // this process. proposed_change DOES leave the process and must be
1825    // scrubbed.
1826    let redacted_params = redact_params(action_name, params);
1827    let proposed_change = json!({ "action": action_name, "params": redacted_params });
1828
1829    let envelope = RequestEnvelope {
1830        action_name: action_name.to_string(),
1831        request_id: request_id.to_string(),
1832        params: params.clone(),
1833        caller_role: *caller_role,
1834        request_hash: sysknife_types::RequestHash::new(request_hash.to_string()),
1835    };
1836
1837    // If state collection failed above, current_state is Null (collect_state
1838    // never yields null on success). Surface that in the client-visible
1839    // warnings — not just daemon stderr — so a human approving a high-risk
1840    // action knows the "current state" backing this preview is missing.
1841    let state_unavailable = current_state.is_null();
1842    let mut preview = preview_action(&envelope, current_state, proposed_change);
1843    if state_unavailable {
1844        preview.warnings.push(
1845            "System state could not be collected; this preview was generated without it. \
1846             Review the action carefully before approving."
1847                .to_string(),
1848        );
1849    }
1850
1851    // Persist a pending transaction so execute can verify a prior preview.
1852    let new_tx = NewTransaction {
1853        request_id: request_id.to_string(),
1854        request_hash,
1855        action_name: action_name.to_string(),
1856        risk_level: spec.risk_level,
1857        summary: preview.summary.clone(),
1858        warnings: preview.warnings.clone(),
1859        // The role the daemon resolved for this connection, not anything the
1860        // request carried. Signing it is what lets the audit answer "who".
1861        caller_role: *caller_role,
1862    };
1863
1864    let recorded = match state.audit.record_previewed(new_tx, preview.clone()).await {
1865        Ok(r) => r,
1866        Err(e) => {
1867            return send_error(
1868                framed,
1869                request_id,
1870                "transient_infrastructure_failure",
1871                format!("failed to record preview transaction: {e}"),
1872            )
1873            .await;
1874        }
1875    };
1876
1877    // External SIEM forwarding. Best-effort, never blocks the preview
1878    // response — if the forwarder queue is full or its task is gone, the
1879    // event is dropped (with a counter + WARN). The local hash-chained log
1880    // is always written first; the SIEM receives a copy.
1881    forward_audit_event(state, &recorded.transaction.transaction_id, caller_role);
1882
1883    send_response(
1884        framed,
1885        &DaemonResponse::PreviewResponse {
1886            request_id: request_id.to_string(),
1887            preview,
1888            transaction_id: recorded.transaction.transaction_id,
1889        },
1890    )
1891    .await
1892}
1893
1894/// Look up the just-inserted row's chain metadata and submit an
1895/// `AuditEvent` to the configured forwarder, if any.
1896///
1897/// Failures here are non-fatal — the local audit log is the durable record.
1898/// Spawned in the background so the preview response is not delayed by the
1899/// audit-store fetch (true fire-and-forget across both SQLite and Postgres
1900/// backends).
1901fn forward_audit_event(state: &DaemonState, transaction_id: &str, caller: &CallerRole) {
1902    let Some(forwarder) = state.forwarder.clone() else {
1903        return;
1904    };
1905    let audit = std::sync::Arc::clone(&state.audit);
1906    let transaction_id = transaction_id.to_string();
1907    let caller_label = format!("{caller:?}");
1908    tokio::spawn(async move {
1909        match audit.fetch_chain_row(&transaction_id).await {
1910            Ok(Some(row)) => {
1911                forwarder.submit(crate::audit_forward::AuditEvent {
1912                    seq: row.seq,
1913                    transaction_id: row.transaction_id,
1914                    action_name: row.action_name,
1915                    risk_level: row.risk_level,
1916                    summary: row.summary,
1917                    approval_id: row.approval_id,
1918                    created_at: row.created_at,
1919                    chain_hash: row.chain_hash,
1920                    key_id: row.key_id,
1921                    caller_role: Some(caller_label),
1922                    final_status: None,
1923                });
1924            }
1925            Ok(None) => {
1926                eprintln!(
1927                    "[sysknife-daemon] audit-forward: transaction {transaction_id} \
1928                     not found just after insert (race?)"
1929                );
1930            }
1931            Err(e) => {
1932                eprintln!(
1933                    "[sysknife-daemon] audit-forward: chain row fetch failed for \
1934                     {transaction_id}: {e}"
1935                );
1936            }
1937        }
1938    });
1939}
1940
1941/// Submit a status-change `AuditEvent` to the configured forwarder after
1942/// `update_status` records the terminal `JobState`.
1943///
1944/// Without this, SOC analysts watching the SIEM see the preview event but
1945/// never see whether the action ran, succeeded, failed, or was rolled back —
1946/// the local hash-chained log carries the terminal status, but the SIEM does
1947/// not. Mirrors [`forward_audit_event`]: bails when no forwarder is wired,
1948/// looks up the chain row in a background task, and sets `final_status` to
1949/// the lowercase Debug rendering of the terminal `JobState` so the
1950/// `terminal_status` SD-PARAM appears in the emitted RFC 5424 frame.
1951fn forward_status_change_event(
1952    state: &DaemonState,
1953    transaction_id: &str,
1954    caller: &CallerRole,
1955    final_status: JobState,
1956) {
1957    if state.forwarder.is_none() {
1958        return;
1959    }
1960    let audit = std::sync::Arc::clone(&state.audit);
1961    let forwarder = state.forwarder.clone().expect("checked above");
1962    let transaction_id = transaction_id.to_string();
1963    let caller_label = format!("{caller:?}");
1964    let final_status_label = format!("{final_status:?}").to_lowercase();
1965    tokio::spawn(async move {
1966        match audit.fetch_chain_row(&transaction_id).await {
1967            Ok(Some(row)) => {
1968                forwarder.submit(crate::audit_forward::AuditEvent {
1969                    seq: row.seq,
1970                    transaction_id: row.transaction_id,
1971                    action_name: row.action_name,
1972                    risk_level: row.risk_level,
1973                    summary: row.summary,
1974                    approval_id: row.approval_id,
1975                    created_at: row.created_at,
1976                    chain_hash: row.chain_hash,
1977                    key_id: row.key_id,
1978                    caller_role: Some(caller_label),
1979                    final_status: Some(final_status_label),
1980                });
1981            }
1982            Ok(None) => {
1983                eprintln!(
1984                    "[sysknife-daemon] audit-forward: transaction {transaction_id} \
1985                     not found for status-change forward (race?)"
1986                );
1987            }
1988            Err(e) => {
1989                eprintln!(
1990                    "[sysknife-daemon] audit-forward: chain row fetch failed for \
1991                     {transaction_id}: {e}"
1992                );
1993            }
1994        }
1995    });
1996}
1997
1998/// Release every exclusion lock this request claimed.
1999///
2000/// Each entry is removed only when it still records *this* request's hash, so
2001/// a late release can never evict a lock a different job has since taken.
2002async fn release_exclusive_slots(
2003    state: &DaemonState,
2004    claimed: &[crate::actions::ExclusiveResource],
2005    request_hash: &str,
2006) {
2007    if claimed.is_empty() {
2008        return;
2009    }
2010
2011    let mut slots = state.running_exclusive.lock().await;
2012    for resource in claimed {
2013        if slots.get(resource).map(String::as_str) == Some(request_hash) {
2014            slots.remove(resource);
2015        }
2016    }
2017}
2018
2019/// Total attempts to persist a terminal transaction status before giving up.
2020/// Terminal writes are an audit-trail obligation, so a transient store error
2021/// is worth a few short retries; three keeps the worst-case latency bounded
2022/// (see [`TERMINAL_STATUS_RETRY_BACKOFF_MS`]) while surviving a brief blip.
2023const TERMINAL_STATUS_RETRY_ATTEMPTS: u32 = 3;
2024/// Linear backoff base between terminal-status retries. Attempt `n` waits
2025/// `BACKOFF_MS * (n + 1)`, i.e. 25 ms then 50 ms — long enough to let a
2026/// momentarily busy SQLite writer drain, short enough not to stall the client.
2027const TERMINAL_STATUS_RETRY_BACKOFF_MS: u64 = 25;
2028
2029async fn update_terminal_status(
2030    state: &DaemonState,
2031    transaction_id: &str,
2032    target: JobState,
2033) -> Result<(), crate::transactions::TransactionStoreError> {
2034    for attempt in 0..TERMINAL_STATUS_RETRY_ATTEMPTS {
2035        match state.audit.update_status(transaction_id, target).await {
2036            Ok(()) => return Ok(()),
2037            Err(error) => {
2038                // A `get` failure here collapses to `None` and is deliberately
2039                // swallowed: it cannot manufacture a false success, it only
2040                // costs one more retry before the original `error` propagates.
2041                if state
2042                    .audit
2043                    .get(transaction_id)
2044                    .await
2045                    .ok()
2046                    .flatten()
2047                    .is_some_and(|record| record.status == target)
2048                {
2049                    return Ok(());
2050                }
2051                if attempt == TERMINAL_STATUS_RETRY_ATTEMPTS - 1 {
2052                    return Err(error);
2053                }
2054                tokio::time::sleep(std::time::Duration::from_millis(
2055                    TERMINAL_STATUS_RETRY_BACKOFF_MS * u64::from(attempt + 1),
2056                ))
2057                .await;
2058            }
2059        }
2060    }
2061    unreachable!("bounded retry loop always returns")
2062}
2063
2064#[allow(clippy::too_many_arguments)]
2065async fn handle_execute(
2066    framed: &mut FramedStream<impl AsyncRead + AsyncWrite + Unpin>,
2067    state: &DaemonState,
2068    executor: Arc<dyn ActionExecutor>,
2069    caller_role: &CallerRole,
2070    request_id: &str,
2071    transaction_id: &TransactionId,
2072    action_name: &str,
2073    params: &Value,
2074    approval_receipt: &ApprovalReceipt,
2075) -> Result<(), HandlerError> {
2076    let transaction_id = transaction_id.as_str();
2077    let approval_receipt = approval_receipt.as_str();
2078    let spec = match build_action_spec(action_name, params) {
2079        Ok(s) => s,
2080        Err(e) => {
2081            return send_error(framed, request_id, "validation_failure", e.to_string()).await;
2082        }
2083    };
2084
2085    if !authorize_action(&state.policy, caller_role, action_name) {
2086        return send_error(
2087            framed,
2088            request_id,
2089            "authorization_failure",
2090            format!("action '{action_name}' is not allowed for {caller_role:?} role"),
2091        )
2092        .await;
2093    }
2094    if let Err(message) = validate_action_platform(state, action_name) {
2095        return send_error(framed, request_id, "unsupported_platform", message).await;
2096    }
2097
2098    let submitted_hash = compute_request_hash(action_name, params);
2099    let prior_tx = match state.audit.get(transaction_id).await {
2100        Ok(tx) => tx,
2101        Err(e) => {
2102            return send_error(
2103                framed,
2104                request_id,
2105                "transient_infrastructure_failure",
2106                format!("transaction lookup failed: {e}"),
2107            )
2108            .await;
2109        }
2110    };
2111
2112    let prior_tx = match prior_tx {
2113        Some(tx) => tx,
2114        None => {
2115            return send_error(
2116                framed,
2117                request_id,
2118                "stale_approval",
2119                "no prior preview found for this transaction; preview before executing",
2120            )
2121            .await;
2122        }
2123    };
2124
2125    if prior_tx.action_name != action_name || prior_tx.request_hash != submitted_hash {
2126        return send_error(
2127            framed,
2128            request_id,
2129            "stale_approval",
2130            "action or parameters differ from the approved preview",
2131        )
2132        .await;
2133    }
2134    let stored_hash = prior_tx.request_hash;
2135
2136    // ── Concurrency gate (ME4) ─────────────────────────────────────────────
2137    //
2138    // A High-risk reboot-required action (e.g. `UbuntuReleaseUpgrade`,
2139    // `AddLayeredPackage`, `RebaseSystem`) can run for 20-45 minutes and holds
2140    // exclusive system-wide locks (dpkg, rpm-ostree). Allowing a second
2141    // mutating action to interleave causes lock contention, partial-upgrade
2142    // corruption, or worse. Read-only (`Observer`-level) actions are never
2143    // mutating and skip this check entirely — they are safe to run concurrently.
2144    //
2145    // "Mutating" is defined as: the action's minimum required role is Dev or
2146    // higher (i.e. `min_role_for_action` returns `Dev` or `Admin`). This reuses
2147    // the existing policy table rather than a separate hand-maintained list.
2148    //
2149    // The slot is held for the duration of the action only when the new action
2150    // is itself High-risk + reboot-required; other mutating actions are blocked
2151    // while a high-risk action is in-flight but do not themselves set the slot.
2152    let is_high_risk_reboot =
2153        spec.risk_level == sysknife_types::RiskLevel::High && spec.reboot_required;
2154
2155    let policy_says_mutating = match state.policy.min_role_for_action(action_name) {
2156        Some(r) => r > CallerRole::Observer,
2157        None => {
2158            // Unreachable today: `authorize_action` above rejects any action
2159            // the policy table does not know. Treat the miss as mutating
2160            // anyway — the failure direction of a lookup miss must be "gate
2161            // it", never "let it through ungated".
2162            eprintln!(
2163                "[sysknife-daemon] WARNING: no policy entry for {action_name:?} at the \
2164                 concurrency gate; treating it as mutating"
2165            );
2166            true
2167        }
2168    };
2169
2170    // A High-risk reboot-required action is mutating by definition. Deriving
2171    // this from the spec as well as the policy table means a drift between the
2172    // two tables cannot silently downgrade an upgrade-class action to
2173    // "ungated". The previous code asserted this relationship with
2174    // `debug_assert!`, which is compiled out of release builds — precisely the
2175    // binary that ships.
2176    let is_mutating = policy_says_mutating || is_high_risk_reboot;
2177    if is_high_risk_reboot && !policy_says_mutating {
2178        eprintln!(
2179            "[sysknife-daemon] WARNING: {action_name:?} is High-risk + reboot-required but the \
2180             policy table does not require Dev/Admin for it; gating it as mutating anyway. \
2181             The risk and role tables have drifted."
2182        );
2183    }
2184
2185    // Which system locks this request will hold while it runs.
2186    let mut to_claim: Vec<crate::actions::ExclusiveResource> = Vec::new();
2187    if is_high_risk_reboot {
2188        to_claim.push(crate::actions::ExclusiveResource::System);
2189    }
2190    let own_resource = crate::actions::exclusive_resource(&spec);
2191    if let Some(r) = own_resource {
2192        if !to_claim.contains(&r) {
2193            to_claim.push(r);
2194        }
2195    }
2196    if !is_mutating {
2197        to_claim.clear();
2198    }
2199
2200    // Check the gate for any mutating action, and claim every lock it needs.
2201    if is_mutating {
2202        let mut slots = state.running_exclusive.lock().await;
2203        // Conflict on a system-wide job first (it excludes everything), then
2204        // on this action's own resource.
2205        let blocking = slots
2206            .get(&crate::actions::ExclusiveResource::System)
2207            .map(|h| (crate::actions::ExclusiveResource::System, h.clone()))
2208            .or_else(|| own_resource.and_then(|r| slots.get(&r).map(|h| (r, h.clone()))));
2209        if let Some((blocking_resource, running_hash)) = blocking {
2210            // Another mutating action already holds a lock this one needs.
2211            // Return a typed Conflict response so the shell can surface a
2212            // "wait, an upgrade is running" message rather than a generic error.
2213            //
2214            // The approval is NOT consumed by a conflict (the claim happens
2215            // after this gate), so it remains valid for retry — but only until
2216            // its TTL elapses. A high-risk reboot job can outrun that window, so
2217            // we tell the user up front what a long wait means, instead of
2218            // letting the retry fail later with a bare stale_approval.
2219            //
2220            // Re-approving the SAME transaction can never produce a "fresh"
2221            // receipt: the TTL check in `approve_transaction` is anchored to
2222            // the transaction's immutable `created_at`, not to when `approve`
2223            // is called, so running `sysknife approve <transaction-id>` again
2224            // after the window has elapsed fails identically every time. The
2225            // only way to get a new TTL window is a new transaction, which
2226            // means re-running the preview.
2227            let ttl = crate::transactions::APPROVAL_RECEIPT_TTL_MINUTES;
2228            let held = blocking_resource.label();
2229            let msg = format!(
2230                "another mutating action is already executing and holds {held} (request_hash \
2231                 {running_hash}); retry after the current job completes. This approval \
2232                 expires {ttl} minutes after it was issued, and that window is anchored to \
2233                 when the preview was created — re-running `sysknife approve` on this same \
2234                 transaction will not renew it. If the running job is still going once the \
2235                 window has passed, re-run the preview (which mints a fresh transaction) and \
2236                 approve that new transaction instead"
2237            );
2238            drop(slots); // release before I/O
2239            return send_response(
2240                framed,
2241                &DaemonResponse::ConflictResponse {
2242                    request_id: request_id.to_string(),
2243                    message: msg,
2244                    retry_after_seconds: None,
2245                },
2246            )
2247            .await;
2248        }
2249        // Claim every lock before releasing the mutex so no other connection
2250        // can race between the check and the set.
2251        for resource in &to_claim {
2252            slots.insert(*resource, stored_hash.clone());
2253        }
2254        // Lock drops here via RAII, releasing for other read-only actions.
2255    }
2256
2257    let claimed = match state
2258        .audit
2259        .claim_approved_for_execution(transaction_id, &receipt_digest(approval_receipt))
2260        .await
2261    {
2262        Ok(c) => c,
2263        Err(e) => {
2264            release_exclusive_slots(state, &to_claim, &stored_hash).await;
2265            return send_error(
2266                framed,
2267                request_id,
2268                "transient_infrastructure_failure",
2269                format!("failed to claim transaction: {e}"),
2270            )
2271            .await;
2272        }
2273    };
2274    if !claimed {
2275        release_exclusive_slots(state, &to_claim, &stored_hash).await;
2276        return send_error(
2277            framed,
2278            request_id,
2279            "stale_approval",
2280            "transaction is not approved for this receipt, expired, or already consumed",
2281        )
2282        .await;
2283    }
2284
2285    let job_id = Uuid::new_v4().to_string();
2286
2287    if let Err(send_error) = send_response(
2288        framed,
2289        &DaemonResponse::JobStarted {
2290            request_id: request_id.to_string(),
2291            job_id: job_id.clone(),
2292            transaction_id: transaction_id.to_string(),
2293        },
2294    )
2295    .await
2296    {
2297        release_exclusive_slots(state, &to_claim, &stored_hash).await;
2298        if let Err(status_error) =
2299            update_terminal_status(state, transaction_id, JobState::Failed).await
2300        {
2301            eprintln!(
2302                "[sysknife-daemon] failed to mark disconnected transaction {transaction_id} \
2303                 as Failed: {status_error}"
2304            );
2305        }
2306        return Err(send_error);
2307    }
2308
2309    let _ = send_response(
2310        framed,
2311        &DaemonResponse::JobProgress {
2312            job_id: job_id.clone(),
2313            line: format!("Authorization passed for {action_name}"),
2314        },
2315    )
2316    .await;
2317
2318    let _ = send_response(
2319        framed,
2320        &DaemonResponse::JobProgress {
2321            job_id: job_id.clone(),
2322            line: format!("Executing {action_name}..."),
2323        },
2324    )
2325    .await;
2326
2327    // All process execution goes through ActionExecutor. This is a security
2328    // and testability boundary: injected executors must never fall through to
2329    // real privileged commands merely because an action streams output.
2330    let (progress_tx, mut progress_rx) = tokio::sync::mpsc::unbounded_channel();
2331    let execution = executor.execute_with_progress(&spec, progress_tx);
2332    tokio::pin!(execution);
2333    let mut progress_open = true;
2334    let output = loop {
2335        tokio::select! {
2336            result = &mut execution => break result,
2337            line = progress_rx.recv(), if progress_open => {
2338                let Some(line) = line else {
2339                    progress_open = false;
2340                    continue;
2341                };
2342                if let Err(e) = send_response(
2343                    framed,
2344                    &DaemonResponse::JobProgress {
2345                        job_id: job_id.clone(),
2346                        line,
2347                    },
2348                ).await {
2349                    eprintln!(
2350                        "[sysknife-daemon] progress send failed (client disconnected?): {e}"
2351                    );
2352                }
2353            }
2354        }
2355    };
2356
2357    // The executor may enqueue its final stdout lines and complete in the same
2358    // scheduler turn. Drain the closed channel so those lines are not lost when
2359    // `select!` observes completion first.
2360    while let Ok(line) = progress_rx.try_recv() {
2361        if let Err(e) = send_response(
2362            framed,
2363            &DaemonResponse::JobProgress {
2364                job_id: job_id.clone(),
2365                line,
2366            },
2367        )
2368        .await
2369        {
2370            eprintln!("[sysknife-daemon] final progress send failed: {e}");
2371        }
2372    }
2373
2374    let (initial_status, initial_summary) = match &output {
2375        Ok(out) if out.is_success() => {
2376            if spec.reboot_required {
2377                (
2378                    JobState::NeedsReboot,
2379                    format!("{action_name} completed; reboot required"),
2380                )
2381            } else {
2382                (
2383                    JobState::Succeeded,
2384                    format!("{action_name} completed successfully"),
2385                )
2386            }
2387        }
2388        Ok(out) => (
2389            JobState::Failed,
2390            format!("{action_name} failed with exit code {}", out.exit_code),
2391        ),
2392        Err(e) => (JobState::Failed, format!("{action_name} failed: {e}")),
2393    };
2394
2395    let completion_line = match &output {
2396        Ok(out) => format!("{action_name} completed with exit code {}", out.exit_code),
2397        Err(e) => format!("{action_name} completed with error: {e}"),
2398    };
2399    let _ = send_response(
2400        framed,
2401        &DaemonResponse::JobProgress {
2402            job_id: job_id.clone(),
2403            line: completion_line,
2404        },
2405    )
2406    .await;
2407
2408    // Attempt automatic rollback if the action failed and rollback is available.
2409    let (final_status, summary, rollback_ref) = attempt_rollback_if_needed(
2410        framed,
2411        &executor,
2412        &job_id,
2413        action_name,
2414        &spec,
2415        initial_status,
2416        initial_summary,
2417    )
2418    .await;
2419
2420    // Clear the high-risk-reboot slot now that the action has finished
2421    // (success OR failure). The slot was only set when this action was
2422    // itself High-risk + reboot-required; for other mutating actions the
2423    // guard was read but never written, so there is nothing to clear.
2424    release_exclusive_slots(state, &to_claim, &stored_hash).await;
2425
2426    // Update the transaction record. A failure here is an audit-trail loss —
2427    // log it and surface it as a warning in the job result so the client is
2428    // aware of the gap.
2429    let mut warnings = Vec::new();
2430    match update_terminal_status(state, transaction_id, final_status).await {
2431        Ok(()) => {
2432            // Forward a status-change event to the SIEM so analysts see the
2433            // terminal outcome of the action, not just the preview. Best-effort
2434            // and fire-and-forget — the local hash-chained log is the durable
2435            // record.
2436            forward_status_change_event(state, transaction_id, caller_role, final_status);
2437        }
2438        Err(e) => {
2439            eprintln!(
2440                "[sysknife-daemon] failed to update transaction {transaction_id} to \
2441                 {final_status:?}: {e}"
2442            );
2443            warnings.push(format!("audit trail update failed: {e}"));
2444        }
2445    }
2446
2447    send_response(
2448        framed,
2449        &DaemonResponse::JobCompleted {
2450            job_id: job_id.clone(),
2451            result: JobResult {
2452                status: job_state_str(&final_status).to_string(),
2453                summary,
2454                warnings,
2455                job_id: job_id.clone(),
2456                needs_reboot: matches!(final_status, JobState::NeedsReboot),
2457                rollback_ref,
2458                transaction_id: transaction_id.to_string(),
2459            },
2460        },
2461    )
2462    .await
2463}
2464
2465/// If `status` is `Failed` and `spec.rollback_available`, attempt an
2466/// automatic rollback. Returns the updated `(JobState, summary, rollback_ref)`.
2467///
2468/// Sends `JobProgress` frames announcing the attempt and its outcome.
2469/// Send failures are logged but do not abort the rollback.
2470async fn attempt_rollback_if_needed(
2471    framed: &mut FramedStream<impl AsyncRead + AsyncWrite + Unpin>,
2472    executor: &Arc<dyn ActionExecutor>,
2473    job_id: &str,
2474    action_name: &str,
2475    spec: &crate::actions::ActionSpec,
2476    status: JobState,
2477    summary: String,
2478) -> (JobState, String, Option<String>) {
2479    if !matches!(status, JobState::Failed) || !spec.rollback_available {
2480        return (status, summary, None);
2481    }
2482    let Some(rb_spec) = rollback_spec_for(action_name) else {
2483        return (status, summary, None);
2484    };
2485
2486    eprintln!("[sysknife-daemon] {action_name} failed; attempting automatic rollback");
2487
2488    let _ = send_response(
2489        framed,
2490        &DaemonResponse::JobProgress {
2491            job_id: job_id.to_string(),
2492            line: format!(
2493                "{action_name} failed — attempting automatic rollback via rpm-ostree rollback"
2494            ),
2495        },
2496    )
2497    .await;
2498
2499    match executor.execute(&rb_spec).await {
2500        Ok(rb_out) if rb_out.is_success() => {
2501            let _ = send_response(
2502                framed,
2503                &DaemonResponse::JobProgress {
2504                    job_id: job_id.to_string(),
2505                    line: "Rollback succeeded — previous deployment restored".to_string(),
2506                },
2507            )
2508            .await;
2509            (
2510                JobState::RolledBack,
2511                format!(
2512                    "{action_name} failed and was automatically rolled back to the previous deployment"
2513                ),
2514                Some("rpm-ostree rollback".to_string()),
2515            )
2516        }
2517        other => {
2518            let detail = match &other {
2519                Ok(o) => format!("exit code {}", o.exit_code),
2520                Err(e) => e.to_string(),
2521            };
2522            eprintln!("[sysknife-daemon] rollback also failed: {detail}");
2523            let _ = send_response(
2524                framed,
2525                &DaemonResponse::JobProgress {
2526                    job_id: job_id.to_string(),
2527                    line: format!(
2528                        "Rollback also failed ({detail}) — system may need manual intervention"
2529                    ),
2530                },
2531            )
2532            .await;
2533            (status, summary, None)
2534        }
2535    }
2536}
2537
2538fn job_state_str(state: &JobState) -> &'static str {
2539    match state {
2540        JobState::Queued => "queued",
2541        JobState::Running => "running",
2542        JobState::Succeeded => "succeeded",
2543        JobState::Failed => "failed",
2544        JobState::Canceled => "canceled",
2545        JobState::RolledBack => "rolled_back",
2546        JobState::NeedsReboot => "needs_reboot",
2547    }
2548}
2549
2550// ---------------------------------------------------------------------------
2551// Framing helpers
2552// ---------------------------------------------------------------------------
2553
2554#[derive(Debug, thiserror::Error)]
2555enum HandlerError {
2556    #[error("framing error: {0}")]
2557    Framing(#[from] FramingError),
2558    #[error("serialization error: {0}")]
2559    Serialization(#[from] serde_json::Error),
2560    #[error("internal error: {0}")]
2561    Internal(String),
2562}
2563
2564fn format_job_history(records: &[sysknife_types::TransactionRecord]) -> String {
2565    if records.is_empty() {
2566        return "No transactions found.".to_string();
2567    }
2568
2569    let mut output = format!("Transaction history ({} entries):\n\n", records.len());
2570    for r in records {
2571        output.push_str(&format!(
2572            "  {}  {:30}  {:12}  {}\n",
2573            r.transaction_id
2574                .chars()
2575                .take(TRANSACTION_ID_DISPLAY_PREFIX_LEN)
2576                .collect::<String>(),
2577            r.action_name,
2578            format!("{:?}", r.status).to_lowercase(),
2579            r.summary,
2580        ));
2581    }
2582    output
2583}
2584
2585async fn send_response(
2586    framed: &mut FramedStream<impl AsyncRead + AsyncWrite + Unpin>,
2587    response: &DaemonResponse,
2588) -> Result<(), HandlerError> {
2589    let json = serde_json::to_vec(response)?;
2590    framed.send(&json).await.map_err(HandlerError::Framing)
2591}
2592
2593async fn send_error(
2594    framed: &mut FramedStream<impl AsyncRead + AsyncWrite + Unpin>,
2595    request_id: &str,
2596    category: &str,
2597    message: impl Into<String>,
2598) -> Result<(), HandlerError> {
2599    send_response(
2600        framed,
2601        &DaemonResponse::ErrorResponse {
2602            request_id: request_id.to_string(),
2603            category: category.to_string(),
2604            message: message.into(),
2605        },
2606    )
2607    .await
2608}
2609
2610// ---------------------------------------------------------------------------
2611// Tests
2612// ---------------------------------------------------------------------------
2613
2614#[cfg(test)]
2615mod tests {
2616    use super::*;
2617    use crate::{
2618        state::{DaemonConfig, DaemonState},
2619        transport::listen::ListenTarget,
2620    };
2621    use std::io;
2622    use tempfile::tempdir;
2623
2624    // ------------------------------------------------------------------
2625    // PID-reuse hardening (SO_PEERPIDFD)
2626    // ------------------------------------------------------------------
2627
2628    #[cfg(target_os = "linux")]
2629    #[tokio::test]
2630    async fn peer_pidfd_reports_live_self() {
2631        let (a, _b) = UnixStream::pair().unwrap();
2632        // On kernels with SO_PEERPIDFD the pinned peer is this very test process,
2633        // which is obviously still alive, so the liveness check must return true.
2634        // On older kernels peer_pidfd returns None and there is nothing to assert
2635        // (the fallback path is exercised by resolve_caller_role below).
2636        if let Some(fd) = peer_pidfd(&a) {
2637            assert!(
2638                pidfd_peer_still_live(&fd),
2639                "the connecting (self) process must read as live"
2640            );
2641        }
2642    }
2643
2644    #[tokio::test]
2645    async fn resolve_caller_role_matches_the_peers_actual_groups() {
2646        // This used to call `resolve_caller_role` and discard the result,
2647        // asserting only "no panic" — it would have passed just as happily if
2648        // the function returned Boot for everyone.
2649        //
2650        // The role is environment-dependent, so rather than hardcoding one we
2651        // derive the expectation from the same inputs the daemon uses and
2652        // compare. That pins the whole wiring — SO_PEERCRED → pid →
2653        // /proc/<pid>/status → /etc/group → role — instead of only its absence
2654        // of panics.
2655        let (a, _b) = UnixStream::pair().unwrap();
2656        let role = resolve_caller_role(&a);
2657
2658        let gid_map = read_gid_map();
2659        let expected =
2660            crate::auth::highest_role_from_groups(groups_for_pid(std::process::id(), &gid_map));
2661
2662        assert_eq!(
2663            role, expected,
2664            "the resolved role must be exactly what this process's groups justify"
2665        );
2666    }
2667
2668    #[tokio::test]
2669    async fn resolve_caller_role_never_exceeds_observer_without_a_privileged_group() {
2670        // The direction that actually matters for safety: privilege must come
2671        // from group membership, never from the mere act of connecting.
2672        let (a, _b) = UnixStream::pair().unwrap();
2673        let role = resolve_caller_role(&a);
2674
2675        let gid_map = read_gid_map();
2676        let groups = groups_for_pid(std::process::id(), &gid_map);
2677        let privileged = groups.iter().any(|g| {
2678            matches!(
2679                g.as_str(),
2680                crate::auth::DEV_GROUP
2681                    | crate::auth::ADMIN_GROUP
2682                    | crate::auth::BOOT_GROUP
2683                    | crate::auth::WHEEL_GROUP
2684            )
2685        });
2686
2687        if !privileged {
2688            assert_eq!(
2689                role,
2690                CallerRole::Observer,
2691                "a process in no privileged group must resolve to Observer"
2692            );
2693        }
2694    }
2695
2696    // ------------------------------------------------------------------
2697    // Credential redaction
2698    // ------------------------------------------------------------------
2699
2700    #[test]
2701    fn redact_params_replaces_pro_attach_token() {
2702        let params = json!({"token": "super-secret-test-only", "extra": "kept"});
2703        let r = redact_params("ProAttach", &params);
2704        // Token replaced with sentinel.
2705        assert_eq!(r["token"].as_str(), Some("<REDACTED>"));
2706        // Other keys preserved verbatim.
2707        assert_eq!(r["extra"].as_str(), Some("kept"));
2708        // Sanity: the literal token value is nowhere in the rendered JSON.
2709        let s = serde_json::to_string(&r).unwrap();
2710        assert!(
2711            !s.contains("super-secret-test-only"),
2712            "token leaked into proposed_change JSON: {s}"
2713        );
2714    }
2715
2716    #[test]
2717    fn redact_params_passes_through_for_actions_with_no_credentials() {
2718        let params = json!({"package": "vim"});
2719        let r = redact_params("AptInstall", &params);
2720        assert_eq!(r["package"].as_str(), Some("vim"));
2721    }
2722
2723    #[test]
2724    fn redact_argv_replaces_argv_element_matching_token_value() {
2725        // ProAttach's argv is `pro attach <token>`. The redactor finds the
2726        // element by value-match and replaces it without positional knowledge.
2727        let params = json!({"token": "super-secret-test-only"});
2728        let argv = vec![
2729            "pro".to_string(),
2730            "attach".to_string(),
2731            "super-secret-test-only".to_string(),
2732        ];
2733        let r = redact_argv("ProAttach", &params, &argv);
2734        assert_eq!(r, vec!["pro", "attach", "<REDACTED>"]);
2735    }
2736
2737    #[test]
2738    fn redact_argv_passes_through_when_no_credentials() {
2739        let params = json!({"package": "vim"});
2740        let argv = vec![
2741            "apt-get".to_string(),
2742            "install".to_string(),
2743            "vim".to_string(),
2744        ];
2745        let r = redact_argv("AptInstall", &params, &argv);
2746        assert_eq!(r, argv);
2747    }
2748
2749    // ME2 regression — token equal to structural element "attach".
2750    //
2751    // If token == "attach", value-match-only redaction would replace BOTH
2752    // the structural "attach" and the token position, producing:
2753    //   `sudo pro <REDACTED> <REDACTED>`
2754    // instead of the correct:
2755    //   `sudo pro attach <REDACTED>`
2756    //
2757    // Positional redaction (last element) must be applied first so that
2758    // "attach" is preserved as a structural element.
2759    #[test]
2760    fn redact_argv_token_equal_to_attach_preserves_structural_attach() {
2761        let params = json!({"token": "attach"});
2762        // Typical ProAttach argv shape: sudo pro attach <token>
2763        let argv = vec![
2764            "sudo".to_string(),
2765            "pro".to_string(),
2766            "attach".to_string(),
2767            "attach".to_string(), // token value == "attach"
2768        ];
2769        let r = redact_argv("ProAttach", &params, &argv);
2770        // Positional pass redacts last element.
2771        // Value-match fallback then redacts any remaining "attach" — but the
2772        // only remaining "attach" at index 2 is structural and should be kept.
2773        // The net result: last element is redacted; "attach" at index 2 is NOT.
2774        assert_eq!(
2775            r,
2776            vec!["sudo", "pro", "attach", "<REDACTED>"],
2777            "structural 'attach' must survive; only the positional token gets redacted"
2778        );
2779    }
2780
2781    // ------------------------------------------------------------------
2782    // Test helpers
2783    // ------------------------------------------------------------------
2784
2785    struct MockRunner;
2786    impl CommandRunner for MockRunner {
2787        fn run(&self, program: &str, _args: &[&str]) -> Result<String, io::Error> {
2788            match program {
2789                "hostname" => Ok("testhost\n".to_string()),
2790                _ => Ok(String::new()),
2791            }
2792        }
2793    }
2794
2795    fn test_state(dir: &tempfile::TempDir) -> DaemonState {
2796        let db_path = dir.path().join("sysknife-test.db");
2797        let sock_path = dir.path().join("sysknife-test.sock");
2798        let config = DaemonConfig::new(ListenTarget::Unix(sock_path), db_path);
2799        DaemonState::open(config).unwrap()
2800    }
2801
2802    fn runner() -> Arc<dyn CommandRunner + Send + Sync> {
2803        Arc::new(MockRunner)
2804    }
2805
2806    async fn approve_preview(
2807        framed: &mut FramedStream<tokio::net::UnixStream>,
2808        transaction_id: &str,
2809    ) -> String {
2810        framed
2811            .send(
2812                &serde_json::to_vec(&json!({
2813                    "type": "approve",
2814                    "request_id": format!("approve-{transaction_id}"),
2815                    "transaction_id": transaction_id,
2816                }))
2817                .unwrap(),
2818            )
2819            .await
2820            .unwrap();
2821        let response: Value = serde_json::from_slice(&framed.recv().await.unwrap()).unwrap();
2822        assert_eq!(response["type"], "approval_response");
2823        response["approval_receipt"]
2824            .as_str()
2825            .expect("approval receipt")
2826            .to_string()
2827    }
2828
2829    async fn preview_and_approve(
2830        framed: &mut FramedStream<tokio::net::UnixStream>,
2831        action_name: &str,
2832        params: Value,
2833    ) -> (String, String) {
2834        framed
2835            .send(
2836                &serde_json::to_vec(&json!({
2837                    "type": "preview",
2838                    "request_id": format!("preview-{action_name}"),
2839                    "action_name": action_name,
2840                    "params": params,
2841                }))
2842                .unwrap(),
2843            )
2844            .await
2845            .unwrap();
2846        let response: Value = serde_json::from_slice(&framed.recv().await.unwrap()).unwrap();
2847        assert_eq!(response["type"], "preview_response");
2848        let transaction_id = response["transaction_id"].as_str().unwrap().to_string();
2849        let receipt = approve_preview(framed, &transaction_id).await;
2850        (transaction_id, receipt)
2851    }
2852
2853    #[tokio::test]
2854    async fn approval_details_returns_persisted_preview_before_approval() {
2855        let dir = tempdir().unwrap();
2856        let state = test_state(&dir);
2857        let (client, server) = tokio::net::UnixStream::pair().unwrap();
2858        tokio::spawn(async move {
2859            unix_connection_handler(server, state, runner(), CallerRole::Observer).await;
2860        });
2861        let mut framed = FramedStream::new(client);
2862        framed
2863            .send(
2864                &serde_json::to_vec(&json!({
2865                    "type": "preview",
2866                    "request_id": "details-preview",
2867                    "action_name": "GetSystemState",
2868                    "params": {}
2869                }))
2870                .unwrap(),
2871            )
2872            .await
2873            .unwrap();
2874        let preview: Value = serde_json::from_slice(&framed.recv().await.unwrap()).unwrap();
2875        let transaction_id = preview["transaction_id"].as_str().unwrap();
2876
2877        framed
2878            .send(
2879                &serde_json::to_vec(&json!({
2880                    "type": "approval_details",
2881                    "request_id": "details-read",
2882                    "transaction_id": transaction_id
2883                }))
2884                .unwrap(),
2885            )
2886            .await
2887            .unwrap();
2888        let details: Value = serde_json::from_slice(&framed.recv().await.unwrap()).unwrap();
2889        assert_eq!(details["type"], "approval_details_response");
2890        assert_eq!(details["transaction_id"], transaction_id);
2891        assert_eq!(details["action_name"], "GetSystemState");
2892        assert_eq!(details["preview"]["risk_level"], "low");
2893        assert!(!details["preview"]["summary"].as_str().unwrap().is_empty());
2894    }
2895
2896    #[tokio::test]
2897    async fn approve_missing_transaction_maps_to_stale_approval() {
2898        let dir = tempdir().unwrap();
2899        let responses = exchange(
2900            test_state(&dir),
2901            CallerRole::Observer,
2902            vec![json!({
2903                "type": "approve",
2904                "request_id": "approve-missing",
2905                "transaction_id": "missing-transaction",
2906            })],
2907            1,
2908        )
2909        .await;
2910
2911        assert_eq!(responses[0]["type"], "error_response");
2912        assert_eq!(responses[0]["category"], "stale_approval");
2913    }
2914
2915    #[tokio::test]
2916    async fn approving_same_transaction_twice_maps_second_to_stale_approval() {
2917        let dir = tempdir().unwrap();
2918        let state = test_state(&dir);
2919        let (client, server) = tokio::net::UnixStream::pair().unwrap();
2920        tokio::spawn(async move {
2921            unix_connection_handler(server, state, runner(), CallerRole::Observer).await;
2922        });
2923        let mut framed = FramedStream::new(client);
2924        let (transaction_id, _) =
2925            preview_and_approve(&mut framed, "GetSystemState", json!({})).await;
2926
2927        framed
2928            .send(
2929                &serde_json::to_vec(&json!({
2930                    "type": "approve",
2931                    "request_id": "approve-again",
2932                    "transaction_id": transaction_id,
2933                }))
2934                .unwrap(),
2935            )
2936            .await
2937            .unwrap();
2938        let response: Value = serde_json::from_slice(&framed.recv().await.unwrap()).unwrap();
2939
2940        assert_eq!(response["type"], "error_response");
2941        assert_eq!(response["category"], "stale_approval");
2942    }
2943
2944    #[tokio::test]
2945    async fn approve_with_forged_commitment_maps_to_integrity_failure() {
2946        // A tampered `approval_id` makes the store return `DatabaseInvariant`
2947        // (fail-closed). The dispatcher must surface that as a distinct,
2948        // non-retryable `integrity_failure` — NOT `transient_infrastructure_
2949        // failure`, which would falsely tell the user a retry can succeed.
2950        let dir = tempdir().unwrap();
2951        let db_path = dir.path().join("sysknife-test.db");
2952        let state = test_state(&dir);
2953        let (client, server) = tokio::net::UnixStream::pair().unwrap();
2954        tokio::spawn(async move {
2955            unix_connection_handler(server, state, runner(), CallerRole::Observer).await;
2956        });
2957        let mut framed = FramedStream::new(client);
2958
2959        framed
2960            .send(
2961                &serde_json::to_vec(&json!({
2962                    "type": "preview",
2963                    "request_id": "preview-forge",
2964                    "action_name": "GetSystemState",
2965                    "params": {},
2966                }))
2967                .unwrap(),
2968            )
2969            .await
2970            .unwrap();
2971        let preview: Value = serde_json::from_slice(&framed.recv().await.unwrap()).unwrap();
2972        let transaction_id = preview["transaction_id"].as_str().unwrap().to_string();
2973
2974        // Forge the stored commitment via an independent connection to the same
2975        // SQLite file so the daemon reads a tampered value on the next request.
2976        rusqlite::Connection::open(&db_path)
2977            .unwrap()
2978            .execute(
2979                "UPDATE transactions SET approval_id = 'forged' WHERE transaction_id = ?1",
2980                rusqlite::params![transaction_id],
2981            )
2982            .unwrap();
2983
2984        framed
2985            .send(
2986                &serde_json::to_vec(&json!({
2987                    "type": "approve",
2988                    "request_id": "approve-forged",
2989                    "transaction_id": transaction_id,
2990                }))
2991                .unwrap(),
2992            )
2993            .await
2994            .unwrap();
2995        let response: Value = serde_json::from_slice(&framed.recv().await.unwrap()).unwrap();
2996
2997        assert_eq!(response["type"], "error_response");
2998        assert_eq!(response["category"], "integrity_failure");
2999    }
3000
3001    #[tokio::test]
3002    async fn query_history_returns_structured_rows_with_created_at_and_risk_level() {
3003        // Regression for the null created_at/risk_level history bug: the
3004        // structured query_history IPC must carry typed rows, not text.
3005        let dir = tempdir().unwrap();
3006        let state = test_state(&dir);
3007        let (client, server) = tokio::net::UnixStream::pair().unwrap();
3008        tokio::spawn(async move {
3009            unix_connection_handler(server, state, runner(), CallerRole::Observer).await;
3010        });
3011        let mut framed = FramedStream::new(client);
3012
3013        framed
3014            .send(
3015                &serde_json::to_vec(&json!({
3016                    "type": "preview",
3017                    "request_id": "preview-hist",
3018                    "action_name": "GetSystemState",
3019                    "params": {},
3020                }))
3021                .unwrap(),
3022            )
3023            .await
3024            .unwrap();
3025        let _ = framed.recv().await.unwrap();
3026
3027        framed
3028            .send(
3029                &serde_json::to_vec(&json!({
3030                    "type": "query_history",
3031                    "request_id": "hist-1",
3032                    "limit": 10,
3033                }))
3034                .unwrap(),
3035            )
3036            .await
3037            .unwrap();
3038        let response: Value = serde_json::from_slice(&framed.recv().await.unwrap()).unwrap();
3039
3040        assert_eq!(response["type"], "history_response");
3041        let entries = response["entries"].as_array().expect("entries array");
3042        assert_eq!(entries.len(), 1);
3043        assert_eq!(entries[0]["action_name"], "GetSystemState");
3044        assert!(
3045            !entries[0]["created_at"].as_str().unwrap_or("").is_empty(),
3046            "created_at must be populated over the structured IPC"
3047        );
3048        assert_eq!(
3049            entries[0]["risk_level"], "low",
3050            "GetSystemState is a low-risk action; risk_level must carry the exact typed value"
3051        );
3052    }
3053
3054    #[tokio::test]
3055    async fn query_history_honours_policy_override_raising_history_above_observer() {
3056        // Authorization consistency: if an operator raises ListJobHistory above
3057        // Observer via [policy.risk_overrides], the structured query_history
3058        // path must refuse it too — it must not be a bypass around that policy.
3059        let dir = tempdir().unwrap();
3060        let real = test_state(&dir);
3061        // risk_overrides raise an action's RISK LEVEL; "high" pushes
3062        // ListJobHistory's derived min-role above Observer.
3063        let overrides =
3064            std::collections::HashMap::from([("ListJobHistory".to_string(), "high".to_string())]);
3065        let policy = crate::policy::PolicyTable::from_overrides(&overrides).unwrap();
3066        let state = crate::state::DaemonState::open_with_audit(
3067            real.config.clone(),
3068            policy,
3069            None,
3070            real.audit.clone(),
3071        );
3072
3073        let (client, server) = tokio::net::UnixStream::pair().unwrap();
3074        tokio::spawn(async move {
3075            unix_connection_handler(server, state, runner(), CallerRole::Observer).await;
3076        });
3077        let mut framed = FramedStream::new(client);
3078        framed
3079            .send(
3080                &serde_json::to_vec(&json!({
3081                    "type": "query_history",
3082                    "request_id": "hist-denied",
3083                    "limit": 10,
3084                }))
3085                .unwrap(),
3086            )
3087            .await
3088            .unwrap();
3089        let response: Value = serde_json::from_slice(&framed.recv().await.unwrap()).unwrap();
3090
3091        assert_eq!(response["type"], "error_response");
3092        assert_eq!(response["category"], "authorization_failure");
3093    }
3094
3095    #[tokio::test]
3096    async fn cancel_queued_transaction_succeeds_and_marks_it_canceled() {
3097        let dir = tempdir().unwrap();
3098        let state = test_state(&dir);
3099        let handler_state = state.clone();
3100        let (client, server) = tokio::net::UnixStream::pair().unwrap();
3101        tokio::spawn(async move {
3102            unix_connection_handler(server, handler_state, runner(), CallerRole::Observer).await;
3103        });
3104        let mut framed = FramedStream::new(client);
3105
3106        framed
3107            .send(
3108                &serde_json::to_vec(&json!({
3109                    "type": "preview",
3110                    "request_id": "preview-cancel",
3111                    "action_name": "GetSystemState",
3112                    "params": {},
3113                }))
3114                .unwrap(),
3115            )
3116            .await
3117            .unwrap();
3118        let preview: Value = serde_json::from_slice(&framed.recv().await.unwrap()).unwrap();
3119        let transaction_id = preview["transaction_id"].as_str().unwrap().to_string();
3120
3121        framed
3122            .send(
3123                &serde_json::to_vec(&json!({
3124                    "type": "cancel",
3125                    "request_id": "cancel-1",
3126                    "transaction_id": transaction_id,
3127                }))
3128                .unwrap(),
3129            )
3130            .await
3131            .unwrap();
3132        let response: Value = serde_json::from_slice(&framed.recv().await.unwrap()).unwrap();
3133
3134        assert_eq!(response["type"], "cancel_response");
3135        assert_eq!(
3136            state
3137                .audit
3138                .get(&transaction_id)
3139                .await
3140                .unwrap()
3141                .unwrap()
3142                .status,
3143            JobState::Canceled
3144        );
3145    }
3146
3147    #[tokio::test]
3148    async fn cancel_running_transaction_is_rejected_and_left_running() {
3149        let dir = tempdir().unwrap();
3150        let state = test_state(&dir);
3151        let handler_state = state.clone();
3152        let (client, server) = tokio::net::UnixStream::pair().unwrap();
3153        tokio::spawn(async move {
3154            unix_connection_handler(server, handler_state, runner(), CallerRole::Observer).await;
3155        });
3156        let mut framed = FramedStream::new(client);
3157        let (transaction_id, receipt) =
3158            preview_and_approve(&mut framed, "GetSystemState", json!({})).await;
3159        // Claim it (Queued -> Running) so it is in-flight from the store's view.
3160        assert!(state
3161            .audit
3162            .claim_approved_for_execution(&transaction_id, &receipt_digest(&receipt))
3163            .await
3164            .unwrap());
3165
3166        framed
3167            .send(
3168                &serde_json::to_vec(&json!({
3169                    "type": "cancel",
3170                    "request_id": "cancel-running",
3171                    "transaction_id": transaction_id,
3172                }))
3173                .unwrap(),
3174            )
3175            .await
3176            .unwrap();
3177        let response: Value = serde_json::from_slice(&framed.recv().await.unwrap()).unwrap();
3178
3179        assert_eq!(response["type"], "error_response");
3180        assert_eq!(response["category"], "not_cancelable");
3181        assert_eq!(
3182            state
3183                .audit
3184                .get(&transaction_id)
3185                .await
3186                .unwrap()
3187                .unwrap()
3188                .status,
3189            JobState::Running,
3190            "an in-flight action must not be disturbed by cancel"
3191        );
3192    }
3193
3194    #[tokio::test]
3195    async fn cancel_missing_transaction_is_rejected() {
3196        let dir = tempdir().unwrap();
3197        let responses = exchange(
3198            test_state(&dir),
3199            CallerRole::Observer,
3200            vec![json!({
3201                "type": "cancel",
3202                "request_id": "cancel-missing",
3203                "transaction_id": "no-such-transaction",
3204            })],
3205            1,
3206        )
3207        .await;
3208        assert_eq!(responses[0]["type"], "error_response");
3209        assert_eq!(responses[0]["category"], "not_cancelable");
3210    }
3211
3212    #[tokio::test]
3213    async fn undelivered_approval_is_revoked_so_the_user_can_retry() {
3214        let dir = tempdir().unwrap();
3215        let state = test_state(&dir);
3216        let (client, server) = tokio::net::UnixStream::pair().unwrap();
3217        let handler_state = state.clone();
3218        tokio::spawn(async move {
3219            unix_connection_handler(server, handler_state, runner(), CallerRole::Observer).await;
3220        });
3221        let mut framed = FramedStream::new(client);
3222        framed
3223            .send(
3224                &serde_json::to_vec(&json!({
3225                    "type": "preview",
3226                    "request_id": "preview-undelivered",
3227                    "action_name": "GetSystemState",
3228                    "params": {},
3229                }))
3230                .unwrap(),
3231            )
3232            .await
3233            .unwrap();
3234        let preview: Value = serde_json::from_slice(&framed.recv().await.unwrap()).unwrap();
3235        let transaction_id = preview["transaction_id"].as_str().unwrap();
3236
3237        let (peer, broken_stream) = tokio::io::duplex(64);
3238        drop(peer);
3239        let mut broken_framed = FramedStream::new(broken_stream);
3240        assert!(handle_approve(
3241            &mut broken_framed,
3242            &state,
3243            &CallerRole::Admin,
3244            "approve-undelivered",
3245            transaction_id,
3246        )
3247        .await
3248        .is_err());
3249
3250        let (retry_stream, receiver) = tokio::io::duplex(4096);
3251        let mut retry_framed = FramedStream::new(retry_stream);
3252        handle_approve(
3253            &mut retry_framed,
3254            &state,
3255            &CallerRole::Admin,
3256            "approve-retry",
3257            transaction_id,
3258        )
3259        .await
3260        .unwrap();
3261        let mut receiver = FramedStream::new(receiver);
3262        let response: Value = serde_json::from_slice(&receiver.recv().await.unwrap()).unwrap();
3263        assert_eq!(response["type"], "approval_response");
3264    }
3265
3266    #[tokio::test]
3267    async fn approval_details_for_running_transaction_maps_to_stale_approval() {
3268        let dir = tempdir().unwrap();
3269        let state = test_state(&dir);
3270        let (client, server) = tokio::net::UnixStream::pair().unwrap();
3271        let handler_state = state.clone();
3272        tokio::spawn(async move {
3273            unix_connection_handler(server, handler_state, runner(), CallerRole::Observer).await;
3274        });
3275        let mut framed = FramedStream::new(client);
3276        let (transaction_id, receipt) =
3277            preview_and_approve(&mut framed, "GetSystemState", json!({})).await;
3278        assert!(state
3279            .audit
3280            .claim_approved_for_execution(&transaction_id, &receipt_digest(&receipt))
3281            .await
3282            .unwrap());
3283
3284        framed
3285            .send(
3286                &serde_json::to_vec(&json!({
3287                    "type": "approval_details",
3288                    "request_id": "details-running",
3289                    "transaction_id": transaction_id,
3290                }))
3291                .unwrap(),
3292            )
3293            .await
3294            .unwrap();
3295        let response: Value = serde_json::from_slice(&framed.recv().await.unwrap()).unwrap();
3296
3297        assert_eq!(response["type"], "error_response");
3298        assert_eq!(response["category"], "stale_approval");
3299    }
3300
3301    /// Send `requests` to a spawned handler, collect exactly `want_responses`
3302    /// response frames, then drop the client to signal EOF.
3303    async fn exchange(
3304        state: DaemonState,
3305        role: CallerRole,
3306        requests: Vec<Value>,
3307        want_responses: usize,
3308    ) -> Vec<Value> {
3309        let (client, server) = tokio::net::UnixStream::pair().unwrap();
3310        tokio::spawn(async move {
3311            unix_connection_handler(server, state, runner(), role).await;
3312        });
3313
3314        let mut framed = FramedStream::new(client);
3315        for req in &requests {
3316            let bytes = serde_json::to_vec(req).unwrap();
3317            framed.send(&bytes).await.unwrap();
3318        }
3319
3320        let mut responses = Vec::new();
3321        for _ in 0..want_responses {
3322            let raw = framed.recv().await.unwrap();
3323            responses.push(serde_json::from_slice::<Value>(&raw).unwrap());
3324        }
3325        responses
3326    }
3327
3328    // ------------------------------------------------------------------
3329    // query_state
3330    // ------------------------------------------------------------------
3331
3332    #[tokio::test]
3333    async fn query_state_returns_state_response() {
3334        let dir = tempdir().unwrap();
3335        let state = test_state(&dir);
3336
3337        let resps = exchange(
3338            state,
3339            CallerRole::Observer,
3340            vec![json!({"type": "query_state", "request_id": "r1"})],
3341            1,
3342        )
3343        .await;
3344
3345        assert_eq!(resps[0]["type"], "state_response");
3346        assert_eq!(resps[0]["request_id"], "r1");
3347        assert_eq!(resps[0]["state"]["host_name"], "testhost");
3348    }
3349
3350    // ------------------------------------------------------------------
3351    // preview
3352    // ------------------------------------------------------------------
3353
3354    #[tokio::test]
3355    async fn preview_returns_hash_and_transaction_id() {
3356        let dir = tempdir().unwrap();
3357        let state = test_state(&dir);
3358
3359        let resps = exchange(
3360            state,
3361            CallerRole::Observer,
3362            vec![json!({
3363                "type": "preview",
3364                "request_id": "r1",
3365                "action_name": "GetSystemState",
3366                "params": {}
3367            })],
3368            1,
3369        )
3370        .await;
3371
3372        assert_eq!(resps[0]["type"], "preview_response");
3373        assert_eq!(resps[0]["request_id"], "r1");
3374        let hash = resps[0]["preview"]["request_hash"].as_str().unwrap();
3375        assert_eq!(
3376            hash.len(),
3377            64,
3378            "request_hash should be a 64-char hex SHA-256"
3379        );
3380        assert!(
3381            !resps[0]["transaction_id"].as_str().unwrap().is_empty(),
3382            "transaction_id must be set"
3383        );
3384    }
3385
3386    #[tokio::test]
3387    async fn preview_hash_is_deterministic() {
3388        // The same action + params must always produce the same hash.
3389        let hash1 = compute_request_hash(
3390            "InstallFlatpak",
3391            &json!({"app_id": "org.gnome.Builder", "remote": "flathub"}),
3392        );
3393        let hash2 = compute_request_hash(
3394            "InstallFlatpak",
3395            &json!({"remote": "flathub", "app_id": "org.gnome.Builder"}),
3396        );
3397        assert_eq!(hash1, hash2, "canonical JSON must sort keys");
3398    }
3399
3400    #[test]
3401    fn canonical_json_recurses_into_arrays() {
3402        // Objects nested inside arrays must also have sorted keys so that
3403        // {"packages": [{"b": 1, "a": 2}]} and {"packages": [{"a": 2, "b": 1}]}
3404        // produce the same hash.
3405        let hash1 =
3406            compute_request_hash("InstallPackages", &json!({"packages": [{"b": 1, "a": 2}]}));
3407        let hash2 =
3408            compute_request_hash("InstallPackages", &json!({"packages": [{"a": 2, "b": 1}]}));
3409        assert_eq!(hash1, hash2, "nested object keys in arrays must be sorted");
3410    }
3411
3412    #[test]
3413    fn canonical_json_preserves_array_element_order() {
3414        // Array element order is semantically significant ("install a then b"
3415        // is different from "install b then a"), so it must be preserved.
3416        let hash1 = compute_request_hash("Op", &json!({"items": ["a", "b"]}));
3417        let hash2 = compute_request_hash("Op", &json!({"items": ["b", "a"]}));
3418        assert_ne!(hash1, hash2, "array element order must be preserved");
3419    }
3420
3421    // ------------------------------------------------------------------
3422    // authorization
3423    // ------------------------------------------------------------------
3424
3425    #[tokio::test]
3426    async fn high_risk_action_rejected_for_observer() {
3427        let dir = tempdir().unwrap();
3428        let state = test_state(&dir);
3429
3430        let resps = exchange(
3431            state,
3432            CallerRole::Observer, // UpdateSystem requires Admin
3433            vec![json!({
3434                "type": "preview",
3435                "request_id": "r1",
3436                "action_name": "UpdateSystem",
3437                "params": {}
3438            })],
3439            1,
3440        )
3441        .await;
3442
3443        assert_eq!(resps[0]["type"], "error_response");
3444        assert_eq!(resps[0]["category"], "authorization_failure");
3445    }
3446
3447    #[tokio::test]
3448    async fn under_privileged_caller_cannot_act_on_admin_transaction() {
3449        // Regression: authorization must be enforced on approve / cancel /
3450        // approval-details, not only at preview time. An Admin-tier transaction
3451        // that was previewed by an Admin caller must be untouchable by an
3452        // under-privileged caller on a *different* connection, yet remain
3453        // actionable by an authorized caller.
3454        let dir = tempdir().unwrap();
3455        let state = test_state(&dir);
3456
3457        // An Admin caller previews an Admin-tier action, persisting a queued tx.
3458        let (client, server) = tokio::net::UnixStream::pair().unwrap();
3459        let handler_state = state.clone();
3460        tokio::spawn(async move {
3461            unix_connection_handler(server, handler_state, runner(), CallerRole::Admin).await;
3462        });
3463        let mut framed = FramedStream::new(client);
3464        framed
3465            .send(
3466                &serde_json::to_vec(&json!({
3467                    "type": "preview",
3468                    "request_id": "preview-admin",
3469                    "action_name": "UpdateSystem",
3470                    "params": {},
3471                }))
3472                .unwrap(),
3473            )
3474            .await
3475            .unwrap();
3476        let preview: Value = serde_json::from_slice(&framed.recv().await.unwrap()).unwrap();
3477        assert_eq!(preview["type"], "preview_response");
3478        let transaction_id = preview["transaction_id"].as_str().unwrap().to_string();
3479
3480        // Every mutating/reading verb an Observer might try must be refused with
3481        // authorization_failure while leaving the transaction untouched.
3482        for (request_id, verb) in [
3483            ("obs-approve", "approve"),
3484            ("obs-cancel", "cancel"),
3485            ("obs-details", "details"),
3486        ] {
3487            let (stream, rx) = tokio::io::duplex(4096);
3488            let mut caller = FramedStream::new(stream);
3489            match verb {
3490                "approve" => handle_approve(
3491                    &mut caller,
3492                    &state,
3493                    &CallerRole::Observer,
3494                    request_id,
3495                    &transaction_id,
3496                )
3497                .await
3498                .unwrap(),
3499                "cancel" => handle_cancel(
3500                    &mut caller,
3501                    &state,
3502                    &CallerRole::Observer,
3503                    request_id,
3504                    &transaction_id,
3505                )
3506                .await
3507                .unwrap(),
3508                _ => handle_approval_details(
3509                    &mut caller,
3510                    &state,
3511                    &CallerRole::Observer,
3512                    request_id,
3513                    &transaction_id,
3514                )
3515                .await
3516                .unwrap(),
3517            }
3518            let mut rx = FramedStream::new(rx);
3519            let resp: Value = serde_json::from_slice(&rx.recv().await.unwrap()).unwrap();
3520            assert_eq!(resp["type"], "error_response", "{verb} must be refused");
3521            assert_eq!(
3522                resp["category"], "authorization_failure",
3523                "{verb} refusal must be an authorization failure"
3524            );
3525        }
3526
3527        // The transaction survived the refusals: an authorized caller can still
3528        // approve it, proving we blocked only the unauthorized path.
3529        let (stream, rx) = tokio::io::duplex(4096);
3530        let mut admin = FramedStream::new(stream);
3531        handle_approve(
3532            &mut admin,
3533            &state,
3534            &CallerRole::Admin,
3535            "admin-approve",
3536            &transaction_id,
3537        )
3538        .await
3539        .unwrap();
3540        let mut rx = FramedStream::new(rx);
3541        let resp: Value = serde_json::from_slice(&rx.recv().await.unwrap()).unwrap();
3542        assert_eq!(resp["type"], "approval_response");
3543    }
3544
3545    #[tokio::test]
3546    async fn medium_risk_action_rejected_for_observer() {
3547        let dir = tempdir().unwrap();
3548        let state = test_state(&dir);
3549
3550        let resps = exchange(
3551            state,
3552            CallerRole::Observer, // InstallFlatpak requires Dev
3553            vec![json!({
3554                "type": "preview",
3555                "request_id": "r1",
3556                "action_name": "InstallFlatpak",
3557                "params": {"username": "alice", "app_id": "org.gnome.Builder", "remote": "flathub"}
3558            })],
3559            1,
3560        )
3561        .await;
3562
3563        assert_eq!(resps[0]["type"], "error_response");
3564        assert_eq!(resps[0]["category"], "authorization_failure");
3565    }
3566
3567    #[tokio::test]
3568    async fn low_risk_action_allowed_for_observer() {
3569        let dir = tempdir().unwrap();
3570        let state = test_state(&dir);
3571
3572        let resps = exchange(
3573            state,
3574            CallerRole::Observer,
3575            vec![json!({
3576                "type": "preview",
3577                "request_id": "r1",
3578                "action_name": "GetSystemState",
3579                "params": {}
3580            })],
3581            1,
3582        )
3583        .await;
3584
3585        assert_eq!(resps[0]["type"], "preview_response");
3586    }
3587
3588    #[test]
3589    fn unsupported_distros_cannot_reach_mutation_paths() {
3590        let dir = tempdir().unwrap();
3591        let mut state = test_state(&dir);
3592        state.host_distro = Some(sysknife_core::distro::DistroId::UbuntuCore {
3593            major: 24,
3594            minor: 4,
3595        });
3596        assert!(validate_action_platform(&state, "AptInstall").is_err());
3597
3598        state.host_distro = Some(sysknife_core::distro::DistroId::Ubuntu {
3599            major: 20,
3600            minor: 4,
3601        });
3602        assert!(validate_action_platform(&state, "UpdateSystem").is_err());
3603    }
3604
3605    #[test]
3606    fn raising_a_read_only_action_via_risk_overrides_does_not_arm_the_platform_fence() {
3607        // `[policy.risk_overrides]` answers "who may run this", not "is this a
3608        // mutation". Deriving `is_mutating` from the override-adjusted role let
3609        // an operator who tightened RBAC on a harmless read also make that read
3610        // fail whenever the host distro could not be detected — a restriction
3611        // they never asked for, appearing in an unrelated subsystem.
3612        let dir = tempdir().unwrap();
3613        let mut state = test_state(&dir);
3614        state.host_distro = None;
3615        let overrides =
3616            std::collections::HashMap::from([("GetDiskUsage".to_string(), "high".to_string())]);
3617        state.policy = crate::policy::PolicyTable::from_overrides(&overrides).unwrap();
3618
3619        assert_eq!(
3620            state.policy.min_role_for_action("GetDiskUsage"),
3621            Some(CallerRole::Admin),
3622            "the override still governs authorization"
3623        );
3624        assert!(
3625            validate_action_platform(&state, "GetDiskUsage").is_ok(),
3626            "a read-only action stays exempt from the distro fence"
3627        );
3628    }
3629
3630    #[test]
3631    fn supported_hosts_still_enforce_action_family() {
3632        let dir = tempdir().unwrap();
3633        let mut state = test_state(&dir);
3634        state.host_distro = Some(sysknife_core::distro::DistroId::Ubuntu {
3635            major: 24,
3636            minor: 4,
3637        });
3638        assert!(validate_action_platform(&state, "AptInstall").is_ok());
3639        assert!(validate_action_platform(&state, "AddLayeredPackage").is_err());
3640    }
3641
3642    // ------------------------------------------------------------------
3643    // query_action / describe — the same fences as preview and execute
3644    // ------------------------------------------------------------------
3645
3646    #[tokio::test]
3647    async fn query_action_refuses_an_action_the_host_family_cannot_run() {
3648        // `preview` and `execute` both call `validate_action_platform`;
3649        // `query_action` reached `build_action_spec` and ran the command. On a
3650        // Debian host that meant shelling out to `rpm-ostree`, so the caller
3651        // got a "No such file or directory" execution error instead of the
3652        // clean refusal the other two paths give.
3653        let dir = tempdir().unwrap();
3654        let mut state = test_state(&dir);
3655        state.host_distro = Some(sysknife_core::distro::DistroId::Ubuntu {
3656            major: 24,
3657            minor: 4,
3658        });
3659
3660        let resps = exchange(
3661            state,
3662            CallerRole::Observer,
3663            vec![json!({
3664                "type": "query_action",
3665                "request_id": "r1",
3666                "action_name": "GetLayeredPackages",
3667                "params": {}
3668            })],
3669            1,
3670        )
3671        .await;
3672
3673        assert_eq!(resps[0]["type"], "error_response");
3674        assert_eq!(resps[0]["category"], "unsupported_platform");
3675    }
3676
3677    #[tokio::test]
3678    async fn describe_refuses_an_action_the_caller_is_not_allowed_to_run() {
3679        // Describe renders the exact command an action would run. It had no
3680        // authorization check at all, so an Observer could enumerate the argv
3681        // of every privileged action on the box.
3682        let dir = tempdir().unwrap();
3683        let state = test_state(&dir);
3684
3685        let resps = exchange(
3686            state,
3687            CallerRole::Observer,
3688            vec![json!({
3689                "type": "describe",
3690                "request_id": "r1",
3691                "action_name": "GrantSudoAccess",
3692                "params": {"username": "alice", "commands": "/usr/bin/ls", "nopasswd": false}
3693            })],
3694            1,
3695        )
3696        .await;
3697
3698        assert_eq!(resps[0]["type"], "error_response");
3699        assert_eq!(resps[0]["category"], "authorization_failure");
3700    }
3701
3702    #[tokio::test]
3703    async fn describe_refuses_an_action_the_host_family_cannot_run() {
3704        let dir = tempdir().unwrap();
3705        let mut state = test_state(&dir);
3706        state.host_distro = Some(sysknife_core::distro::DistroId::Ubuntu {
3707            major: 24,
3708            minor: 4,
3709        });
3710
3711        let resps = exchange(
3712            state,
3713            CallerRole::Admin,
3714            vec![json!({
3715                "type": "describe",
3716                "request_id": "r1",
3717                "action_name": "AddLayeredPackage",
3718                "params": {"package": "vim"}
3719            })],
3720            1,
3721        )
3722        .await;
3723
3724        assert_eq!(resps[0]["type"], "error_response");
3725        assert_eq!(resps[0]["category"], "unsupported_platform");
3726    }
3727
3728    #[tokio::test]
3729    async fn describe_still_answers_for_an_allowed_action() {
3730        // The fences must not turn describe into a privileged-only call: a
3731        // read-only action stays describable by an Observer.
3732        let dir = tempdir().unwrap();
3733        let state = test_state(&dir);
3734
3735        let resps = exchange(
3736            state,
3737            CallerRole::Observer,
3738            vec![json!({
3739                "type": "describe",
3740                "request_id": "r1",
3741                "action_name": "GetDiskUsage",
3742                "params": {}
3743            })],
3744            1,
3745        )
3746        .await;
3747
3748        assert_eq!(resps[0]["type"], "describe_response");
3749    }
3750
3751    // ------------------------------------------------------------------
3752    // execute — stale approval
3753    // ------------------------------------------------------------------
3754
3755    #[tokio::test]
3756    async fn execute_without_prior_preview_returns_stale_approval() {
3757        let dir = tempdir().unwrap();
3758        let state = test_state(&dir);
3759
3760        let resps = exchange(
3761            state,
3762            CallerRole::Observer,
3763            vec![json!({
3764                "type": "execute",
3765                "request_id": "r1",
3766                "transaction_id": "missing-transaction",
3767                "action_name": "GetSystemState",
3768                "params": {},
3769                "approval_receipt": "unissued-receipt"
3770            })],
3771            1,
3772        )
3773        .await;
3774
3775        assert_eq!(resps[0]["type"], "error_response");
3776        assert_eq!(resps[0]["category"], "stale_approval");
3777    }
3778
3779    #[tokio::test]
3780    async fn execute_with_changed_params_rejects_without_consuming_approval() {
3781        let dir = tempdir().unwrap();
3782        let state = test_state(&dir);
3783        let (client, server) = tokio::net::UnixStream::pair().unwrap();
3784        let handler_state = state.clone();
3785        tokio::spawn(async move {
3786            unix_connection_handler(server, handler_state, runner(), CallerRole::Observer).await;
3787        });
3788        let mut framed = FramedStream::new(client);
3789        let (transaction_id, receipt) = preview_and_approve(
3790            &mut framed,
3791            "GetServiceStatus",
3792            json!({"unit": "sshd.service"}),
3793        )
3794        .await;
3795
3796        framed
3797            .send(
3798                &serde_json::to_vec(&json!({
3799                    "type": "execute",
3800                    "request_id": "changed-params",
3801                    "transaction_id": transaction_id,
3802                    "action_name": "GetServiceStatus",
3803                    "params": {"unit": "cron.service"},
3804                    "approval_receipt": receipt,
3805                }))
3806                .unwrap(),
3807            )
3808            .await
3809            .unwrap();
3810        let response: Value = serde_json::from_slice(&framed.recv().await.unwrap()).unwrap();
3811
3812        assert_eq!(response["type"], "error_response");
3813        assert_eq!(response["category"], "stale_approval");
3814        assert_eq!(
3815            state
3816                .audit
3817                .get(&transaction_id)
3818                .await
3819                .unwrap()
3820                .unwrap()
3821                .status,
3822            JobState::Queued
3823        );
3824        assert!(state
3825            .audit
3826            .claim_approved_for_execution(&transaction_id, &receipt_digest(&receipt))
3827            .await
3828            .unwrap());
3829    }
3830
3831    #[tokio::test]
3832    async fn execute_with_changed_action_rejects_without_consuming_approval() {
3833        let dir = tempdir().unwrap();
3834        let state = test_state(&dir);
3835        let (client, server) = tokio::net::UnixStream::pair().unwrap();
3836        let handler_state = state.clone();
3837        tokio::spawn(async move {
3838            unix_connection_handler(server, handler_state, runner(), CallerRole::Observer).await;
3839        });
3840        let mut framed = FramedStream::new(client);
3841        let (transaction_id, receipt) =
3842            preview_and_approve(&mut framed, "GetSystemState", json!({})).await;
3843
3844        framed
3845            .send(
3846                &serde_json::to_vec(&json!({
3847                    "type": "execute",
3848                    "request_id": "changed-action",
3849                    "transaction_id": transaction_id,
3850                    "action_name": "GetDiskUsage",
3851                    "params": {},
3852                    "approval_receipt": receipt,
3853                }))
3854                .unwrap(),
3855            )
3856            .await
3857            .unwrap();
3858        let response: Value = serde_json::from_slice(&framed.recv().await.unwrap()).unwrap();
3859
3860        assert_eq!(response["type"], "error_response");
3861        assert_eq!(response["category"], "stale_approval");
3862        assert_eq!(
3863            state
3864                .audit
3865                .get(&transaction_id)
3866                .await
3867                .unwrap()
3868                .unwrap()
3869                .status,
3870            JobState::Queued
3871        );
3872        assert!(state
3873            .audit
3874            .claim_approved_for_execution(&transaction_id, &receipt_digest(&receipt))
3875            .await
3876            .unwrap());
3877    }
3878
3879    #[tokio::test]
3880    async fn job_started_send_failure_releases_slot_and_marks_transaction_failed() {
3881        let dir = tempdir().unwrap();
3882        let mut state = test_state(&dir);
3883        state.host_distro = Some(sysknife_core::distro::DistroId::FedoraSilverblue { version: 44 });
3884        let (client, server) = tokio::net::UnixStream::pair().unwrap();
3885        let handler_state = state.clone();
3886        tokio::spawn(async move {
3887            unix_connection_handler(server, handler_state, runner(), CallerRole::Admin).await;
3888        });
3889        let mut preview_stream = FramedStream::new(client);
3890        let params = json!({"package": "vim"});
3891        let (transaction_id, receipt) =
3892            preview_and_approve(&mut preview_stream, "AddLayeredPackage", params.clone()).await;
3893
3894        let (peer, broken_stream) = tokio::io::duplex(64);
3895        drop(peer);
3896        let mut broken_framed = FramedStream::new(broken_stream);
3897        let typed_transaction_id = TransactionId(transaction_id.clone());
3898        let typed_receipt = ApprovalReceipt(receipt);
3899        let result = handle_execute(
3900            &mut broken_framed,
3901            &state,
3902            Arc::new(crate::executor::RealActionExecutor),
3903            &CallerRole::Admin,
3904            "execute-disconnected",
3905            &typed_transaction_id,
3906            "AddLayeredPackage",
3907            &params,
3908            &typed_receipt,
3909        )
3910        .await;
3911
3912        assert!(result.is_err(), "the initial response write must fail");
3913        assert!(
3914            state.running_exclusive.lock().await.is_empty(),
3915            "a disconnected client must not retain any exclusion lock"
3916        );
3917        assert_eq!(
3918            state
3919                .audit
3920                .get(&transaction_id)
3921                .await
3922                .unwrap()
3923                .unwrap()
3924                .status,
3925            JobState::Failed,
3926            "a claimed transaction must not remain Running when execution never starts"
3927        );
3928    }
3929
3930    // ------------------------------------------------------------------
3931    // execute — full preview → execute flow
3932    // ------------------------------------------------------------------
3933
3934    #[tokio::test]
3935    async fn execute_after_preview_with_receipt_returns_job_completed() {
3936        let dir = tempdir().unwrap();
3937        let state = test_state(&dir);
3938        let (client, server) = tokio::net::UnixStream::pair().unwrap();
3939
3940        tokio::spawn(async move {
3941            unix_connection_handler(server, state, runner(), CallerRole::Observer).await;
3942        });
3943
3944        let mut framed = FramedStream::new(client);
3945
3946        // Step 1: preview.
3947        let preview_req = json!({
3948            "type": "preview",
3949            "request_id": "r1",
3950            "action_name": "GetSystemState",
3951            "params": {}
3952        });
3953        framed
3954            .send(&serde_json::to_vec(&preview_req).unwrap())
3955            .await
3956            .unwrap();
3957
3958        let raw = framed.recv().await.unwrap();
3959        let preview_resp: Value = serde_json::from_slice(&raw).unwrap();
3960        assert_eq!(preview_resp["type"], "preview_response");
3961        let transaction_id = preview_resp["transaction_id"].as_str().unwrap();
3962        let receipt = approve_preview(&mut framed, transaction_id).await;
3963
3964        // Step 2: execute with the one-time receipt the daemon returned.
3965        let exec_req = json!({
3966            "type": "execute",
3967            "request_id": "r2",
3968            "transaction_id": transaction_id,
3969            "action_name": "GetSystemState",
3970            "params": {},
3971            "approval_receipt": receipt
3972        });
3973        framed
3974            .send(&serde_json::to_vec(&exec_req).unwrap())
3975            .await
3976            .unwrap();
3977
3978        // Expect job_started first.
3979        let raw = framed.recv().await.unwrap();
3980        let started: Value = serde_json::from_slice(&raw).unwrap();
3981        assert_eq!(started["type"], "job_started");
3982        let job_id = started["job_id"].as_str().unwrap().to_string();
3983
3984        // Drain frames until job_completed (there may be job_progress frames).
3985        loop {
3986            let raw = framed.recv().await.unwrap();
3987            let msg: Value = serde_json::from_slice(&raw).unwrap();
3988            let msg_type = msg["type"].as_str().unwrap();
3989            match msg_type {
3990                "job_progress" => {
3991                    assert_eq!(msg["job_id"], job_id);
3992                }
3993                "job_completed" => {
3994                    assert_eq!(msg["job_id"], job_id);
3995                    let status = msg["result"]["status"].as_str().unwrap();
3996                    // rpm-ostree may not be available in CI; both outcomes are valid.
3997                    assert!(
3998                        matches!(status, "succeeded" | "failed" | "needs_reboot"),
3999                        "unexpected status: {status}"
4000                    );
4001                    break;
4002                }
4003                other => panic!("unexpected message type: {other}"),
4004            }
4005        }
4006    }
4007
4008    // ------------------------------------------------------------------
4009    // T12 — `update_status` failure surfaces as a job-completed warning
4010    //
4011    // When the audit-log update fails after a job runs, the dispatcher
4012    // logs to stderr AND attaches a "audit trail update failed: …"
4013    // string to the job_completed response's `warnings` field so the
4014    // client can flag the audit gap. The warning emission was
4015    // unverified: a regression that swapped the warnings push for an
4016    // early return would silently drop the audit-loss signal.
4017    // ------------------------------------------------------------------
4018
4019    #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
4020    async fn update_status_failure_surfaces_as_audit_warning_in_job_completed() {
4021        use crate::audit_chain::{AuditKey, ChainRow, VerifyOutcome};
4022        use crate::store::AuditStore;
4023        use crate::transactions::{
4024            NewTransaction as NewTx, RecordedPreviewedTransaction, TransactionStoreError,
4025        };
4026        use sysknife_types::{JobState, PreviewEnvelope, TransactionRecord};
4027
4028        // A tiny `AuditStore` wrapper that delegates to the real
4029        // SQLite-backed store for everything except `update_status`,
4030        // which returns the most plausible production failure mode
4031        // ("not found" — e.g. the row was rotated out before the dispatcher
4032        // could close the loop). The dispatcher must still emit
4033        // job_completed with the warning attached.
4034        #[derive(Debug)]
4035        struct UpdateStatusFails(std::sync::Arc<dyn AuditStore>);
4036
4037        #[async_trait::async_trait]
4038        impl AuditStore for UpdateStatusFails {
4039            async fn record(&self, t: NewTx) -> Result<TransactionRecord, TransactionStoreError> {
4040                self.0.record(t).await
4041            }
4042            async fn record_previewed(
4043                &self,
4044                t: NewTx,
4045                p: PreviewEnvelope,
4046            ) -> Result<RecordedPreviewedTransaction, TransactionStoreError> {
4047                self.0.record_previewed(t, p).await
4048            }
4049            async fn get(
4050                &self,
4051                id: &str,
4052            ) -> Result<Option<TransactionRecord>, TransactionStoreError> {
4053                self.0.get(id).await
4054            }
4055            async fn get_preview(
4056                &self,
4057                id: &str,
4058            ) -> Result<Option<PreviewEnvelope>, TransactionStoreError> {
4059                self.0.get_preview(id).await
4060            }
4061            async fn update_status(
4062                &self,
4063                id: &str,
4064                _s: JobState,
4065            ) -> Result<(), TransactionStoreError> {
4066                Err(TransactionStoreError::NotFound(id.to_string()))
4067            }
4068            async fn approve_transaction(
4069                &self,
4070                id: &str,
4071            ) -> Result<Option<String>, TransactionStoreError> {
4072                self.0.approve_transaction(id).await
4073            }
4074            async fn revoke_unconsumed_approval(
4075                &self,
4076                id: &str,
4077            ) -> Result<bool, TransactionStoreError> {
4078                self.0.revoke_unconsumed_approval(id).await
4079            }
4080            async fn claim_approved_for_execution(
4081                &self,
4082                id: &str,
4083                digest: &str,
4084            ) -> Result<bool, TransactionStoreError> {
4085                self.0.claim_approved_for_execution(id, digest).await
4086            }
4087            async fn cleanup_stale_queued(&self) -> Result<u64, TransactionStoreError> {
4088                self.0.cleanup_stale_queued().await
4089            }
4090            async fn cancel_queued(&self, id: &str) -> Result<bool, TransactionStoreError> {
4091                self.0.cancel_queued(id).await
4092            }
4093            async fn list_transactions(
4094                &self,
4095                limit: u32,
4096                status: Option<&str>,
4097                action: Option<&str>,
4098                since: Option<u32>,
4099            ) -> Result<Vec<TransactionRecord>, TransactionStoreError> {
4100                self.0.list_transactions(limit, status, action, since).await
4101            }
4102            async fn list_history(
4103                &self,
4104                limit: u32,
4105                status: Option<&str>,
4106                action: Option<&str>,
4107                since: Option<u32>,
4108            ) -> Result<Vec<crate::transactions::JobHistoryEntry>, TransactionStoreError>
4109            {
4110                self.0.list_history(limit, status, action, since).await
4111            }
4112            async fn fetch_chain_row(
4113                &self,
4114                id: &str,
4115            ) -> Result<Option<ChainRow>, TransactionStoreError> {
4116                self.0.fetch_chain_row(id).await
4117            }
4118            async fn fetch_chain_rows(&self) -> Result<Vec<ChainRow>, TransactionStoreError> {
4119                self.0.fetch_chain_rows().await
4120            }
4121            async fn fetch_event_rows(
4122                &self,
4123            ) -> Result<Vec<crate::audit_chain::EventRow>, TransactionStoreError> {
4124                self.0.fetch_event_rows().await
4125            }
4126            async fn verify_audit_chain(
4127                &self,
4128                k: &AuditKey,
4129            ) -> Result<VerifyOutcome, TransactionStoreError> {
4130                self.0.verify_audit_chain(k).await
4131            }
4132        }
4133
4134        let dir = tempdir().unwrap();
4135        let real = test_state(&dir);
4136        // Wrap the inner audit store with our update-status-failing decorator.
4137        let faulty: std::sync::Arc<dyn AuditStore> =
4138            std::sync::Arc::new(UpdateStatusFails(real.audit.clone()));
4139        let cfg = real.config.clone();
4140        let state = crate::state::DaemonState::open_with_audit(
4141            cfg,
4142            crate::policy::PolicyTable::empty(),
4143            None,
4144            faulty,
4145        );
4146
4147        let (client, server) = tokio::net::UnixStream::pair().unwrap();
4148        tokio::spawn(async move {
4149            unix_connection_handler(server, state, runner(), CallerRole::Observer).await;
4150        });
4151        let mut framed = FramedStream::new(client);
4152
4153        // Preview + execute against a low-risk action so Observer can run it.
4154        framed
4155            .send(
4156                &serde_json::to_vec(&json!({
4157                    "type": "preview",
4158                    "request_id": "r1",
4159                    "action_name": "GetSystemState",
4160                    "params": {}
4161                }))
4162                .unwrap(),
4163            )
4164            .await
4165            .unwrap();
4166        let preview_resp: Value = serde_json::from_slice(&framed.recv().await.unwrap()).unwrap();
4167        let transaction_id = preview_resp["transaction_id"].as_str().unwrap();
4168        let receipt = approve_preview(&mut framed, transaction_id).await;
4169
4170        framed
4171            .send(
4172                &serde_json::to_vec(&json!({
4173                    "type": "execute",
4174                    "request_id": "r2",
4175                    "transaction_id": transaction_id,
4176                    "action_name": "GetSystemState",
4177                    "params": {},
4178                    "approval_receipt": receipt
4179                }))
4180                .unwrap(),
4181            )
4182            .await
4183            .unwrap();
4184
4185        // Drain frames until we hit job_completed.
4186        let mut completed: Option<Value> = None;
4187        for _ in 0..30 {
4188            let raw = framed.recv().await.unwrap();
4189            let msg: Value = serde_json::from_slice(&raw).unwrap();
4190            if msg["type"] == "job_completed" {
4191                completed = Some(msg);
4192                break;
4193            }
4194        }
4195        let completed = completed.expect("dispatcher must emit job_completed");
4196        let warnings = completed["result"]["warnings"]
4197            .as_array()
4198            .expect("warnings array on job_completed");
4199
4200        assert!(
4201            warnings
4202                .iter()
4203                .any(|w| w.as_str().unwrap_or("").contains("audit trail update failed")),
4204            "expected an `audit trail update failed` warning when update_status returns Err; got: {warnings:#?}"
4205        );
4206    }
4207
4208    // ------------------------------------------------------------------
4209    // T4 — concurrent execute race at the dispatcher boundary
4210    //
4211    // The transactions store guarantees that receipt consumption and the
4212    // Queued-to-Running transition happen in one atomic claim; the second
4213    // caller observes that the receipt is consumed and bails. Drive
4214    // that contract through TWO concurrent unix_connection_handler instances
4215    // sharing one DaemonState: one preview produces a Queued row, then
4216    // two executes race for it.  Exactly one must reach job_started;
4217    // the other must surface `stale_approval`.
4218    //
4219    // The test runs against the multi-thread tokio runtime so the two
4220    // handlers are genuinely parallel — single-thread would serialise
4221    // them and never exercise the conditional UPDATE.
4222    // ------------------------------------------------------------------
4223
4224    #[tokio::test(flavor = "multi_thread", worker_threads = 4)]
4225    async fn concurrent_executes_against_one_preview_serialise_to_one_winner() {
4226        let dir = tempdir().unwrap();
4227        let state = test_state(&dir);
4228
4229        // ── Producer: one preview to seed a Queued row ───────────────
4230        let (pc, ps) = tokio::net::UnixStream::pair().unwrap();
4231        {
4232            let state = state.clone();
4233            tokio::spawn(async move {
4234                unix_connection_handler(ps, state, runner(), CallerRole::Observer).await;
4235            });
4236        }
4237        let mut framed_p = FramedStream::new(pc);
4238        framed_p
4239            .send(
4240                &serde_json::to_vec(&json!({
4241                    "type": "preview",
4242                    "request_id": "preview-once",
4243                    "action_name": "GetSystemState",
4244                    "params": {}
4245                }))
4246                .unwrap(),
4247            )
4248            .await
4249            .unwrap();
4250        let raw = framed_p.recv().await.unwrap();
4251        let preview_resp: Value = serde_json::from_slice(&raw).unwrap();
4252        let transaction_id = preview_resp["transaction_id"].as_str().unwrap().to_string();
4253        let approval_receipt = approve_preview(&mut framed_p, &transaction_id).await;
4254
4255        // ── Two execute connections sharing the same DaemonState ─────
4256        async fn exec_once(
4257            state: crate::state::DaemonState,
4258            transaction_id: String,
4259            approval_receipt: String,
4260            request_id: &'static str,
4261        ) -> Vec<Value> {
4262            let (c, s) = tokio::net::UnixStream::pair().unwrap();
4263            tokio::spawn(async move {
4264                unix_connection_handler(s, state, runner(), CallerRole::Observer).await;
4265            });
4266            let mut f = FramedStream::new(c);
4267            f.send(
4268                &serde_json::to_vec(&json!({
4269                    "type": "execute",
4270                    "request_id": request_id,
4271                    "transaction_id": transaction_id,
4272                    "action_name": "GetSystemState",
4273                    "params": {},
4274                    "approval_receipt": approval_receipt
4275                }))
4276                .unwrap(),
4277            )
4278            .await
4279            .unwrap();
4280
4281            // Read frames until we hit either job_completed (winner path)
4282            // or error_response (loser path). Cap at 30 frames so a stuck
4283            // handler can't hang the test.
4284            let mut frames = Vec::new();
4285            for _ in 0..30 {
4286                let raw = match f.recv().await {
4287                    Ok(b) => b,
4288                    Err(_) => break,
4289                };
4290                let v: Value = serde_json::from_slice(&raw).unwrap();
4291                let kind = v["type"].as_str().unwrap_or("").to_string();
4292                frames.push(v);
4293                if kind == "job_completed" || kind == "error_response" {
4294                    break;
4295                }
4296            }
4297            frames
4298        }
4299
4300        let (a, b) = tokio::join!(
4301            exec_once(
4302                state.clone(),
4303                transaction_id.clone(),
4304                approval_receipt.clone(),
4305                "exec-A"
4306            ),
4307            exec_once(
4308                state.clone(),
4309                transaction_id.clone(),
4310                approval_receipt.clone(),
4311                "exec-B"
4312            ),
4313        );
4314
4315        // Each side must end in either job_completed or error_response.
4316        let terminal = |frames: &[Value]| -> &'static str {
4317            for f in frames {
4318                match f["type"].as_str().unwrap_or("") {
4319                    "job_completed" => return "win",
4320                    "error_response" => return "lose",
4321                    _ => {}
4322                }
4323            }
4324            "neither"
4325        };
4326        let outcomes = [terminal(&a), terminal(&b)];
4327
4328        // Exactly one winner, exactly one loser. Order is unspecified.
4329        let mut sorted = outcomes;
4330        sorted.sort();
4331        assert_eq!(
4332            sorted,
4333            ["lose", "win"],
4334            "exactly one execute must win and one must lose; got {outcomes:?}\n A={a:#?}\n B={b:#?}"
4335        );
4336
4337        // The loser must be flagged as stale_approval, not some other
4338        // category — that's the receipt claim's wire contract.
4339        let loser = if outcomes[0] == "lose" { &a } else { &b };
4340        let err_frame = loser
4341            .iter()
4342            .find(|f| f["type"] == "error_response")
4343            .expect("loser has an error_response");
4344        assert_eq!(
4345            err_frame["category"], "stale_approval",
4346            "loser must be classified stale_approval; got {err_frame:#?}"
4347        );
4348    }
4349
4350    // ------------------------------------------------------------------
4351    // T5 — replay attack at the dispatcher boundary
4352    //
4353    // The transaction store verifies and consumes the one-time receipt while
4354    // moving the row from Queued to Running. A captured receipt submitted a
4355    // second time must surface as `stale_approval`. Pin that contract through
4356    // the live dispatcher, not just the store.
4357    // ------------------------------------------------------------------
4358
4359    #[tokio::test]
4360    async fn re_executing_a_completed_approval_returns_stale_approval() {
4361        let dir = tempdir().unwrap();
4362        let state = test_state(&dir);
4363        let (client, server) = tokio::net::UnixStream::pair().unwrap();
4364
4365        tokio::spawn(async move {
4366            unix_connection_handler(server, state, runner(), CallerRole::Observer).await;
4367        });
4368
4369        let mut framed = FramedStream::new(client);
4370
4371        // Step 1: preview a low-risk action so we can drive the full
4372        // execute path under Observer (which can run Get* actions).
4373        framed
4374            .send(
4375                &serde_json::to_vec(&json!({
4376                    "type": "preview",
4377                    "request_id": "r1",
4378                    "action_name": "GetSystemState",
4379                    "params": {}
4380                }))
4381                .unwrap(),
4382            )
4383            .await
4384            .unwrap();
4385
4386        let raw = framed.recv().await.unwrap();
4387        let preview_resp: Value = serde_json::from_slice(&raw).unwrap();
4388        assert_eq!(preview_resp["type"], "preview_response");
4389        let transaction_id = preview_resp["transaction_id"].as_str().unwrap().to_string();
4390        let approval_receipt = approve_preview(&mut framed, &transaction_id).await;
4391
4392        // Step 2: first execute — must succeed and reach a terminal state.
4393        framed
4394            .send(
4395                &serde_json::to_vec(&json!({
4396                    "type": "execute",
4397                    "request_id": "r2",
4398                    "transaction_id": transaction_id,
4399                    "action_name": "GetSystemState",
4400                    "params": {},
4401                    "approval_receipt": approval_receipt
4402                }))
4403                .unwrap(),
4404            )
4405            .await
4406            .unwrap();
4407
4408        // Drain everything until job_completed so the transaction row
4409        // has been moved out of Queued.
4410        loop {
4411            let raw = framed.recv().await.unwrap();
4412            let msg: Value = serde_json::from_slice(&raw).unwrap();
4413            match msg["type"].as_str().unwrap() {
4414                "job_started" | "job_progress" => continue,
4415                "job_completed" => break,
4416                other => panic!("unexpected message during first execute: {other}"),
4417            }
4418        }
4419
4420        // Step 3: replay the same one-time receipt. The receipt is consumed,
4421        // so the dispatcher must reject it rather than re-execute the action.
4422        framed
4423            .send(
4424                &serde_json::to_vec(&json!({
4425                    "type": "execute",
4426                    "request_id": "r3-replay",
4427                    "transaction_id": transaction_id,
4428                    "action_name": "GetSystemState",
4429                    "params": {},
4430                    "approval_receipt": approval_receipt
4431                }))
4432                .unwrap(),
4433            )
4434            .await
4435            .unwrap();
4436
4437        let raw = framed.recv().await.unwrap();
4438        let resp: Value = serde_json::from_slice(&raw).unwrap();
4439        assert_eq!(
4440            resp["type"], "error_response",
4441            "replay must produce error_response, got: {resp}"
4442        );
4443        assert_eq!(
4444            resp["category"], "stale_approval",
4445            "replay must be classified as stale_approval, got: {}",
4446            resp["category"]
4447        );
4448        assert_eq!(resp["request_id"], "r3-replay");
4449    }
4450
4451    // ------------------------------------------------------------------
4452    // describe
4453    // ------------------------------------------------------------------
4454
4455    #[tokio::test]
4456    async fn describe_returns_command_and_risk_for_known_action() {
4457        let dir = tempdir().unwrap();
4458        let state = test_state(&dir);
4459
4460        let resps = exchange(
4461            state,
4462            CallerRole::Observer,
4463            vec![json!({
4464                "type": "describe",
4465                "request_id": "r1",
4466                "action_name": "GetDateTime",
4467                "params": {}
4468            })],
4469            1,
4470        )
4471        .await;
4472
4473        assert_eq!(resps[0]["type"], "describe_response");
4474        assert_eq!(resps[0]["request_id"], "r1");
4475        assert_eq!(resps[0]["command"], "timedatectl");
4476        assert_eq!(resps[0]["risk_level"], "low");
4477        assert_eq!(resps[0]["reboot_required"], false);
4478    }
4479
4480    #[tokio::test]
4481    async fn describe_returns_error_for_unknown_action() {
4482        let dir = tempdir().unwrap();
4483        let state = test_state(&dir);
4484
4485        let resps = exchange(
4486            state,
4487            CallerRole::Observer,
4488            vec![json!({
4489                "type": "describe",
4490                "request_id": "r1",
4491                "action_name": "NotARealAction",
4492                "params": {}
4493            })],
4494            1,
4495        )
4496        .await;
4497
4498        assert_eq!(resps[0]["type"], "error_response");
4499        assert_eq!(resps[0]["category"], "validation_failure");
4500    }
4501
4502    // ------------------------------------------------------------------
4503    // unknown message type
4504    // ------------------------------------------------------------------
4505
4506    #[tokio::test]
4507    async fn unknown_message_type_returns_validation_failure() {
4508        let dir = tempdir().unwrap();
4509        let state = test_state(&dir);
4510
4511        let resps = exchange(
4512            state,
4513            CallerRole::Observer,
4514            vec![json!({"type": "does_not_exist", "request_id": "r1"})],
4515            1,
4516        )
4517        .await;
4518
4519        assert_eq!(resps[0]["type"], "error_response");
4520        assert_eq!(resps[0]["category"], "validation_failure");
4521    }
4522
4523    // ------------------------------------------------------------------
4524    // Dispatcher progress relay
4525    // ------------------------------------------------------------------
4526
4527    #[tokio::test]
4528    async fn fast_executor_progress_is_drained_before_job_completed() {
4529        struct FastProgressExecutor;
4530
4531        #[async_trait::async_trait]
4532        impl ActionExecutor for FastProgressExecutor {
4533            async fn execute(
4534                &self,
4535                _spec: &crate::actions::ActionSpec,
4536            ) -> Result<crate::executor::ExecutionOutput, crate::executor::ExecutorError>
4537            {
4538                unreachable!("dispatcher uses execute_with_progress")
4539            }
4540
4541            async fn execute_with_progress(
4542                &self,
4543                _spec: &crate::actions::ActionSpec,
4544                progress: tokio::sync::mpsc::UnboundedSender<String>,
4545            ) -> Result<crate::executor::ExecutionOutput, crate::executor::ExecutorError>
4546            {
4547                progress.send("last line before exit".to_string()).unwrap();
4548                Ok(crate::executor::ExecutionOutput {
4549                    stdout: "last line before exit\n".to_string(),
4550                    stderr: String::new(),
4551                    exit_code: 0,
4552                })
4553            }
4554        }
4555
4556        let dir = tempdir().unwrap();
4557        let state = test_state(&dir);
4558        let (client, server) = tokio::net::UnixStream::pair().unwrap();
4559        tokio::spawn(async move {
4560            connection_handler_with_executor(
4561                server,
4562                state,
4563                runner(),
4564                Arc::new(FastProgressExecutor),
4565                CallerRole::Observer,
4566            )
4567            .await;
4568        });
4569        let mut framed = FramedStream::new(client);
4570        let (transaction_id, receipt) =
4571            preview_and_approve(&mut framed, "GetSystemState", json!({})).await;
4572        framed
4573            .send(
4574                &serde_json::to_vec(&json!({
4575                    "type": "execute",
4576                    "request_id": "execute-fast-progress",
4577                    "transaction_id": transaction_id,
4578                    "action_name": "GetSystemState",
4579                    "params": {},
4580                    "approval_receipt": receipt,
4581                }))
4582                .unwrap(),
4583            )
4584            .await
4585            .unwrap();
4586
4587        let mut progress_lines = Vec::new();
4588        loop {
4589            let response: Value = serde_json::from_slice(&framed.recv().await.unwrap()).unwrap();
4590            match response["type"].as_str().unwrap() {
4591                "job_progress" => {
4592                    progress_lines.push(response["line"].as_str().unwrap().to_string())
4593                }
4594                "job_started" => {}
4595                "job_completed" => break,
4596                other => panic!("unexpected response: {other}"),
4597            }
4598        }
4599        assert!(progress_lines
4600            .iter()
4601            .any(|line| line == "last line before exit"));
4602    }
4603
4604    // ------------------------------------------------------------------
4605    // vsock token auth gate
4606    // ------------------------------------------------------------------
4607
4608    #[cfg(target_os = "linux")]
4609    mod vsock_auth {
4610        use super::*;
4611        use std::sync::Arc;
4612
4613        fn executor() -> Arc<dyn ActionExecutor> {
4614            Arc::new(crate::executor::RealActionExecutor)
4615        }
4616
4617        /// The daemon refuses a token file other local users can read, so
4618        /// tests must provision one the way an operator is told to
4619        /// (`install -m 600`). `std::fs::write` alone lands at 0644 under the
4620        /// usual umask and would be rejected.
4621        fn write_token_mode(path: &std::path::Path, mode: u32) {
4622            use std::os::unix::fs::PermissionsExt;
4623            std::fs::set_permissions(path, std::fs::Permissions::from_mode(mode)).unwrap();
4624        }
4625
4626        /// A bearer token any local user can read is not a credential. The
4627        /// daemon must refuse it rather than grant `token_role()` to whoever
4628        /// presents it.
4629        #[tokio::test]
4630        async fn group_or_world_readable_token_file_is_rejected() {
4631            let dir = tempdir().unwrap();
4632            let token_path = dir.path().join("token");
4633            std::fs::write(&token_path, "test-secret").unwrap();
4634            write_token_mode(&token_path, 0o644);
4635
4636            assert!(
4637                crate::auth::validate_token_against_file("test-secret", &token_path).is_none(),
4638                "a 0644 token file must be rejected even when the token matches"
4639            );
4640
4641            // Same file, same token, tightened permissions — now accepted.
4642            write_token_mode(&token_path, 0o600);
4643            assert!(
4644                crate::auth::validate_token_against_file("test-secret", &token_path).is_some(),
4645                "a 0600 token file with a matching token must be accepted"
4646            );
4647        }
4648
4649        /// Send a valid auth frame then a `query_state` — connection must succeed.
4650        #[tokio::test]
4651        async fn valid_token_grants_access() {
4652            let dir = tempdir().unwrap();
4653            let token_path = dir.path().join("token");
4654            std::fs::write(&token_path, "test-secret").unwrap();
4655            write_token_mode(&token_path, 0o600);
4656
4657            let (client, server) = tokio::io::duplex(4 * 1024 * 1024);
4658            let state = test_state(&dir);
4659
4660            tokio::spawn(async move {
4661                let mut framed = FramedStream::new(server);
4662                let role = authenticate_vsock_token(&mut framed, &token_path).await;
4663                if let Some(r) = role {
4664                    dispatch_loop(&mut framed, state, runner(), executor(), r).await;
4665                }
4666            });
4667
4668            let mut framed = FramedStream::new(client);
4669            let auth = serde_json::json!({"type": "auth", "token": "test-secret"});
4670            framed
4671                .send(&serde_json::to_vec(&auth).unwrap())
4672                .await
4673                .unwrap();
4674            let req = serde_json::json!({"type": "query_state", "request_id": "vsock-r1"});
4675            framed
4676                .send(&serde_json::to_vec(&req).unwrap())
4677                .await
4678                .unwrap();
4679            let raw = framed.recv().await.unwrap();
4680            let resp: serde_json::Value = serde_json::from_slice(&raw).unwrap();
4681            assert_eq!(resp["type"], "state_response");
4682        }
4683
4684        /// Wrong token — connection must be closed immediately (EOF on client).
4685        #[tokio::test]
4686        async fn wrong_token_closes_connection() {
4687            let dir = tempdir().unwrap();
4688            let token_path = dir.path().join("token");
4689            std::fs::write(&token_path, "correct").unwrap();
4690            write_token_mode(&token_path, 0o600);
4691
4692            let (client, server) = tokio::io::duplex(4 * 1024 * 1024);
4693            let state = test_state(&dir);
4694
4695            tokio::spawn(async move {
4696                let mut framed = FramedStream::new(server);
4697                let _ = authenticate_vsock_token(&mut framed, &token_path).await;
4698                // None returned — handler closes connection (drops framed)
4699                drop(state);
4700            });
4701
4702            let mut framed = FramedStream::new(client);
4703            let auth = serde_json::json!({"type": "auth", "token": "wrong"});
4704            framed
4705                .send(&serde_json::to_vec(&auth).unwrap())
4706                .await
4707                .unwrap();
4708            // Server dropped the stream — recv must fail (EOF or reset)
4709            let result = framed.recv().await;
4710            assert!(
4711                result.is_err(),
4712                "expected EOF after wrong token, got {:?}",
4713                result
4714            );
4715        }
4716
4717        /// EOF before any auth frame — connection must be closed without panic.
4718        #[tokio::test]
4719        async fn eof_before_auth_frame_closes_connection() {
4720            let dir = tempdir().unwrap();
4721            let token_path = dir.path().join("token");
4722            std::fs::write(&token_path, "secret").unwrap();
4723            write_token_mode(&token_path, 0o600);
4724
4725            let (client, server) = tokio::io::duplex(4 * 1024 * 1024);
4726
4727            tokio::spawn(async move {
4728                let mut framed = FramedStream::new(server);
4729                let result = authenticate_vsock_token(&mut framed, &token_path).await;
4730                assert!(result.is_none());
4731            });
4732
4733            // Close write side immediately (sends EOF)
4734            drop(client);
4735        }
4736
4737        /// Malformed JSON as first frame — must return None without panic.
4738        #[tokio::test]
4739        async fn malformed_json_frame_closes_connection() {
4740            let dir = tempdir().unwrap();
4741            let token_path = dir.path().join("token");
4742            std::fs::write(&token_path, "secret").unwrap();
4743            write_token_mode(&token_path, 0o600);
4744
4745            let (client, server) = tokio::io::duplex(4 * 1024 * 1024);
4746
4747            let handle = tokio::spawn(async move {
4748                let mut framed = FramedStream::new(server);
4749                let result = authenticate_vsock_token(&mut framed, &token_path).await;
4750                assert!(result.is_none());
4751            });
4752
4753            let mut framed = FramedStream::new(client);
4754            framed.send(b"not json at all {{{{").await.unwrap();
4755            drop(framed);
4756            handle.await.unwrap();
4757        }
4758
4759        /// Wrong `type` field — must return None.
4760        #[tokio::test]
4761        async fn wrong_msg_type_closes_connection() {
4762            let dir = tempdir().unwrap();
4763            let token_path = dir.path().join("token");
4764            std::fs::write(&token_path, "secret").unwrap();
4765            write_token_mode(&token_path, 0o600);
4766
4767            let (client, server) = tokio::io::duplex(4 * 1024 * 1024);
4768
4769            let handle = tokio::spawn(async move {
4770                let mut framed = FramedStream::new(server);
4771                let result = authenticate_vsock_token(&mut framed, &token_path).await;
4772                assert!(result.is_none());
4773            });
4774
4775            let mut framed = FramedStream::new(client);
4776            let bad = serde_json::json!({"type": "query_state", "token": "secret"});
4777            framed
4778                .send(&serde_json::to_vec(&bad).unwrap())
4779                .await
4780                .unwrap();
4781            drop(framed);
4782            handle.await.unwrap();
4783        }
4784    }
4785
4786    // ------------------------------------------------------------------
4787    // stream-type agnosticism (vsock / duplex)
4788    // ------------------------------------------------------------------
4789
4790    #[tokio::test]
4791    async fn connection_handler_accepts_duplex_stream() {
4792        // Proves the handler works with any AsyncRead+AsyncWrite stream,
4793        // not only UnixStream. This is the key invariant enabling vsock support.
4794        let (client, server) = tokio::io::duplex(4 * 1024 * 1024);
4795        let dir = tempdir().unwrap();
4796        let state = test_state(&dir);
4797        tokio::spawn(unix_connection_handler(
4798            server,
4799            state,
4800            runner(),
4801            CallerRole::Observer,
4802        ));
4803
4804        let mut framed = FramedStream::new(client);
4805        let req = json!({"type": "query_state", "request_id": "duplex-r1"});
4806        framed
4807            .send(&serde_json::to_vec(&req).unwrap())
4808            .await
4809            .unwrap();
4810        let raw = framed.recv().await.unwrap();
4811        let resp: serde_json::Value = serde_json::from_slice(&raw).unwrap();
4812        assert_eq!(resp["type"], "state_response");
4813        assert_eq!(resp["request_id"], "duplex-r1");
4814    }
4815}