Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,9 @@ jobs:
with:
python-version: "3.11"

- name: Firewall backend reporter fixtures
run: python tests/test_firewall_state.py

# Pinned, because this job holds the workflow token: an unpinned global
# install lets a compromised release of any of these three run arbitrary
# code on the trusted side of the build. Bump deliberately.
Expand Down
8 changes: 8 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,14 @@ Releases before `0.2.5` predate the public launch; their notes live in the

## [Unreleased]

### Added

- Add read-only nftables ruleset and firewall-backend observations. General
firewall queries preserve failed/unknown probes and do not equate inactive
ufw with an unfiltered host. Safety notes precede bounded diagnostic excerpts
so large rulesets retain valid JSON and the interpretation caveat within the
planner output cap; mutating nftables actions remain out of scope (#239).

### Changed

- Remove whole-binary shell and runuser sudo grants. Firewall, group, Snap,
Expand Down
2 changes: 1 addition & 1 deletion CONTRIBUTING.md
Original file line number Diff line number Diff line change
Expand Up @@ -47,7 +47,7 @@ table below.
| **Ubuntu LTS support** | The current suite is 83 Ubuntu stories. Committed live-VM evidence covers 79 of those Ubuntu stories on each LTS release, with a committed replay twin that reproduces each run: 22.04, 24.04 and 26.04 all at 79/79. The four additional stories are not yet included in a committed live-VM run. `ubuntu-vm.sh` accepts `UBUNTU_RELEASE=jammy\|noble\|resolute`. Remaining: story coverage for the cross-family actions, and one Debian-only action still has no story: `GrubSetKargs`. | medium |
| **Distro detection coverage** | Robust `/etc/os-release` parsing for every release we claim to support. Pure-function tests against real fixture files, no integration mocks. The existing fixtures at the bottom of `crates/sysknife-core/src/distro.rs` show the shape. | easy |
| **Action catalogue gaps** | Add a typed action (for example `EnableFirewallZone`). Small and isolated, and every PR carries the policy entry, the risk level and the tests. | easy |
| **E2E story coverage** | Real prompts, real LLM, real daemon. The suite is 137 stories: 54 atomic + 83 Ubuntu. What is left is the cross-family middle: of the action names available on both families, 59 are still untouched by any story, plus 10 Fedora-only and 1 Ubuntu-only ones. See #233 for the clustered map. | medium |
| **E2E story coverage** | Real prompts, real LLM, real daemon. The suite is 137 stories: 54 atomic + 83 Ubuntu. What is left is the cross-family middle: of the action names available on both families, 61 are still untouched by any story, plus 10 Fedora-only and 1 Ubuntu-only ones. See #233 for the clustered map. | medium |
| **Fedora Atomic validation** | The action families exist and `DistroId::is_supported()` returns true for Atomic 41 and up. Nobody has run `tests/e2e/atomic-vm.sh` against a current release. Needs Fedora Atomic hardware or a VM host. | tedious |
| **Demo recording on real hardware** | Replace the bundled demo GIF with a 30-second recording on real Ubuntu 26.04 with rollback visible. | easy |

Expand Down
2 changes: 2 additions & 0 deletions Makefile
Original file line number Diff line number Diff line change
Expand Up @@ -119,6 +119,7 @@ daemon-install: daemon-install-preflight build
# from the daemon source and fails if one is missing.
install -Dm 755 packaging/sysknife-apt-pin-edit $(HELPERS)/apt-pin-edit
install -Dm 755 packaging/sysknife-action-steps $(HELPERS)/action-steps
install -Dm 755 packaging/sysknife-firewall-state $(HELPERS)/firewall-state
install -Dm 755 packaging/sysknife-audit-edit $(HELPERS)/audit-edit
install -Dm 755 packaging/sysknife-fail2ban-jail-edit $(HELPERS)/fail2ban-jail-edit
install -Dm 755 packaging/sysknife-grub-kargs-edit $(HELPERS)/grub-kargs-edit
Expand Down Expand Up @@ -150,6 +151,7 @@ daemon-uninstall:
rm -f $(TMPFILES)/sysknife.conf
rm -f $(HELPERS)/apt-pin-edit
rm -f $(HELPERS)/action-steps
rm -f $(HELPERS)/firewall-state
rm -f $(HELPERS)/audit-edit
rm -f $(HELPERS)/fail2ban-jail-edit
rm -f $(HELPERS)/grub-kargs-edit
Expand Down
4 changes: 2 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -303,7 +303,7 @@ milestone.
| Component | State |
|---|---|
| `sysknife-brain` — LLM planner, tool loop, safety fence | ✅ |
| `sysknife-daemon` — 190 typed actions, auth, preview, transactions | ✅ |
| `sysknife-daemon` — 192 typed actions, auth, preview, transactions | ✅ |
| Live IPC + streaming + atomic-host rollback (rpm-ostree) | ✅ |
| Terminal approval gate — one-time, TTL-bounded receipts | ✅ |
| MCP server (Claude Code / Cursor / any MCP client) | ✅ |
Expand All @@ -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,860 Rust tests and 72 frontend tests** form the current deterministic
**1,861 Rust tests and 72 frontend tests** form the current deterministic
release baseline.

## Configure your LLM
Expand Down
6 changes: 4 additions & 2 deletions apps/sysknife-cli/src/mcp_server.rs
Original file line number Diff line number Diff line change
Expand Up @@ -444,6 +444,8 @@ const MCP_READ_ONLY_ACTIONS: &[&str] = &[
"GetFirewallState",
"GetNetworkStatus",
"GetListeningPorts",
"GetNftablesRuleset",
"GetFirewallBackendState",
"ResolvectlStatus",
"GetDateTime",
"ListUsers",
Expand Down Expand Up @@ -1825,8 +1827,8 @@ mod tests {
classified, observer_actions,
"every Observer-callable action must be explicitly classified as read-only or mutating"
);
assert_eq!(observer_actions.len(), 63);
assert_eq!(read_only.len(), 62);
assert_eq!(observer_actions.len(), 65);
assert_eq!(read_only.len(), 64);
assert_eq!(mutating, BTreeSet::from(["AptUpdate"]));
}

Expand Down
4 changes: 4 additions & 0 deletions crates/sysknife-brain/src/planning_tools/propose_plan.rs
Original file line number Diff line number Diff line change
Expand Up @@ -145,6 +145,10 @@ pub const KNOWN_ACTIONS: &[(&str, &str)] = &[
this is runtime status, NOT the saved configuration; on Ubuntu the saved config is NetplanGetConfig"),
("GetListeningPorts",
"show listening TCP/UDP sockets and the process bound to each (ss -tulpn) — no params; read-only; use for \"what is listening on port X?\""),
("GetNftablesRuleset",
"read the current nftables ruleset — no params; read-only; rules do not by themselves prove that traffic is blocked"),
("GetFirewallBackendState",
"inspect nftables, ufw and firewalld observations — no params; read-only; use for general firewall status; unavailable or inactive frontends do not prove the host is unfiltered"),
("ConfigureWifi",
"connect to a Wi-Fi network — params: ssid*, password (optional for open networks)"),
("SetDnsServers",
Expand Down
6 changes: 3 additions & 3 deletions crates/sysknife-brain/src/planning_tools/query_tools.rs
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,7 @@ pub fn query_tools() -> Vec<ToolDefinition> {
},
ToolDefinition {
name: "query_firewall".into(),
description: "Show current firewall rules and allowed services.".into(),
description: "Inspect nftables, ufw and firewalld observations; preserve unknown status when probes fail. An inactive frontend does not prove the host is unfiltered.".into(),
input_schema: empty_schema.clone(),
},
ToolDefinition {
Expand Down Expand Up @@ -232,7 +232,7 @@ pub fn query_tool_to_action(
match tool_name {
"query_ufw_rules" => Ok(Some(("UfwStatus", serde_json::json!({"numbered": true})))),
"query_services" => Ok(Some(("ListServices", serde_json::json!({})))),
"query_firewall" => Ok(Some(("GetFirewallState", serde_json::json!({})))),
"query_firewall" => Ok(Some(("GetFirewallBackendState", serde_json::json!({})))),
"query_deployments" => Ok(Some(("ListDeployments", serde_json::json!({})))),
"query_packages" => Ok(Some(("GetLayeredPackages", serde_json::json!({})))),
"query_containers" => Ok(Some(("ListContainers", serde_json::json!({})))),
Expand Down Expand Up @@ -314,7 +314,7 @@ mod tests {
);
assert_eq!(
query_tool_to_action("query_firewall", &empty),
Ok(Some(("GetFirewallState", serde_json::json!({}))))
Ok(Some(("GetFirewallBackendState", serde_json::json!({}))))
);
assert_eq!(
query_tool_to_action("query_deployments", &empty),
Expand Down
56 changes: 56 additions & 0 deletions crates/sysknife-daemon/src/actions/network.rs
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,8 @@ pub fn specs() -> Vec<ActionSpec> {
get_firewall_state(),
get_network_status(),
get_listening_ports(),
get_nftables_ruleset(),
get_firewall_backend_state(),
]
}

Expand Down Expand Up @@ -105,6 +107,28 @@ pub fn get_network_status() -> ActionSpec {
}
}

/// Inspect nftables without modifying rules or accepting caller-controlled argv.
pub fn get_nftables_ruleset() -> ActionSpec {
ActionSpec {
action_name: "GetNftablesRuleset",
mechanism: command_mechanism("sudo", ["nft", "list", "ruleset"]),
risk_level: RiskLevel::Low,
reboot_required: false,
rollback_available: false,
}
}

/// Report nftables and frontend observations, preserving unknown probe results.
pub fn get_firewall_backend_state() -> ActionSpec {
ActionSpec {
action_name: "GetFirewallBackendState",
mechanism: command_mechanism("/usr/lib/sysknife/firewall-state", [] as [&str; 0]),
risk_level: RiskLevel::Low,
reboot_required: false,
rollback_available: false,
}
}

/// List listening TCP/UDP sockets and, where the daemon has permission, the
/// owning process (`ss -tulpnH`). Read-only; answers "what is listening on port
/// X?". Run without sudo (like `GetNetworkStatus`'s `ip`); the socket/port list
Expand All @@ -120,3 +144,35 @@ pub fn get_listening_ports() -> ActionSpec {
rollback_available: false,
}
}

#[cfg(test)]
mod firewall_tests {
use super::*;
use crate::actions::ActionMechanism;

#[test]
fn firewall_queries_have_fixed_read_only_mechanisms() {
for (spec, program, args) in [
(
get_nftables_ruleset(),
"sudo",
vec!["nft", "list", "ruleset"],
),
(
get_firewall_backend_state(),
"/usr/lib/sysknife/firewall-state",
vec![],
),
] {
assert_eq!(spec.risk_level, RiskLevel::Low);
assert!(!spec.reboot_required && !spec.rollback_available);
assert_eq!(
spec.mechanism,
ActionMechanism::Command {
program,
args: args.into_iter().map(String::from).collect(),
}
);
}
}
}
2 changes: 2 additions & 0 deletions crates/sysknife-daemon/src/executor.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1315,6 +1315,8 @@ pub fn build_action_spec(action_name: &str, params: &Value) -> Result<ActionSpec
"GetFirewallState" => Ok(network::get_firewall_state()),
"GetNetworkStatus" => Ok(network::get_network_status()),
"GetListeningPorts" => Ok(network::get_listening_ports()),
"GetNftablesRuleset" => Ok(network::get_nftables_ruleset()),
"GetFirewallBackendState" => Ok(network::get_firewall_backend_state()),
"ConfigureWifi" => {
let ssid = validated_safe_arg(require_str(params, "ssid")?, "ssid")?;
// password is optional — open networks connect without one.
Expand Down
2 changes: 2 additions & 0 deletions crates/sysknife-daemon/src/preview.rs
Original file line number Diff line number Diff line change
Expand Up @@ -143,6 +143,8 @@ fn preview_profile(action_name: &str) -> PreviewProfile {
| "GetMemoryInfo"
| "GetNetworkStatus"
| "GetListeningPorts"
| "GetNftablesRuleset"
| "GetFirewallBackendState"
| "GetJournalLog"
| "GetLvmReport"
| "GetSysctl"
Expand Down
2 changes: 2 additions & 0 deletions crates/sysknife-daemon/tests/actions_batch2.rs
Original file line number Diff line number Diff line change
Expand Up @@ -244,6 +244,8 @@ fn network_family_covers_wifi_dns_and_firewall() {
"GetFirewallState",
"GetNetworkStatus",
"GetListeningPorts",
"GetNftablesRuleset",
"GetFirewallBackendState",
]
);
}
Expand Down
21 changes: 21 additions & 0 deletions crates/sysknife-daemon/tests/helper_install_coverage.rs
Original file line number Diff line number Diff line change
Expand Up @@ -202,6 +202,27 @@ fn every_referenced_helper_has_a_sudoers_grant() {
.expect("read sudoers");
for helper in referenced_helpers() {
let expected = format!("/usr/lib/sysknife/{helper}");
if helper == "firewall-state" {
assert!(
!sudoers
.lines()
.filter(|line| !line.trim_start().starts_with('#'))
.any(|line| line.contains(&expected)),
"{expected} must have NO sudoers grant: the reporter must not be root-callable"
);
// This reporter runs as the daemon user; only its fixed probe
// commands have sudo grants. Do not grant the whole helper root.
use sysknife_daemon::actions::{all_specs, ActionMechanism};
let specs = all_specs();
let reporter = specs
.iter()
.find(|s| s.action_name == "GetFirewallBackendState")
.expect("backend reporter is catalogued");
assert!(matches!(&reporter.mechanism,
ActionMechanism::Command { program, args }
if *program == expected && args.is_empty()));
continue;
}
assert!(
sudoers.contains(&expected),
"sudoers must grant {expected}, otherwise the action prompts for a password \
Expand Down
2 changes: 2 additions & 0 deletions crates/sysknife-types/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -138,6 +138,8 @@ pub const KNOWN_ACTION_NAMES: &[&str] = &[
"GetFirewallState",
"GetNetworkStatus",
"GetListeningPorts",
"GetNftablesRuleset",
"GetFirewallBackendState",
"ConfigureWifi",
"SetDnsServers",
"ConfigureFirewall",
Expand Down
4 changes: 3 additions & 1 deletion docs/action-reference.md
Original file line number Diff line number Diff line change
Expand Up @@ -182,6 +182,8 @@ Every row is derived from the live code: the command from each action's `ActionS
| `GetFirewallState` | `firewall-cmd --list-all` | Low | All | – | – | show current firewalld zones, open services, and port rules — no params |
| `GetNetworkStatus` | `ip -brief addr` | Low | All | – | – | show LIVE network state: interfaces, IP addresses, and connection state — no params; this is runtime status, NOT the saved configuration; on Ubuntu the saved config is NetplanGetConfig |
| `GetListeningPorts` | `ss -tulpnH` | Low | All | – | – | show listening TCP/UDP sockets and the process bound to each (ss -tulpn) — no params; read-only; use for "what is listening on port X?" |
| `GetNftablesRuleset` | `sudo nft list ruleset` | Low | All | – | – | read the current nftables ruleset — no params; read-only; rules do not by themselves prove that traffic is blocked |
| `GetFirewallBackendState` | `/usr/lib/sysknife/firewall-state` | Low | All | – | – | inspect nftables, ufw and firewalld observations — no params; read-only; use for general firewall status; unavailable or inactive frontends do not prove the host is unfiltered |

## resolvectl

Expand Down Expand Up @@ -396,4 +398,4 @@ Every row is derived from the live code: the command from each action's `ActionS

---

_189 actions have an `ActionSpec` and are tabled above. The full catalogue (`KNOWN_ACTION_NAMES`) also includes `ListJobHistory`, which the dispatcher handles before the executor, for **190** total._
_191 actions have an `ActionSpec` and are tabled above. The full catalogue (`KNOWN_ACTION_NAMES`) also includes `ListJobHistory`, which the dispatcher handles before the executor, for **192** total._
2 changes: 1 addition & 1 deletion docs/architecture.md
Original file line number Diff line number Diff line change
Expand Up @@ -71,7 +71,7 @@ component uses the same resolution order.

Privileged. The only component that touches the system. Provides:

- 190 typed actions (rpm-ostree, systemd, firewall, users, containers,
- 192 typed actions (rpm-ostree, systemd, firewall, users, containers,
flatpak, toolbox, SSH, kernel args, …)
- Role-based authorization (`Observer` → `Dev` → `Admin`, plus `Boot`)
- Policy enforcement: stale-approval detection, request hash validation
Expand Down
2 changes: 1 addition & 1 deletion docs/distro-support.md
Original file line number Diff line number Diff line change
Expand Up @@ -91,7 +91,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,860 Rust tests plus 72 frontend
The deterministic workspace baseline is 1,861 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.

Expand Down
37 changes: 37 additions & 0 deletions docs/firewall-observations.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
# Firewall observations

Use `GetFirewallBackendState` for a general firewall-state question. It probes
nftables JSON, `ufw status verbose`, and `firewall-cmd --list-all`. It reports
observed frontends and hooked nftables rules, preserving the individual probe
output excerpts and failure status. `query_firewall` uses this action during planning.

The helper computes summaries from complete probe output before bounding the
diagnostics. State, backend observations, nftables counts and the safety note
precede `probes`. Each stdout excerpt is limited to 1,024 JSON-encoded bytes,
each stderr excerpt to 512, including escaping and the explicit
`[truncated by firewall-state]` marker. This leaves the complete JSON response
below the planner's 8 KiB cap, including for non-ASCII or escape-heavy output.
Small outputs remain unchanged. Excerpts can still contain firewall topology
and are sent to the configured model; run the read-only commands locally for
complete output rather than relying on these diagnostic excerpts.

`GetNftablesRuleset` runs the fixed read-only `sudo nft list ruleset` command.
The sudoers grants allow only that command and its JSON form; neither grant
allows changing the ruleset. The reporter helper itself runs without sudo.

An inactive ufw frontend does not imply the machine has no firewall. Likewise,
an empty nftables ruleset or a failed probe is not proof that traffic is
unfiltered: legacy iptables, other namespaces and other mechanisms can exist.
The reporter returns `unknown` when it cannot identify an observed backend.
Even observed rules do not establish whether particular traffic is blocked.

Frontends may coexist or use nftables underneath, so the output is a list of
observations rather than a mutually exclusive backend guess. Existing
`GetFirewallState` remains the firewalld-specific zone query, and `UfwStatus`
remains ufw-specific. Neither is a general host-firewall verdict.

The first change for [#239](https://github.com/lacs-project/sysknife/issues/239)
does not add rule mutation or automatically refuse installed ufw tooling based
on a probe result. Mutating parity needs a separate design for tables, chains,
handles and rollback. Live Debian validation remains separate from fixture
tests and from distro eligibility.
2 changes: 1 addition & 1 deletion docs/introduction.md
Original file line number Diff line number Diff line change
Expand Up @@ -144,7 +144,7 @@ flow.

## Status

190 typed actions · 1,860 Rust tests + 72 frontend tests · MIT
192 typed actions · 1,861 Rust tests + 72 frontend tests · MIT

SysKnife is the reference implementation of the
[LACS specification](https://github.com/lacs-project/specification) — a
Expand Down
2 changes: 1 addition & 1 deletion docs/typed-actions.md
Original file line number Diff line number Diff line change
Expand Up @@ -62,7 +62,7 @@ codebase. On the daemon side (`sysknife-daemon`), each action is backed by an
| `reboot_required` | Whether the daemon should warn the caller before proceeding |
| `rollback_available` | Whether a failure triggers automatic rollback |

As of this writing the catalogue defines **190 actions** across families such
As of this writing the catalogue defines **192 actions** across families such
as Deployment, Services, Package Layering, Flatpak, Containers, Toolbox,
Network, Identity, SSH Keys, Package Repositories, apt/snap/ufw/netplan/grub
(Debian-family), and rpm-ostree/AppArmor/cloud-init/Pro (Fedora-family). Each
Expand Down
Loading
Loading