Skip to main content

max / alloy

image: grant the console the settings it fronts, and nothing else Tier 1 of wiki note alloy-privilege. timedatectl, hostnamectl and localectl all ship their actions as auth_admin_keep, so without this the settings screen would raise a password prompt to change a timezone. The person sitting at the machine, already logged into it, setting its own clock is not performing an administrative act that a password clarifies. Five actions, granted only to a session that is active, local and in wheel. Held back deliberately and named in the file: set-time, because the clock row is display-only and moving a system clock invalidates certificates and reorders logs; set-machine-info, which carries the pretty hostname and the machine's location; NetworkManager settings.modify.system; and all of rpm-ostree, where the prompt is the point. The list is short because a rules file is a security artifact. The failure to avoid is the grant that grows by a line during an unrelated change, which is how Omarchy shipped NOPASSWD on all of systemctl to enable one unit. Two guards: a test asserting the grant list is exactly what was agreed and that the held-back four are still held back, and a build-time check that polkit is in the base image and every granted action is one the image defines. A grant naming a renamed action is inert and says nothing about it. Caller constraint recorded in the file: hostnamectl with no scope flag also sets the pretty name, which needs set-machine-info. The console must use --static rather than widening the grant to save a flag.
Author: Max Johnson <me@maxj.phd> · 2026-07-24 19:08 UTC
Signed with PGP, not checked
Commit: 318042040d8780fd96198a7d05611fa77f281ca5
Parent: 5b36769
4 files changed, +220 insertions, -1 deletion
@@ -435,6 +435,37 @@
435 435 COPY etc/ /etc/
436 436 COPY usr/ /usr/
437 437
438 + # =====================================================================
439 + # polkit rules — assert the grant is not inert.
440 + #
441 + # usr/share/polkit-1/rules.d/50-alloy-settings.rules turns five actions
442 + # into silent yeses so `alloy settings` does not raise a password prompt
443 + # to change a timezone. Two ways that file can be shipped and do
444 + # nothing at all, both silent:
445 + #
446 + # - polkit is not in the base image, so nothing reads rules.d
447 + # - an action id was renamed upstream, so the grant names something
448 + # that no longer exists and the console prompts anyway
449 + #
450 + # Both are checked here rather than discovered on a booted machine,
451 + # where the symptom is a password prompt nobody can explain. The action
452 + # ids are read back out of the shipped rule so this cannot drift from
453 + # it. Grant rationale is in the rule's own header and in wiki note
454 + # `alloy-privilege`.
455 + # =====================================================================
456 + RUN set -eux; \
457 + rules=/usr/share/polkit-1/rules.d/50-alloy-settings.rules; \
458 + [ -d /usr/share/polkit-1/actions ] \
459 + || { echo "no polkit in the base image; $rules would never be read" >&2; exit 1; }; \
460 + [ -f "$rules" ] || { echo "$rules did not land" >&2; exit 1; }; \
461 + granted=$(sed -n '/var granted = \[/,/]/p' "$rules" | grep -o '"[a-z0-9.-]*"' | tr -d '"'); \
462 + [ -n "$granted" ] || { echo "read no action ids out of $rules" >&2; exit 1; }; \
463 + for action in $granted; do \
464 + grep -qr "action id=\"$action\"" /usr/share/polkit-1/actions/ \
465 + || { echo "granted action $action is not one this image defines" >&2; exit 1; }; \
466 + done; \
467 + echo "polkit: granted $(echo "$granted" | wc -l) actions, all defined"
468 +
438 469 # =====================================================================
439 470 # Systemd presets — shipped via etc/systemd/{system,user}-preset/
440 471 # in the config tree above. Split across system-preset (greetd,
M docs/IMAGE.md +1 -1
@@ -68,7 +68,7 @@
68 68 2. **Third-party repos:** Tailscale, any COPRs Alloy depends on for packages not in Fedora main.
69 69 3. **Package additions:** the full Alloy stack from [STACK.md](STACK.md): compositor, bar, launcher, notifications, terminal, editor, shell, viewers, utilities, continuity daemons, fonts, themes.
70 70 4. **Package removals:** stock Silverblue desktop pieces Alloy replaces (gnome-shell, gdm; the latter gated on the greeter pick).
71 - 5. **Config tree:** the tree at `etc/skel/.config/*` and `etc/skel/.mozilla/*` (new-user defaults, including the Firefox first-launch profile seed), `etc/*` (system-wide, including `etc/firefox/policies/policies.json`), and `usr/lib64/firefox/*` (Firefox autoconfig + `mozilla.cfg` default prefs) in the repo maps 1:1 into the image.
71 + 5. **Config tree:** the tree at `etc/skel/.config/*` and `etc/skel/.mozilla/*` (new-user defaults, including the Firefox first-launch profile seed), `etc/*` (system-wide, including `etc/firefox/policies/policies.json`), `usr/lib64/firefox/*` (Firefox autoconfig + `mozilla.cfg` default prefs), and `usr/share/polkit-1/rules.d/*` (which system settings the console may change without a prompt) in the repo maps 1:1 into the image. The polkit rule is asserted at build time against the actions the image actually defines, since a grant naming a renamed action is inert and silent about it.
72 72 6. **Systemd presets:** which services are enabled by default (syncthing off by default, gammastep off until enrolled, alloy-hinged conditionally on FW12, etc.).
73 73 7. **Branding:** os-release, plymouth splash.
74 74 8. **Validation:** `bootc container lint` runs at build.
@@ -1,0 +1,117 @@
1 + //! The shipped polkit rules are a security artifact, so the grant list is
2 + //! asserted rather than reviewed.
3 + //!
4 + //! `usr/share/polkit-1/rules.d/50-alloy-settings.rules` turns five
5 + //! `auth_admin_keep` actions into silent yeses for an active local session in
6 + //! wheel, so that `alloy settings` does not raise a password prompt to change a
7 + //! timezone. The reasoning is in the file's own header and in wiki note
8 + //! `alloy-privilege`.
9 + //!
10 + //! What this guards against is the failure mode that file exists to avoid:
11 + //! quiet broadening. A grant that grows by one line during an unrelated change
12 + //! is exactly how Omarchy ended up shipping `NOPASSWD: /usr/bin/systemctl` to
13 + //! enable one unit. Changing the list here should take a second commit and a
14 + //! moment's thought, which is what this test costs.
15 + //!
16 + //! It reads the file rather than executing it: polkit's rules are mozjs and
17 + //! there is no JS engine in this dependency tree. The shape being asserted is
18 + //! one Alloy authors and controls.
19 +
20 + use std::path::PathBuf;
21 +
22 + fn rules() -> String {
23 + let path = PathBuf::from(env!("CARGO_MANIFEST_DIR"))
24 + .join("../../usr/share/polkit-1/rules.d/50-alloy-settings.rules");
25 + std::fs::read_to_string(&path)
26 + .unwrap_or_else(|error| panic!("reading {}: {error}", path.display()))
27 + }
28 +
29 + /// The action ids inside the rule's `granted` array literal.
30 + fn granted() -> Vec<String> {
31 + let text = rules();
32 + let body = text
33 + .split_once("var granted = [")
34 + .expect("the rule declares a `granted` array")
35 + .1
36 + .split_once(']')
37 + .expect("the array is closed")
38 + .0;
39 + body.split(',')
40 + .map(str::trim)
41 + .filter(|line| !line.is_empty())
42 + .map(|line| line.trim_matches('"').to_string())
43 + .collect()
44 + }
45 +
46 + /// Every action the file grants, spelled out. Kept here rather than derived so
47 + /// that adding one to the rules file fails until it is added here too.
48 + const EXPECTED: [&str; 5] = [
49 + "org.freedesktop.timedate1.set-timezone",
50 + "org.freedesktop.timedate1.set-ntp",
51 + "org.freedesktop.hostname1.set-static-hostname",
52 + "org.freedesktop.locale1.set-locale",
53 + "org.freedesktop.locale1.set-keyboard",
54 + ];
55 +
56 + #[test]
57 + fn the_grant_list_is_exactly_what_was_agreed() {
58 + assert_eq!(
59 + granted(),
60 + EXPECTED,
61 + "the shipped polkit grant changed. If that was deliberate, update \
62 + EXPECTED and say why in the commit; if it was not, this is a widening \
63 + of what the console can do without asking.",
64 + );
65 + }
66 +
67 + // The three that make "the person sitting at the machine" true. Without local
68 + // the grant reaches SSH, where the justification does not hold at all; without
69 + // active it reaches a session left open on another VT; without wheel it reaches
70 + // every account on the machine.
71 + #[test]
72 + fn the_grant_is_conditioned_on_an_active_local_administrator() {
73 + let text = rules();
74 + for guard in [
75 + "subject.active",
76 + "subject.local",
77 + "subject.isInGroup(\"wheel\")",
78 + ] {
79 + assert!(text.contains(guard), "the rule dropped `{guard}`");
80 + }
81 + }
82 +
83 + // The file grants and never denies. A rule returning NOT would override a later
84 + // one with a better reason to allow something, which is a way to break an
85 + // unrelated screen from a file nobody thought to look in.
86 + #[test]
87 + fn the_rule_never_returns_a_denial() {
88 + let text = rules();
89 + assert!(text.contains("polkit.Result.YES"));
90 + assert!(
91 + !text.contains("polkit.Result.NOT") && !text.contains("polkit.Result.NO"),
92 + "this file is not the place to deny anything",
93 + );
94 + }
95 +
96 + // Four that were considered and left prompting. Naming them individually means
97 + // the test fails if one is quietly added, rather than only if the count changes.
98 + #[test]
99 + fn the_actions_held_back_are_still_held_back() {
100 + let granted = granted();
101 + for action in [
102 + // The console's clock row is display-only, and moving a system clock
103 + // invalidates certificates and reorders logs.
104 + "org.freedesktop.timedate1.set-time",
105 + // Pretty hostname, icon, chassis, deployment, and location.
106 + "org.freedesktop.hostname1.set-machine-info",
107 + // Saving a new connection is a real administrative act.
108 + "org.freedesktop.NetworkManager.settings.modify.system",
109 + // Changing the system image should be asked about; the prompt is the point.
110 + "org.projectatomic.rpmostree1.upgrade",
111 + ] {
112 + assert!(
113 + !granted.contains(&action.to_string()),
114 + "`{action}` is granted, and the file's own header says it is not",
115 + );
116 + }
117 + }
@@ -1,0 +1,71 @@
1 + /* Alloy: the settings a laptop owner changes about their own machine.
2 + *
3 + * `alloy settings` fronts timedatectl, hostnamectl and localectl. Every one of
4 + * those actions ships as auth_admin_keep, so without this file the System tab
5 + * would raise a password prompt to change a timezone. Granting them outright to
6 + * an active local session in wheel is the honest reading: the person sitting at
7 + * the machine, already logged into it, setting its clock is not performing an
8 + * administrative act that a password would clarify. See wiki note
9 + * `alloy-privilege` for the ladder this is the first rung of, and for what is
10 + * deliberately left prompting.
11 + *
12 + * The list is short on purpose. A rules file is a security artifact and should
13 + * be readable in one sitting; the failure mode to avoid is a grant that quietly
14 + * covers more than anyone remembers asking for. Omarchy issue 5708 is the
15 + * cautionary case, a first-run sudoers line of NOPASSWD on all of
16 + * /usr/bin/systemctl written to enable one unit.
17 + *
18 + * Not granted, deliberately:
19 + *
20 + * timedate1.set-time the clock row is display-only; the console does
21 + * not set the time by hand, and moving a system
22 + * clock invalidates certificates and reorders logs
23 + * timedate1.set-local-rtc nothing in Alloy offers it
24 + * hostname1.set-machine-info pretty hostname, icon, chassis, deployment and
25 + * location. Location in particular is not a thing
26 + * to hand over for a screen that sets a hostname
27 + * hostname1.get-product-uuid, .get-hardware-serial identifiers, not settings
28 + * NetworkManager.settings.modify.system saving a new connection is a real
29 + * administrative act and keeps its prompt
30 + * rpmostree1.* changing the system image should be asked about;
31 + * the prompt there is the point
32 + *
33 + * CALLER CONSTRAINT. `hostnamectl hostname NAME` with no scope flag sets the
34 + * static, transient and pretty names, and the pretty one needs
35 + * set-machine-info, which is not granted here. The console must call
36 + * `hostnamectl --static hostname NAME`; hostnamed applies the static name to
37 + * the running kernel hostname on its own. Granting set-machine-info to avoid
38 + * one flag would trade a wider grant for less typing.
39 + *
40 + * Ordering: polkit reads /etc/polkit-1/rules.d and /usr/share/polkit-1/rules.d
41 + * as one lexically sorted set, and the first rule to return a value wins. This
42 + * file ships at 50- so anything a user drops in /etc with a lower number
43 + * overrides it outright.
44 + */
45 +
46 + polkit.addRule(function (action, subject) {
47 + var granted = [
48 + "org.freedesktop.timedate1.set-timezone",
49 + "org.freedesktop.timedate1.set-ntp",
50 + "org.freedesktop.hostname1.set-static-hostname",
51 + "org.freedesktop.locale1.set-locale",
52 + "org.freedesktop.locale1.set-keyboard"
53 + ];
54 +
55 + /* active: the session in front of the screen, not one left open on another
56 + * VT. local: not over SSH, where "the person sitting at the machine" is the
57 + * whole justification and does not hold. wheel: the group Fedora's polkit
58 + * already treats as administrators, and the one `alloy install` puts the
59 + * first account in.
60 + *
61 + * Returning nothing rather than NOT for everything else, so this file
62 + * grants and never denies: a rule that returned NOT would override a later
63 + * one that had a better reason to allow something.
64 + */
65 + if (granted.indexOf(action.id) < 0) {
66 + return;
67 + }
68 + if (subject.active && subject.local && subject.isInGroup("wheel")) {
69 + return polkit.Result.YES;
70 + }
71 + });