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.
Where to read next
- First install: Installation
- Customize the dialog: Configuration
- Wire into sudo / su: PAM wiring
- Something broke: Troubleshooting
- Curious about the design: Architecture
- Want to contribute: Contributing
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 confirmedsudoandpkexecstill 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:
- Builds the workspace as the invoking user (cargo target/ stays user-owned).
- Records every replaced file's pre-install state in
/var/lib/sentinel/install.state. - Verifies modes/owners on every installed file.
- 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]
| Key | Type | Default | Description |
|---|---|---|---|
enabled | bool | true | Master switch. When false, the module returns PAM_IGNORE and the rest of the stack runs unchanged. |
timeout | uint | 30 | Auto-deny timeout in seconds. 0 disables the timeout (the dialog stays open until the user clicks). |
randomize_buttons | bool | true | Swap Allow/Deny positions randomly to deter scripted clickers. |
headless_action | enum | "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_info | bool | true | Display the requesting process's exe/cmdline in the dialog. |
log_attempts | bool | true | Log every allow/deny/timeout to syslog (auth.info). |
min_display_time_ms | uint | 500 | Disable the Allow button for this many ms after the dialog appears, blocking instant scripted clicks. |
remember_seconds | uint | 300 | "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_sentinelmodule relays the decision to thesentinel-brokerdaemon — 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 byinstall.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(default300), so the checkbox is shown there by default. - The terminal
sudo/su/sudo-ipaths have a compiled default of0(off), but the shippedconfig/sentinel.confopts them into300. Set a service back to0to require confirmation every time; disable the GUI path with[general].remember_seconds = 0or[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 command — pkexec 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_keepare 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;pkexecdoes not usekeep, 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/sugrant is keyed by the full command (so a grant forsudo pacman -Syudoes not coversudo pacman -U <file>), but it is honored by every process sharing your audit session for the window. Under the shippedauth sufficientwiring a remembered grant is passwordless for its duration (it short-circuits the stack beforepam_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
sudocaches credentials for ~5 min (timestamp_timeout), which lets a back-to-backsudoskip 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 withvisudo) so everysudoruns 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-sudoat install time to skip terminal wiring (and this override) altogether.
[appearance]
| Key | Type | Default | Description |
|---|---|---|---|
title | string | "Authentication Required" | Dialog title. No substitutions. |
message | string | 'The application "%p" is requesting elevated privileges.' | Primary message. Tokens: see below. |
secondary | string | "" | 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]
| Key | Type | Default | Description |
|---|---|---|---|
sound_name | string | "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.
| Key | Type | Default | Description |
|---|---|---|---|
allow | list of string | [] | Auto-allow (no dialog) when an entry matches. |
deny | list 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
allowentry is passwordless elevation for that target — as load-bearing as asudoersNOPASSWDline. 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.
| Key | Type | Default | Description |
|---|---|---|---|
on_deny | bool | false | Notify when a request is denied (including silent [policy] denials, where no dialog appeared). |
on_timeout | bool | false | Notify when a request auto-denies on timeout. |
[notifications]
on_deny = true
on_timeout = true
Tokens
Inside message and secondary the following expand at runtime:
| Token | Expands to |
|---|---|
%u | Username being authenticated |
%s | PAM service name (sudo, polkit-1, …) |
%p | Requesting 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_ALL → LC_MESSAGES → LANG, reduced to its
2-letter language code (tr_TR.UTF-8 → tr), 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 bashkeeps 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 returns | Stack 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:
- Boot to a TTY (Ctrl+Alt+F2 typically).
- Log in as root, or sudo into a recovery shell.
- 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_ALL → LC_MESSAGES → LANG 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>/environactually hasLANG=... - 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). ReturnPAM_SUCCESSimmediately. - Dialog: spawn the frontend helper (
sentinel-helper-kde), wait for Allow / Deny / timeout. ReturnPAM_SUCCESSon Allow,PAM_AUTH_ERRotherwise. - Headless: no Wayland display detected. Return whatever
headless_actionsays (defaultPAM_IGNORE→ password prompt). - Disabled:
enabled = falsein 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:
- The caller (
pam_sentinel) verifies the owner uid oforg.sentinel.Agentmatches the user being authenticated (GetConnectionUnixUser), defeating a same-name squatter from another uid. - The bus policy permits only
rootto callTakeApproval.
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_*viasentinel_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/TIMEOUTon 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_msafter 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 withmold.
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 cdylibtarget/release/sentinel-polkit-agent— the polkit agenttarget/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:
| Var | Default | Meaning |
|---|---|---|
SENTINEL_PREFIX | /usr | Install prefix for binaries. |
SENTINEL_SYSCONFDIR | /etc | Where sentinel.conf lives. |
SENTINEL_LIBEXECDIR | lib | Subdir 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
.sha256files
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 runpkexec ./install.sh && pkexec trueend-to-end before opening the PR. A regression here can lock people out ofsudoor polkit. - i18n changes — test with
LANG=tr_TR.UTF-8 pkexec trueor any shipped locale. Theevery_bundle_has_required_keysandevery_bundle_has_matching_placeholderstests 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.rsdoc comment for the steps; the test suite catches missing keys + placeholder drift.
Architecture references
- Architecture for the design and the trust boundaries.
- Configuration for the on-disk schema.
- PAM wiring for the install-time semantics.
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
- Preferred: GitHub Private Vulnerability Reporting ("Report a vulnerability" button).
- Email:
atay@oezcan.mewith subject "Sentinel security".
Please don't open public issues for security bugs.
Threat model summary
Sentinel has two trust boundaries:
-
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>/environagainst a strict allowlist). -
The polkit agent runs as the user, owns the bypass socket at
$XDG_RUNTIME_DIR/sentinel-agent.sock(mode0600). The bypass protocol verifies peer uid viaSO_PEERCREDand the peer'scommagainst the kernel-truncatedpolkit-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.