From 897856c175bcb4a29b992a171893562736c3495c Mon Sep 17 00:00:00 2001 From: hushen <190065939+918154429@users.noreply.github.com> Date: Thu, 10 Sep 2026 13:28:04 +0800 Subject: [PATCH 1/8] feat(network): report nftables and firewall backend observations --- .github/workflows/ci.yml | 3 + CHANGELOG.md | 3 + CONTRIBUTING.md | 2 +- Makefile | 2 + README.md | 2 +- apps/sysknife-cli/src/mcp_server.rs | 2 + .../src/planning_tools/propose_plan.rs | 4 ++ .../src/planning_tools/query_tools.rs | 6 +- crates/sysknife-daemon/src/actions/network.rs | 56 +++++++++++++++ crates/sysknife-daemon/src/executor.rs | 2 + crates/sysknife-daemon/src/preview.rs | 2 + .../sysknife-daemon/tests/actions_batch2.rs | 2 + crates/sysknife-types/src/lib.rs | 2 + docs/action-reference.md | 4 +- docs/architecture.md | 2 +- docs/firewall-observations.md | 27 ++++++++ docs/introduction.md | 2 +- docs/typed-actions.md | 2 +- packaging/sysknife-firewall-state | 69 +++++++++++++++++++ packaging/sysknife-sudoers | 3 + scripts/ci-local.sh | 1 + tests/test_firewall_state.py | 60 ++++++++++++++++ 22 files changed, 249 insertions(+), 9 deletions(-) create mode 100644 docs/firewall-observations.md create mode 100755 packaging/sysknife-firewall-state create mode 100644 tests/test_firewall_state.py diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 7f9300ff..7e5a7f2a 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -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. diff --git a/CHANGELOG.md b/CHANGELOG.md index 8d8d55fb..5dbb264c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -14,6 +14,9 @@ Releases before `0.2.5` predate the public launch; their notes live in the ### Changed +- 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; mutating nftables actions remain out of scope (#239). - 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 diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 65fac168..328caaca 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -47,7 +47,7 @@ table below. | **Ubuntu LTS support** | All three LTS releases are validated against the full story suite on a live VM, each with a committed replay twin that reproduces it: 22.04, 24.04 and 26.04 all at 79/79. `ubuntu-vm.sh` accepts `UBUNTU_RELEASE=jammy\|noble\|resolute`. Remaining: story coverage for the cross-family actions, and five Debian-only ones that still have none: the four fail2ban actions and `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 133 stories: 54 atomic + 79 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 5 Ubuntu-only ones. See #233 for the clustered map. | medium | +| **E2E story coverage** | Real prompts, real LLM, real daemon. The suite is 133 stories: 54 atomic + 79 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 5 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 | diff --git a/Makefile b/Makefile index 3824d78f..b9ef2c01 100644 --- a/Makefile +++ b/Makefile @@ -118,6 +118,7 @@ daemon-install: daemon-install-preflight build # sysknife-daemon --test helper_install_coverage` derives the required set # 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-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 @@ -148,6 +149,7 @@ daemon-uninstall: rm -f $(SYSUSERS)/sysknife.conf rm -f $(TMPFILES)/sysknife.conf rm -f $(HELPERS)/apt-pin-edit + rm -f $(HELPERS)/firewall-state rm -f $(HELPERS)/audit-edit rm -f $(HELPERS)/fail2ban-jail-edit rm -f $(HELPERS)/grub-kargs-edit diff --git a/README.md b/README.md index 77f2dcb4..f5aa635c 100644 --- a/README.md +++ b/README.md @@ -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) | ✅ | diff --git a/apps/sysknife-cli/src/mcp_server.rs b/apps/sysknife-cli/src/mcp_server.rs index c139939e..224b4cfd 100644 --- a/apps/sysknife-cli/src/mcp_server.rs +++ b/apps/sysknife-cli/src/mcp_server.rs @@ -444,6 +444,8 @@ const MCP_READ_ONLY_ACTIONS: &[&str] = &[ "GetFirewallState", "GetNetworkStatus", "GetListeningPorts", + "GetNftablesRuleset", + "GetFirewallBackendState", "ResolvectlStatus", "GetDateTime", "ListUsers", diff --git a/crates/sysknife-brain/src/planning_tools/propose_plan.rs b/crates/sysknife-brain/src/planning_tools/propose_plan.rs index a5859f20..f266b134 100644 --- a/crates/sysknife-brain/src/planning_tools/propose_plan.rs +++ b/crates/sysknife-brain/src/planning_tools/propose_plan.rs @@ -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", diff --git a/crates/sysknife-brain/src/planning_tools/query_tools.rs b/crates/sysknife-brain/src/planning_tools/query_tools.rs index 1aab020d..02a4dfab 100644 --- a/crates/sysknife-brain/src/planning_tools/query_tools.rs +++ b/crates/sysknife-brain/src/planning_tools/query_tools.rs @@ -16,7 +16,7 @@ pub fn query_tools() -> Vec { }, 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 { @@ -226,7 +226,7 @@ pub fn query_tool_to_action( ) -> Result, String> { match tool_name { "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!({})))), @@ -304,7 +304,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), diff --git a/crates/sysknife-daemon/src/actions/network.rs b/crates/sysknife-daemon/src/actions/network.rs index b4b63d67..54723cd9 100644 --- a/crates/sysknife-daemon/src/actions/network.rs +++ b/crates/sysknife-daemon/src/actions/network.rs @@ -9,6 +9,8 @@ pub fn specs() -> Vec { get_firewall_state(), get_network_status(), get_listening_ports(), + get_nftables_ruleset(), + get_firewall_backend_state(), ] } @@ -115,6 +117,60 @@ 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, + } +} + +#[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(), + } + ); + } + } +} + /// 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 diff --git a/crates/sysknife-daemon/src/executor.rs b/crates/sysknife-daemon/src/executor.rs index 219eec4d..be22186c 100644 --- a/crates/sysknife-daemon/src/executor.rs +++ b/crates/sysknife-daemon/src/executor.rs @@ -1315,6 +1315,8 @@ pub fn build_action_spec(action_name: &str, params: &Value) -> Result 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. diff --git a/crates/sysknife-daemon/src/preview.rs b/crates/sysknife-daemon/src/preview.rs index 6d1d7e85..e80770da 100644 --- a/crates/sysknife-daemon/src/preview.rs +++ b/crates/sysknife-daemon/src/preview.rs @@ -143,6 +143,8 @@ fn preview_profile(action_name: &str) -> PreviewProfile { | "GetMemoryInfo" | "GetNetworkStatus" | "GetListeningPorts" + | "GetNftablesRuleset" + | "GetFirewallBackendState" | "GetJournalLog" | "GetLvmReport" | "GetSysctl" diff --git a/crates/sysknife-daemon/tests/actions_batch2.rs b/crates/sysknife-daemon/tests/actions_batch2.rs index 7cc85758..1e3f6bbd 100644 --- a/crates/sysknife-daemon/tests/actions_batch2.rs +++ b/crates/sysknife-daemon/tests/actions_batch2.rs @@ -244,6 +244,8 @@ fn network_family_covers_wifi_dns_and_firewall() { "GetFirewallState", "GetNetworkStatus", "GetListeningPorts", + "GetNftablesRuleset", + "GetFirewallBackendState", ] ); } diff --git a/crates/sysknife-types/src/lib.rs b/crates/sysknife-types/src/lib.rs index 5d43f2ff..8f2f7606 100644 --- a/crates/sysknife-types/src/lib.rs +++ b/crates/sysknife-types/src/lib.rs @@ -138,6 +138,8 @@ pub const KNOWN_ACTION_NAMES: &[&str] = &[ "GetFirewallState", "GetNetworkStatus", "GetListeningPorts", + "GetNftablesRuleset", + "GetFirewallBackendState", "ConfigureWifi", "SetDnsServers", "ConfigureFirewall", diff --git a/docs/action-reference.md b/docs/action-reference.md index 2e9551d1..e7e2ff11 100644 --- a/docs/action-reference.md +++ b/docs/action-reference.md @@ -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 @@ -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._ diff --git a/docs/architecture.md b/docs/architecture.md index ddc266af..b50a9aa1 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -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 diff --git a/docs/firewall-observations.md b/docs/firewall-observations.md new file mode 100644 index 00000000..5094a38d --- /dev/null +++ b/docs/firewall-observations.md @@ -0,0 +1,27 @@ +# 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 and failure status. `query_firewall` uses this action during planning. + +`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. diff --git a/docs/introduction.md b/docs/introduction.md index ae321a48..3e118fda 100644 --- a/docs/introduction.md +++ b/docs/introduction.md @@ -141,7 +141,7 @@ flow. ## Status -190 typed actions · 1,851 Rust tests + 72 frontend tests · MIT +192 typed actions · 1,851 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/docs/typed-actions.md b/docs/typed-actions.md index fa2d3cf7..aaa86d5a 100644 --- a/docs/typed-actions.md +++ b/docs/typed-actions.md @@ -53,7 +53,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 diff --git a/packaging/sysknife-firewall-state b/packaging/sysknife-firewall-state new file mode 100755 index 00000000..d6bfcb2c --- /dev/null +++ b/packaging/sysknife-firewall-state @@ -0,0 +1,69 @@ +#!/usr/bin/env python3 +"""Read firewall observations without treating an inactive frontend as no firewall. + +Installed root-owned at /usr/lib/sysknife/firewall-state, executed unprivileged. +Only the fixed nft/ufw read commands use the daemon account's exact sudo grants. +""" +import json +import os +import subprocess +import sys + + +def probe(argv): + try: + result = subprocess.run(argv, capture_output=True, text=True, timeout=5, + env={**os.environ, "LC_ALL": "C"}) + return {"status": "ok" if result.returncode == 0 else "failed", + "stdout": result.stdout, "stderr": result.stderr, + "returncode": result.returncode} + except FileNotFoundError: + return {"status": "unavailable", "stdout": "", "stderr": "command unavailable"} + except subprocess.TimeoutExpired: + return {"status": "timeout", "stdout": "", "stderr": "probe exceeded 5 seconds"} + except OSError as error: + return {"status": "failed", "stdout": "", "stderr": str(error)} + + +def summarize(nft, ufw, firewalld): + backends = [] + if ufw["status"] == "ok" and "Status: active" in ufw["stdout"].splitlines(): + backends.append("ufw") + if firewalld["status"] == "ok" and firewalld["stdout"].strip(): + backends.append("firewalld") + nft_state = {"status": "unknown"} + if nft["status"] == "ok": + try: + entries = json.loads(nft["stdout"])["nftables"] + if not isinstance(entries, list) or not all(isinstance(e, dict) for e in entries): + raise ValueError("nftables must be a list of objects") + chains = [e["chain"] for e in entries if isinstance(e.get("chain"), dict)] + hooks = [c for c in chains if c.get("hook")] + rules = sum(isinstance(e.get("rule"), dict) for e in entries) + nft_state = {"status": "rules_present" if rules or hooks else "no_rules_observed", + "rule_count": rules, "base_chain_count": len(hooks)} + if hooks and (rules or any(c.get("policy") == "drop" for c in hooks)): + backends.append("nftables") + except (ValueError, KeyError, TypeError): + nft_state = {"status": "unknown", "reason": "invalid nft JSON output"} + return {"state": "observations_available" if backends else "unknown", + "backends_observed": backends, "nftables": nft_state, + "probes": {"nftables": nft, "ufw": ufw, "firewalld": firewalld}, + "note": "Frontends can share nftables. Rules or an active frontend do not prove " + "that particular traffic is blocked. Empty/failed probes do not prove " + "the host is unfiltered; legacy iptables and other mechanisms may exist."} + + +def main(): + if len(sys.argv) != 1: + print("firewall-state accepts no arguments", file=sys.stderr) + return 2 + result = summarize(probe(["sudo", "-n", "nft", "-j", "list", "ruleset"]), + probe(["sudo", "-n", "ufw", "status", "verbose"]), + probe(["firewall-cmd", "--list-all"])) + print(json.dumps(result)) + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/packaging/sysknife-sudoers b/packaging/sysknife-sudoers index 4babd4c6..3a71c4d1 100644 --- a/packaging/sysknife-sudoers +++ b/packaging/sysknife-sudoers @@ -14,6 +14,9 @@ # not this file. See each grant's comment for its exact call sites and rationale. # # IMPORTANT: test with `visudo -cf /etc/sudoers.d/sysknife` before deploying. +# Fixed read-only nftables probes; no wildcard or rule-changing command. +sysknife ALL=(root) NOPASSWD: /usr/sbin/nft list ruleset +sysknife ALL=(root) NOPASSWD: /usr/sbin/nft -j list ruleset # # Note: `Defaults:sysknife !requiretty` was removed. Ubuntu 26.04 ships sudo-rs # (0.2.x — the memory-safe Rust reimplementation, now the default; verified on a diff --git a/scripts/ci-local.sh b/scripts/ci-local.sh index eae32cd3..14d5335c 100755 --- a/scripts/ci-local.sh +++ b/scripts/ci-local.sh @@ -260,6 +260,7 @@ hygiene_shellcheck() ( run_hygiene_group() { printf '\n### hygiene\n' + run_step 'hygiene: firewall backend reporter fixtures' python3 "$repo_root/tests/test_firewall_state.py" run_step 'hygiene: check_repo_completeness.sh' bash "$repo_root/scripts/check_repo_completeness.sh" run_step 'hygiene: check_release_versions.sh' bash "$repo_root/scripts/check_release_versions.sh" run_step 'hygiene: release-version-pins.test.sh' bash "$repo_root/tests/release/release-version-pins.test.sh" diff --git a/tests/test_firewall_state.py b/tests/test_firewall_state.py new file mode 100644 index 00000000..031b4724 --- /dev/null +++ b/tests/test_firewall_state.py @@ -0,0 +1,60 @@ +"""Fixture tests for the read-only firewall reporter; no host probes run.""" +import importlib.machinery +import importlib.util +import json +from pathlib import Path +import unittest + +path = Path(__file__).resolve().parents[1] / "packaging/sysknife-firewall-state" +loader = importlib.machinery.SourceFileLoader("firewall_state", str(path)) +spec = importlib.util.spec_from_loader(loader.name, loader) +module = importlib.util.module_from_spec(spec) +loader.exec_module(module) + + +def probe(stdout="", status="ok"): + return {"status": status, "stdout": stdout, "stderr": ""} + + +def nft(entries): + return probe(json.dumps({"nftables": entries})) + + +class FirewallStateTests(unittest.TestCase): + def test_nft_rules_are_visible_when_ufw_is_inactive(self): + result = module.summarize(nft([ + {"chain": {"hook": "input", "policy": "drop"}}, + {"rule": {"expr": [{"accept": None}]}} + ]), probe("Status: inactive"), probe(status="failed")) + self.assertEqual(result["nftables"]["status"], "rules_present") + self.assertIn("nftables", result["backends_observed"]) + self.assertNotIn("ufw", result["backends_observed"]) + + def test_active_ufw_and_nft_are_not_exclusive(self): + result = module.summarize(nft([{"chain": {"hook": "input", "policy": "drop"}}]), probe("Status: active\nTo Action From"), probe(status="failed")) + self.assertEqual(result["backends_observed"], ["ufw", "nftables"]) + + def test_neither_never_claims_the_host_is_unfiltered(self): + result = module.summarize(nft([]), probe("Status: inactive"), probe(status="failed")) + self.assertEqual(result["state"], "unknown") + self.assertEqual(result["backends_observed"], []) + + def test_unavailable_permission_denied_and_malformed_are_unknown(self): + for p in [probe(status="unavailable"), probe(status="failed"), probe(status="timeout"), probe("not json"), probe('{}'), probe('{"nftables":{}}')]: + result = module.summarize(p, probe("Status: inactive"), probe(status="failed")) + self.assertEqual(result["state"], "unknown") + self.assertNotEqual(result["nftables"]["status"], "no_rules_observed") + + def test_unhooked_rules_and_empty_tables_do_not_prove_filtering(self): + for entries in [[{"table": {"name": "filter"}}], [{"chain": {"name": "unused"}}, {"rule": {"expr": [{"drop": None}]}}]]: + result = module.summarize(nft(entries), probe(status="unavailable"), probe(status="unavailable")) + self.assertEqual(result["state"], "unknown") + + def test_firewalld_output_is_preserved(self): + result = module.summarize(nft([]), probe("Status: inactive"), probe("public (active)\n services: ssh")) + self.assertIn("firewalld", result["backends_observed"]) + self.assertIn("services: ssh", result["probes"]["firewalld"]["stdout"]) + + +if __name__ == "__main__": + unittest.main() From c4aaef99a55490857a56ccea00ead7ddbe9d7d20 Mon Sep 17 00:00:00 2001 From: hushen <190065939+918154429@users.noreply.github.com> Date: Thu, 10 Sep 2026 13:29:36 +0800 Subject: [PATCH 2/8] test(packaging): distinguish unprivileged firewall reporter --- .../tests/helper_install_coverage.rs | 14 ++++++++++++++ 1 file changed, 14 insertions(+) diff --git a/crates/sysknife-daemon/tests/helper_install_coverage.rs b/crates/sysknife-daemon/tests/helper_install_coverage.rs index e8af31e2..3a0456a2 100644 --- a/crates/sysknife-daemon/tests/helper_install_coverage.rs +++ b/crates/sysknife-daemon/tests/helper_install_coverage.rs @@ -202,6 +202,20 @@ 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" { + // 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 \ From 35c24aa3f508cde67685921fdefa1468942fa86e Mon Sep 17 00:00:00 2001 From: hushen <190065939+918154429@users.noreply.github.com> Date: Thu, 10 Sep 2026 13:31:38 +0800 Subject: [PATCH 3/8] style(network): place firewall tests after production items --- crates/sysknife-daemon/src/actions/network.rs | 33 ++++++++++--------- 1 file changed, 17 insertions(+), 16 deletions(-) diff --git a/crates/sysknife-daemon/src/actions/network.rs b/crates/sysknife-daemon/src/actions/network.rs index 54723cd9..e1bc0a02 100644 --- a/crates/sysknife-daemon/src/actions/network.rs +++ b/crates/sysknife-daemon/src/actions/network.rs @@ -139,6 +139,23 @@ pub fn get_firewall_backend_state() -> ActionSpec { } } +/// 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 +/// is complete regardless of privilege, process attribution is best-effort. +pub fn get_listening_ports() -> ActionSpec { + ActionSpec { + action_name: "GetListeningPorts", + // -t tcp, -u udp, -l listening only, -p show process, -n numeric + // (no DNS/service-name lookups), -H suppress the header row. + mechanism: command_mechanism("ss", ["-tulpnH"]), + risk_level: RiskLevel::Low, + reboot_required: false, + rollback_available: false, + } +} + + #[cfg(test)] mod firewall_tests { use super::*; @@ -170,19 +187,3 @@ mod firewall_tests { } } } - -/// 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 -/// is complete regardless of privilege, process attribution is best-effort. -pub fn get_listening_ports() -> ActionSpec { - ActionSpec { - action_name: "GetListeningPorts", - // -t tcp, -u udp, -l listening only, -p show process, -n numeric - // (no DNS/service-name lookups), -H suppress the header row. - mechanism: command_mechanism("ss", ["-tulpnH"]), - risk_level: RiskLevel::Low, - reboot_required: false, - rollback_available: false, - } -} From 03c5425cc89c9bc27a6c4b8795f20658341c2960 Mon Sep 17 00:00:00 2001 From: hushen <190065939+918154429@users.noreply.github.com> Date: Thu, 10 Sep 2026 13:33:05 +0800 Subject: [PATCH 4/8] style(network): normalize module spacing --- crates/sysknife-daemon/src/actions/network.rs | 1 - 1 file changed, 1 deletion(-) diff --git a/crates/sysknife-daemon/src/actions/network.rs b/crates/sysknife-daemon/src/actions/network.rs index e1bc0a02..ade76fe1 100644 --- a/crates/sysknife-daemon/src/actions/network.rs +++ b/crates/sysknife-daemon/src/actions/network.rs @@ -155,7 +155,6 @@ pub fn get_listening_ports() -> ActionSpec { } } - #[cfg(test)] mod firewall_tests { use super::*; From 85702d797c883d7281df4535ba4c697e26e124ab Mon Sep 17 00:00:00 2001 From: hushen <190065939+918154429@users.noreply.github.com> Date: Thu, 10 Sep 2026 13:38:03 +0800 Subject: [PATCH 5/8] test(mcp): account for two new observer queries --- apps/sysknife-cli/src/mcp_server.rs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/apps/sysknife-cli/src/mcp_server.rs b/apps/sysknife-cli/src/mcp_server.rs index 224b4cfd..4bed33d2 100644 --- a/apps/sysknife-cli/src/mcp_server.rs +++ b/apps/sysknife-cli/src/mcp_server.rs @@ -1827,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"])); } From 7bf0b44796a2fd4d035f6b37e97faf69a0a80d5a Mon Sep 17 00:00:00 2001 From: hushen <190065939+918154429@users.noreply.github.com> Date: Tue, 15 Sep 2026 00:17:59 +0800 Subject: [PATCH 6/8] test: record 1853 tests observed in Linux CI --- 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 964154a0..6bd120c0 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,852 Rust tests and 72 frontend tests** form the current deterministic +**1,853 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 a622c644..0578df7f 100644 --- a/docs/distro-support.md +++ b/docs/distro-support.md @@ -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,852 Rust tests plus 72 frontend +The deterministic workspace baseline is 1,853 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 6a84bbbd..863d24d8 100644 --- a/docs/introduction.md +++ b/docs/introduction.md @@ -141,7 +141,7 @@ flow. ## Status -192 typed actions · 1,852 Rust tests + 72 frontend tests · MIT +192 typed actions · 1,853 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 f40c3476..e175f7b4 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": 1852, + "tests": 1853, "version": 2 } From a43efb8eaf176f48ed83beb230098016cef7d733 Mon Sep 17 00:00:00 2001 From: hushen <190065939+918154429@users.noreply.github.com> Date: Tue, 15 Sep 2026 00:18:00 +0800 Subject: [PATCH 7/8] fix: upgrade rustls for RUSTSEC-2026-0285 --- Cargo.lock | 17 +++++++++-------- 1 file changed, 9 insertions(+), 8 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 83cebc79..acd832f6 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -242,9 +242,9 @@ checksum = "c08606f8c3cbf4ce6ec8e28fb0014a2c086708fe954eaa885384a6165172e7e8" [[package]] name = "aws-lc-rs" -version = "1.16.3" +version = "1.18.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0ec6fb3fe69024a75fa7e1bfb48aa6cf59706a101658ea01bfd33b2b248a038f" +checksum = "b281d307588d634de920874890732659e2e7672f72b5e10e81badc1a8a83621e" dependencies = [ "aws-lc-sys", "zeroize", @@ -252,14 +252,15 @@ dependencies = [ [[package]] name = "aws-lc-sys" -version = "0.40.0" +version = "0.45.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f50037ee5e1e41e7b8f9d161680a725bd1626cb6f8c7e901f91f942850852fe7" +checksum = "9bff6c3b54fad79a2e60b8102caf565819711497c1f5f092f49508e2f5c31b27" dependencies = [ "cc", "cmake", "dunce", "fs_extra", + "pkg-config", ] [[package]] @@ -4010,9 +4011,9 @@ dependencies = [ [[package]] name = "rustls" -version = "0.23.39" +version = "0.23.45" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7c2c118cb077cca2822033836dfb1b975355dfb784b5e8da48f7b6c5db74e60e" +checksum = "0d41d731c7d2f962d1ccc364cec258de3c0e93b38c2fb3ba97ac74513048d634" dependencies = [ "aws-lc-rs", "once_cell", @@ -4074,9 +4075,9 @@ checksum = "f87165f0995f63a9fbeea62b64d10b4d9d8e78ec6d7d51fb2125fda7bb36788f" [[package]] name = "rustls-webpki" -version = "0.103.13" +version = "0.103.15" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "61c429a8649f110dddef65e2a5ad240f747e85f7758a6bccc7e5777bd33f756e" +checksum = "f3c3cf1d8b1e7d4927e2d154c3fcb02979afb9939629c62cd9048d4f07b60ac2" dependencies = [ "aws-lc-rs", "ring", From 2fd71f8b31856d987c9c7c914f965fa49749c74a Mon Sep 17 00:00:00 2001 From: hushen <190065939+918154429@users.noreply.github.com> Date: Tue, 15 Sep 2026 09:48:40 +0800 Subject: [PATCH 8/8] fix(network): bound firewall diagnostics before planner truncation --- CHANGELOG.md | 4 ++- docs/firewall-observations.md | 12 ++++++++- packaging/sysknife-firewall-state | 34 ++++++++++++++++++++++-- tests/test_firewall_state.py | 43 +++++++++++++++++++++++++++++++ 4 files changed, 89 insertions(+), 4 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 88c90f8b..5bba7cec 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -16,7 +16,9 @@ Releases before `0.2.5` predate the public launch; their notes live in the - 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; mutating nftables actions remain out of scope (#239). + 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). ## [0.15.0] — 2026-09-10 diff --git a/docs/firewall-observations.md b/docs/firewall-observations.md index 5094a38d..b085bc07 100644 --- a/docs/firewall-observations.md +++ b/docs/firewall-observations.md @@ -3,7 +3,17 @@ 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 and failure status. `query_firewall` uses this action during planning. +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 diff --git a/packaging/sysknife-firewall-state b/packaging/sysknife-firewall-state index f21b9365..9c245fef 100755 --- a/packaging/sysknife-firewall-state +++ b/packaging/sysknife-firewall-state @@ -10,6 +10,33 @@ import subprocess import sys +TRUNCATION_MARKER = "\n[truncated by firewall-state]" +# Budgets include JSON quotes/escaping, not just source characters. All six +# streams together leave room for the summary below the brain's 8 KiB cap. +STDOUT_JSON_BYTES = 1024 +STDERR_JSON_BYTES = 512 + + +def bounded_text(text, budget): + if len(json.dumps(text)) <= budget: + return text + remaining = budget - len(json.dumps(TRUNCATION_MARKER)) + prefix = [] + for char in text: + width = len(json.dumps(char)) - 2 + if width > remaining: + break + prefix.append(char) + remaining -= width + return "".join(prefix) + TRUNCATION_MARKER + + +def bounded_probe(observation): + return {**observation, + "stdout": bounded_text(observation["stdout"], STDOUT_JSON_BYTES), + "stderr": bounded_text(observation["stderr"], STDERR_JSON_BYTES)} + + def probe(argv): try: result = subprocess.run(argv, capture_output=True, text=True, timeout=5, @@ -48,10 +75,13 @@ def summarize(nft, ufw, firewalld): nft_state = {"status": "unknown", "reason": "invalid nft JSON output"} return {"state": "observations_available" if backends else "unknown", "backends_observed": backends, "nftables": nft_state, - "probes": {"nftables": nft, "ufw": ufw, "firewalld": firewalld}, "note": "Frontends can share nftables. Rules or an active frontend do not prove " "that particular traffic is blocked. Empty/failed probes do not prove " - "the host is unfiltered; legacy iptables and other mechanisms may exist."} + "the host is unfiltered; legacy iptables and other mechanisms may exist.", + # Parse complete evidence above; only diagnostic excerpts are capped. + # Keep interpretation before diagnostics even if another layer caps it. + "probes": {"nftables": bounded_probe(nft), "ufw": bounded_probe(ufw), + "firewalld": bounded_probe(firewalld)}} def main(): diff --git a/tests/test_firewall_state.py b/tests/test_firewall_state.py index 031b4724..272acdf6 100644 --- a/tests/test_firewall_state.py +++ b/tests/test_firewall_state.py @@ -2,6 +2,7 @@ import importlib.machinery import importlib.util import json +import re from pathlib import Path import unittest @@ -21,6 +22,48 @@ def nft(entries): class FirewallStateTests(unittest.TestCase): + def test_large_ruleset_keeps_summary_and_caveat_before_bounded_probes(self): + entries = [{"chain": {"hook": "forward", "policy": "drop"}}] + entries += [{"rule": {"comment": "forward rule " + "x" * 200}} for _ in range(120)] + original = nft(entries) + result = module.summarize(original, probe("Status: inactive"), probe(status="failed")) + payload = json.dumps(result) + self.assertLess(payload.index('"note"'), payload.index('"probes"')) + self.assertEqual(result["nftables"]["rule_count"], 120) + self.assertIn("nftables", result["backends_observed"]) + self.assertIn("[truncated by firewall-state]", result["probes"]["nftables"]["stdout"]) + self.assertEqual(json.loads(original["stdout"])["nftables"], entries) + self.assert_payload_survives_brain_cap(payload) + + def assert_payload_survives_brain_cap(self, payload): + # Read the shipped cap so a future reduction cannot silently invalidate + # the helper's wire budget. The entire JSON must fit, not just its note. + source = (path.parents[1] / "crates/sysknife-brain/src/sanitize.rs").read_text(encoding="utf-8") + match = re.search(r"pub const MAX_OUTPUT_BYTES: usize = (\d+) \* (\d+);", source) + self.assertIsNotNone(match) + cap = int(match[1]) * int(match[2]) + encoded = payload.encode("utf-8") + self.assertLessEqual(len(encoded), cap) + survived = json.loads(encoded[:cap]) + self.assertIn("Empty/failed probes do not prove", survived["note"]) + + def test_all_probe_streams_are_bounded_after_json_escaping(self): + for text in ['"\\\n\t' * 5000, "防火墙😀" * 5000]: + with self.subTest(text=text[:8]): + p = {"status": "failed", "stdout": text, "stderr": text, "returncode": 1} + result = module.summarize(p, p, p) + self.assertEqual(result["state"], "unknown") + for observation in result["probes"].values(): + self.assertEqual(observation["returncode"], 1) + for field in ("stdout", "stderr"): + self.assertIn("[truncated by firewall-state]", observation[field]) + self.assert_payload_survives_brain_cap(json.dumps(result)) + + def test_small_probe_streams_are_preserved_without_a_marker(self): + p = {"status": "failed", "stdout": "a\\b\n中文", "stderr": "permission denied", "returncode": 1} + result = module.summarize(p, p, p) + self.assertEqual(result["probes"], {"nftables": p, "ufw": p, "firewalld": p}) + def test_nft_rules_are_visible_when_ufw_is_inactive(self): result = module.summarize(nft([ {"chain": {"hook": "input", "policy": "drop"}},