Skip to content

Architecture

Atay Özcan edited this page May 2, 2026 · 2 revisions

Architecture

Sentinel is two cooperating processes glued by a Unix pipe:

┌──────────────┐    fork+exec      ┌──────────────────┐
│ pam_sentinel │ ─────────────────▶│ sentinel-helper  │
│   (.so in    │   pipe(stdout)    │  (Wayland GUI)   │
│  caller PAM) │ ◀─────────────────│                  │
└──────────────┘   "ALLOW\n" /     └──────────────────┘
                   "DENY\n" / "TIMEOUT\n"

pam_sentinel.so

Loaded by libpam into whatever process is authenticating (sudo, pkexec, etc). Implements pam_sm_authenticate and returns one of:

PAM result When
PAM_SUCCESS Helper printed ALLOW
PAM_AUTH_ERR Helper printed DENY, or TIMEOUT with default policy
PAM_IGNORE Headless and headless_action = "password"; or enabled = false; or config parse error (fail-open)
PAM_AUTHINFO_UNAVAIL Helper failed to spawn or crashed

Process flow (per auth attempt)

  1. Read config/etc/security/sentinel.conf. Parse failure logs and returns PAM_IGNORE.
  2. Pick service config — apply [services.<svc>] overrides on top of [general] for the current PAM service.
  3. Detect display — if XDG_RUNTIME_DIR and WAYLAND_DISPLAY of the target user aren't set, take the headless_action path.
  4. Resolve requesting process — read /proc/<requesting_pid>/exe and /proc/<requesting_pid>/comm to populate %p substitutions.
  5. Fork + exec helper — drop privileges to the target user (setgid + setuid + initgroups), dup2 a pipe write end onto stdout, execv sentinel-helper. The PAM module never holds root on the helper side.
  6. Waitpoll(POLLIN, timeout). Helper has its own internal timeout; the PAM-side poll uses helper_timeout + 5s as a safety margin. If poll times out the module sends SIGKILL and reaps.
  7. Read — read up to 32 bytes from the pipe; first newline-terminated token is the verdict.
  8. Map and return — translate ALLOW/DENY/TIMEOUT → PAM result.

Privilege separation

pam_sentinel.so runs in the auth process's privilege context (often root for sudo, root for pkexec). The helper does not — the fork sets uid/gid to the target user (the user being authenticated to; for sudo -u root that's root, but for sudo -u alice it's alice) and the helper runs entirely as that user. The helper never sees or writes anything to disk that requires elevated privilege; the only thing it returns is three bytes of stdout.

sentinel-helper

A libcosmic + iced application that creates a single zwlr-layer-shell-v1 overlay surface:

Layer-shell setting Value
Layer Overlay
Anchor TOP | BOTTOM | LEFT | RIGHT (full-screen)
KeyboardInteractivity Exclusive
exclusive_zone -1 (ignore other layer surfaces)
namespace "sentinel"

The view is a translucent black backdrop (55% alpha) filling the output, with the cosmic-themed dialog card centered on top. Allow is disabled for min_display_time_ms after surface creation (defaults to 500 ms) to block instant scripted clicks.

Output

After the user clicks (or timeout fires), the helper prints exactly one of ALLOW\n, DENY\n, TIMEOUT\n to stdout and exits with code 0 (allow) or 1 (deny / timeout).

Auto-deny on Escape keypress and on window close (compositor forcibly destroyed the surface).

Why layer-shell instead of session-lock

An earlier draft used ext-session-lock-v1 to take over the entire session. Two problems:

  1. Hard-fail risk: a buggy lock implementation locks the user out irrecoverably.
  2. The session-lock protocol is intended for "everything-blocking" screensavers — semantically wrong for a one-shot confirmation.

Layer-shell + exclusive keyboard achieves the same UX (full-screen overlay, blocks input to everything beneath) without the lock-out risk. The cost is that a malicious compositor or process running as the same user could in theory inject events via wlr_virtual_pointer/virtual_keyboard. For the realistic threat model (a clickjacking app, a runaway script) this is plenty.

Transactional install/uninstall

install.sh writes one line to /var/lib/sentinel/install.state per file it touches:

CREATED \t /usr/lib/sentinel-helper                      \t
CREATED \t /usr/lib/security/pam_sentinel.so             \t
REPLACED\t /etc/pam.d/polkit-1                           \t /etc/pam.d/polkit-1.pre-sentinel.bak

A trap on ERR INT TERM walks the same in-memory log in reverse and restores pre-install state if anything fails. uninstall.sh reads the on-disk state file and does the same restoration logic.

Rationale: an install/uninstall script that touches PAM config has high blast radius (a botched run can lock the user out of sudo). Make every change reversible, atomic, and idempotent.

Source layout

.
├── Cargo.toml                  # workspace root
├── crates/
│   ├── sentinel-config/        # shared since v0.5 — schema, parser,
│   │   │                       # format_message, log_kv quote helper
│   │   ├── build.rs            # bakes SENTINEL_CONFIG_PATH
│   │   └── src/lib.rs
│   ├── pam-sentinel/
│   │   ├── build.rs            # bakes SENTINEL_HELPER_PATH
│   │   └── src/
│   │       ├── lib.rs          # PAM hook entrypoints + structured outcome logs
│   │       ├── helper.rs       # fork/exec + pipe communication
│   │       ├── agent_bypass.rs # bypass socket client (since v0.4)
│   │       ├── locale.rs       # since v0.5: pull LANG/LC_* from
│   │       │                   #   /proc/<requesting_pid>/environ,
│   │       │                   #   validate, forward to helper child
│   │       ├── proc_info.rs    # /proc/<pid>/exe lookups
│   │       └── display.rs      # WAYLAND_DISPLAY / runtime dir detection
│   ├── sentinel-helper/
│   │   ├── locales/            # since v0.5 — 12 embedded fluent bundles
│   │   │   ├── en-US/sentinel-helper.ftl
│   │   │   ├── de-DE/sentinel-helper.ftl
│   │   │   └── …
│   │   └── src/
│   │       ├── main.rs         # cosmic boot + layer-shell auto-downgrade heuristic
│   │       ├── app.rs          # ConfirmApp Application impl + layer-shell init,
│   │       │                   #   process-icon resolution, default-string
│   │       │                   #   detection for i18n
│   │       ├── i18n.rs         # since v0.5 — fluent-bundle wrapper
│   │       ├── cli.rs          # clap arg parser (incl. --layer-shell flag)
│   │       └── result.rs       # Outcome enum
│   └── sentinel-polkit-agent/  # since v0.4
│       └── src/
│           ├── main.rs         # zbus + tokio boot, agent registration
│           ├── agent.rs        # AuthenticationAgent impl + inflight mutex
│           ├── session.rs      # one-auth orchestration
│           ├── helper_ui.rs    # spawn sentinel-helper, parse verdict
│           ├── helper1.rs      # talk to /run/polkit/agent-helper.socket
│           ├── socket_server.rs# bypass socket the PAM module connects to
│           ├── approval_queue.rs
│           ├── identity.rs
│           ├── subject.rs
│           └── authority.rs
├── config/                     # config files installed under /etc
├── install.sh / uninstall.sh   # transactional installer + in-place agent restart
├── packaging/                  # Arch PKGBUILDs, systemd drop-in, XDG autostart
├── nix/module.nix              # NixOS module
├── flake.nix
├── scripts/build-release.sh
└── .github/workflows/
    ├── ci.yml                  # since v0.5 — fmt/clippy/test/build on PRs
    └── release.yml             # tag v* → builds .deb/.rpm/tarballs + GH release

Clone this wiki locally