Skip to main content

max / alloy

Say on the usb screen whether anything is policing the bus Part of step 3. The image carries usbguard and deliberately does not run it, and until now the screen said nothing about that -- so a reader who could see the tooling was present could reasonably conclude the bus was being policed. Enforcement is now a state the screen reads and states: absent, unarmed with no policy, unarmed with a policy written, or armed. The three unarmed states are Info rather than Warn on purpose. This is what every Alloy machine ships as, it is a documented position, and a screen that cried warning about its own default would train the reader to skip the row. Two things measured rather than assumed. rules.conf is 0600 root:root, so the console cannot read it -- but the directory above is world-executable, so it can stat it, and size is the whole of what this needs: empty against written is the difference between arming being safe and arming being a brick. And the daemon state comes from `systemctl is-active` rather than from usbguard, because every verb except generate-policy goes over the daemon's IPC socket and exits 1 with "IPC connect: Connection refused" when it is not there, which conflates "not running" with "running and refusing me". The acting half is deliberately not in this commit. `allow-device` is addressed by the device id the daemon assigns in `list-devices`, not by anything in sysfs, so the join from a row on this screen to a device usbguard will act on cannot be written against a machine where the daemon has never run. It goes with the bench tests against a real armed daemon rather than shipping as a guess.
Author: Max Johnson <me@maxj.phd> · 2026-08-23 00:08 UTC
Signed with PGP, not checked
Commit: 54f14f8e1bbe671de1c21933f41645038ac8087c
Parent: f346518
2 files changed, +247 insertions, -14 deletions
@@ -58,15 +58,28 @@
58 58 //! go straight to sysfs and every parse is a pure function over a string, so the
59 59 //! tests describe machines this one is not.
60 60 //!
61 - //! The image gained `usbguard` on 2026-08-22 and this module still does not use
62 - //! it, which is deliberate on both counts. The package is there so the action
63 - //! half has something to call; its daemon ships disabled, because its stock
64 - //! policy is an empty rule file with `ImplicitPolicyTarget=block` and arming
65 - //! that deauthorizes the machine's own keyboard at boot. Reading sysfs rather
66 - //! than `usbguard list-devices` is what keeps this screen answering on a machine
67 - //! where the daemon is off, which today is every machine. The action half, when
68 - //! it arrives, fronts the CLI: `allow-device`, `block-device`, and
69 - //! `allow-device --permanent` for a decision that survives a reboot.
61 + //! The image gained `usbguard` on 2026-08-22 and the device lists still do not
62 + //! use it, which is deliberate on both counts. The package is there so the
63 + //! action half has something to call; its daemon ships disabled, because its
64 + //! stock policy is an empty rule file with `ImplicitPolicyTarget=block` and
65 + //! arming that deauthorizes the machine's own keyboard at boot. Reading sysfs
66 + //! rather than `usbguard list-devices` is what keeps this screen answering on a
67 + //! machine where the daemon is off, which today is every machine.
68 + //!
69 + //! What the screen does say is [`Enforcement`]: whether anything is policing the
70 + //! bus at all. That is a read of two files and one `systemctl is-active`, no
71 + //! privilege and no IPC, and it is on screen because the alternative is worse
72 + //! than silence. A reader who can see that the image carries usbguard, and is
73 + //! told nothing about it, can reasonably conclude the bus is being policed.
74 + //!
75 + //! The acting half is not here yet, and the reason is a measurement rather than
76 + //! a plan. On usbguard-1.1.4 every verb except `generate-policy` goes over the
77 + //! daemon's IPC socket, and `allow-device` is addressed by the device id the
78 + //! daemon assigns in `list-devices` — not by anything in sysfs. So the join from
79 + //! a row on this screen to a device usbguard will act on cannot be written
80 + //! against a machine where the daemon has never run, which is every machine
81 + //! this has been developed on. It ships with the bench tests, against a real
82 + //! armed daemon, rather than as a guess that reads as a fact.
70 83 //!
71 84 //! What did arrive on 2026-08-22 is the keyboard gate, `usr/bin/alloy-usb-gate`,
72 85 //! which is the clause that makes deny-unknown safe to arm at all: it drops
@@ -91,7 +104,7 @@
91 104 use ratatui::text::Line;
92 105 use ratatui::widgets::{Paragraph, Wrap};
93 106
94 - use crate::cli::CommandLog;
107 + use crate::cli::{CommandLog, Invocation};
95 108 use crate::shell::{Flow, View, block_title, truncate};
96 109
97 110 /// The USB device tree. One directory per device, plus one per interface.
@@ -771,6 +784,114 @@
771 784 modes
772 785 }
773 786
787 + // ---- reading the enforcement state ----
788 +
789 + /// usbguard's own binary. Its presence is what says the action half is even
790 + /// possible on this machine; images before 2026-08-22 do not carry it.
791 + const USBGUARD: &str = "usr/bin/usbguard";
792 +
793 + /// The rule set the daemon enforces.
794 + ///
795 + /// Mode 0600 root:root, so an ordinary console user cannot read it. It can
796 + /// still `stat` it, because the directory above is world-executable, and the
797 + /// size is the whole of what this screen needs: a policy that exists against
798 + /// one that does not is the difference between arming being safe and arming
799 + /// being a brick.
800 + const POLICY: &str = "etc/usbguard/rules.conf";
801 +
802 + /// What USB device authorization is actually doing on this machine.
803 + ///
804 + /// Read rather than assumed, and worth reading because the answer is
805 + /// counter-intuitive on every machine shipped so far: the image carries
806 + /// usbguard and deliberately does not run it (docs/STACK.md, and the
807 + /// Containerfile assertion that holds it that way). A screen that showed
808 + /// attachments without saying that would let someone conclude the bus was
809 + /// being policed because the tooling was clearly present.
810 + #[derive(Debug, Clone, Copy, PartialEq, Eq)]
811 + pub(crate) enum Enforcement {
812 + /// usbguard is not on this machine at all.
813 + Absent,
814 + /// Installed, not running. `policy` is whether a rule set has been written.
815 + ///
816 + /// The two cases are very different and the screen says which. With no
817 + /// policy, arming denies everything including the keyboard, and the whole
818 + /// reason the unit ships disabled. With a policy, arming is an ordinary
819 + /// decision.
820 + Unarmed { policy: bool },
821 + /// The daemon is running and applying its rule set.
822 + Armed,
823 + }
824 +
825 + impl Enforcement {
826 + /// The one-line answer for the status bar.
827 + pub(crate) fn line(self) -> (Severity, &'static str) {
828 + match self {
829 + // Not a warning. This is what every Alloy machine ships as, it is
830 + // a deliberate position with a document behind it, and a screen
831 + // that cried WARN about its own shipped default would train the
832 + // reader to ignore the row.
833 + Self::Absent => (
834 + Severity::Info,
835 + "usbguard is not on this machine, so nothing can enforce USB policy",
836 + ),
837 + Self::Unarmed { policy: false } => (
838 + Severity::Info,
839 + "USB authorization is not enforced, and no policy is written yet",
840 + ),
841 + Self::Unarmed { policy: true } => (
842 + Severity::Info,
843 + "USB authorization is not enforced; a policy is written and waiting",
844 + ),
845 + Self::Armed => (
846 + Severity::Healthy,
847 + "USB authorization is enforced by usbguard",
848 + ),
849 + }
850 + }
851 + }
852 +
853 + /// Read the enforcement state.
854 + ///
855 + /// `armed` comes from the caller because it is a process spawn rather than a
856 + /// file read, and keeping it out means everything below is a pure function over
857 + /// a directory tree that the tests can build.
858 + pub(crate) fn enforcement(log: &mut CommandLog) -> Enforcement {
859 + enforcement_in(Path::new("/"), daemon_running(log))
860 + }
861 +
862 + fn enforcement_in(root: &Path, armed: bool) -> Enforcement {
863 + if !root.join(USBGUARD).exists() {
864 + // Armed with no binary is not a state that exists; if the caller says
865 + // so, the binary is what is authoritative.
866 + return Enforcement::Absent;
867 + }
868 + if armed {
869 + return Enforcement::Armed;
870 + }
871 + // `len` rather than reading: see POLICY. A file we are not allowed to open
872 + // still has a size, and "empty" is the only thing this has to distinguish.
873 + let policy = std::fs::metadata(root.join(POLICY)).is_ok_and(|meta| meta.len() > 0);
874 + Enforcement::Unarmed { policy }
875 + }
876 +
877 + /// Whether usbguard is running, asked of systemd rather than of usbguard.
878 + ///
879 + /// `usbguard list-devices` would answer too, and answers wrongly for this
880 + /// purpose: measured on usbguard-1.1.4, every verb except `generate-policy`
881 + /// goes over the daemon's IPC socket and exits 1 with "IPC connect: Connection
882 + /// refused" when it is not there. So the CLI conflates "not running" with
883 + /// "running and refusing me", which are different sentences to put on a screen.
884 + /// `systemctl is-active` needs no privilege and no socket.
885 + fn daemon_running(log: &mut CommandLog) -> bool {
886 + // Quiet: this is state the screen reads to draw itself, not an action
887 + // anyone asked for, and the log pane's promise is about the latter.
888 + log.quiet(|_log| {
889 + Invocation::new("systemctl")
890 + .args(["is-active", "--quiet", "usbguard.service"])
891 + .probe()
892 + })
893 + }
894 +
774 895 // ---- the view ----
775 896
776 897 /// Which list `alloy usb` is showing.
@@ -812,6 +933,9 @@
812 933 /// nobody plugged in are the first eight rows otherwise, and the question
813 934 /// this screen answers is about what is attached.
814 935 show_controllers: bool,
936 + /// What is policing the bus, if anything. Re-read on every refresh, because
937 + /// arming and disarming happen outside this screen today.
938 + enforcement: Enforcement,
815 939 }
816 940
817 941 impl UsbView {
@@ -823,6 +947,7 @@
823 947 attachment_cursor: Cursor::new(),
824 948 connector_cursor: Cursor::new(),
825 949 show_controllers: false,
950 + enforcement: Enforcement::Absent,
826 951 };
827 952 view.refresh(log);
828 953 view
@@ -833,9 +958,10 @@
833 958 /// Quiet as far as the log is concerned: nothing runs, so there is no argv
834 959 /// to show, and a read that writes nothing is not an action the log's
835 960 /// promise is about.
836 - fn refresh(&mut self, _log: &mut CommandLog) {
961 + fn refresh(&mut self, log: &mut CommandLog) {
837 962 self.attachments = attachments();
838 963 self.connectors = connectors();
964 + self.enforcement = enforcement(log);
839 965 self.attachment_cursor.resize(self.rows().len());
840 966 self.connector_cursor.resize(self.connectors.len());
841 967 }
@@ -1112,7 +1238,7 @@
1112 1238
1113 1239 fn status(&self) -> Option<(Severity, String)> {
1114 1240 // A device the kernel refused is the one state on this screen that is
1115 - // not merely information, so it gets the line.
1241 + // not merely information, so it gets the line ahead of everything else.
1116 1242 let blocked = self
1117 1243 .attachments
1118 1244 .iter()
@@ -1124,13 +1250,22 @@
1124 1250 format!("{blocked} attachment(s) not authorized"),
1125 1251 ));
1126 1252 }
1253 + // Then what is policing the bus, which on every machine shipped so far
1254 + // is nothing. Saying so is the point: the image carries usbguard, so a
1255 + // reader who saw the tooling and no statement about it could reasonably
1256 + // conclude the bus was being policed.
1257 + if self.enforcement != Enforcement::Armed {
1258 + let (severity, line) = self.enforcement.line();
1259 + return Some((severity, line.to_string()));
1260 + }
1127 1261 if self.tab == Tab::Connectors && self.connectors.is_empty() {
1128 1262 return Some((
1129 1263 Severity::Healthy,
1130 1264 "this machine reports no Type-C connectors".to_string(),
1131 1265 ));
1132 1266 }
1133 - None
1267 + let (severity, line) = self.enforcement.line();
1268 + Some((severity, line.to_string()))
1134 1269 }
1135 1270
1136 1271 fn render(&self, frame: &mut Frame, area: Rect, theme: &Theme) {
@@ -1592,4 +1727,102 @@
1592 1727 Some("left top")
1593 1728 );
1594 1729 }
1730 +
1731 + // ---- the enforcement state ----
1732 +
1733 + /// A fake root carrying whichever of usbguard's two files the case wants.
1734 + fn machine(case: &str, binary: bool, policy: Option<&str>) -> PathBuf {
1735 + let root = scratch(case);
1736 + if binary {
1737 + std::fs::create_dir_all(root.join("usr/bin")).unwrap();
1738 + std::fs::write(root.join(USBGUARD), "").unwrap();
1739 + }
1740 + if let Some(rules) = policy {
1741 + std::fs::create_dir_all(root.join("etc/usbguard")).unwrap();
1742 + std::fs::write(root.join(POLICY), rules).unwrap();
1743 + }
1744 + root
1745 + }
1746 +
1747 + // The state every Alloy machine ships in, and the one the screen most has
1748 + // to say out loud: the tooling is present and nothing is running it.
1749 + #[test]
1750 + fn an_image_that_carries_usbguard_and_does_not_run_it_reads_as_unarmed() {
1751 + let root = machine("unarmed", true, Some(""));
1752 + assert_eq!(
1753 + enforcement_in(&root, false),
1754 + Enforcement::Unarmed { policy: false },
1755 + );
1756 + }
1757 +
1758 + // The difference the screen exists to draw. An empty rule file plus
1759 + // ImplicitPolicyTarget=block means arming denies the keyboard; a written
1760 + // one means arming is an ordinary decision. Same daemon state, and the two
1761 + // must not read alike.
1762 + #[test]
1763 + fn a_written_policy_is_a_different_state_from_an_empty_one() {
1764 + let empty = machine("policy-empty", true, Some(""));
1765 + let written = machine("policy-written", true, Some("allow id 1d6b:0002\n"));
1766 + assert_eq!(
1767 + enforcement_in(&empty, false),
1768 + Enforcement::Unarmed { policy: false },
1769 + );
1770 + assert_eq!(
1771 + enforcement_in(&written, false),
1772 + Enforcement::Unarmed { policy: true },
1773 + );
1774 + assert_ne!(
1775 + enforcement_in(&empty, false).line().1,
1776 + enforcement_in(&written, false).line().1,
1777 + "the two say the same sentence, and they are the difference between \
1778 + arming being safe and arming being a brick",
1779 + );
1780 + }
1781 +
1782 + // A missing rules.conf is the same answer as an empty one. usbguard ships
1783 + // the file, so its absence is a stripped machine rather than a policy.
1784 + #[test]
1785 + fn a_missing_rule_file_reads_as_no_policy_rather_than_as_an_error() {
1786 + let root = machine("policy-absent", true, None);
1787 + assert_eq!(
1788 + enforcement_in(&root, false),
1789 + Enforcement::Unarmed { policy: false },
1790 + );
1791 + }
1792 +
1793 + #[test]
1794 + fn a_machine_without_the_binary_is_absent_whatever_systemd_says() {
1795 + let root = machine("no-binary", false, None);
1796 + assert_eq!(enforcement_in(&root, false), Enforcement::Absent);
1797 + // Armed with no binary is not a state that exists. If systemd claims a
1798 + // unit is active on a machine with no usbguard, the binary is what the
1799 + // screen believes, rather than reporting an enforcement nothing can be
1800 + // performing.
1801 + assert_eq!(enforcement_in(&root, true), Enforcement::Absent);
1802 + }
1803 +
1804 + #[test]
1805 + fn a_running_daemon_reads_as_armed() {
1806 + let root = machine("armed", true, Some("allow id 1d6b:0002\n"));
1807 + assert_eq!(enforcement_in(&root, true), Enforcement::Armed);
1808 + assert_eq!(enforcement_in(&root, true).line().0, Severity::Healthy);
1809 + }
1810 +
1811 + // Only the armed state is Healthy. The other three are Info rather than
1812 + // Warn on purpose: an image shipping its own documented default must not
1813 + // cry warning about it, or the row stops being read.
1814 + #[test]
1815 + fn the_shipped_default_is_stated_rather_than_warned_about() {
1816 + for state in [
1817 + Enforcement::Absent,
1818 + Enforcement::Unarmed { policy: false },
1819 + Enforcement::Unarmed { policy: true },
1820 + ] {
1821 + assert_eq!(
1822 + state.line().0,
1823 + Severity::Info,
1824 + "{state:?} warns about a state every Alloy machine ships in",
1825 + );
1826 + }
1827 + }
1595 1828 }
@@ -187,7 +187,7 @@
187 187 "the flap guard is gone; a keyboard outside the policy makes the gate \
188 188 open and close forever",
189 189 );
190 - assert!(code.contains("FLAP_WINDOW"), "the flap window is gone",);
190 + assert!(code.contains("FLAP_WINDOW"), "the flap window is gone");
191 191 }
192 192
193 193 // A latch that nothing can clear is worse than no latch: enforcement back on,