Skip to content
15 changes: 15 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,21 @@ 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).
- 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`.

### Fixed

- Attach the default safety audit log in `LlmPlanner::from_config`, the
Expand Down
5 changes: 3 additions & 2 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
7 changes: 6 additions & 1 deletion HACKING.md
Original file line number Diff line number Diff line change
Expand Up @@ -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:

Expand Down
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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,846 Rust tests and 72 frontend tests** form the current deterministic
**1,851 Rust tests and 72 frontend tests** form the current deterministic
release baseline.

## Configure your LLM
Expand Down
143 changes: 109 additions & 34 deletions apps/sysknife-cli/src/distro_routing.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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.
//!
Expand All @@ -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_supported_host, DEBIAN_ONLY_ACTIONS,
FEDORA_ONLY_ACTIONS, UBUNTU_ONLY_ACTIONS,
};

// ---------------------------------------------------------------------------
// Public API
Expand All @@ -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),
));
Expand All @@ -61,14 +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 (DEBIAN_ONLY_ACTIONS.contains(&action_name) || FEDORA_ONLY_ACTIONS.contains(&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"
Expand Down Expand Up @@ -116,34 +121,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());
}

// -----------------------------------------------------------------------
Expand Down Expand Up @@ -193,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]
Expand Down Expand Up @@ -277,8 +321,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"
);
}

Expand Down Expand Up @@ -313,6 +357,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 {
Expand Down
32 changes: 26 additions & 6 deletions apps/sysknife-cli/src/mcp_server.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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;

Expand Down Expand Up @@ -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),
}
}

Expand Down Expand Up @@ -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<String> =
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<String> = 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(),
Expand Down
9 changes: 9 additions & 0 deletions apps/sysknife-cli/src/runner.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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()),
}
Expand Down
4 changes: 1 addition & 3 deletions crates/sysknife-brain/src/planner.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
Expand Down
Loading
Loading