Skip to main content

max / alloy

Let a row on alloy usb allow, block or remember a device Step 3's acting half, which needed a machine that had run a usbguard daemon before it could be written. fw12 is that machine: its only keyboard is i8042, so usbguard has no jurisdiction over it and arming there cannot take it away. Three keys on the attachments tab, offered only while the daemon is running because every verb goes over its IPC socket. Block asks first; allow and remember can only give access back. Three measurements, each contradicting the obvious implementation: - Device ids belong to the running daemon and start wherever it decides. Seven devices came back numbered 8 through 14, so nothing here can derive one and a cached id does not survive a restart. Every action re-reads list-devices immediately before it acts. - list-devices prints via-port for every device despite DeviceRulesWithPort=false, which governs generate-policy alone. The value is the sysfs directory name verbatim, so it is the exact join from a row to a device id. Nothing else in the listing is a key: the Bluetooth radio reports an empty serial and an empty name together. - allow-device --permanent does not append. It rewrites the matching rule's target in place, dropping parent-hash, and does nothing when that target is already set. No escalation: IPCAllowedGroups=wheel is the shipped default and the console user is in wheel, so all three verbs ran as uid 1000. Also answers whether arming survives an update. Seeding rules.conf made ostree admin config-diff report it modified, and that entry is what carries a file into the next deployment.
Co-Authored-By
Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session
https://claude.ai/code/session_01WFBzMprSmNCfvdj2cGZyka
Author: Max Johnson <me@maxj.phd> · 2026-09-08 19:07 UTC
Signed with PGP, not checked
Commit: 2bc39a0a643296b0a9a93c195bfd31155766a428
Parent: 0839b7f
5 files changed, +547 insertions, -15 deletions
@@ -261,6 +261,53 @@
261 261 blob and are not sysfs, so the composed form omits them, which is a rule usbguard
262 262 takes because a rule matches on the attributes it states.
263 263
264 + **The acting half has landed, and it is where reading the manual would have gone
265 + wrong.** Three keys on the attachments tab: `a` allows the selected device for
266 + this session, `b` blocks it, `s` saves it so the allow survives a reboot. They
267 + are offered in the hint row only while the daemon is running, since every one of
268 + them goes over its IPC socket and would otherwise come back
269 + `IPC connect: Connection refused`. `?` lists them regardless, under a group named
270 + for the condition, because a reader on an unarmed machine is the one who needs to
271 + know they exist.
272 +
273 + `allow-device` and `block-device` are addressed by a device id the daemon assigns
274 + and nothing else knows. Measured on fw12 against usbguard-1.1.4: a daemon started
275 + seconds earlier numbered seven devices `8` through `14`, so the id is neither
276 + one-based nor derivable on this side, and it does not survive the daemon
277 + restarting. Every action therefore re-reads `usbguard list-devices` immediately
278 + before it acts rather than remembering an id from the last refresh, and that
279 + lookup is not logged, so the pane shows the verb the key was pressed for instead
280 + of the bookkeeping in front of it.
281 +
282 + The join from a row to that id is `via-port`, which is the measured surprise.
283 + `DeviceRulesWithPort=false` is the shipped default and reads like the port is
284 + unavailable; it governs `generate-policy` only. `list-devices` prints `via-port`
285 + for every device regardless, and the value is the sysfs directory name verbatim,
286 + `2-4` for an attachment and `usb1` for a root hub. Nothing else in the listing is
287 + a key: fw12's Bluetooth radio reports an empty serial and an empty name together,
288 + so a join on those matches it to anything else equally anonymous, and two of the
289 + same dongle share their ids. The address is unique by construction.
290 +
291 + Nothing escalates. usbguard ships `IPCAllowedGroups=wheel` and the console user is
292 + in `wheel`, so `allow-device` and `block-device` both returned 0 as uid 1000 with
293 + no sudo and no polkit agent. This is the one writing screen that needs neither
294 + tier of wiki `alloy-privilege`'s ladder.
295 +
296 + `s` is `allow-device --permanent`, and it does not do what the name suggests. It
297 + does not append a rule: the daemon finds the rule already matching the device and
298 + rewrites its target in place, dropping `parent-hash` as it goes, and does nothing
299 + at all when a rule with that target is already there. So pressing it twice writes
300 + one rule, and a policy from `generate-policy` does not grow a duplicate line per
301 + keypress. There is deliberately no permanent block. The screen's lasting action is
302 + the one that gives access back; a rule set that grew permanent blocks from a
303 + keypress is a machine someone has to edit a 0600 root-owned file to recover.
304 +
305 + `b` asks first and the other two do not. A block deauthorizes the device
306 + immediately, which for a mounted disk is a yanked filesystem rather than an
307 + unmount, and allowing or remembering can only ever give access back. The modal
308 + holds the address rather than the row index, since the list can re-sort while it
309 + is up.
310 +
264 311 **Two tabs, because a charger and a keyboard are answered by different
265 312 subsystems.** The bus knows what enumerated and what each interface claims to be;
266 313 the Type-C connectors know which way power is flowing, whether the partner speaks
@@ -465,6 +465,10 @@
465 465
466 466 Measured against the image rather than read off the manual. `usbguard allow-device --help` states `(<id> | <rule> | <partial-rule>)`, so a rule composed from sysfs is an accepted argument and not only the device id the daemon hands out, and `usbguard generate-policy` runs in a container with no daemon at all, exits 0, and emits rules keyed on id, serial, name, via-port and with-interface. The join from a console row to a device usbguard acts on is therefore a pure function over fields the console already reads, and `alloy usb` composes it today. What needs a machine is applying a rule, and what it needs there is a writable `/sys`: with `/sys` mounted read-only the daemon starts and then rejects every device with `SysFSDevice: authorized: Permission denied`, and the verbs that answer from the device list fail behind it. Two fields of a generated rule are not composable from sysfs, `hash` and `parent-hash`, which are over the USB descriptor blob. A rule matches on the attributes it states, so the hash-free form is a rule usbguard takes.
467 467
468 + **A daemon has now run, and three things it does are not what the manual implies.** Measured on fw12 on 2026-09-08, the first machine in this tree ever to run one: it is a Framework Laptop 12 whose only keyboard is `AT Translated Set 2 keyboard` on `BUS_I8042`, so usbguard has no jurisdiction over the keyboard and cannot take it away, which is what made arming there safe when it is not safe on fw13. First, device ids are the daemon's own and start wherever it decides: seven devices came back numbered `8` through `14`, so nothing on the console side can derive one and a cached id does not survive a restart. Second, `list-devices` prints `via-port` for every device even though `DeviceRulesWithPort=false`; that setting governs `generate-policy` alone, and the printed value is the sysfs directory name verbatim, which is the exact join from a console row to a device id. Third, `allow-device --permanent` does not append to the rule set. It rewrites the target of the rule already matching the device, in place, dropping `parent-hash`, and does nothing when that target is already set, so a generated policy does not grow a duplicate line per keypress. `IPCAllowedGroups=wheel` is the shipped default and the console user is in `wheel`, so every one of those verbs ran as uid 1000 with no sudo and no polkit. Errors arrive on stderr with exit 1.
469 +
470 + **Arming survives an update, and that is now measured rather than assumed.** `rules.conf` lives in `/etc`, which is the merged directory rather than part of the image, and seeding it with `usbguard generate-policy` immediately made `ostree admin config-diff` report `M usbguard/rules.conf`. That entry is the mechanism: a file ostree tracks as locally modified is carried into the next deployment, so a policy written on one boot is the policy the machine arms with after `bootc upgrade`. A policy that silently vanished on update would have been worse than none, which is why this was answered before the preset line was allowed anywhere near the image.
471 +
468 472 Rejected: **shipping the package on `client` only.** A server-profile machine has a USB bus, and the ruling gives that profile the stricter half of the policy precisely because it has no keyboard to lock out and its recovery path is a provider console. It needs this more than a laptop does, not less.
469 473
470 474 Rejected: **enabling the unit now and writing the policy later.** It is one line and it would work on the bench for as long as the bench machine has a PS/2 keyboard, then take out the first machine that does not. There is no partial credit available here: an image that boots armed with an empty policy is worse than one with no usbguard at all, because the second is merely unprotected and the first is bricked.
@@ -9,8 +9,17 @@
9 9 //! its keyboard. Making the attachment legible first means the policy, when it
10 10 //! arrives, is turned on from a screen that already shows what it will act on.
11 11 //!
12 - //! So this module reads sysfs and nothing else. There is no usbguard dependency
13 - //! here, and the screen works on an image that has never heard of it.
12 + //! So everything the screen *draws* comes from sysfs and nothing else. There is
13 + //! no usbguard dependency in the reading half, and the screen works on an image
14 + //! that has never heard of it.
15 + //!
16 + //! The acting half does run usbguard, and it is kept to the three keys that
17 + //! change something. It is offered only where the daemon is up, since every one
18 + //! of its verbs goes over that daemon's IPC socket, so the shape above survives:
19 + //! a machine with no usbguard, or with usbguard sitting disabled the way every
20 + //! Alloy image ships it, draws the same screen from the same files and simply
21 + //! has nothing to press. See [`policy`] for the three measurements that half
22 + //! rests on, none of which could be had without a machine that had run a daemon.
14 23 //!
15 24 //! # A charger and a keyboard are different rows
16 25 //!
@@ -9,6 +9,7 @@
9 9 use std::path::Path;
10 10
11 11 use alloy_tui::Severity;
12 + use anyhow::{Result, bail};
12 13
13 14 use super::model::{Attachment, Interface, class_of};
14 15 use crate::cli::{CommandLog, Invocation};
@@ -254,12 +255,11 @@
254 255 ///
255 256 /// The inverse of [`rule_for`]: the daemon prints its own device id and then the
256 257 /// same rule grammar `generate-policy` writes, and this reads it into the fields
257 - /// this screen is keyed on. Nothing here spawns usbguard yet, since every verb
258 - /// but `generate-policy` needs the daemon and no machine has run one, so the
259 - /// allow comes off with the verb that calls it.
258 + /// this screen is keyed on. The id is the whole reason this exists — it is what
259 + /// [`act`] addresses, and the daemon is the only thing that knows it.
260 260 mod listing {
261 - #![allow(dead_code)]
262 -
261 + // Only [`Listed::rule`] wants it, and that is a test-only assertion.
262 + #[cfg(test)]
263 263 use super::rule_line;
264 264 use crate::usb::bus::serial_of;
265 265
@@ -272,6 +272,13 @@
272 272 #[derive(Debug, Clone, PartialEq, Eq)]
273 273 pub(super) struct Listed {
274 274 /// The daemon's device id, which is what `allow-device <id>` addresses.
275 + ///
276 + /// Assigned by the running daemon and meaningful only to it. Measured on
277 + /// fw12: a freshly started daemon numbered seven devices `8` through
278 + /// `14`, so the id is neither one-based nor derivable from anything on
279 + /// this side, and it does not survive the daemon restarting. Every
280 + /// action therefore re-reads the listing immediately before it acts
281 + /// rather than remembering an id from the last refresh.
275 282 pub(super) id: u32,
276 283 /// `allow`, `block` or `reject`.
277 284 pub(super) target: String,
@@ -283,10 +290,20 @@
283 290 pub(super) name: String,
284 291 /// `class:subclass:protocol`, one entry per interface.
285 292 pub(super) interfaces: Vec<String>,
293 + /// The kernel address the daemon saw this device at, which is the join
294 + /// back to a row on this screen. See [`super::listed_for`].
295 + pub(super) via_port: Option<String>,
286 296 }
287 297
288 298 impl Listed {
289 299 /// The hash-free rule for this device, in the form [`rule_for`](super::rule_for) emits.
300 + ///
301 + /// Test-only. Nothing on the screen shows the daemon's rule — the
302 + /// detail pane composes its own from sysfs, which is what lets it say
303 + /// something on a machine with no daemon at all. What this is for is
304 + /// the assertion that the parse keeps every field that rule is built
305 + /// from, so a listing and a composed rule can be compared as text.
306 + #[cfg(test)]
290 307 pub(super) fn rule(&self) -> String {
291 308 rule_line(
292 309 &self.target,
@@ -324,6 +341,7 @@
324 341 serial: None,
325 342 name: String::new(),
326 343 interfaces: Vec::new(),
344 + via_port: None,
327 345 };
328 346 while let Some(attribute) = scan.word() {
329 347 match attribute {
@@ -335,8 +353,9 @@
335 353 "serial" => listed.serial = scan.quoted().as_deref().and_then(serial_of),
336 354 "name" => listed.name = scan.quoted().unwrap_or_default(),
337 355 "with-interface" => listed.interfaces = scan.interfaces(),
338 - // `hash`, `parent-hash`, `via-port` and `with-connect-type` are the
339 - // rest of the grammar. Their values are consumed so the scan stays
356 + "via-port" => listed.via_port = scan.quoted(),
357 + // `hash`, `parent-hash` and `with-connect-type` are the rest of
358 + // the grammar. Their values are consumed so the scan stays
340 359 // aligned on attribute boundaries, and dropped because nothing on
341 360 // this screen is keyed on them.
342 361 _ => {
@@ -412,6 +431,130 @@
412 431 }
413 432 }
414 433
434 + // ---- acting on a device ----
435 +
436 + /// What the console can do to a device the daemon is holding.
437 + ///
438 + /// Three verbs rather than two plus a flag, because "save this device" is a
439 + /// different sentence from "allow it now" to the person pressing the key, and
440 + /// the difference is exactly the one that outlives the reboot.
441 + #[derive(Debug, Clone, Copy, PartialEq, Eq)]
442 + pub(super) enum Act {
443 + /// Authorize it for this session. Forgotten when the daemon restarts.
444 + Allow,
445 + /// Deauthorize it now. Also only for this session.
446 + Block,
447 + /// Allow it and write that into the rule set, so it survives a reboot.
448 + ///
449 + /// Measured on usbguard-1.1.4, and not what the name suggests: this does
450 + /// **not** append a rule. The daemon finds the rule already matching the
451 + /// device and rewrites its target in place, dropping `parent-hash` as it
452 + /// goes, and does nothing at all when a rule with that target is already
453 + /// there. So a policy generated by `generate-policy` does not grow a
454 + /// duplicate line every time someone presses the key.
455 + Remember,
456 + }
457 +
458 + impl Act {
459 + /// The usbguard subcommand.
460 + const fn verb(self) -> &'static str {
461 + match self {
462 + Self::Allow | Self::Remember => "allow-device",
463 + Self::Block => "block-device",
464 + }
465 + }
466 +
467 + /// Whether the rule set is written as well as the running state.
468 + const fn permanent(self) -> bool {
469 + matches!(self, Self::Remember)
470 + }
471 +
472 + /// What the key does, for the hint row and the `?` overlay.
473 + pub(super) const fn label(self) -> &'static str {
474 + match self {
475 + Self::Allow => "allow",
476 + Self::Block => "block",
477 + Self::Remember => "save this device",
478 + }
479 + }
480 + }
481 +
482 + /// The listing entry for a row on this screen, joined on the kernel address.
483 + ///
484 + /// `via-port` is the join and it is the measured surprise here: usbguard ships
485 + /// `DeviceRulesWithPort=false`, which reads like the port is not available, and
486 + /// it governs `generate-policy` only. `list-devices` prints `via-port` for every
487 + /// device regardless — measured on fw12 against usbguard-1.1.4 — and the value
488 + /// is the sysfs directory name verbatim, `2-4` for an attachment and `usb1` for
489 + /// a root hub, which is exactly [`Attachment::address`].
490 + ///
491 + /// Nothing else in the listing is a key. Two of the same dongle share id, name
492 + /// and an empty serial; the Bluetooth radio on fw12 reports `serial ""` and
493 + /// `name ""` together, so a join on those matches it to anything else equally
494 + /// anonymous. The address is the one field that is unique by construction.
495 + fn listed_for<'a>(
496 + listing: &'a [listing::Listed],
497 + device: &Attachment,
498 + ) -> Option<&'a listing::Listed> {
499 + listing
500 + .iter()
501 + .find(|listed| listed.via_port.as_deref() == Some(device.address.as_str()))
502 + }
503 +
504 + /// Ask the daemon what it is holding.
505 + ///
506 + /// Quiet: this runs as the first half of an action the user did ask for, and
507 + /// the pane's promise is about the verb they pressed a key for. Logging the
508 + /// lookup as well would push the `allow-device` line off a two-row pane with
509 + /// the command nobody typed.
510 + fn list_devices(log: &mut CommandLog) -> Result<Vec<listing::Listed>> {
511 + let output = log.quiet(|log| Invocation::new("usbguard").arg("list-devices").run(log))?;
512 + Ok(listing::parse_listed(&output))
513 + }
514 +
515 + /// The argv for one action, spelled in one place so a test can read it.
516 + fn act_invocation(id: u32, act: Act) -> Invocation {
517 + let invocation = Invocation::new("usbguard").args([act.verb(), &id.to_string()]);
518 + if act.permanent() {
519 + invocation.arg("--permanent")
520 + } else {
521 + invocation
522 + }
523 + }
524 +
525 + /// Do `act` to `device`.
526 + ///
527 + /// The id is looked up here rather than carried on the row, because it belongs
528 + /// to the running daemon and to nothing else: see [`listing::Listed::id`]. A
529 + /// cached id survives a daemon restart as a number that now addresses a
530 + /// different device, and the action this module exists to perform is the one
531 + /// where that matters.
532 + ///
533 + /// No privilege escalation. usbguard ships `IPCAllowedGroups=wheel` and the
534 + /// console user is in `wheel`, so the IPC call goes through as uid 1000 with no
535 + /// sudo and no polkit — measured on fw12, where `allow-device` and
536 + /// `block-device` both returned 0 unprivileged.
537 + pub(super) fn act(device: &Attachment, act: Act, log: &mut CommandLog) -> Result<()> {
538 + let listing = list_devices(log)?;
539 + let Some(listed) = listed_for(&listing, device) else {
540 + // The daemon is running (nothing else gets this far) and does not have
541 + // this device. Worth saying rather than swallowing: it is the shape a
542 + // device unplugged between the refresh and the keypress leaves. No
543 + // command ran, so the pane gets the note form the mock backends already
544 + // use, rather than nothing at all for a key that visibly did something.
545 + log.record(
546 + format!("# usbguard is not holding {}", device.address),
547 + Severity::Warn,
548 + );
549 + bail!(
550 + "usbguard is not holding anything at {}; refresh and try again",
551 + device.address
552 + );
553 + };
554 + act_invocation(listed.id, act).run(log)?;
555 + Ok(())
556 + }
557 +
415 558 #[cfg(test)]
416 559 mod tests {
417 560 use std::path::PathBuf;
@@ -741,4 +884,225 @@
741 884 assert!(Enforcement::Unarmed { policy: true }.takes_rules());
742 885 assert!(Enforcement::Armed.takes_rules());
743 886 }
887 +
888 + // ---- the daemon's own listing, and the join ----
889 +
890 + /// `usbguard list-devices` on fw12, captured 2026-09-08 against
891 + /// usbguard-1.1.4 with a policy seeded by `generate-policy`.
892 + ///
893 + /// The first listing this project has ever had, and the reason step 3 waited
894 + /// for hardware: every verb but `generate-policy` needs a running daemon, so
895 + /// nothing about the id or the framing could be had by reading. Kept
896 + /// verbatim, hashes and all, because the parts this side drops are as much
897 + /// of the measurement as the parts it keeps.
898 + ///
899 + /// Trimmed to the four devices that carry a distinct shape. The two other
900 + /// root hubs are the same shape as the two here.
901 + const FW12_LISTING: &str = concat!(
902 + r#"8: allow id 1d6b:0002 serial "0000:00:0d.0" name "xHCI Host Controller" "#,
903 + r#"hash "d3YN7OD60Ggqc9hClW0/al6tlFEshidDnQKzZRRk410=" "#,
904 + r#"parent-hash "Y1kBdG1uWQr5CjULQs7uh2F6pHgFb6VDHcWLk83v+tE=" "#,
905 + r#"via-port "usb1" with-interface 09:00:00 with-connect-type """#,
906 + "\n",
907 + r#"12: allow id 0781:55a9 serial "03007220051326104554" name " SanDisk 3.2Gen1" "#,
908 + r#"hash "WppRVx7NtRHjXdX24AvOKi/KAcNIO8kicRwZP2F+OKk=" "#,
909 + r#"parent-hash "G+G3Mro8zBWJavFOAQUtoNiOsZSfBCt2XqHfOufYFis=" "#,
910 + r#"via-port "2-4" with-interface { 08:06:50 08:06:62 } with-connect-type "hotplug""#,
911 + "\n",
912 + r#"13: allow id 32ac:001d serial "FRAPABCHA1521303LF" "#,
913 + r#"name "Framework Laptop 12 Webcam Module" "#,
914 + r#"hash "p7038Qb1XdPOqJYikYcQRJ/QCdI8nv+vWGyNcOhyyas=" "#,
915 + r#"parent-hash "jEP/6WzviqdJ5VSeTUY8PatCNBKeaREvo2OqdplND/o=" "#,
916 + "via-port \"3-7\" with-interface { 0e:01:01 0e:02:01 0e:02:01 0e:02:01 0e:02:01 ",
917 + "0e:02:01 0e:02:01 0e:02:01 0e:02:01 fe:01:01 } with-connect-type \"hardwired\"",
918 + "\n",
919 + r#"14: allow id 8087:0033 serial "" name "" "#,
920 + r#"hash "ciwwGozaSw4maEXfs4NdvETeMt6bnFEK6f4vmCqfud0=" "#,
921 + r#"parent-hash "jEP/6WzviqdJ5VSeTUY8PatCNBKeaREvo2OqdplND/o=" "#,
922 + "via-port \"3-10\" with-interface { e0:01:01 e0:01:01 e0:01:01 e0:01:01 ",
923 + "e0:01:01 e0:01:01 e0:01:01 e0:01:01 } with-connect-type \"hardwired\"",
924 + );
925 +
926 + /// A sysfs tree matching the fw12 capture, so the join is asserted against
927 + /// the two sides it actually has to join.
928 + fn fw12_bus(case: &str) -> Vec<Attachment> {
929 + let root = scratch(case);
930 + dir(
931 + &root,
932 + "usb1",
933 + &[
934 + ("idVendor", "1d6b"),
935 + ("idProduct", "0002"),
936 + ("product", "xHCI Host Controller"),
937 + ("serial", "0000:00:0d.0"),
938 + ("speed", "480"),
939 + ],
940 + );
941 + dir(
942 + &root,
943 + "2-4",
944 + &[
945 + ("idVendor", "0781"),
946 + ("idProduct", "55a9"),
947 + ("product", " SanDisk 3.2Gen1"),
948 + ("serial", "03007220051326104554"),
949 + ("speed", "5000"),
950 + ],
951 + );
952 + dir(
953 + &root,
954 + "3-7",
955 + &[
956 + ("idVendor", "32ac"),
957 + ("idProduct", "001d"),
958 + ("product", "Framework Laptop 12 Webcam Module"),
959 + ("serial", "FRAPABCHA1521303LF"),
960 + ("speed", "480"),
961 + ],
962 + );
963 + // The Bluetooth radio, which reports neither a serial nor a name.
964 + dir(
965 + &root,
966 + "3-10",
967 + &[
968 + ("idVendor", "8087"),
969 + ("idProduct", "0033"),
970 + ("product", ""),
971 + ("serial", ""),
972 + ("speed", "12"),
973 + ],
974 + );
975 + attachments_in(&root)
976 + }
977 +
978 + // The measurement the whole acting half rests on. Ids come from the daemon
979 + // and start wherever it decides: this listing, from a daemon started
980 + // seconds earlier on a machine with seven devices, numbers them 8 through
981 + // 14. Anything that derived an id on this side, or cached one across a
982 + // daemon restart, would address a different device.
983 + #[test]
984 + fn device_ids_come_from_the_daemon_and_are_not_one_based() {
985 + let listed = listing::parse_listed(FW12_LISTING);
986 + assert_eq!(listed.len(), 4, "{listed:#?}");
987 + let ids: Vec<u32> = listed.iter().map(|device| device.id).collect();
988 + assert_eq!(ids, [8, 12, 13, 14]);
989 + }
990 +
991 + // `DeviceRulesWithPort=false` is the shipped default and reads like the port
992 + // is unavailable. It governs `generate-policy` only: `list-devices` prints
993 + // `via-port` for every device, which is what makes the join exact.
994 + #[test]
995 + fn every_listed_device_carries_the_port_a_rule_would_not() {
996 + let listed = listing::parse_listed(FW12_LISTING);
997 + for device in &listed {
998 + assert!(
999 + device.via_port.is_some(),
1000 + "no via-port to join on: {device:#?}",
1001 + );
1002 + assert!(
1003 + !device.rule().contains("via-port"),
1004 + "the composed rule must stay port-free: {}",
1005 + device.rule(),
1006 + );
1007 + }
1008 + }
1009 +
1010 + // The join, both shapes at once: a root hub addressed `usb1` and an
1011 + // attachment addressed `2-4`. `via-port` is the sysfs directory name
1012 + // verbatim in both cases, which is why one comparison covers them.
1013 + #[test]
1014 + fn a_row_joins_to_its_listing_entry_on_the_kernel_address() {
1015 + let listed = listing::parse_listed(FW12_LISTING);
1016 + let bus = fw12_bus("join-fw12");
1017 + let found: Vec<(String, u32)> = bus
1018 + .iter()
1019 + .filter_map(|device| {
1020 + listed_for(&listed, device).map(|entry| (device.address.clone(), entry.id))
1021 + })
1022 + .collect();
1023 + assert_eq!(
1024 + found,
1025 + [
1026 + ("usb1".to_string(), 8),
1027 + ("2-4".to_string(), 12),
1028 + ("3-7".to_string(), 13),
1029 + ("3-10".to_string(), 14),
1030 + ],
1031 + );
1032 + }
1033 +
1034 + // Why the join is not on id, serial or name. fw12's Bluetooth radio reports
1035 + // an empty serial and an empty name together, so every field but the
1036 + // address is shared with anything else equally anonymous -- and two of the
1037 + // same dongle would share the vendor and product ids as well.
1038 + #[test]
1039 + fn an_anonymous_device_still_joins_because_the_address_is_unique() {
1040 + let listed = listing::parse_listed(FW12_LISTING);
1041 + let radio = listed.iter().find(|device| device.id == 14).unwrap();
1042 + assert_eq!(radio.serial, None);
1043 + assert_eq!(radio.name, "");
1044 +
1045 + let bus = fw12_bus("join-anonymous");
1046 + let row = bus.iter().find(|device| device.address == "3-10").unwrap();
1047 + assert_eq!(listed_for(&listed, row).map(|entry| entry.id), Some(14));
1048 + }
1049 +
1050 + // A device unplugged between the refresh and the keypress. The listing has
1051 + // no entry at that address, and the answer is nothing rather than the
1052 + // nearest match.
1053 + #[test]
1054 + fn a_row_the_daemon_is_not_holding_joins_to_nothing() {
1055 + let listed = listing::parse_listed(FW12_LISTING);
1056 + let root = scratch("join-missing");
1057 + dir(
1058 + &root,
1059 + "9-9",
1060 + &[
1061 + ("idVendor", "0781"),
1062 + ("idProduct", "55a9"),
1063 + ("product", " SanDisk 3.2Gen1"),
1064 + ("serial", "03007220051326104554"),
1065 + ("speed", "5000"),
1066 + ],
1067 + );
1068 + let gone = attachments_in(&root).pop().unwrap();
1069 + // Same vendor, product and serial as the stick at `2-4`, and a
1070 + // different port. A join on identity would have allowed the wrong row.
1071 + assert_eq!(listed_for(&listed, &gone), None);
1072 + }
1073 +
1074 + // ---- what each key runs ----
1075 +
1076 + // The log pane's promise is that every action shows its argv, so the argv is
1077 + // worth asserting rather than trusting. `--permanent` is the only difference
1078 + // between allowing for this session and remembering the device.
1079 + #[test]
1080 + fn each_action_spells_the_command_the_log_pane_shows() {
1081 + assert_eq!(
1082 + act_invocation(13, Act::Allow).display(),
1083 + "usbguard allow-device 13",
1084 + );
1085 + assert_eq!(
1086 + act_invocation(13, Act::Block).display(),
1087 + "usbguard block-device 13",
1088 + );
1089 + assert_eq!(
1090 + act_invocation(13, Act::Remember).display(),
1091 + "usbguard allow-device 13 --permanent",
1092 + );
1093 + }
1094 +
1095 + // Remembering is an allow. The pair that differs is the target, not the
1096 + // verb, and a `block --permanent` is deliberately not offered: the screen's
1097 + // permanent action is the one that gets a device back, and a rule set that
1098 + // grows permanent blocks from a keypress is a machine someone has to edit
1099 + // 0600 root-owned files to recover.
1100 + #[test]
1101 + fn only_remembering_writes_the_rule_set() {
1102 + assert!(!Act::Allow.permanent());
1103 + assert!(!Act::Block.permanent());
1104 + assert!(Act::Remember.permanent());
1105 + assert_eq!(Act::Remember.verb(), Act::Allow.verb());
1106 + assert_eq!(Act::Remember.label(), "save this device");
1107 + }
744 1108 }
@@ -16,9 +16,9 @@
16 16 use super::bus::attachments;
17 17 use super::connectors::{Connector, connectors};
18 18 use super::model::Attachment;
19 - use super::policy::{Enforcement, Scope, enforcement, rule_for};
19 + use super::policy::{Act, Enforcement, Scope, act, enforcement, rule_for};
20 20 use crate::cli::CommandLog;
21 - use crate::shell::{Flow, View, block_title, truncate};
21 + use crate::shell::{Confirm, Flow, View, block_title, truncate};
22 22
23 23 /// Which list `alloy usb` is showing.
24 24 #[derive(Debug, Clone, Copy, PartialEq, Eq)]
@@ -62,6 +62,14 @@
62 62 /// What is policing the bus, if anything. Re-read on every refresh, because
63 63 /// arming and disarming happen outside this screen today.
64 64 enforcement: Enforcement,
65 + /// The block waiting on the confirmation modal, with the address it was
66 + /// aimed at.
67 + ///
68 + /// The address rather than the row index: the modal is up for as long as the
69 + /// user takes to read it, and a list that re-sorted underneath it would
70 + /// leave an index pointing at a different device. The address is what the
71 + /// action is keyed on anyway.
72 + pending_block: Option<String>,
65 73 }
66 74
67 75 impl UsbView {
@@ -74,6 +82,7 @@
74 82 connector_cursor: Cursor::new(),
75 83 show_controllers: false,
76 84 enforcement: Enforcement::Absent,
85 + pending_block: None,
77 86 };
78 87 view.refresh(log);
79 88 view
@@ -348,16 +357,29 @@
348 357 }
349 358
350 359 fn hints(&self) -> Vec<Hint> {
351 - vec![
360 + let mut hints = vec![
352 361 hint("j/k", "select"),
353 362 hint("h/l", "tab"),
354 363 hint("c", "controllers"),
355 364 hint("r", "refresh"),
356 - ]
365 + ];
366 + // The three actions appear only where they would work. Unarmed, the
367 + // daemon holds nothing and every one of them would come back "IPC
368 + // connect: Connection refused"; a hint row offering them anyway would
369 + // be advertising three keys that cannot do anything on the machine
370 + // every Alloy install ships as.
371 + if self.enforcement == Enforcement::Armed && self.tab == Tab::Attachments {
372 + hints.extend([
373 + hint("a", Act::Allow.label()),
374 + hint("b", Act::Block.label()),
375 + hint("s", "save"),
376 + ]);
377 + }
378 + hints
357 379 }
358 380
359 381 fn keys(&self) -> Vec<KeyGroup<'static>> {
360 - vec![KeyGroup::new(
382 + let mut groups = vec![KeyGroup::new(
361 383 "this pane",
362 384 vec![
363 385 binding("j/k", "select"),
@@ -365,7 +387,19 @@
365 387 binding("c", "show/hide root hubs"),
366 388 binding("r", "refresh"),
367 389 ],
368 - )]
390 + )];
391 + // Listed unconditionally, unlike the hint row. `?` is the reference for
392 + // what the screen can do, and a reader on an unarmed machine is exactly
393 + // the one who needs to know these exist and what turns them on.
394 + groups.push(KeyGroup::new(
395 + "when usbguard is running",
396 + vec![
397 + binding("a", "allow this device for now"),
398 + binding("b", "block this device for now"),
399 + binding("s", Act::Remember.label()),
400 + ],
401 + ));
402 + groups
369 403 }
370 404
371 405 fn unanswered(&self) -> &'static [Action] {
@@ -503,8 +537,82 @@
503 537 self.attachment_cursor.resize(len);
504 538 }
505 539 KeyCode::Char('r') => self.refresh(log),
540 + KeyCode::Char('a') => return self.act_on_selection(Act::Allow, log),
541 + KeyCode::Char('s') => return self.act_on_selection(Act::Remember, log),
542 + // The one action that takes something away. A block deauthorizes
543 + // the device immediately, which for a mounted disk is a yanked
544 + // filesystem and for an input device is a hand leaving the desk
545 + // without it, so it asks first. Allowing and remembering both only
546 + // ever give access back and go straight through.
547 + KeyCode::Char('b') => {
548 + if self.enforcement != Enforcement::Armed || self.tab != Tab::Attachments {
549 + return Flow::Continue;
550 + }
551 + let Some(device) = self.selected_attachment() else {
552 + return Flow::Continue;
553 + };
554 + let message = format!(
555 + "{} at {} loses access immediately. A mounted disk is yanked \
556 + rather than unmounted.",
557 + if device.product.is_empty() {
558 + format!("{}:{}", device.vendor_id, device.product_id)
559 + } else {
560 + device.product.clone()
561 + },
562 + device.address,
563 + );
564 + self.pending_block = Some(device.address.clone());
565 + return Flow::Confirm(Confirm::destructive("block device", message));
566 + }
506 567 _ => {}
507 568 }
508 569 Flow::Continue
509 570 }
571 +
572 + fn confirmed(&mut self, log: &mut CommandLog) -> Flow {
573 + let Some(address) = self.pending_block.take() else {
574 + return Flow::Continue;
575 + };
576 + // Re-found by address rather than held as a reference, because the
577 + // borrow could not have survived the modal, and re-finding is also what
578 + // makes a device unplugged while the modal was up a miss rather than a
579 + // block aimed at whatever took its place.
580 + if let Some(device) = self
581 + .attachments
582 + .iter()
583 + .find(|device| device.address == address)
584 + {
585 + let _ = act(device, Act::Block, log);
586 + }
587 + // Either way: the failure is already in the log pane with its argv and
588 + // an error severity, and what the screen owes the user now is the state
589 + // the bus is actually in.
590 + self.refresh(log);
591 + Flow::Continue
592 + }
593 +
594 + fn cancelled(&mut self) {
595 + self.pending_block = None;
596 + }
597 + }
598 +
599 + impl UsbView {
600 + /// Run an action against the selected row, if the machine can take one.
601 + ///
602 + /// Silent where usbguard is not running: the keys are not offered in the
603 + /// hint row there, and a keypress that is not advertised doing nothing is
604 + /// better than one that runs a command guaranteed to fail.
605 + fn act_on_selection(&mut self, action: Act, log: &mut CommandLog) -> Flow {
606 + if self.enforcement != Enforcement::Armed || self.tab != Tab::Attachments {
607 + return Flow::Continue;
608 + }
609 + if let Some(device) = self.selected_attachment() {
610 + // Cloned so the action is not holding a borrow of `self` when the
611 + // refresh below replaces the list it points into.
612 + let device = device.clone();
613 + let _ = act(&device, action, log);
614 + self.refresh(log);
615 + }
616 + Flow::Continue
617 + }
510 618 }