Sentinel

A Windows UAC-style confirmation dialog for Linux privilege escalation, delivered as a shared PAM + polkit-agent backend plus a native KDE Plasma (Kirigami) desktop frontend, sentinel-helper-kde. Wayland-only, sudo-rs friendly; the layer-shell dialog renders on KWin/Wayland and other wlroots-style compositors (Hyprland, Sway, Niri, River, Wayfire).

What it does

When a privileged binary's PAM stack hits pam_sentinel.so (typically /etc/pam.d/polkit-1 and optionally /etc/pam.d/sudo), the polkit agent spawns the frontend helper, sentinel-helper-kde. The helper paints a zwlr-layer-shell-v1 overlay surface — full-screen translucent backdrop, exclusive keyboard focus, dialog card centered — and waits for Allow, Deny, or a configurable timeout (auto-deny).

  • Allow → PAM passes auth without a password.
  • Deny / timeout / no Wayland display → PAM continues to the next module (typically pam_unix, the password prompt).

Sentinel also ships sentinel-polkit-agent, a per-user polkit authentication agent that registers with the session and forwards polkit-mediated auth requests through the same Allow/Deny dialog. Its one-shot pre-approval reaches pam_sentinel.so over the system D-Bus (org.sentinel.Agent), which keeps the bypass working under SELinux.

Threat model & where to start

Sentinel sits in the PAM authentication path. A misconfiguration can lock you out of sudo, polkit, or login. Read the Troubleshooting page before you install, and open a second root shell during the first install (pkexec bash) until you've verified sudo still works.

For the security model, see Architecture and Security policy.

Installation

Sentinel ships through several channels. AUR is first-class (Arch + sudo-rs are the primary target); NixOS users get a flake; everyone else either installs the prebuilt binary tarball or builds from source.

Before you install: Sentinel sits in the PAM auth path. Open a second root shell first (pkexec bash) and keep it open until you've confirmed sudo and pkexec still work. The Troubleshooting page covers recovery.

Arch Linux (AUR)

sentinel-kde (KDE Plasma) is built from this repository.

yay -S sentinel-kde       # KDE Plasma / Kirigami dialog

It backup=s /etc/security/sentinel.conf and /etc/pam.d/polkit-1 so a pacman -Rsn won't clobber your customisations.

NixOS

The repo's flake.nix exposes a NixOS module:

{
  inputs.sentinel.url = "github:atayoez/sentinel";

  outputs = { self, nixpkgs, sentinel, ... }: {
    nixosConfigurations.<host> = nixpkgs.lib.nixosSystem {
      modules = [
        sentinel.nixosModules.default
        ({ ... }: {
          services.sentinel.enable = true;
          services.sentinel.enableForSudo = false;  # opt-in
        })
      ];
    };
  };
}

Or run the helper ad-hoc without installing:

nix run github:atayoez/sentinel -- --timeout 10 --randomize

Generic binary tarball

Each release publishes a prebuilt bundle per arch, sentinel-kde-<ver>-<arch>-linux.tar.gz. Extract and run its install.sh with SENTINEL_SKIP_BUILD=1 — no toolchain needed.

curl -LO https://github.com/atayoez/sentinel/releases/latest/download/sentinel-kde-0.9.0-x86_64-linux.tar.gz
tar xzf sentinel-kde-0.9.0-x86_64-linux.tar.gz
cd sentinel-kde-0.9.0
sudo SENTINEL_SKIP_BUILD=1 ./install.sh

Source

The KDE frontend installs from packaging-kde/, pulling in the shared backend.

git clone https://github.com/atayoez/sentinel
cd sentinel
pkexec ./packaging-kde/install.sh

The installer:

  1. Builds the workspace as the invoking user (cargo target/ stays user-owned).
  2. Records every replaced file's pre-install state in /var/lib/sentinel/install.state.
  3. Verifies modes/owners on every installed file.
  4. Restarts the polkit agent in-place so changes take effect without log-out.

pkexec ./packaging-kde/uninstall.sh rolls everything back to the recorded pre-install state.

The --enable-sudo flag opts into wiring pam_sentinel.so into /etc/pam.d/sudo (default: off — see PAM wiring for why).

Verifying release artifacts

Every artifact is signed by Sigstore via GitHub's artifact attestations:

gh attestation verify sentinel-kde-0.9.0-x86_64-linux.tar.gz \
    --repo atayoez/sentinel

The signature binds the file's sha256 to the release.yml workflow run that produced it.

Configuration

Sentinel reads /etc/security/sentinel.conf (TOML) on every PAM auth attempt — no daemon to reload. The file is root-owned and not user-writable on purpose: a per-user override layer would defeat the UAC contract by letting an unprivileged user lower their own timeout to zero.

Sections

[general]

KeyTypeDefaultDescription
enabledbooltrueMaster switch. When false, the module returns PAM_IGNORE and the rest of the stack runs unchanged.
timeoutuint30Auto-deny timeout in seconds. 0 disables the timeout (the dialog stays open until the user clicks).
randomize_buttonsbooltrueSwap Allow/Deny positions randomly to deter scripted clickers.
headless_actionenum"password"What to do when no Wayland display is available. "allow" silently grants (DANGEROUS), "deny" silently rejects, "password" falls through to the next PAM module (typically pam_unix).
show_process_infobooltrueDisplay the requesting process's exe/cmdline in the dialog.
log_attemptsbooltrueLog every allow/deny/timeout to syslog (auth.info).
min_display_time_msuint500Disable the Allow button for this many ms after the dialog appears, blocking instant scripted clicks.
remember_secondsuint300"Remember" window for the polkit/GUI path. The dialog shows a "Remember for N min" checkbox by default; tick it and Allow to let repeat requests from the same login session skip the dialog for this many seconds. Both paths key the grant on the action/service + the full command, so it never covers a different command. 0 hides the checkbox; hard-capped at 900. Terminal sudo/su have a compiled default of 0, but the shipped config opts them into 300. See below.

The remember window is a sudo-timestamp analogue. The opt-in checkbox is shown by default on the polkit/GUI path (set remember_seconds = 0 to hide it); ticking it is still opt-in per request (the box defaults unchecked, so nothing is auto-allowed unless you tick it on that prompt). A grant is bound to your loginuid and kernel audit sessionid, so it can't be replayed in another session or by another user, and is scoped to exactly what it was granted for — both paths key the grant on (action, whole command) (sudo pacman -Syu can never auto-allow sudo pacman -U /tmp/evil, and pkexec id never covers pkexec rm) — never a blanket allow. It is enforced by two trust-appropriate backends:

  • sudo / su (PAM path): the pam_sentinel module relays the decision to the sentinel-broker daemon — a sandboxed, unprivileged service that holds grants in memory (no on-disk artifact to forge or roll back) and serves only root peers over a Unix socket. Grants evaporate when the broker stops, and the module is fail-closed: if the broker is unreachable, you simply get the dialog. (Installed and enabled by install.sh.)
  • polkit / GUI (agent, per-user): an in-memory cache that evaporates on logout (the agent restarts with the session).

Defaults. The two paths have different blast radii, so the compiled defaults differ — but the shipped config enables both:

  • The polkit/GUI path inherits [general].remember_seconds (default 300), so the checkbox is shown there by default.
  • The terminal sudo/su/sudo-i paths have a compiled default of 0 (off), but the shipped config/sentinel.conf opts them into 300. Set a service back to 0 to require confirmation every time; disable the GUI path with [general].remember_seconds = 0 or [services."polkit-1"].remember_seconds = 0.

Because grants are keyed by the full command, the generic pkexec action (org.freedesktop.policykit.exec, "run any command as root") is remembered per commandpkexec id only ever auto-allows pkexec id, never pkexec rm …. (Earlier versions excluded pkexec entirely because the key was command-blind; that is fixed.)

polkit's own caching is separate. Actions whose policy uses auth_admin_keep/auth_self_keep are cached by polkit itself for the session after the first auth — independent of Sentinel's window, and not overridden by Sentinel. The first auth is still gated by the dialog; pkexec does not use keep, so the "run anything" path is re-confirmed every time.

Never remembered (always re-prompts), on both paths: arbitrary-code gateways used as the elevated command — shells, language interpreters, and common shell-escapers (editors, pagers, find, …), via a shared denylist (sentinel_shared::remember_eligible_command); plus, on the terminal path, interactive root shells and cred-cache invocations (sudo -s/-i/-v, su). Conservative, non-exhaustive; the primary bound is full-command binding, so keep windows short.

⚠️ Terminal caveat. A sudo/su grant is keyed by the full command (so a grant for sudo pacman -Syu does not cover sudo pacman -U <file>), but it is honored by every process sharing your audit session for the window. Under the shipped auth sufficient wiring a remembered grant is passwordless for its duration (it short-circuits the stack before pam_unix). Enable the terminal window only if you accept that trade-off.

A request with no audit session is never remembered.

sudo's own timestamp. By default sudo caches credentials for ~5 min (timestamp_timeout), which lets a back-to-back sudo skip the PAM stack entirely — so Sentinel never sees it and this per-command window is bypassed by sudo's blanket session cache. The installer therefore drops /etc/sudoers.d/sentinel-timestamp (Defaults timestamp_timeout=0, validated with visudo) so every sudo runs the PAM stack and Sentinel's per-command remember is the only cache. Remove that file (or uninstall) to restore sudo's default timestamp; pass --no-sudo at install time to skip terminal wiring (and this override) altogether.

[appearance]

KeyTypeDefaultDescription
titlestring"Authentication Required"Dialog title. No substitutions.
messagestring'The application "%p" is requesting elevated privileges.'Primary message. Tokens: see below.
secondarystring""Optional hint line below the message. Empty by default — naming the buttons in the secondary text broke under randomize_buttons in 0.5.x.

[audio]

KeyTypeDefaultDescription
sound_namestring"dialog-warning"Freedesktop sound name (NOT a file path). Empty string disables. Resolved via canberra-gtk-play if installed.

[services.<name>]

Per-PAM-service overrides. The overridable keys are enabled, timeout, randomize, and remember_seconds. Unknown keys are a parse error (a typo fails loudly rather than being silently dropped). Omitted keys inherit from [general]except remember_seconds, which inherits [general].remember_seconds only for polkit-1 and defaults to 0 (off) for terminal services; see the remember window.

[services.polkit-1]
timeout = 60          # more lenient for GUI auth

[services.su]
enabled = false       # never confirm `su`, fall through to password

[services.sudo]
remember_seconds = 300  # per-command 5-min window; set 0 to confirm every time

[policy]

Static allow/deny lists evaluated before the dialog. Disabled by default (empty lists never match), so behaviour is unchanged until you opt in.

KeyTypeDefaultDescription
allowlist of string[]Auto-allow (no dialog) when an entry matches.
denylist of string[]Auto-deny (no dialog). Takes precedence over allow.

Each entry matches the requesting program's resolved executable path (/proc/<pid>/exe, e.g. /usr/bin/pacman — never the spoofable argv[0]), that path's basename when the entry contains no /, or the polkit action id (agent path).

⚠️ An allow entry is passwordless elevation for that target — as load-bearing as a sudoers NOPASSWD line. Prefer absolute paths, keep the list short.

[policy]
allow = [
    "/usr/bin/topgrade",                         # full path (recommended)
    "pacman",                                    # basename: any path named 'pacman'
]
deny = [
    "org.freedesktop.systemd1.manage-units",     # polkit action id
]

[notifications]

Desktop notifications (via notify-send / libnotify) on the polkit/GUI auth path. Terminal sudo/su denials are already visible in the terminal, so they're not covered. Both default off.

KeyTypeDefaultDescription
on_denyboolfalseNotify when a request is denied (including silent [policy] denials, where no dialog appeared).
on_timeoutboolfalseNotify when a request auto-denies on timeout.
[notifications]
on_deny = true
on_timeout = true

Tokens

Inside message and secondary the following expand at runtime:

TokenExpands to
%uUsername being authenticated
%sPAM service name (sudo, polkit-1, …)
%pRequesting process's executable path basename
%%Literal %

Unknown %x sequences are preserved verbatim so a typo is visible to the admin in the rendered dialog.

Example

# /etc/security/sentinel.conf

[general]
enabled = true
timeout = 30
randomize_buttons = true
headless_action = "password"
min_display_time_ms = 500
remember_seconds = 300        # GUI checkbox window (default); 0 = hide/off

[appearance]
title = "Authentication Required"
message = 'The application "%p" is requesting elevated privileges.'
secondary = ""

[audio]
sound_name = "dialog-warning"

# Auto-allow/deny before the dialog (off by default — empty lists).
[policy]
allow = []
deny = []

# Desktop notification on deny/timeout (polkit/GUI path).
[notifications]
on_deny = false
on_timeout = false

[services.sudo]
timeout = 30
remember_seconds = 300        # per-command window (shipped default); 0 = confirm every time

[services."polkit-1"]
timeout = 60

[services.su]
enabled = false

[services.gdm-password]
enabled = false

[services.lightdm]
enabled = false

[services.sddm]
enabled = false

Localization

The helper's UI chrome (button labels, "Show details" toggle, "Auto-deny in Ns") is localized from the system locale (LC_ALL/LC_MESSAGES/LANG).

The KDE helper (sentinel-helper-kde) localizes this chrome via sentinel_shared::ui_i18n, which ships 12 languages: en, de, es, fr, it, ja, nl, pl, pt, ru, tr, zh.

The dialog message/title/secondary are admin-supplied via this config file — they're rendered verbatim as you write them. If you leave the defaults (title, message), the helper substitutes the locale's own translation; once you customise them, your text wins.

Locale resolution: LC_ALLLC_MESSAGESLANG, reduced to its 2-letter language code (tr_TR.UTF-8tr), falling back to en for unknown or unset values.

PAM wiring

Sentinel needs to be referenced from the PAM stacks of whichever services should trigger the confirmation dialog. The packages wire /etc/pam.d/polkit-1 automatically; everything else is opt-in.

Always test on a fresh install with a second root shell open. A typo in a PAM file can lock you out of sudo. pkexec bash keeps a working privileged shell available even if the rest of the stack breaks.

polkit (default)

/etc/pam.d/polkit-1 is owned by Sentinel after a package install:

#%PAM-1.0
auth       sufficient pam_sentinel.so
auth       include    system-auth
account    include    system-auth
password   include    system-auth
session    include    system-auth

The sufficient control means: if Sentinel returns PAM_SUCCESS (user clicked Allow), polkit skips the rest of the stack — no password needed. Any other return (Deny / timeout / no Wayland) falls through to system-auth which prompts for the password.

sudo (opt-in)

The package does not wire /etc/pam.d/sudo automatically — a mistake there can lock you out of root entirely.

To opt in via the source installer:

pkexec ./install.sh --enable-sudo

To do it manually:

# /etc/pam.d/sudo
#%PAM-1.0
auth       sufficient pam_sentinel.so
auth       include    system-auth
account    include    system-auth
password   include    system-auth
session    include    system-auth

A sufficient Sentinel followed by system-auth is the safest shape: any Sentinel failure (helper crash, missing display) produces PAM_AUTH_ERR, which makes the stack continue to the password prompt. You're never prevented from authenticating; Sentinel just adds a confirmation step on top.

sudo-rs

sudo-rs reads the same /etc/pam.d/sudo stack as sudo. No separate wiring; the steps above cover both.

su

A [services.su] enabled = false block in sentinel.conf is the recommended approach (Sentinel returns PAM_IGNORE, su falls through to password). If you want Sentinel to gate su too, mirror the sudo wiring into /etc/pam.d/su.

What to do if you locked yourself out

The pkexec bash from your second root shell is the rescue hatch:

# In the rescue shell:
pkexec ./uninstall.sh   # restores backed-up /etc/pam.d/* files

If both pkexec and sudo are broken (extremely rare; would require pam_sentinel.so to crash on every dlopen), boot to a TTY and edit /etc/pam.d/{sudo,polkit-1} by hand to remove the pam_sentinel.so line.

The installer's transactional state file is at /var/lib/sentinel/install.state; every replaced file has a .pre-sentinel.bak copy alongside it. Worst-case manual recovery:

mv /etc/pam.d/sudo.pre-sentinel.bak /etc/pam.d/sudo
mv /etc/pam.d/polkit-1.pre-sentinel.bak /etc/pam.d/polkit-1

How sufficient interacts with the rest of the stack

PAM's sufficient control is "if this passes, we're done; if it fails, keep going". That makes Sentinel a strict additive confirmation: never weakens auth, only ever ADDS a click.

Sentinel returnsStack behaviour
PAM_SUCCESS (Allow)Skip rest of auth, grant access.
PAM_AUTH_ERR (Deny / timeout / crash)Continue to next module → password prompt.
PAM_IGNORE (disabled / headless / fallthrough)Continue to next module → password prompt.

There's no configuration where Sentinel returning anything makes auth easier than the underlying password stack. Worst case it's neutral (you still type your password); best case (Allow) it's a single click instead of a password.

Caveat — the remember window. When the optional remember window is enabled for a service ([services.<name>].remember_seconds), a fresh matching grant auto-allows with no dialog — and under sufficient, no password — for the window's duration, relaxing the per-request click above. The shipped config opts terminal sudo/su/sudo-i into a per-command window (the compiled default is still 0), so a verbatim repeat of the same command can auto-allow; set [services.<name>].remember_seconds = 0 to keep the strict "adds a click, never weakens" behavior for that service. The installer also disables sudo's own credential cache so this window is the only one. The polkit/GUI remember default (on) is a per-session, in-memory cache and does not touch this terminal stack. See Configuration → remember window.

Troubleshooting

"I can't log in / sudo doesn't work" — recovery

pkexec ./uninstall.sh from your second root shell undoes everything the installer did, restoring .pre-sentinel.bak files in place.

If both sudo and pkexec are broken:

  1. Boot to a TTY (Ctrl+Alt+F2 typically).
  2. Log in as root, or sudo into a recovery shell.
  3. Restore manually:
    mv /etc/pam.d/sudo.pre-sentinel.bak /etc/pam.d/sudo
    mv /etc/pam.d/polkit-1.pre-sentinel.bak /etc/pam.d/polkit-1
    rm /usr/lib/security/pam_sentinel.so
    

The dialog never appears

Check the agent is registered:

pgrep -fxa /usr/lib/sentinel-polkit-agent
journalctl -t sentinel-polkit-agent --since "5 minutes ago" --no-pager

You should see:

agent socket listening at /run/user/1000/sentinel-agent.sock
registered as polkit auth agent (object path /com/github/sentinel/PolkitAgent)

If "another agent is registered, retrying": another polkit agent (polkit-kde, polkit-gnome, …) is winning the registration race. On Plasma the installer masks plasma-polkit-agent.service so Sentinel is the session's sole agent; if it crept back, mask it again and restart the Sentinel unit:

systemctl --user mask plasma-polkit-agent.service
systemctl --user restart sentinel-polkit-agent.service

If the agent is running but the dialog still doesn't show: likely the compositor doesn't implement zwlr-layer-shell-v1. Sentinel auto-falls-back to xdg-toplevel on GNOME/Mutter, but force it with:

sentinel-helper-kde --windowed --title test --message hi

pkexec shows "Error executing command as another user: Not authorized"

That's pkexec's standard error after a failed auth — including a clean Deny click in Sentinel. The "incident has been reported" line is hardcoded in pkexec(1). Sentinel can't suppress it from the agent side; polkit doesn't differentiate "user politely declined" from "auth failed" in its protocol.

sudo true shows "sudo-rs" in the dialog instead of "true"

Make sure you're on v0.6.1+ (this was fixed in that release). Earlier versions read /proc/<sudo-pid>/exe without stripping the elevation wrapper.

sentinel-helper-kde --version

sudo -v (or topgrade / paru cred-cache) shows "sudo-rs" not the

parent process

Fixed in v0.7.0. When the elevation wrapper has no target argv (sudo -v), the PAM module walks up to PPid and uses the parent's exe.

Dialog appears but the wrong language

Sentinel's helper reads LC_ALLLC_MESSAGESLANG to pick its embedded locale bundle. PAM modules typically run inside privileged binaries (sudo, helper-1) that scrub LANG from their env, so the helper recovers locale variables from /proc/<requesting-pid>/environ against a strict allowlist.

Check the test:

LANG=tr_TR.UTF-8 pkexec true

Should render the Turkish dialog. If it doesn't:

  • Check /proc/<your-shell-pid>/environ actually has LANG=...
  • Check the locale's language code is one of the 12 shipped: en, de, es, fr, it, ja, nl, pl, pt, ru, tr, zh. Other languages fall back to English.

I want more verbose logs

The agent supports --debug:

pkill -fx /usr/lib/sentinel-polkit-agent
/usr/lib/sentinel-polkit-agent --debug &

The --debug mode dumps details[…] from every BeginAuthentication call — useful for diagnosing process-name display bugs.

The PAM module always logs at INFO level under syslog identifier pam_sentinel (AUTH facility). Get all auth events from the last 5 minutes:

journalctl -t pam_sentinel -t sentinel-polkit-agent \
    --since "5 minutes ago" --no-pager | grep "event=auth"

Reporting bugs

bug_report.yml is the standard template. For compositor compatibility specifically, the compositor compat template feeds the README compatibility table directly.

For security issues, use private vulnerability reporting — see security policy.

Architecture

Sentinel is a shared backend (a PAM module, a polkit agent, and a shared crate) plus a KDE Plasma GUI frontend, in a single Cargo workspace.

crates/
├── sentinel-shared/        # config schema, /proc + logind readers,
│                           # Outcome wire enum, log_kv helpers,
│                           # POLKIT_PAM_SERVICE const, audit::init_syslog
├── pam-sentinel/           # cdylib → /usr/lib/security/pam_sentinel.so
├── sentinel-polkit-agent/  # bin → /usr/lib/sentinel-polkit-agent
└── sentinel-helper-kde/    # KDE Plasma / Kirigami (cxx-qt) dialog → /usr/lib/sentinel-helper-kde

The backend stays out of the GUI dependency graph: a bare cargo build compiles the auth path without Qt. The helper speaks a small CLI contract — ALLOW/DENY/TIMEOUT on stdout — so the backend can spawn it without linking any GUI code.

The PAM module — pam_sentinel.so

Loaded by libpam on every authentication attempt for whatever services have it wired in. For each call it picks one of:

  • Bypass: the polkit agent has already pre-approved this auth; consume it over D-Bus (org.sentinel.Agent). Return PAM_SUCCESS immediately.
  • Dialog: spawn the frontend helper (sentinel-helper-kde), wait for Allow / Deny / timeout. Return PAM_SUCCESS on Allow, PAM_AUTH_ERR otherwise.
  • Headless: no Wayland display detected. Return whatever headless_action says (default PAM_IGNORE → password prompt).
  • Disabled: enabled = false in config → PAM_IGNORE.

Identifying the requesting user uses /proc/<ppid>/loginuid (set by PAM at login, inherited through forks, immune to setuid). Falls back to /proc/<ppid>/status Uid: line, then getuid().

The displayed process name uses /proc/<pid>/cmdline of the privileged binary (sudo, pkexec, helper-1) and strips the elevation wrapper via sentinel_shared::strip_elevation_prefix. For wrappers with no target argv (sudo -v for cred-cache), it walks PPid to the calling process so the dialog shows the user-facing originator (paru, topgrade) rather than sudo-rs.

The polkit agent — sentinel-polkit-agent

A per-user agent that registers with polkitd as the session's org.freedesktop.PolicyKit1.AuthenticationAgent. Forks sentinel-helper-kde for the dialog, then satisfies polkit's cookie validation via polkit-agent-helper-1 over its socket.

Bypass channel (system D-Bus)

The agent claims org.sentinel.Agent on the system bus and exposes a TakeApproval method. When the agent's own helper-1 invocation runs, the pam_sentinel.so inside it (running as root) calls TakeApproval, gets a one-shot true / false, and short-circuits to PAM_SUCCESS without spawning a second dialog.

D-Bus — not a unix socket — because polkit-agent-helper-1 runs as policykit_t under SELinux, which is denied writing an arbitrary socket but is permitted dbus send_msg to user domains (the same path pam_fprintd uses). The bypass therefore works under SELinux (openSUSE Tumbleweed, etc.) with no custom policy. The system-bus policy in packaging/dbus/org.sentinel.Agent.conf lets any user own the name but only root send to it.

Per-call check:

  1. The caller (pam_sentinel) verifies the owner uid of org.sentinel.Agent matches the user being authenticated (GetConnectionUnixUser), defeating a same-name squatter from another uid.
  2. The bus policy permits only root to call TakeApproval.

Approvals are one-shot, expire after 1 second, and cancel-authentication drains the queue so a stale approval can't be picked up by a racing auth.

Identity selection

unix-user identities are preferred over groups; the matching uid wins over alternatives; first non-root unix-user is the fallback. See crates/sentinel-polkit-agent/src/identity.rs.

Agent autostart and the sessionid constraint

The agent must register with polkitd from inside the user's login session: RegisterAuthenticationAgent checks that the caller's session matches the subject's, so the agent needs the compositor's XDG_SESSION_ID. A bare systemd --user unit under user@<uid>.service runs with a DIFFERENT sessionid and gets rejected with "Passed session and the session the caller is in differs". Plasma 6 runs its own polkit agent as a systemd --user service scoped to the graphical session, and Sentinel mirrors that: it ships sentinel-polkit-agent.service (PartOf=graphical-session.target), which Plasma's session management starts within the correct graphical session, so the agent inherits the session id and registers cleanly. The installer enables the unit per-user and masks plasma-polkit-agent.service so Sentinel is the session's sole polkit agent.

The helper — sentinel-helper-kde

The GUI binary that paints the dialog — sentinel-helper-kde (KDE Plasma / Kirigami via cxx-qt). The backend spawns it over a small CLI contract. Per-spawn:

  • Initializes UI-string localization from LANG / LC_* via sentinel_shared::ui_i18n (translations embedded at compile time).
  • Plays the freedesktop sound cue via canberra-gtk-play (silent fallback if not installed).
  • Decides layer-shell vs xdg-toplevel rendering (auto-falls-back to xdg-toplevel on Mutter-based desktops).
  • Renders the card; emits ALLOW / DENY / TIMEOUT on stdout and exits with the matching code.

Keyboard accessibility:

  • Tab / Shift+Tab — cycle Allow / Deny.
  • Enter / Space — activate focused button.
  • Escape — always denies (intercepted regardless of focus).
  • Allow button is disabled for min_display_time_ms after the dialog appears, blocking instant scripted clicks.

Wire formats

Helper → caller

The helper writes one of ALLOW\n, DENY\n, TIMEOUT\n to stdout and exits with 0 (Allow) or 1 (Deny / Timeout). The sentinel_shared::Outcome enum is the single source of truth for the parser.

Audit log

Lines emitted under syslog identifier pam_sentinel or sentinel-polkit-agent, AUTH facility:

event=auth.allow source=dialog user=alice service=sudo process=pacman uid=1000 latency_ms=2891 session_type=wayland session_class=user session_remote=0
event=auth.allow source=bypass uid=1000
event=auth.deny  source=dialog user=alice service=sudo process=true uid=1000 latency_ms=12440 …
event=auth.timeout source=agent user=alice action=org.freedesktop.policykit.exec process=pacman …
event=auth.headless reason=no-wayland user=alice service=sudo …

Format is logfmt (whitespace-separated key=value, values quoted when necessary). Designed for journalctl -t pam_sentinel --output=cat | grep event=auth.deny to be the SRE-friendly query.

Bypass channel

System-bus method on org.sentinel.Agent:

pam_sentinel → agent:  TakeApproval()
agent → pam_sentinel:  true    (approval popped, fast-path the auth)
                       or
                       false   (no approval; fall through to the dialog)

Compatibility matrix

See README#Compatibility for the per-compositor status table.

Threat model

See Security policy for the explicit trust boundaries — what the PAM module trusts vs. doesn't, what the agent will refuse, supply-chain integrity via Sigstore attestations.

Building from source

Toolchain

  • Rust 1.85+ (workspace MSRV pinned in rust-toolchain.toml).
  • Backend + KDE helper native deps: libpam0g-dev, libxkbcommon-dev, libwayland-dev, libfontconfig1-dev, libfreetype6-dev, pkg-config. (Arch: pam wayland libxkbcommon fontconfig freetype2 mesa vulkan-icd-loader.)
  • KDE helper also needs Qt 6 + KF 6 + cxx-qt's private headers (openSUSE: qt6-base-devel qt6-base-private-devel qt6-declarative-devel qt6-declarative-private-devel kf6-kirigami-imports kf6-qqc2-desktop-style layer-shell-qt6-imports qt6-wayland; Arch: qt6-base qt6-declarative kirigami layer-shell-qt). It links with mold.

Building

The backend (PAM module + agent) is in default-members, so a bare build skips both GUI toolchains. Build a frontend explicitly.

git clone https://github.com/atayoez/sentinel
cd sentinel

cargo build --release --locked                          # backend only (no Qt)
cargo build --release --locked -p sentinel-helper-kde   # + KDE frontend
# `--workspace` builds everything, including the Qt-based KDE helper.

This produces:

  • target/release/libpam_sentinel.so — the cdylib
  • target/release/sentinel-polkit-agent — the polkit agent
  • target/release/sentinel-helper-kde — the KDE (Kirigami) dialog

Running tests

cargo test --workspace --locked
cargo fmt --all -- --check
cargo clippy --workspace --all-targets -- -D warnings

cargo audit and cargo deny check run in CI; install locally with cargo install --locked cargo-audit cargo-deny.

Compile-time configuration

The workspace's build.rs files bake install paths into the binaries via env vars at compile time:

VarDefaultMeaning
SENTINEL_PREFIX/usrInstall prefix for binaries.
SENTINEL_SYSCONFDIR/etcWhere sentinel.conf lives.
SENTINEL_LIBEXECDIRlibSubdir under PREFIX for the helper + agent.

For a custom-prefix build:

SENTINEL_PREFIX=/usr/local SENTINEL_SYSCONFDIR=/usr/local/etc \
    cargo build --release --workspace --locked

The PAM module + agent compile-time-bake the helper's absolute path, so they always know where to spawn it from.

Test it locally without an install

./packaging-kde/scripts/dev-test.sh

This installs to system paths, compiles a small pam_authtest probe that calls pam_authenticate() for a dedicated test service, runs the probe, and rolls everything back unconditionally on exit. Refuses to run if Sentinel is already installed (prevents accidental clobbering of your real config).

Building distribution packages

./packaging-kde/scripts/build-release.sh 0.8.0

Produces dist/:

  • sentinel-kde-0.8.0.tar.gz (source)
  • sentinel-kde-0.8.0-x86_64-linux.tar.gz (binary, install layout)
  • per-arch .sha256 files

For an RPM:

cargo generate-rpm -p crates/sentinel-helper-kde

Shell completions and man pages

sentinel-polkit-agent auto-generates its shell completions and man page:

sentinel-polkit-agent completions bash > /etc/bash_completion.d/sentinel-polkit-agent
sentinel-polkit-agent man > /usr/share/man/man1/sentinel-polkit-agent.1

The release tarballs and packages ship these pre-rendered.

Contributing

Thanks for thinking about contributing! Sentinel sits in the PAM authentication path, so reviewers are pickier than average — but the flow itself is normal GitHub fork-PR-merge.

Development quickstart

git clone https://github.com/atayoez/sentinel
cd sentinel

cargo build --release --workspace
cargo test --workspace --locked
cargo fmt --all -- --check
cargo clippy --workspace --all-targets -- -D warnings

Things reviewers check

The PR template lists the gate. Most-important items:

  • Sentinel sits in the PAM auth path. If you touch pam-sentinel, the agent, or the helper, please run pkexec ./install.sh && pkexec true end-to-end before opening the PR. A regression here can lock people out of sudo or polkit.
  • i18n changes — test with LANG=tr_TR.UTF-8 pkexec true or any shipped locale. The every_bundle_has_required_keys and every_bundle_has_matching_placeholders tests catch most issues but not all rendering quirks.
  • Install / uninstall — please test the rollback path too (pkexec ./packaging-kde/uninstall.sh).
  • i18n: adding a new locale — see crates/sentinel-shared/src/ui_i18n.rs doc comment for the steps; the test suite catches missing keys + placeholder drift.

Architecture references

Reporting bugs

Use bug_report.yml for general bugs, or compositor_compat.yml for "did Sentinel work on $compositor" reports (the table in the README is fed from these).

Security issues go through GitHub Private Vulnerability Reporting — see the security policy.

Discussions

Open-ended questions ("would you take a PR for X?", "is this in scope?") go in Discussions rather than issues.

License

By contributing you agree your changes ship under GPL-3.0-or-later, Sentinel's license. New files should carry the SPDX header (see existing files for the convention; reuse lint enforces).

Security policy

The full text lives at .github/SECURITY.md in the repo root and is what GitHub renders on the security tab. This page is a brief summary; reporters should follow the canonical copy.

Reporting a vulnerability

  1. Preferred: GitHub Private Vulnerability Reporting ("Report a vulnerability" button).
  2. Email: atay@oezcan.me with subject "Sentinel security".

Please don't open public issues for security bugs.

Threat model summary

Sentinel has two trust boundaries:

  1. The PAM module (pam_sentinel.so) runs in-process of whatever privileged binary's PAM stack references it — sudo, helper-1, su. It trusts libpam, root-owned /etc/security/sentinel.conf, and the kernel's /proc/<pid>/loginuid. It doesn't trust the host binary's environment (locale variables are recovered from the user's /proc/<pid>/environ against a strict allowlist).

  2. The polkit agent runs as the user, owns the bypass socket at $XDG_RUNTIME_DIR/sentinel-agent.sock (mode 0600). The bypass protocol verifies peer uid via SO_PEERCRED and the peer's comm against the kernel-truncated polkit-agent-helper-1.

Detailed threat model in .github/SECURITY.md.

Supply-chain integrity

Every release artifact ships with a Sigstore artifact attestation binding the file's sha256 to the GitHub Actions run that produced it. Verify:

gh attestation verify <file> --repo atayoez/sentinel

Downstream packagers (AUR, Debian, Fedora) are encouraged to run this in their build hooks.

Out of scope

  • Same-uid attacks (a process running as your user can drive polkit directly; Sentinel is a UI confirmation, not a sandbox).
  • Compositor / kernel issues themselves.
  • Issues in upstream sudo, polkit, pam_unix, polkit-agent-helper-1.

When in doubt, send the report anyway and we'll triage.