From d85296358bf60ac02becdaec513b5886237f7f0c Mon Sep 17 00:00:00 2001 From: hushen <190065939+918154429@users.noreply.github.com> Date: Mon, 7 Sep 2026 12:10:30 +0800 Subject: [PATCH 1/6] fix(core): separate Ubuntu action fences from planner defaults --- CHANGELOG.md | 11 ++ CLAUDE.md | 5 +- HACKING.md | 7 +- apps/sysknife-cli/src/distro_routing.rs | 81 ++++++--- apps/sysknife-cli/src/mcp_server.rs | 32 +++- apps/sysknife-cli/src/runner.rs | 9 + crates/sysknife-brain/src/planner.rs | 4 +- .../src/planning_tools/propose_plan.rs | 107 +++++++++-- crates/sysknife-brain/src/prompt.rs | 50 +++++ crates/sysknife-brain/tests/planner.rs | 8 + crates/sysknife-core/src/action_family.rs | 171 +++++++++++++++--- crates/sysknife-daemon/src/dispatcher.rs | 39 ++-- .../tests/action_consistency.rs | 55 ++++-- .../tests/action_reference_doc.rs | 14 +- .../tests/prompt_risk_labels.rs | 2 + crates/sysknife-types/src/lib.rs | 8 +- docs/action-compatibility.md | 32 ++++ docs/action-reference.md | 4 +- 18 files changed, 529 insertions(+), 110 deletions(-) create mode 100644 docs/action-compatibility.md diff --git a/CHANGELOG.md b/CHANGELOG.md index 1a8b8a47..441f811f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -12,6 +12,17 @@ Releases before `0.2.5` predate the public launch; their notes live in the ## [Unreleased] +### Changed + +- Separate Ubuntu identity requirements from Debian-family mechanisms and + planner defaults. Canonical services, PPAs and the reboot sentinel require + Ubuntu itself; portable tools are no longer refused merely for being another + distribution's default. Ubuntu and Fedora default catalogues and host + eligibility remain unchanged (#237). +- `DistroHint` now carries a distribution `id`, and `propose_plan_tool_def` + accepts the full hint rather than a family string. This is a public Rust API + change requiring a middle-digit release while the project is in `0.y`. + ## [0.13.1] — 2026-09-05 The last digit moves. No shipped code changed: `crates/**`, `apps/*/src/**` and diff --git a/CLAUDE.md b/CLAUDE.md index 436fbd02..4d159d7e 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -114,8 +114,9 @@ contain Fedora action names.** This is structural isolation — the model cannot propose `AptInstall` on a Fedora host or `AddLayeredPackage` on Ubuntu, even if it hallucinates. -When adding or renaming an action, update the `FEDORA_ONLY_ACTIONS` and -`DEBIAN_ONLY_ACTIONS` string-slice constants that back the safety-fence unit tests. +When adding or renaming an action, update the appropriate `FEDORA_ONLY_ACTIONS`, +`DEBIAN_ONLY_ACTIONS` or `UBUNTU_ONLY_ACTIONS` hard fence. Portable-tool defaults +belong in `NON_CANONICAL_ON_*`; see `docs/action-compatibility.md`. ### The six worked examples are not optional diff --git a/HACKING.md b/HACKING.md index 20238774..f8cf0c9d 100644 --- a/HACKING.md +++ b/HACKING.md @@ -802,8 +802,13 @@ The family lists are one source of truth in | Constant | Meaning | |---|---| -| `DEBIAN_ONLY_ACTIONS` | `Apt*`, `Snap*`, `Ufw*`, `Distrobox*`, `Netplan*`, … | +| `DEBIAN_ONLY_ACTIONS` | apt/dpkg and Debian's GRUB interface | +| `UBUNTU_ONLY_ACTIONS` | Canonical services, Ubuntu PPAs, release upgrades, reboot sentinel | | `FEDORA_ONLY_ACTIONS` | `RebaseSystem`, `AddLayeredPackage`, … | +| `NON_CANONICAL_ON_*` | Planner defaults for portable tools; never execution fences | + +See [action compatibility](docs/action-compatibility.md) for the distinction +between distribution identity, family, planner defaults and host eligibility. Three places consume them, so they cannot drift apart: diff --git a/apps/sysknife-cli/src/distro_routing.rs b/apps/sysknife-cli/src/distro_routing.rs index 6638965b..0e56d626 100644 --- a/apps/sysknife-cli/src/distro_routing.rs +++ b/apps/sysknife-cli/src/distro_routing.rs @@ -3,8 +3,9 @@ //! Some actions in the SysKnife catalogue are distro-specific — they are only //! meaningful on a particular package-manager family: //! -//! - `Apt*`, `Snap*`, `Ufw*`, `Distrobox*`, `Netplan*` require a Debian-family -//! distro (Ubuntu or Debian). +//! - apt/dpkg and Debian's GRUB interface require a Debian-family distro. +//! - Ubuntu services, PPAs and the reboot sentinel require Ubuntu itself. +//! - Installable tools such as snap, ufw and netplan are planner preferences. //! - rpm-ostree shaped actions (`RebaseSystem`, `AddLayeredPackage`, …) require //! a Fedora-family distro. //! @@ -22,7 +23,10 @@ use sysknife_core::distro::{DistroFamily, DistroId}; // The canonical family lists are the single source of truth in // `sysknife-core::action_family`; the daemon fence, this routing guard, and the // brain prompt all reference the same constants so they cannot drift apart. -use sysknife_core::action_family::{DEBIAN_ONLY_ACTIONS, FEDORA_ONLY_ACTIONS}; +use sysknife_core::action_family::{ + action_matches_distro, action_requires_distro, DEBIAN_ONLY_ACTIONS, FEDORA_ONLY_ACTIONS, + UBUNTU_ONLY_ACTIONS, +}; // --------------------------------------------------------------------------- // Public API @@ -45,9 +49,14 @@ pub fn check_action_distro(action_name: &str, distro: Option<&DistroId>) -> Resu }; let family = distro.family(); + if UBUNTU_ONLY_ACTIONS.contains(&action_name) && !action_matches_distro(action_name, distro) { + return Err(format!( + "{action_name} requires Ubuntu itself; current distro is {distro}" + )); + } if DEBIAN_ONLY_ACTIONS.contains(&action_name) && family != DistroFamily::Debian { return Err(format!( - "{action_name} is only valid on Debian-family distros (apt/snap/ufw); \ + "{action_name} is only valid on Debian-family distros (apt/dpkg/GRUB); \ current distro is {distro} ({family_name})", family_name = family_label(&family), )); @@ -66,9 +75,7 @@ pub fn check_action_distro(action_name: &str, distro: Option<&DistroId>) -> Resu // this mutate" — `AptUpdate` is Low/Observer and runs `sudo apt-get update`. // A client that refuses what the daemon would run is confusing; a client that // *permits* what the daemon refuses is worse, so both stay strict together. - if (DEBIAN_ONLY_ACTIONS.contains(&action_name) || FEDORA_ONLY_ACTIONS.contains(&action_name)) - && !distro.is_supported() - { + if action_requires_distro(action_name) && !distro.is_supported() { return Err(format!( "{action_name} is disabled on unsupported distro {distro}; \ see docs/distro-support.md" @@ -116,34 +123,31 @@ mod tests { } #[test] - fn snap_install_on_fedora_silverblue_returns_error() { + fn snap_install_on_fedora_silverblue_is_not_hard_fenced() { let distro = DistroId::FedoraSilverblue { version: 41 }; let result = check_action_distro("SnapInstall", Some(&distro)); - assert!(result.is_err()); - let msg = result.unwrap_err(); - assert!(msg.contains("Debian-family"), "got: {msg}"); - assert!(msg.contains("FedoraSilverblue 41"), "got: {msg}"); + assert!(result.is_ok()); } #[test] - fn ufw_allow_on_fedora_returns_error() { - let distro = DistroId::Fedora { version: 41 }; + fn ufw_allow_on_fedora_is_not_hard_fenced() { + let distro = DistroId::FedoraSilverblue { version: 41 }; let result = check_action_distro("UfwAllow", Some(&distro)); - assert!(result.is_err()); + assert!(result.is_ok()); } #[test] - fn netplan_apply_on_fedora_returns_error() { - let distro = DistroId::Fedora { version: 41 }; + fn netplan_apply_on_fedora_is_not_hard_fenced() { + let distro = DistroId::FedoraSilverblue { version: 41 }; let result = check_action_distro("NetplanApply", Some(&distro)); - assert!(result.is_err()); + assert!(result.is_ok()); } #[test] - fn distrobox_create_on_fedora_returns_error() { - let distro = DistroId::Fedora { version: 41 }; + fn distrobox_create_on_fedora_is_not_hard_fenced() { + let distro = DistroId::FedoraSilverblue { version: 41 }; let result = check_action_distro("DistroboxCreate", Some(&distro)); - assert!(result.is_err()); + assert!(result.is_ok()); } // ----------------------------------------------------------------------- @@ -277,8 +281,8 @@ mod tests { let fedora = DistroId::FedoraSilverblue { version: 41 }; assert!( - check_action_distro("GetHostState", Some(&fedora)).is_err(), - "the fence runs both ways: Fedora has GetSystemState" + check_action_distro("GetHostState", Some(&fedora)).is_ok(), + "hostnamectl is portable even when Fedora prefers deployment state" ); } @@ -313,6 +317,37 @@ mod tests { } } + #[test] + fn ubuntu_only_actions_require_identity_and_eligibility() { + let ubuntu = DistroId::Ubuntu { + major: 24, + minor: 4, + }; + for action in UBUNTU_ONLY_ACTIONS { + assert!( + check_action_distro(action, Some(&ubuntu)).is_ok(), + "{action}" + ); + for distro in [ + DistroId::Debian { version: Some(13) }, + DistroId::FedoraSilverblue { version: 41 }, + DistroId::UbuntuCore { + major: 24, + minor: 4, + }, + DistroId::Ubuntu { + major: 18, + minor: 4, + }, + ] { + assert!( + check_action_distro(action, Some(&distro)).is_err(), + "{action} on {distro}" + ); + } + } + } + #[test] fn all_fedora_only_actions_rejected_on_ubuntu() { let distro = DistroId::Ubuntu { diff --git a/apps/sysknife-cli/src/mcp_server.rs b/apps/sysknife-cli/src/mcp_server.rs index 1287edd5..c139939e 100644 --- a/apps/sysknife-cli/src/mcp_server.rs +++ b/apps/sysknife-cli/src/mcp_server.rs @@ -61,7 +61,7 @@ use sysknife_brain::config::BrainConfig; use sysknife_brain::planner::LlmPlanner; use sysknife_brain::planning_tools::propose_plan::KNOWN_ACTIONS; use sysknife_brain::state_client::StateClient as _; -use sysknife_core::action_family::{DEBIAN_ONLY_ACTIONS, FEDORA_ONLY_ACTIONS}; +use sysknife_core::action_family::action_requires_distro; use sysknife_core::distro::DistroId; use sysknife_daemon::actions::OBSERVER_MUTATING_ACTIONS; @@ -506,10 +506,7 @@ fn action_is_available_on_distro(action_name: &str, distro: Option<&DistroId>) - Some(distro) => { crate::distro_routing::check_action_distro(action_name, Some(distro)).is_ok() } - None => { - !DEBIAN_ONLY_ACTIONS.contains(&action_name) - && !FEDORA_ONLY_ACTIONS.contains(&action_name) - } + None => !action_requires_distro(action_name), } } @@ -1896,11 +1893,34 @@ mod tests { assert!(!unknown_names.contains("sysknife_apt_search")); assert!(!unknown_names.contains("sysknife_get_system_state")); + let debian = DistroId::Debian { version: Some(13) }; + let debian_names: std::collections::HashSet = + direct_read_only_tool_router(Some(&debian)) + .list_all() + .into_iter() + .map(|tool| tool.name.to_string()) + .collect(); + for action in sysknife_core::action_family::UBUNTU_ONLY_ACTIONS { + let name = direct_action_tool_name(action); + for routed in [&fedora_names, &debian_names, &unknown_names] { + assert!( + !routed.contains(&name), + "Ubuntu-only direct tool leaked: {name}" + ); + } + if MCP_READ_ONLY_ACTIONS.contains(action) { + assert!( + ubuntu_names.contains(&name), + "Ubuntu direct tool lost: {name}" + ); + } + } + let read_only_names: std::collections::HashSet = MCP_READ_ONLY_ACTIONS .iter() .map(|action| direct_action_tool_name(action)) .collect(); - for routed_names in [&ubuntu_names, &fedora_names, &unknown_names] { + for routed_names in [&ubuntu_names, &fedora_names, &debian_names, &unknown_names] { let unexpected: Vec<_> = routed_names.difference(&read_only_names).collect(); assert!( unexpected.is_empty(), diff --git a/apps/sysknife-cli/src/runner.rs b/apps/sysknife-cli/src/runner.rs index 40cce555..e9d5b7c6 100644 --- a/apps/sysknife-cli/src/runner.rs +++ b/apps/sysknife-cli/src/runner.rs @@ -53,6 +53,15 @@ pub fn distro_id_to_hint(distro: &sysknife_core::distro::DistroId) -> DistroHint DistroFamily::Other => DISTRO_FAMILY_OTHER, }; DistroHint { + id: match distro { + sysknife_core::distro::DistroId::Ubuntu { .. } => "ubuntu", + sysknife_core::distro::DistroId::UbuntuCore { .. } => "ubuntu-core", + sysknife_core::distro::DistroId::Debian { .. } => "debian", + sysknife_core::distro::DistroId::Fedora { .. } + | sysknife_core::distro::DistroId::FedoraSilverblue { .. } => "fedora", + sysknife_core::distro::DistroId::Other { id, .. } => id, + } + .to_string(), family, version: Some(distro.to_string()), } diff --git a/crates/sysknife-brain/src/planner.rs b/crates/sysknife-brain/src/planner.rs index 65a96e9d..03dea38d 100644 --- a/crates/sysknife-brain/src/planner.rs +++ b/crates/sysknife-brain/src/planner.rs @@ -684,9 +684,7 @@ impl LlmPlanner { t.extend(query_tools()); t.push(crate::planning_tools::preferences::remember_tool_def()); t.push(crate::planning_tools::preferences::forget_tool_def()); - t.push(propose_plan_tool_def( - self.distro_hint.as_ref().map(|h| h.family), - )); + t.push(propose_plan_tool_def(self.distro_hint.as_ref())); t.push(crate::planning_tools::refuse::refuse_tool_def()); t } diff --git a/crates/sysknife-brain/src/planning_tools/propose_plan.rs b/crates/sysknife-brain/src/planning_tools/propose_plan.rs index ad5f4130..263667ec 100644 --- a/crates/sysknife-brain/src/planning_tools/propose_plan.rs +++ b/crates/sysknife-brain/src/planning_tools/propose_plan.rs @@ -9,6 +9,7 @@ use crate::planner::{Plan, PlanRiskLevel, PlanStep, PlanningError}; use crate::provider::ToolDefinition; use sysknife_core::action_family::{ DEBIAN_ONLY_ACTIONS, FEDORA_ONLY_ACTIONS, NON_CANONICAL_ON_DEBIAN, + NON_CANONICAL_ON_DEBIAN_HOST, NON_CANONICAL_ON_FEDORA, UBUNTU_ONLY_ACTIONS, }; use sysknife_types::{DISTRO_FAMILY_DEBIAN, DISTRO_FAMILY_FEDORA, DISTRO_FAMILY_OTHER}; @@ -377,7 +378,7 @@ reports live interface state"), "modify GRUB kernel arguments and run update-grub — params: append (list), delete (list), bare tokens only (no '='); both lists are screened for boot-security downgrades; Ubuntu only; High risk; requires reboot"), // ── Ubuntu / reboot ─────────────────────────────────────────────────────── ("CheckPendingReboot", - "check whether a reboot is pending (/var/run/reboot-required) — no params; Ubuntu/Debian only; read-only"), + "check whether a reboot is pending (/var/run/reboot-required) — no params; Ubuntu only; read-only"), // ── Cross-distro / resolvectl (systemd-resolved) ────────────────────────── ("ResolvectlStatus", "show DNS resolution status for all network interfaces (resolvectl status) — no params; cross-distro (any systemd-resolved host); read-only"), @@ -457,14 +458,14 @@ reports live interface state"), "list Multipass VMs and their state — no params; Ubuntu only; read-only"), ]; -/// Should `action` be offered to a planner running on `family`? +/// Should `action` be offered on the detected distribution? /// /// Two different reasons to withhold one, and they must stay distinct — merging /// them turned a planning fix into an execution-fence regression: /// /// * the family fence in [`sysknife_core::action_family`], which says the action /// *cannot* run there; and -/// * [`NON_CANONICAL_ON_DEBIAN`], which says it can, but the family has its own +/// * the `NON_CANONICAL_ON_*` lists, which say it can, but the family has its own /// canonical tool and the planner should reach for that instead. /// /// A detected `other` family (Arch, openSUSE, anything unrecognised) is filtered @@ -474,21 +475,39 @@ reports live interface state"), /// /// No hint at all still offers everything — without a detected family there is no /// basis to exclude anything, and a generic deployment has to be able to plan. -fn available_on(action: &str, family: Option<&str>) -> bool { +fn available_on(action: &str, hint: Option<&sysknife_types::DistroHint>) -> bool { + let family = hint.map(|hint| hint.family); + if let Some(hint) = hint { + if hint.id != "ubuntu" && UBUNTU_ONLY_ACTIONS.contains(&action) { + return false; + } + if hint.family == DISTRO_FAMILY_DEBIAN + && hint.id != "ubuntu" + && NON_CANONICAL_ON_DEBIAN_HOST.contains(&action) + { + return false; + } + } match family { Some(DISTRO_FAMILY_DEBIAN) => { !FEDORA_ONLY_ACTIONS.contains(&action) && !NON_CANONICAL_ON_DEBIAN.contains(&action) } - Some(DISTRO_FAMILY_FEDORA) => !DEBIAN_ONLY_ACTIONS.contains(&action), + Some(DISTRO_FAMILY_FEDORA) => { + !DEBIAN_ONLY_ACTIONS.contains(&action) + && !UBUNTU_ONLY_ACTIONS.contains(&action) + && !NON_CANONICAL_ON_FEDORA.contains(&action) + } Some(DISTRO_FAMILY_OTHER) => { - !FEDORA_ONLY_ACTIONS.contains(&action) && !DEBIAN_ONLY_ACTIONS.contains(&action) + !FEDORA_ONLY_ACTIONS.contains(&action) + && !DEBIAN_ONLY_ACTIONS.contains(&action) + && !UBUNTU_ONLY_ACTIONS.contains(&action) } _ => true, } } -/// Build the `propose_plan` tool definition, offering only the actions that the -/// detected distro family can actually run. +/// Build the `propose_plan` tool definition from both family and distribution +/// identity, including the planner preferences appropriate to that host. /// /// The filter is not a nicety. `prompt.rs` renders per-distro prose and a test /// asserts the Debian prompt names no Fedora action, but this schema used to @@ -496,12 +515,12 @@ fn available_on(action: &str, family: Option<&str>) -> bool { /// `firewall-cmd` or `toolbox` action on Ubuntu having never seen it in the /// prompt, and did, in two live VM stories. Prose cannot fix that; only not /// offering the action can. -pub fn propose_plan_tool_def(family: Option<&str>) -> ToolDefinition { +pub fn propose_plan_tool_def(hint: Option<&sysknife_types::DistroHint>) -> ToolDefinition { // One filtered pass feeds both the enum and the catalogue, so the model can // never be told about an action the enum would reject. let offered: Vec<&(&str, &str)> = KNOWN_ACTIONS .iter() - .filter(|(name, _)| available_on(name, family)) + .filter(|(name, _)| available_on(name, hint)) .collect(); let action_enum: Vec = offered @@ -703,6 +722,62 @@ pub fn parse_proposed_plan(intent: &str, input: &serde_json::Value) -> Result) -> ToolDefinition { + let hint = family.map(|family| sysknife_types::DistroHint { + id: if family == DISTRO_FAMILY_DEBIAN { + "ubuntu" + } else { + family + } + .into(), + family, + version: None, + }); + propose_plan_tool_def(hint.as_ref()) + } + + #[test] + fn debian_host_does_not_inherit_ubuntu_tools_or_preferences() { + let hint = sysknife_types::DistroHint { + id: "debian".into(), + family: DISTRO_FAMILY_DEBIAN, + // Deliberately misleading display text: it must not grant Ubuntu identity. + version: Some("Ubuntu 24.04".into()), + }; + let def = propose_plan_tool_def(Some(&hint)); + let offered = offered_actions(&def); + for action in UBUNTU_ONLY_ACTIONS + .iter() + .chain(NON_CANONICAL_ON_DEBIAN_HOST) + { + assert!( + !offered.contains(&action.to_string()), + "Debian must not offer {action}" + ); + } + for action in DEBIAN_ONLY_ACTIONS { + assert!( + offered.contains(&action.to_string()), + "Debian-family capability lost: {action}" + ); + } + let ubuntu = sysknife_types::DistroHint { + id: "ubuntu".into(), + ..hint + }; + let ubuntu_def = propose_plan_tool_def(Some(&ubuntu)); + let offered = offered_actions(&ubuntu_def); + for action in UBUNTU_ONLY_ACTIONS + .iter() + .chain(NON_CANONICAL_ON_DEBIAN_HOST) + { + assert!( + offered.contains(&action.to_string()), + "Ubuntu capability lost: {action}" + ); + } + } + fn valid_input(risk: &str) -> serde_json::Value { serde_json::json!({ "summary": "do the thing", @@ -973,7 +1048,7 @@ mod tests { #[test] fn debian_tool_def_omits_fedora_only_actions() { - let def = propose_plan_tool_def(Some(DISTRO_FAMILY_DEBIAN)); + let def = tool_def_for_family(Some(DISTRO_FAMILY_DEBIAN)); let offered = offered_actions(&def); let catalogue = def.input_schema["properties"]["steps"]["items"]["properties"] ["action_name"]["description"] @@ -1006,7 +1081,7 @@ mod tests { #[test] fn fedora_tool_def_omits_debian_only_actions() { - let def = propose_plan_tool_def(Some(DISTRO_FAMILY_FEDORA)); + let def = tool_def_for_family(Some(DISTRO_FAMILY_FEDORA)); let offered = offered_actions(&def); for name in DEBIAN_ONLY_ACTIONS { assert!( @@ -1028,7 +1103,7 @@ mod tests { // daemon must still be able to run them there — an Ubuntu host that // installed firewalld has not lost firewall management, and `UfwStatus` // reporting "inactive" on such a host would be a confident wrong answer. - let offered = offered_actions(&propose_plan_tool_def(Some(DISTRO_FAMILY_DEBIAN))); + let offered = offered_actions(&tool_def_for_family(Some(DISTRO_FAMILY_DEBIAN))); for name in NON_CANONICAL_ON_DEBIAN { assert!( !offered.contains(&name.to_string()), @@ -1041,7 +1116,7 @@ mod tests { ); } // Still offered where they are canonical. - let fedora = offered_actions(&propose_plan_tool_def(Some(DISTRO_FAMILY_FEDORA))); + let fedora = offered_actions(&tool_def_for_family(Some(DISTRO_FAMILY_FEDORA))); for name in NON_CANONICAL_ON_DEBIAN { assert!( fedora.contains(&name.to_string()), @@ -1055,7 +1130,7 @@ mod tests { // The CLI routing guard refuses every family-specific action on a host // that is neither Debian nor Fedora, so offering them here would spend a // paid call on a plan certain to be rejected. - let offered = offered_actions(&propose_plan_tool_def(Some(DISTRO_FAMILY_OTHER))); + let offered = offered_actions(&tool_def_for_family(Some(DISTRO_FAMILY_OTHER))); for name in FEDORA_ONLY_ACTIONS.iter().chain(DEBIAN_ONLY_ACTIONS.iter()) { assert!( !offered.contains(&name.to_string()), @@ -1082,7 +1157,7 @@ mod tests { // they are ever built from two separate passes, this catches the drift // where the model is told about an action it is not allowed to name. for family in [Some(DISTRO_FAMILY_DEBIAN), Some(DISTRO_FAMILY_FEDORA), None] { - let def = propose_plan_tool_def(family); + let def = tool_def_for_family(family); let catalogue = def.input_schema["properties"]["steps"]["items"]["properties"] ["action_name"]["description"] .as_str() diff --git a/crates/sysknife-brain/src/prompt.rs b/crates/sysknife-brain/src/prompt.rs index 6a1c49d7..f5fdad9e 100644 --- a/crates/sysknife-brain/src/prompt.rs +++ b/crates/sysknife-brain/src/prompt.rs @@ -1066,6 +1066,28 @@ fn render_fedora_prompt(prefs: Option<&str>, hint: &sysknife_types::DistroHint) } fn render_debian_prompt(prefs: Option<&str>, hint: &sysknife_types::DistroHint) -> String { + if hint.id != "ubuntu" { + // Debian-family membership must not import Ubuntu's Pro/PPA/snap + // examples. The schema supplies the exact family/identity-filtered + // catalogue; eligibility is still enforced by the CLI and daemon. + let mut s = String::new(); + s.push_str(PREAMBLE); + s.push_str(SPOTLIGHTING_CLAUSE); + push_shared(&mut s, EXAMPLES, &DEBIAN_STATE_ACTION); + push_shared(&mut s, CROSS_DISTRO_RISK_TABLES, &DEBIAN_STATE_ACTION); + s.push_str(CROSS_DISTRO_RISK_RULES); + s.push_str( + "\n## Detected distro\nDebian-family host; Ubuntu identity has not been established. \ + Use the offered action catalogue, with AptInstall/AptRemove/AptSearch for packages. \ + Family compatibility does not establish host eligibility.\n", + ); + push_shared(&mut s, CROSS_DISTRO_DISAMBIGUATION, &DEBIAN_STATE_ACTION); + push_shared(&mut s, CROSS_DISTRO_PARAMS, &DEBIAN_STATE_ACTION); + s.push_str(CONSTRAINTS); + push_shared(&mut s, PREFERENCE_TOOLS, &DEBIAN_STATE_ACTION); + append_prefs(&mut s, prefs); + return s; + } let version = hint.version.as_deref().unwrap_or("(version unknown)"); // Sized to the rendered prompt (measured ~41 KB) — see the Fedora renderer // above for why. @@ -1156,6 +1178,7 @@ mod tests { fn fedora_hint() -> DistroHint { DistroHint { + id: "fedora".into(), family: DISTRO_FAMILY_FEDORA, version: Some("Fedora 41 (Silverblue)".to_string()), } @@ -1163,6 +1186,7 @@ mod tests { fn debian_hint() -> DistroHint { DistroHint { + id: "ubuntu".into(), family: DISTRO_FAMILY_DEBIAN, version: Some("Ubuntu 24.04".to_string()), } @@ -1547,6 +1571,14 @@ mod tests { "Debian-only", sysknife_core::action_family::DEBIAN_ONLY_ACTIONS, ), + ( + "Ubuntu-only", + sysknife_core::action_family::UBUNTU_ONLY_ACTIONS, + ), + ( + "non-canonical-on-Debian-host", + sysknife_core::action_family::NON_CANONICAL_ON_DEBIAN_HOST, + ), ( "non-canonical-on-Debian", sysknife_core::action_family::NON_CANONICAL_ON_DEBIAN, @@ -1562,4 +1594,22 @@ mod tests { } } } + + #[test] + fn debian_prompt_does_not_inherit_ubuntu_examples() { + let mut hint = debian_hint(); + hint.id = "debian".into(); + let prompt = build_system_prompt(None, Some(&hint)); + for action in sysknife_core::action_family::UBUNTU_ONLY_ACTIONS + .iter() + .chain(sysknife_core::action_family::NON_CANONICAL_ON_DEBIAN_HOST) + { + assert!( + !prompt.contains(action), + "Debian prompt advertises {action}" + ); + } + assert!(prompt.contains("AptInstall")); + assert!(prompt.contains("GetHostState")); + } } diff --git a/crates/sysknife-brain/tests/planner.rs b/crates/sysknife-brain/tests/planner.rs index 284e7301..488de565 100644 --- a/crates/sysknife-brain/tests/planner.rs +++ b/crates/sysknife-brain/tests/planner.rs @@ -1380,6 +1380,7 @@ async fn run_and_capture_system( #[tokio::test] async fn prompt_with_fedora_hint_contains_fedora_actions_and_excludes_apt() { let hint = DistroHint { + id: "fedora".into(), family: DISTRO_FAMILY_FEDORA, version: Some("Fedora 41".to_string()), }; @@ -1436,6 +1437,7 @@ async fn prompt_with_fedora_hint_contains_fedora_actions_and_excludes_apt() { #[tokio::test] async fn prompt_with_ubuntu_hint_contains_apt_and_excludes_rpm_ostree() { let hint = DistroHint { + id: "ubuntu".into(), family: DISTRO_FAMILY_DEBIAN, version: Some("Ubuntu 24.04".to_string()), }; @@ -1555,6 +1557,7 @@ fn story_coverage_cases() -> &'static [StoryCoverage] { #[tokio::test] async fn story_coverage_fedora_prompt_contains_fedora_actions_and_excludes_ubuntu() { let fedora_hint = DistroHint { + id: "fedora".into(), family: DISTRO_FAMILY_FEDORA, version: Some("Fedora 41".to_string()), }; @@ -1586,6 +1589,7 @@ async fn story_coverage_fedora_prompt_contains_fedora_actions_and_excludes_ubunt #[tokio::test] async fn story_coverage_ubuntu_prompt_contains_ubuntu_actions_and_excludes_fedora() { let ubuntu_hint = DistroHint { + id: "ubuntu".into(), family: DISTRO_FAMILY_DEBIAN, version: Some("Ubuntu 24.04".to_string()), }; @@ -1618,6 +1622,7 @@ async fn story_coverage_ubuntu_prompt_contains_ubuntu_actions_and_excludes_fedor async fn story_coverage_second_fedora_case_install_packages() { // "install a system package" — Fedora uses AddLayeredPackage / InstallPackages, not AptInstall let fedora_hint = DistroHint { + id: "fedora".into(), family: DISTRO_FAMILY_FEDORA, version: Some("FedoraSilverblue 41".to_string()), }; @@ -1644,6 +1649,7 @@ async fn story_coverage_second_fedora_case_install_packages() { async fn story_coverage_third_fedora_case_rollback() { // "rollback system" — Fedora uses RollbackDeployment, not an apt command let fedora_hint = DistroHint { + id: "fedora".into(), family: DISTRO_FAMILY_FEDORA, version: Some("Fedora 42".to_string()), }; @@ -1670,6 +1676,7 @@ async fn story_coverage_third_fedora_case_rollback() { async fn story_coverage_second_ubuntu_case_snap_install() { // "install via snap" — Ubuntu uses SnapInstall, not AddLayeredPackage let ubuntu_hint = DistroHint { + id: "ubuntu".into(), family: DISTRO_FAMILY_DEBIAN, version: Some("Ubuntu 22.04".to_string()), }; @@ -1696,6 +1703,7 @@ async fn story_coverage_second_ubuntu_case_snap_install() { async fn story_coverage_third_ubuntu_case_apt_search() { // "search for a package" — Ubuntu uses AptSearch, not SearchFlatpakApps alone let ubuntu_hint = DistroHint { + id: "ubuntu".into(), family: DISTRO_FAMILY_DEBIAN, version: Some("Ubuntu 26.04".to_string()), }; diff --git a/crates/sysknife-core/src/action_family.rs b/crates/sysknife-core/src/action_family.rs index dffd4ed2..5da5b1d2 100644 --- a/crates/sysknife-core/src/action_family.rs +++ b/crates/sysknife-core/src/action_family.rs @@ -107,12 +107,10 @@ pub const NON_CANONICAL_ON_DEBIAN: &[&str] = &[ /// Debian-family action names that are NOT available on Fedora-family distros. /// -/// Grouped by underlying tool: apt, snap, ufw, distrobox, netplan, grub, plus -/// the Ubuntu-only tiers (AppArmor, cloud-init, flatpak, fail2ban, Pro, …). +/// These drive apt/dpkg or Debian's GRUB configuration/update interface. +/// Ubuntu-specific services live in [`UBUNTU_ONLY_ACTIONS`]; installable tools +/// are planner preferences, not execution fences. pub const DEBIAN_ONLY_ACTIONS: &[&str] = &[ - // The Debian answer to "what is this host?" — counterpart to Fedora's - // `GetSystemState`, which describes deployments an apt host has none of. - "GetHostState", "AptUpdate", "AptUpgrade", "AptInstall", @@ -130,8 +128,56 @@ pub const DEBIAN_ONLY_ACTIONS: &[&str] = &[ "GetAptPins", "SetAptPin", "RemoveAptPin", + "GrubGetKargs", + "GrubSetKargs", +]; + +/// Ubuntu-specific services and repository formats. A Debian-family hint alone +/// is insufficient: PPAs serve packages built for an Ubuntu series, even when +/// add-apt-repository itself is installed on Debian. +/// +/// CheckPendingReboot relies on Ubuntu's update-notifier sentinel. Until a +/// Debian producer is validated, do not interpret a missing sentinel there as +/// evidence that no reboot is needed. Debian eligibility is unchanged. +pub const UBUNTU_ONLY_ACTIONS: &[&str] = &[ "AddPpa", "RemovePpa", + "CheckPendingReboot", + "UbuntuReleaseUpgrade", + "ProStatus", + "ProAttach", + "ProDetach", + "EnableProService", + "DisableProService", + "LivepatchStatus", +]; + +/// Installable on Debian itself, but not its default administrative tools. +/// Unlike [`NON_CANONICAL_ON_DEBIAN`], this applies only to non-Ubuntu members +/// of the Debian family. It must never be consumed by an execution fence. +pub const NON_CANONICAL_ON_DEBIAN_HOST: &[&str] = &[ + "SnapInstall", + "SnapRemove", + "SnapRefresh", + "SnapHold", + "SnapUnhold", + "SnapList", + "SnapInfo", + "SnapRevert", + "SnapClassicInstall", + "NetplanGetConfig", + "NetplanApply", + "NetplanSet", + "NetplanGenerate", + "MultipassList", +]; + +/// Ubuntu's default catalogue contains these portable tools. Keep the Fedora +/// planner on its existing defaults while allowing operators to execute tools +/// they installed. Mechanism-derived tests prevent preference from creeping +/// back into either hard family fence. +pub const NON_CANONICAL_ON_FEDORA: &[&str] = &[ + "GetHostState", "SnapInstall", "SnapRemove", "SnapRefresh", @@ -154,10 +200,6 @@ pub const DEBIAN_ONLY_ACTIONS: &[&str] = &[ "NetplanApply", "NetplanSet", "NetplanGenerate", - "GrubGetKargs", - "GrubSetKargs", - "CheckPendingReboot", - // Tier 2 — Ubuntu-only "AppArmorStatus", "AppArmorEnforce", "AppArmorComplain", @@ -170,19 +212,37 @@ pub const DEBIAN_ONLY_ACTIONS: &[&str] = &[ "Fail2banBanIp", "Fail2banUnbanIp", "ConfigureFail2banJail", - // Tier 3 - "UbuntuReleaseUpgrade", - "ProStatus", - "ProAttach", - "ProDetach", - "EnableProService", - "DisableProService", - "LivepatchStatus", "MultipassList", "UfwDeleteRule", "UfwLimit", ]; +/// Whether the action requires a detected distro before even a read can run. +pub fn action_requires_distro(action: &str) -> bool { + [ + FEDORA_ONLY_ACTIONS, + DEBIAN_ONLY_ACTIONS, + UBUNTU_ONLY_ACTIONS, + ] + .iter() + .any(|list| list.contains(&action)) +} + +/// Mechanism compatibility only; callers must separately check host eligibility. +/// Unknown action names remain the catalogue validator's responsibility. +pub fn action_matches_distro(action: &str, distro: &crate::distro::DistroId) -> bool { + use crate::distro::{DistroFamily, DistroId}; + if UBUNTU_ONLY_ACTIONS.contains(&action) { + matches!(distro, DistroId::Ubuntu { .. }) + } else if DEBIAN_ONLY_ACTIONS.contains(&action) { + distro.family() == DistroFamily::Debian + } else if FEDORA_ONLY_ACTIONS.contains(&action) { + distro.family() == DistroFamily::Fedora + } else { + true + } +} + #[cfg(test)] mod tests { use super::*; @@ -191,18 +251,34 @@ mod tests { /// and Debian-only would make the family fence contradict itself. #[test] fn family_lists_are_disjoint() { - for action in FEDORA_ONLY_ACTIONS { - assert!( - !DEBIAN_ONLY_ACTIONS.contains(action), - "{action} is listed as both Fedora-only and Debian-only" - ); + let lists = [ + FEDORA_ONLY_ACTIONS, + DEBIAN_ONLY_ACTIONS, + UBUNTU_ONLY_ACTIONS, + ]; + for (index, list) in lists.iter().enumerate() { + for action in *list { + for other in &lists[index + 1..] { + assert!( + !other.contains(action), + "{action} has conflicting hard fences" + ); + } + } } } /// No accidental duplicate entries within a single list. #[test] fn family_lists_have_no_duplicates() { - for list in [FEDORA_ONLY_ACTIONS, DEBIAN_ONLY_ACTIONS] { + for list in [ + FEDORA_ONLY_ACTIONS, + DEBIAN_ONLY_ACTIONS, + UBUNTU_ONLY_ACTIONS, + NON_CANONICAL_ON_DEBIAN, + NON_CANONICAL_ON_DEBIAN_HOST, + NON_CANONICAL_ON_FEDORA, + ] { let mut sorted = list.to_vec(); sorted.sort_unstable(); let unique = sorted.len(); @@ -210,4 +286,53 @@ mod tests { assert_eq!(unique, sorted.len(), "duplicate action in family list"); } } + + #[test] + fn planner_preferences_are_not_execution_fences() { + for action in NON_CANONICAL_ON_DEBIAN + .iter() + .chain(NON_CANONICAL_ON_DEBIAN_HOST) + .chain(NON_CANONICAL_ON_FEDORA) + { + for fence in [ + FEDORA_ONLY_ACTIONS, + DEBIAN_ONLY_ACTIONS, + UBUNTU_ONLY_ACTIONS, + ] { + assert!( + !fence.contains(action), + "{action} is both portable and hard-fenced" + ); + } + } + } + + #[test] + fn ubuntu_identity_is_stricter_than_debian_family() { + use crate::distro::DistroId; + let ubuntu = DistroId::Ubuntu { + major: 24, + minor: 4, + }; + let debian = DistroId::Debian { version: Some(13) }; + let derivative = DistroId::Other { + id: "linuxmint".into(), + version_id: None, + id_like: vec!["ubuntu".into(), "debian".into()], + }; + for action in UBUNTU_ONLY_ACTIONS { + assert!(action_matches_distro(action, &ubuntu), "{action}"); + assert!(!action_matches_distro(action, &debian), "{action}"); + assert!(!action_matches_distro(action, &derivative), "{action}"); + assert!(action_requires_distro(action), "{action}"); + } + for action in DEBIAN_ONLY_ACTIONS { + assert!(action_matches_distro(action, &ubuntu), "{action}"); + assert!(action_matches_distro(action, &debian), "{action}"); + } + assert!( + !debian.is_supported(), + "classification must not enable Debian" + ); + } } diff --git a/crates/sysknife-daemon/src/dispatcher.rs b/crates/sysknife-daemon/src/dispatcher.rs index 1f52b002..1821c5b7 100644 --- a/crates/sysknife-daemon/src/dispatcher.rs +++ b/crates/sysknife-daemon/src/dispatcher.rs @@ -896,18 +896,9 @@ async fn authorize_for_transaction( // Family-specific action lists come from the single source of truth in // `sysknife-core::action_family`, shared with the CLI routing guard and the // brain prompt so the execution fence can never drift out of parity. -use sysknife_core::action_family::{DEBIAN_ONLY_ACTIONS, FEDORA_ONLY_ACTIONS}; +use sysknife_core::action_family::{action_matches_distro, action_requires_distro}; fn validate_action_platform(state: &DaemonState, action_name: &str) -> Result<(), String> { - use sysknife_core::distro::DistroFamily; - - let required_family = if DEBIAN_ONLY_ACTIONS.contains(&action_name) { - Some(DistroFamily::Debian) - } else if FEDORA_ONLY_ACTIONS.contains(&action_name) { - Some(DistroFamily::Fedora) - } else { - None - }; // Deliberately the compile-time baseline, not `state.policy`. Whether an // action mutates the system is a property of the action; whether a given // caller may run it is what `[policy.risk_overrides]` decides. Reading it @@ -917,7 +908,7 @@ fn validate_action_platform(state: &DaemonState, action_name: &str) -> Result<() // access-control change. let is_mutating = crate::policy::min_role_for_action(action_name) .is_some_and(|role| role > CallerRole::Observer); - if required_family.is_none() && !is_mutating { + if !action_requires_distro(action_name) && !is_mutating { return Ok(()); } @@ -946,7 +937,7 @@ fn validate_action_platform(state: &DaemonState, action_name: &str) -> Result<() "cannot run {action_name} on unsupported host {distro}; see docs/distro-support.md" )); } - if required_family.is_some_and(|family| distro.family() != family) { + if !action_matches_distro(action_name, distro) { return Err(format!( "action {action_name} is incompatible with supported host {distro}" )); @@ -5047,6 +5038,30 @@ mod tests { }); assert!(validate_action_platform(&state, "AptInstall").is_ok()); assert!(validate_action_platform(&state, "AddLayeredPackage").is_err()); + + for action in sysknife_core::action_family::UBUNTU_ONLY_ACTIONS { + assert!(validate_action_platform(&state, action).is_ok(), "{action}"); + } + state.host_distro = Some(sysknife_core::distro::DistroId::FedoraSilverblue { version: 41 }); + for action in sysknife_core::action_family::UBUNTU_ONLY_ACTIONS { + assert!( + validate_action_platform(&state, action).is_err(), + "{action}" + ); + } + for action in sysknife_core::action_family::NON_CANONICAL_ON_FEDORA { + assert!( + validate_action_platform(&state, action).is_ok(), + "portable {action}" + ); + } + state.host_distro = None; + for action in sysknife_core::action_family::UBUNTU_ONLY_ACTIONS { + assert!( + validate_action_platform(&state, action).is_err(), + "undetected {action}" + ); + } } // ------------------------------------------------------------------ diff --git a/crates/sysknife-daemon/tests/action_consistency.rs b/crates/sysknife-daemon/tests/action_consistency.rs index 738fb007..63252c12 100644 --- a/crates/sysknife-daemon/tests/action_consistency.rs +++ b/crates/sysknife-daemon/tests/action_consistency.rs @@ -15,7 +15,7 @@ use std::collections::BTreeSet; use serde_json::json; use sysknife_brain::planning_tools::propose_plan::KNOWN_ACTIONS; -use sysknife_core::action_family::{DEBIAN_ONLY_ACTIONS, FEDORA_ONLY_ACTIONS}; +use sysknife_core::action_family::{DEBIAN_ONLY_ACTIONS, FEDORA_ONLY_ACTIONS, UBUNTU_ONLY_ACTIONS}; use sysknife_daemon::actions::{all_specs, ActionSpec}; use sysknife_daemon::executor::build_action_spec; use sysknife_daemon::policy::{min_role_for_action, role_for_risk_level}; @@ -327,23 +327,20 @@ const DEBIAN_TOOLS: &[&str] = &[ "apt-mark", "apt-cache", "dpkg", - "snap", - "ufw", - "netplan", - "add-apt-repository", - "do-release-upgrade", - "canonical-livepatch", - "multipass", - "aa-status", - "aa-enforce", - "aa-complain", - "cloud-init", - "fail2ban-client", + "apt-pin-edit", "update-grub", "unattended-upgrade", ]; -const DEBIAN_PATHS: &[&str] = &["/etc/apt/", "/etc/default/grub", "/var/run/reboot-required"]; +const DEBIAN_PATHS: &[&str] = &["/etc/apt/", "/etc/default/grub"]; + +const UBUNTU_TOOLS: &[&str] = &[ + "pro", + "add-apt-repository", + "do-release-upgrade", + "canonical-livepatch", +]; +const UBUNTU_PATHS: &[&str] = &["/var/run/reboot-required"]; /// The full command line (or file path) an action drives, as one searchable /// string. `sudo sh -c "…"` wrappers hide the real tool inside an argument, so @@ -390,10 +387,17 @@ fn family_fence_agrees_with_each_action_s_mechanism() { || FEDORA_PATHS.iter().any(|p| text.contains(p)); let debian_shaped = DEBIAN_TOOLS.iter().any(|t| mentions_tool(&text, t)) || DEBIAN_PATHS.iter().any(|p| text.contains(p)); + let ubuntu_shaped = UBUNTU_TOOLS.iter().any(|t| mentions_tool(&text, t)) + || UBUNTU_PATHS.iter().any(|p| text.contains(p)); // An action cannot be shaped by both families' tooling; if one ever is, // the token lists need splitting rather than the fence. - if fedora_shaped && debian_shaped { + if [fedora_shaped, debian_shaped, ubuntu_shaped] + .iter() + .filter(|x| **x) + .count() + > 1 + { wrong.push(format!( "{name}: mechanism mentions both families' tooling: {text}" )); @@ -412,6 +416,23 @@ fn family_fence_agrees_with_each_action_s_mechanism() { "{name}: drives Debian-only tooling but is not in DEBIAN_ONLY_ACTIONS ({text})" )); } + if ubuntu_shaped != UBUNTU_ONLY_ACTIONS.contains(&name) { + wrong.push(format!( + "{name}: Ubuntu fence disagrees with mechanism ({text})" + )); + } + // Reverse direction: a portable mechanism cannot be hard-fenced just + // because it is the planner's preferred tool on one supported distro. + if FEDORA_ONLY_ACTIONS.contains(&name) && !fedora_shaped { + wrong.push(format!( + "{name}: Fedora fence exceeds its mechanism ({text})" + )); + } + if DEBIAN_ONLY_ACTIONS.contains(&name) && !debian_shaped { + wrong.push(format!( + "{name}: Debian fence exceeds its mechanism ({text})" + )); + } } assert!( @@ -428,7 +449,9 @@ fn the_unfenced_by_decision_list_is_still_load_bearing() { // fenced properly. for name in UNFENCED_BY_DECISION { assert!( - !FEDORA_ONLY_ACTIONS.contains(name) && !DEBIAN_ONLY_ACTIONS.contains(name), + !FEDORA_ONLY_ACTIONS.contains(name) + && !DEBIAN_ONLY_ACTIONS.contains(name) + && !UBUNTU_ONLY_ACTIONS.contains(name), "{name} is now fenced; remove it from UNFENCED_BY_DECISION" ); let spec = all_specs() diff --git a/crates/sysknife-daemon/tests/action_reference_doc.rs b/crates/sysknife-daemon/tests/action_reference_doc.rs index b55fd937..b7fae462 100644 --- a/crates/sysknife-daemon/tests/action_reference_doc.rs +++ b/crates/sysknife-daemon/tests/action_reference_doc.rs @@ -15,7 +15,9 @@ use std::collections::BTreeMap; use std::path::PathBuf; use sysknife_brain::planning_tools::propose_plan::KNOWN_ACTIONS; -use sysknife_core::action_family::{DEBIAN_ONLY_ACTIONS, FEDORA_ONLY_ACTIONS}; +use sysknife_core::action_family::{ + DEBIAN_ONLY_ACTIONS, FEDORA_ONLY_ACTIONS, NON_CANONICAL_ON_FEDORA, UBUNTU_ONLY_ACTIONS, +}; use sysknife_daemon::actions::{catalogue, ActionMechanism, ActionSpec}; /// Ordered (section title, specs) pairs — one per action module. The order and @@ -79,7 +81,10 @@ fn command(m: &ActionMechanism) -> String { fn distro(name: &str) -> &'static str { if FEDORA_ONLY_ACTIONS.contains(&name) { "Fedora" - } else if DEBIAN_ONLY_ACTIONS.contains(&name) { + } else if DEBIAN_ONLY_ACTIONS.contains(&name) + || UBUNTU_ONLY_ACTIONS.contains(&name) + || NON_CANONICAL_ON_FEDORA.contains(&name) + { "Ubuntu" } else { "All" @@ -99,8 +104,9 @@ fn build_reference() -> String { Every row is derived from the live code: the command from each action's \ `ActionSpec` mechanism, the risk from its `risk_level`, the distro from \ `sysknife-core::action_family`, and the description from the brain's \ - `KNOWN_ACTIONS` list. **Distro** is `All` (cross-distro), `Ubuntu` \ - (Debian-family only), or `Fedora` (atomic-host only). **Rb** = requires \ + `KNOWN_ACTIONS` list. **Distro** identifies the default supported catalogue: \ + `All`, `Ubuntu`, or `Fedora`. It includes planner preferences, not just \ + hard execution fences; see [action compatibility](action-compatibility.md). **Rb** = requires \ reboot; **Ro** = automatic rollback available.\n\n", ); diff --git a/crates/sysknife-daemon/tests/prompt_risk_labels.rs b/crates/sysknife-daemon/tests/prompt_risk_labels.rs index 18a5132c..8bbca33c 100644 --- a/crates/sysknife-daemon/tests/prompt_risk_labels.rs +++ b/crates/sysknife-daemon/tests/prompt_risk_labels.rs @@ -94,10 +94,12 @@ fn labelled_risks(prompt: &str, known: &BTreeMap) -> BTreeMap Vec<(&'static str, String)> { let fedora = DistroHint { + id: "fedora".into(), family: DISTRO_FAMILY_FEDORA, version: Some("Fedora Silverblue 44".to_string()), }; let debian = DistroHint { + id: "ubuntu".into(), family: DISTRO_FAMILY_DEBIAN, version: Some("Ubuntu 24.04".to_string()), }; diff --git a/crates/sysknife-types/src/lib.rs b/crates/sysknife-types/src/lib.rs index 3acc8556..5d43f2ff 100644 --- a/crates/sysknife-types/src/lib.rs +++ b/crates/sysknife-types/src/lib.rs @@ -25,8 +25,8 @@ pub const MAX_MESSAGE_BYTES: usize = 4 * 1024 * 1024; /// Planner-facing distro snapshot injected into the system prompt. /// /// This is a deliberately lightweight type: it captures only what the planner -/// needs to pick the right action family (`family`) and to produce accurate -/// human-readable output (`version`). Heavy detection logic and the full +/// needs to pick the right action family (`family`), distinguish Ubuntu-only +/// mechanisms (`id`), and produce human-readable output (`version`). Detection and the full /// `DistroId` enum stay in `sysknife-core`; the CLI converts `DistroId` → /// `DistroHint` at startup so the brain never depends on `sysknife-core`. /// @@ -38,6 +38,10 @@ pub const MAX_MESSAGE_BYTES: usize = 4 * 1024 * 1024; /// it needs. #[derive(Clone, Debug, PartialEq, Eq)] pub struct DistroHint { + /// Detected distribution ID, e.g. `ubuntu` or `debian`. Family membership + /// alone cannot authorize an Ubuntu-only mechanism. Never derive this from + /// the human-readable version string. + pub id: String, /// Broad distro family: `"fedora"`, `"debian"`, or `"other"`. /// /// Use `DISTRO_FAMILY_FEDORA`, `DISTRO_FAMILY_DEBIAN`, and diff --git a/docs/action-compatibility.md b/docs/action-compatibility.md new file mode 100644 index 00000000..3ea46d51 --- /dev/null +++ b/docs/action-compatibility.md @@ -0,0 +1,32 @@ +# Action compatibility + +Host eligibility, mechanism compatibility, and planner preference answer different +questions. The constants in `sysknife-core::action_family` separate them: + +- `DEBIAN_ONLY_ACTIONS` covers apt/dpkg and the Debian GRUB interface. +- `UBUNTU_ONLY_ACTIONS` covers Canonical services, release upgrades, Ubuntu PPAs, + and the Ubuntu reboot sentinel. A Debian family or `ID_LIKE=ubuntu` hint does + not establish Ubuntu identity. +- `FEDORA_ONLY_ACTIONS` covers rpm-ostree/DNF mechanisms. +- `NON_CANONICAL_ON_DEBIAN` withholds firewalld/toolbox from the entire Debian + family's planner. `NON_CANONICAL_ON_DEBIAN_HOST` additionally withholds + snap/netplan/Multipass on non-Ubuntu Debian-family hosts. +- `NON_CANONICAL_ON_FEDORA` keeps the Fedora planner on its existing defaults. + Portable mechanisms such as ufw, AppArmor, fail2ban, Flatpak and distrobox can + still execute when the operator has installed and configured their tools. + +Only the hard lists feed the daemon and CLI compatibility fences. The MCP +surface uses the same routing checks and withholds hard-restricted actions when +detection fails. The planner receives an explicit distribution ID alongside the +family; display text never grants Ubuntu capabilities. + +The action reference's Distro column describes the default supported catalogue, +including preferences. It is not a claim that a portable tool cannot be installed +elsewhere. The default Ubuntu and Fedora catalogues remain unchanged by this split. + +Debian is still ineligible under `DistroId::is_supported()`. This change does not +enable its mutations or claim live Debian validation. PPAs contain packages built +for an Ubuntu series, even on Debian releases that provide add-apt-repository. +`CheckPendingReboot` reads `/var/run/reboot-required`, normally produced by +Ubuntu's update-notifier. A missing file is not sufficient evidence on Debian; +the action stays Ubuntu-only pending validation of a Debian producer or backend. diff --git a/docs/action-reference.md b/docs/action-reference.md index 1d51201a..2e9551d1 100644 --- a/docs/action-reference.md +++ b/docs/action-reference.md @@ -3,7 +3,7 @@ **This file is generated. Do not edit by hand.** Regenerate with `UPDATE_ACTION_REFERENCE=1 cargo test -p sysknife-daemon --test action_reference_doc`; a plain `cargo test` fails if it drifts from the catalogue. -Every row is derived from the live code: the command from each action's `ActionSpec` mechanism, the risk from its `risk_level`, the distro from `sysknife-core::action_family`, and the description from the brain's `KNOWN_ACTIONS` list. **Distro** is `All` (cross-distro), `Ubuntu` (Debian-family only), or `Fedora` (atomic-host only). **Rb** = requires reboot; **Ro** = automatic rollback available. +Every row is derived from the live code: the command from each action's `ActionSpec` mechanism, the risk from its `risk_level`, the distro from `sysknife-core::action_family`, and the description from the brain's `KNOWN_ACTIONS` list. **Distro** identifies the default supported catalogue: `All`, `Ubuntu`, or `Fedora`. It includes planner preferences, not just hard execution fences; see [action compatibility](action-compatibility.md). **Rb** = requires reboot; **Ro** = automatic rollback available. ## Deployment (atomic host) @@ -256,7 +256,7 @@ Every row is derived from the live code: the command from each action's `ActionS | Action | Command | Risk | Distro | Rb | Ro | Description | |---|---|---|---|---|---|---| -| `CheckPendingReboot` | `bash -c "if test -f /var/run/reboot-required; then cat /var/run/reboot-required; cat /var/run/reboot-required.pkgs 2>/dev/null; true; else echo 'No reboot required.'; fi"` | Low | Ubuntu | – | – | check whether a reboot is pending (/var/run/reboot-required) — no params; Ubuntu/Debian only; read-only | +| `CheckPendingReboot` | `bash -c "if test -f /var/run/reboot-required; then cat /var/run/reboot-required; cat /var/run/reboot-required.pkgs 2>/dev/null; true; else echo 'No reboot required.'; fi"` | Low | Ubuntu | – | – | check whether a reboot is pending (/var/run/reboot-required) — no params; Ubuntu only; read-only | ## AppArmor From dda0830e1375f24a15fa2e883ef03e665d9abba7 Mon Sep 17 00:00:00 2001 From: hushen <190065939+918154429@users.noreply.github.com> Date: Mon, 7 Sep 2026 12:13:18 +0800 Subject: [PATCH 2/6] test(core): pin fence inventory and Fedora planner defaults --- .../src/planning_tools/propose_plan.rs | 6 +++++- crates/sysknife-daemon/tests/action_consistency.rs | 14 +++++++++++++- 2 files changed, 18 insertions(+), 2 deletions(-) diff --git a/crates/sysknife-brain/src/planning_tools/propose_plan.rs b/crates/sysknife-brain/src/planning_tools/propose_plan.rs index 263667ec..14844e78 100644 --- a/crates/sysknife-brain/src/planning_tools/propose_plan.rs +++ b/crates/sysknife-brain/src/planning_tools/propose_plan.rs @@ -1083,7 +1083,11 @@ mod tests { fn fedora_tool_def_omits_debian_only_actions() { let def = tool_def_for_family(Some(DISTRO_FAMILY_FEDORA)); let offered = offered_actions(&def); - for name in DEBIAN_ONLY_ACTIONS { + for name in DEBIAN_ONLY_ACTIONS + .iter() + .chain(UBUNTU_ONLY_ACTIONS) + .chain(NON_CANONICAL_ON_FEDORA) + { assert!( !offered.contains(&name.to_string()), "Fedora tool def offered Debian-only action {name}" diff --git a/crates/sysknife-daemon/tests/action_consistency.rs b/crates/sysknife-daemon/tests/action_consistency.rs index 63252c12..580251c6 100644 --- a/crates/sysknife-daemon/tests/action_consistency.rs +++ b/crates/sysknife-daemon/tests/action_consistency.rs @@ -378,8 +378,20 @@ const UNFENCED_BY_DECISION: &[&str] = &[]; #[test] fn family_fence_agrees_with_each_action_s_mechanism() { let mut wrong = Vec::new(); + let specs = all_specs(); + for name in FEDORA_ONLY_ACTIONS + .iter() + .chain(DEBIAN_ONLY_ACTIONS) + .chain(UBUNTU_ONLY_ACTIONS) + { + if !specs.iter().any(|spec| spec.action_name == *name) { + wrong.push(format!( + "{name}: hard fence names an action absent from the catalogue" + )); + } + } - for spec in all_specs() { + for spec in specs { let name = spec.action_name; let text = mechanism_text(&spec); From 37e3a124313236a67574c3ce3ab5cb4f4df72b03 Mon Sep 17 00:00:00 2001 From: hushen <190065939+918154429@users.noreply.github.com> Date: Mon, 7 Sep 2026 12:14:52 +0800 Subject: [PATCH 3/6] test(daemon): recognize Debian helper and history mechanisms --- crates/sysknife-daemon/tests/action_consistency.rs | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/crates/sysknife-daemon/tests/action_consistency.rs b/crates/sysknife-daemon/tests/action_consistency.rs index 580251c6..c95b778d 100644 --- a/crates/sysknife-daemon/tests/action_consistency.rs +++ b/crates/sysknife-daemon/tests/action_consistency.rs @@ -323,16 +323,20 @@ const FEDORA_PATHS: &[&str] = &["/etc/yum.repos.d"]; /// Tokens that mean Debian-family only. const DEBIAN_TOOLS: &[&str] = &[ + "apt", "apt-get", "apt-mark", "apt-cache", "dpkg", "apt-pin-edit", + // Shipped helpers encapsulate /etc/apt writes and update-grub respectively. + "unattended-upgrades-edit", + "grub-kargs-edit", "update-grub", "unattended-upgrade", ]; -const DEBIAN_PATHS: &[&str] = &["/etc/apt/", "/etc/default/grub"]; +const DEBIAN_PATHS: &[&str] = &["/etc/apt/", "/var/log/apt/", "/etc/default/grub"]; const UBUNTU_TOOLS: &[&str] = &[ "pro", From 396535416063fdd8d10211bc48696ccda2f27928 Mon Sep 17 00:00:00 2001 From: hushen <190065939+918154429@users.noreply.github.com> Date: Mon, 7 Sep 2026 12:20:46 +0800 Subject: [PATCH 4/6] docs: include action compatibility in the published book --- docs/SUMMARY.md | 1 + 1 file changed, 1 insertion(+) diff --git a/docs/SUMMARY.md b/docs/SUMMARY.md index 6518314d..0d6bc688 100644 --- a/docs/SUMMARY.md +++ b/docs/SUMMARY.md @@ -18,6 +18,7 @@ - [Architecture & Trust Boundaries](architecture.md) - [Typed Actions](typed-actions.md) - [Action Reference](action-reference.md) +- [Action Compatibility](action-compatibility.md) - [The Audit Chain](the-audit-chain.md) - [Automatic Rollback](automatic-rollback.md) From 7305f5c9ad6dff687f17bb7c06052d487ab0c232 Mon Sep 17 00:00:00 2001 From: hushen <190065939+918154429@users.noreply.github.com> Date: Tue, 8 Sep 2026 16:07:46 +0800 Subject: [PATCH 5/6] test: record the measured 1850-test workspace baseline The full Linux workspace run on ded30b2 passed 1850 tests with 6 skipped. Record that measured count and synchronize all three published claims. Evidence: https://github.com/lacs-project/sysknife/actions/runs/34201809070/job/101982085611 --- README.md | 2 +- docs/distro-support.md | 2 +- docs/introduction.md | 2 +- tests/evidence/workspace-tests.json | 2 +- 4 files changed, 4 insertions(+), 4 deletions(-) diff --git a/README.md b/README.md index e6ab4db2..684c9b15 100644 --- a/README.md +++ b/README.md @@ -314,7 +314,7 @@ milestone. | **Every Ubuntu LTS validated** — 22.04, 24.04 and 26.04 all at 79/79, each with a replay twin that reproduces it | ✅ | | Telegram approval interface | 📋 roadmap | -**1,845 Rust tests and 72 frontend tests** form the current deterministic +**1,850 Rust tests and 72 frontend tests** form the current deterministic release baseline. ## Configure your LLM diff --git a/docs/distro-support.md b/docs/distro-support.md index 415d35bf..8e558d63 100644 --- a/docs/distro-support.md +++ b/docs/distro-support.md @@ -82,7 +82,7 @@ family and the atomic story family are implemented and covered by the workspace suite. What is missing is a way to put the helpers somewhere the daemon's own grants already point. -The deterministic workspace baseline is 1,845 Rust tests plus 72 frontend +The deterministic workspace baseline is 1,850 Rust tests plus 72 frontend tests. Those tests verify action construction, policy, approval, storage, and UI behavior, but they do not replace a real distribution VM run. diff --git a/docs/introduction.md b/docs/introduction.md index f26a54d7..078f91c2 100644 --- a/docs/introduction.md +++ b/docs/introduction.md @@ -141,7 +141,7 @@ flow. ## Status -190 typed actions · 1,845 Rust tests + 72 frontend tests · MIT +190 typed actions · 1,850 Rust tests + 72 frontend tests · MIT SysKnife is the reference implementation of the [LACS specification](https://github.com/lacs-project/specification) — a diff --git a/tests/evidence/workspace-tests.json b/tests/evidence/workspace-tests.json index 5f66b8d5..47a9a4af 100644 --- a/tests/evidence/workspace-tests.json +++ b/tests/evidence/workspace-tests.json @@ -4,6 +4,6 @@ "tests": "cargo nextest run --workspace --locked" }, "frontend_tests": 72, - "tests": 1845, + "tests": 1850, "version": 2 } From b6f76a36bf986a74444ae3fb13657428cc748697 Mon Sep 17 00:00:00 2001 From: hushen <190065939+918154429@users.noreply.github.com> Date: Wed, 9 Sep 2026 13:48:38 +0800 Subject: [PATCH 6/6] fix(core): share supported-host routing across portable actions --- CHANGELOG.md | 4 ++ apps/sysknife-cli/src/distro_routing.rs | 68 +++++++++++++++---- .../src/planning_tools/propose_plan.rs | 50 ++++++++++---- crates/sysknife-core/src/action_family.rs | 25 ++++++- crates/sysknife-daemon/src/dispatcher.rs | 5 +- .../tests/prompt_risk_labels.rs | 8 ++- docs/action-compatibility.md | 15 +++- 7 files changed, 142 insertions(+), 33 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index f7532ccb..12b65a4a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -19,6 +19,10 @@ Releases before `0.2.5` predate the public launch; their notes live in the Ubuntu itself; portable tools are no longer refused merely for being another distribution's default. Ubuntu and Fedora default catalogues and host eligibility remain unchanged (#237). +- Keep portable tools behind the CLI supported-host gate and out of unknown-family + planner catalogues. At the daemon, portable Observer reads such as `UfwStatus` + can now run without distro detection; mutations and hard-fenced reads still + require an eligible host. `AptUpdate` remains hard-fenced despite its Low risk. - `DistroHint` now carries a distribution `id`, and `propose_plan_tool_def` accepts the full hint rather than a family string. This is a public Rust API change requiring a middle-digit release while the project is in `0.y`. diff --git a/apps/sysknife-cli/src/distro_routing.rs b/apps/sysknife-cli/src/distro_routing.rs index 0e56d626..f6aca051 100644 --- a/apps/sysknife-cli/src/distro_routing.rs +++ b/apps/sysknife-cli/src/distro_routing.rs @@ -24,8 +24,8 @@ use sysknife_core::distro::{DistroFamily, DistroId}; // `sysknife-core::action_family`; the daemon fence, this routing guard, and the // brain prompt all reference the same constants so they cannot drift apart. use sysknife_core::action_family::{ - action_matches_distro, action_requires_distro, DEBIAN_ONLY_ACTIONS, FEDORA_ONLY_ACTIONS, - UBUNTU_ONLY_ACTIONS, + action_matches_distro, action_requires_supported_host, DEBIAN_ONLY_ACTIONS, + FEDORA_ONLY_ACTIONS, UBUNTU_ONLY_ACTIONS, }; // --------------------------------------------------------------------------- @@ -70,12 +70,10 @@ pub fn check_action_distro(action_name: &str, distro: Option<&DistroId>) -> Resu )); } - // Kept in step with the daemon's own fence in `validate_action_platform`, - // which does not exempt reads either: the RBAC role is a bad proxy for "does - // this mutate" — `AptUpdate` is Low/Observer and runs `sudo apt-get update`. - // A client that refuses what the daemon would run is confusing; a client that - // *permits* what the daemon refuses is worse, so both stay strict together. - if action_requires_distro(action_name) && !distro.is_supported() { + // Host eligibility is wider than mechanism compatibility: portable tools + // still cannot mutate an unsupported host. Conservatively withhold the + // entire distro-policy set here, including its reads, before approval. + if action_requires_supported_host(action_name) && !distro.is_supported() { return Err(format!( "{action_name} is disabled on unsupported distro {distro}; \ see docs/distro-support.md" @@ -197,12 +195,54 @@ mod tests { } #[test] - fn snap_install_on_ubuntu_is_ok() { - let distro = DistroId::Ubuntu { - major: 24, - minor: 4, - }; - assert!(check_action_distro("SnapInstall", Some(&distro)).is_ok()); + fn portable_tools_require_supported_hosts_before_approval() { + // Literal cases pin both portable families independently of the lists + // being split. A supported host may use its non-default tools. + for action in [ + "SnapInstall", + "UfwEnable", + "UfwAllow", + "UfwLimit", + "AppArmorEnforce", + "Fail2banBanIp", + "ConfigureFail2banJail", + "ConfigureFirewall", + "CreateToolbox", + "AptUpdate", + ] { + for distro in [ + DistroId::Debian { version: Some(13) }, + DistroId::Ubuntu { + major: 18, + minor: 4, + }, + DistroId::Fedora { version: 41 }, + DistroId::Other { + id: "arch".into(), + version_id: None, + id_like: vec![], + }, + ] { + assert!( + check_action_distro(action, Some(&distro)).is_err(), + "{action} must be refused before approval on {distro}" + ); + } + for distro in [ + DistroId::Ubuntu { + major: 24, + minor: 4, + }, + DistroId::FedoraSilverblue { version: 41 }, + ] { + if action != "AptUpdate" { + assert!( + check_action_distro(action, Some(&distro)).is_ok(), + "portable {action} must remain usable on {distro}" + ); + } + } + } } #[test] diff --git a/crates/sysknife-brain/src/planning_tools/propose_plan.rs b/crates/sysknife-brain/src/planning_tools/propose_plan.rs index 14844e78..a5859f20 100644 --- a/crates/sysknife-brain/src/planning_tools/propose_plan.rs +++ b/crates/sysknife-brain/src/planning_tools/propose_plan.rs @@ -8,8 +8,9 @@ use crate::action_name::ActionName; use crate::planner::{Plan, PlanRiskLevel, PlanStep, PlanningError}; use crate::provider::ToolDefinition; use sysknife_core::action_family::{ - DEBIAN_ONLY_ACTIONS, FEDORA_ONLY_ACTIONS, NON_CANONICAL_ON_DEBIAN, - NON_CANONICAL_ON_DEBIAN_HOST, NON_CANONICAL_ON_FEDORA, UBUNTU_ONLY_ACTIONS, + action_requires_supported_host, DEBIAN_ONLY_ACTIONS, FEDORA_ONLY_ACTIONS, + NON_CANONICAL_ON_DEBIAN, NON_CANONICAL_ON_DEBIAN_HOST, NON_CANONICAL_ON_FEDORA, + UBUNTU_ONLY_ACTIONS, }; use sysknife_types::{DISTRO_FAMILY_DEBIAN, DISTRO_FAMILY_FEDORA, DISTRO_FAMILY_OTHER}; @@ -469,9 +470,9 @@ reports live interface state"), /// canonical tool and the planner should reach for that instead. /// /// A detected `other` family (Arch, openSUSE, anything unrecognised) is filtered -/// against **both** fences, matching the CLI routing guard: it refuses every -/// family-specific action on such a host, so offering them invites a plan that is -/// certain to be rejected after a paid call. +/// against hard fences and portable-tool preferences, matching the CLI routing +/// guard: it refuses every distro-policy action on such a host, so offering them +/// invites a plan that is certain to be rejected after a paid call. /// /// No hint at all still offers everything — without a detected family there is no /// basis to exclude anything, and a generic deployment has to be able to plan. @@ -497,11 +498,7 @@ fn available_on(action: &str, hint: Option<&sysknife_types::DistroHint>) -> bool && !UBUNTU_ONLY_ACTIONS.contains(&action) && !NON_CANONICAL_ON_FEDORA.contains(&action) } - Some(DISTRO_FAMILY_OTHER) => { - !FEDORA_ONLY_ACTIONS.contains(&action) - && !DEBIAN_ONLY_ACTIONS.contains(&action) - && !UBUNTU_ONLY_ACTIONS.contains(&action) - } + Some(DISTRO_FAMILY_OTHER) => !action_requires_supported_host(action), _ => true, } } @@ -1134,8 +1131,37 @@ mod tests { // The CLI routing guard refuses every family-specific action on a host // that is neither Debian nor Fedora, so offering them here would spend a // paid call on a plan certain to be rejected. - let offered = offered_actions(&tool_def_for_family(Some(DISTRO_FAMILY_OTHER))); - for name in FEDORA_ONLY_ACTIONS.iter().chain(DEBIAN_ONLY_ACTIONS.iter()) { + let def = tool_def_for_family(Some(DISTRO_FAMILY_OTHER)); + let offered = offered_actions(&def); + // Literal portable cases keep this regression visible even if a list + // is narrowed again. Check the description as well as the enum. + let catalogue = def.input_schema["properties"]["steps"]["items"]["properties"] + ["action_name"]["description"] + .as_str() + .unwrap(); + for name in [ + "SnapInstall", + "UfwEnable", + "AppArmorEnforce", + "ConfigureFirewall", + "CreateToolbox", + ] { + assert!(!offered.contains(&name.to_string()), "offered {name}"); + assert!( + !catalogue + .lines() + .any(|line| line.starts_with(&format!("{name} — "))), + "catalogue describes unavailable {name}" + ); + } + for name in FEDORA_ONLY_ACTIONS + .iter() + .chain(DEBIAN_ONLY_ACTIONS) + .chain(UBUNTU_ONLY_ACTIONS) + .chain(NON_CANONICAL_ON_DEBIAN) + .chain(NON_CANONICAL_ON_DEBIAN_HOST) + .chain(NON_CANONICAL_ON_FEDORA) + { assert!( !offered.contains(&name.to_string()), "an unrecognised-family host was offered family-specific action {name}" diff --git a/crates/sysknife-core/src/action_family.rs b/crates/sysknife-core/src/action_family.rs index a1af9318..88ded1b6 100644 --- a/crates/sysknife-core/src/action_family.rs +++ b/crates/sysknife-core/src/action_family.rs @@ -93,8 +93,8 @@ pub const FEDORA_ONLY_ACTIONS: &[&str] = &[ /// `UfwStatus` answered `Status: inactive` — a confident wrong answer instead of /// a refusal. /// -/// Consumed only by `sysknife-brain`'s catalogue filter. The fence must not read -/// it: preference is not impossibility. +/// Used by catalogue filtering and the conservative supported-host routing +/// guard, never as a mechanism incompatibility on an eligible host. pub const NON_CANONICAL_ON_DEBIAN: &[&str] = &[ // Ubuntu's canonical firewall is ufw (UfwStatus, UfwAllow/UfwDeny). "GetFirewallState", @@ -154,7 +154,7 @@ pub const UBUNTU_ONLY_ACTIONS: &[&str] = &[ /// Installable on Debian itself, but not its default administrative tools. /// Unlike [`NON_CANONICAL_ON_DEBIAN`], this applies only to non-Ubuntu members -/// of the Debian family. It must never be consumed by an execution fence. +/// of the Debian family. It does not impose a mechanism execution fence. pub const NON_CANONICAL_ON_DEBIAN_HOST: &[&str] = &[ "SnapInstall", "SnapRemove", @@ -228,6 +228,25 @@ pub fn action_requires_distro(action: &str) -> bool { .any(|list| list.contains(&action)) } +/// Whether planning and client routing require a supported host for this action. +/// +/// Includes portable tools with distro-specific defaults, not just hard +/// mechanism fences. Splitting those tools out of a hard fence must not let +/// an ineligible host reach approval for a mutation the daemon will refuse. +/// On eligible hosts, only [`action_matches_distro`] restricts mechanisms. +/// This conservative client/catalogue gate also withholds portable reads; +/// the daemon's read-only detection exemption remains separate. +pub fn action_requires_supported_host(action: &str) -> bool { + action_requires_distro(action) + || [ + NON_CANONICAL_ON_DEBIAN, + NON_CANONICAL_ON_DEBIAN_HOST, + NON_CANONICAL_ON_FEDORA, + ] + .iter() + .any(|list| list.contains(&action)) +} + /// Mechanism compatibility only; callers must separately check host eligibility. /// Unknown action names remain the catalogue validator's responsibility. pub fn action_matches_distro(action: &str, distro: &crate::distro::DistroId) -> bool { diff --git a/crates/sysknife-daemon/src/dispatcher.rs b/crates/sysknife-daemon/src/dispatcher.rs index 5fdbb062..b8f13221 100644 --- a/crates/sysknife-daemon/src/dispatcher.rs +++ b/crates/sysknife-daemon/src/dispatcher.rs @@ -963,7 +963,10 @@ fn validate_action_platform(state: &DaemonState, action_name: &str) -> Result<() return Ok(()); } - // Both gates below apply to family-tagged actions whether or not they read. + // Both gates below apply to hard-fenced actions whether or not they read, + // and to all baseline non-Observer actions. Portable Observer reads are + // exempt above, even without distro detection; planner preferences do not + // turn into daemon mechanism fences. // // An earlier revision exempted read-only ones, on the reasoning that // docs/distro-support.md promises to refuse only *mutating* actions on an diff --git a/crates/sysknife-daemon/tests/prompt_risk_labels.rs b/crates/sysknife-daemon/tests/prompt_risk_labels.rs index 8bbca33c..984ca1d4 100644 --- a/crates/sysknife-daemon/tests/prompt_risk_labels.rs +++ b/crates/sysknife-daemon/tests/prompt_risk_labels.rs @@ -98,13 +98,19 @@ fn rendered_prompts() -> Vec<(&'static str, String)> { family: DISTRO_FAMILY_FEDORA, version: Some("Fedora Silverblue 44".to_string()), }; - let debian = DistroHint { + let ubuntu = DistroHint { id: "ubuntu".into(), family: DISTRO_FAMILY_DEBIAN, version: Some("Ubuntu 24.04".to_string()), }; + let debian = DistroHint { + id: "debian".into(), + family: DISTRO_FAMILY_DEBIAN, + version: Some("Debian 13".to_string()), + }; vec![ ("fedora", build_system_prompt(None, Some(&fedora))), + ("ubuntu", build_system_prompt(None, Some(&ubuntu))), ("debian", build_system_prompt(None, Some(&debian))), ("generic", build_system_prompt(None, None)), ] diff --git a/docs/action-compatibility.md b/docs/action-compatibility.md index 3ea46d51..15f9a968 100644 --- a/docs/action-compatibility.md +++ b/docs/action-compatibility.md @@ -15,8 +15,19 @@ questions. The constants in `sysknife-core::action_family` separate them: Portable mechanisms such as ufw, AppArmor, fail2ban, Flatpak and distrobox can still execute when the operator has installed and configured their tools. -Only the hard lists feed the daemon and CLI compatibility fences. The MCP -surface uses the same routing checks and withholds hard-restricted actions when +Only the hard lists feed the daemon and CLI mechanism compatibility fences. +The shared `action_requires_supported_host` predicate also includes portable +tools for CLI host eligibility and unknown-family catalogue filtering. Those +hosts must not reach approval for mutations the daemon will refuse. This +conservative client gate withholds portable reads too; it does not restrict +portable tools on eligible Ubuntu or Fedora Atomic hosts. + +At the daemon, portable Observer reads such as `UfwStatus` and `SnapList` no +longer require distro detection after leaving the hard lists. This widens +read-only inspection; mutations still require an eligible host, and the +Low-risk mutating `AptUpdate` remains hard-fenced. + +The MCP surface uses the same routing checks and withholds hard-restricted actions when detection fails. The planner receives an explicit distribution ID alongside the family; display text never grants Ubuntu capabilities.