Twelve completed or superseded task records leave the live queue; the byte-identical rename duplicate is removed.\n\nThe authority and somatic source records now point at SAF, while tasks 33, 48, 49, 53, 60, and 76 say what is actually left.
383 lines
19 KiB
Markdown
383 lines
19 KiB
Markdown
# Session authority — doctrine
|
|
|
|
> **Historical doctrine and audit record.** The living architecture is now
|
|
> `~/Projects/souveraine/saf/authority/01-session.md`. Keep the detail here for
|
|
> provenance and the decisions it records; update SAF when the architecture
|
|
> changes.
|
|
|
|
The concepts behind the session/trust work, locked down so they stop living in
|
|
chat logs. This is *why*; `SESSION-TRUST-ARCHITECTURE.md` is the contract and
|
|
`TRUST-BOUNDARY-MATRIX.md` is the enforcement status. Where this and the code
|
|
disagree, the code is right — fix one or the other in the same change.
|
|
|
|
## 1. Why Linux has no session manager
|
|
|
|
Unix's security boundary is the UID. Everything running as you *is* you — your
|
|
shell, your editor, a binary you curl-piped last Tuesday. There was never a
|
|
concept of "the session" as an authority separate from "whatever runs as this
|
|
user," because nothing needed gating from the user's own processes. X11 made it
|
|
explicit: any authenticated client could do anything, and session management
|
|
(XSMP) was a save-file, not a boundary.
|
|
|
|
What did need gating was narrower: things affecting *other people on the box*.
|
|
Suspend, poweroff, VT-switch, killing another user's session. That is the entire
|
|
reason logind's inhibitors and polkit actions cluster there and nowhere else.
|
|
ConsoleKit (2007) → logind (2011) is the closest thing to a session manager
|
|
Linux grew, and its job is deliberately small: who is logged in on which seat,
|
|
and arbitration of the few operations touching shared hardware.
|
|
|
|
polkit and xdg-desktop-portal look like a permission broker but exist for a
|
|
different reason: Flatpak. Sandboxed apps have zero ambient access — the first
|
|
things on Linux that aren't automatically you. Portals are the mediated door.
|
|
This is why the stack feels redundant and inconsistent: two overlapping trust
|
|
models bolted together, unified by convention and a shared bus. Unsandboxed apps
|
|
(still most of the desktop) ignore both and call `system()`.
|
|
|
|
Wayland quietly pulled session-manager authority into the compositor without
|
|
naming it that — `ext-session-lock-v1` and `ext-idle-notify-v1` exist because
|
|
Wayland's founding move was "the compositor is the sole trusted actor; anything
|
|
privileged goes through a protocol it can refuse."
|
|
|
|
The phone did not invent the need for a session manager. It removed the ability
|
|
to shrug it off — one screen, one battery, no fallback device, and an
|
|
expectation since Android/iOS that a misbehaving app cannot hold a wake lock
|
|
forever or walk past the lock screen.
|
|
|
|
**So this is not a redundant permissions layer.** It is the thing classical
|
|
Linux never had: one authority treating compositor state (lock, idle) and system
|
|
state (suspend, inhibit) as facets of the same session, instead of three
|
|
subsystems queried separately and reconciled by hand.
|
|
|
|
## 2. Capability tiers — one policy system, not three features
|
|
|
|
> Read §13 first. The tiers below describe what is gated; §13 sets the default
|
|
> posture they gate against, and it is not the one this section implies.
|
|
|
|
The lock screen, media, and the agent are not three subsystems. They are one
|
|
policy system with tiers. Tier is **metadata on the guarded methods that already
|
|
exist** (`{minTier: 'stepUp'}`), checked at the point that currently checks
|
|
`state().locked`. It is not a fourth subsystem.
|
|
|
|
| Tier | Requirement | Examples |
|
|
| --- | --- | --- |
|
|
| `ambient` | none | clock, weather, timers, MPRIS transport, general Q&A that touches neither memory nor sensorium |
|
|
| `personal` | session genuinely unlocked (`!screenLocked && !screenLockSecure`, live check) | messages, calendar, memories, fleet state, conversation history |
|
|
| `stepUp` | fresh re-auth, even when already unlocked | sending, pushes, deletes, payments, physical access — **and reading personal content on the lock surface** |
|
|
|
|
### stepUp is a reveal mechanism, not only an action gate
|
|
|
|
The tier table read naively implies personal content is binary: hidden until the
|
|
session unlocks. That is wrong for the sole-user phone. Notification bodies,
|
|
message content, and agent output stay hidden at ambient — but a fingerprint
|
|
touch on the lock surface should reveal them **in place, without unlocking the
|
|
session**.
|
|
|
|
That means action families include `read`, not just mutation verbs. On a phone
|
|
you are the only user of, `read` is the family that matters most.
|
|
|
|
Mechanically this needs no new machinery: a `PamContext` against
|
|
`souveraine-stepup` accepting fingerprint mints a short-lived grant, and
|
|
`LockContentPolicy.allowsOnLock()` gains a third condition — not just
|
|
`ambient || promotedAmbient`, but `|| hasFreshGrant('read')`.
|
|
|
|
Gated on the fingerprint sensor coming online (not yet true on blueline).
|
|
|
|
### Media metadata is a toggle, not a fixed answer
|
|
|
|
`nowPlaying` metadata is ambient by default, configurable to personal. Android
|
|
and iOS both punt this to the user because it genuinely varies by person. Wire
|
|
it into the tier system; do not special-case it.
|
|
|
|
### Two policy questions, deliberately unanswered
|
|
|
|
- **Freshness window.** Prompting per tagged action is sudo asking per line of a
|
|
script. Needs a trust window (last-success timestamp vs expiry). The number is
|
|
a deliberate policy setting, not an implementation accident.
|
|
- **In-flight output on lock.** If lock fires mid-turn, personal/step-up output
|
|
is withheld or redacted; only ambient remains. Same rule as notification
|
|
content — not a separate rule for agent output.
|
|
|
|
## 3. Step-up is decoupled from the session lock
|
|
|
|
`PamContext` is a standalone connection type, not baked into `WlSessionLock`.
|
|
Point a second, narrower context at a custom service
|
|
(`/etc/pam.d/souveraine-stepup`, accepting password or fingerprint in any
|
|
order), run that one conversation, gate that one call.
|
|
|
|
Step-up never unlocks the session. It never accepts a boolean from an agent as
|
|
proof. The grant is short-lived, in-memory, bound to an action family, and
|
|
cleared on lock, session end, PAM failure, and expiry.
|
|
|
|
## 4. Never hold state the protocol owns
|
|
|
|
The rule that keeps this from becoming a second source of truth:
|
|
|
|
- `locked` comes from `LockedHint` / `Lock()` / `Unlock()` — never a hand-tracked
|
|
bool. A shadow copy can disagree with logind, and anything asking logind
|
|
directly (loginctl, another client, you at 2am) sees something different than
|
|
the shell shows.
|
|
- Idle goes through `ext-idle-notify-v1` so other apps' inhibitors are visible to
|
|
us and ours to them. Hand-rolled idle detection is blind to both.
|
|
- Suspend goes through logind's `Suspend()`, never `/sys/power/state` — that is
|
|
where decades of S3-vs-s2idle and firmware-quirk handling live.
|
|
- Auth goes through PAM. It is the one surface everything on the system already
|
|
agrees on (sudo, sshd, fprintd, login). Reinventing it is the one move that
|
|
would genuinely be redesigning a Linux fundamental.
|
|
|
|
Being a *client* of logind and the Wayland protocols is the same role
|
|
gnome-session, ksmserver, and swayidle+swaylock have always had. That is a
|
|
well-trodden category, not a hack on the OS. It stops being that the moment we
|
|
keep parallel state.
|
|
|
|
## 5. Idle belongs to the compositor; logind owns only sleep and shutdown
|
|
|
|
`ext-idle-notify-v1` (`IdleMonitor`) and `idle-inhibit-unstable-v1`
|
|
(`IdleInhibitor`) are native Wayland protocol bindings — no external process, no
|
|
polling. Idle staging becomes several `IdleMonitor` instances at different
|
|
timeouts, declared in config and legible to the agent, instead of exec
|
|
directives parsed out of hypridle.conf.
|
|
|
|
`IdleMonitor.respectIdleInhibitors` (default true) already factors active
|
|
inhibitors into `isIdle`.
|
|
|
|
logind's `Inhibit()` is therefore needed only for **sleep and shutdown** — the
|
|
categories genuinely outside compositor authority. That is a far smaller surface
|
|
than "wrap all of logind."
|
|
|
|
Caveat before removing the hypridle unit: both protocols must actually be bound
|
|
on the build. Verify separately on x86 and SDM845 — do not assume parity.
|
|
|
|
## 6. The inhibit vocabulary is portal-shaped, not logind-shaped
|
|
|
|
logind's `what` is a fixed set: `shutdown`, `sleep`, `idle`, `handle-*-key`,
|
|
`handle-lid-switch`. **There is no `logout` or `user-switch` in logind** —
|
|
nothing in the kernel or systemd cares whether you are mid-logout.
|
|
|
|
The four-flag bitmask (1=Logout, 2=UserSwitch, 4=Suspend, 8=Idle) is
|
|
`org.freedesktop.portal.Inhibit` — the GNOME session-manager convention, later
|
|
standardized as the portal interface. It is what browsers actually call when a
|
|
video call wants to stop the screen locking. Build the agent-facing surface on
|
|
that semantic (third-party software already speaks it), translate per flag:
|
|
|
|
| Flag | Underlying mechanism |
|
|
| --- | --- |
|
|
| IDLE | toggle `IdleInhibitor.enabled` — no process, no fd |
|
|
| SUSPEND | fd-holding `systemd-inhibit` process (§7) |
|
|
| LOGOUT / USER_SWITCH | pure shell policy — our own entry points check the registry; nothing to inhibit at OS level |
|
|
|
|
Only expose a kind once it owns a real backend. Accepting a kind we cannot
|
|
enforce is a lie in a result shape.
|
|
|
|
Eventually this sets up registering as a real `org.freedesktop.portal.Inhibit`
|
|
backend so browsers' "prevent lock during this call" lands in the same registry
|
|
instead of silently doing nothing. Separate design pass; not folded in now.
|
|
|
|
## 7. The fd problem
|
|
|
|
No generic D-Bus binding in QML, and `Inhibit()`'s entire contract is "hold this
|
|
fd open; close it to release." A `Process` that runs `loginctl` and exits cannot
|
|
hold it — the fd dies with the process.
|
|
|
|
The pattern everything else uses (systemd-inhibit, swayidle): spawn a long-lived
|
|
wrapper that takes the lock and blocks.
|
|
|
|
```
|
|
systemd-inhibit --what=sleep --who=souveraine --why="lock before suspend" \
|
|
--mode=delay sleep infinity
|
|
```
|
|
|
|
The lock is held exactly as long as the QML `Process` object lives.
|
|
`uninhibit(cookie)` is "kill this Process." The cookie is a locally-generated
|
|
UUID mapped to the Process; systemd owns the fd, we never see it.
|
|
|
|
If process-per-inhibit proves fragile (e.g. merging N reasons into one D-Bus
|
|
inhibitor), the next step up is a small long-lived Rust helper owning the fd,
|
|
taking commands over stdin/socket — the same shape as RedFlag's privileged
|
|
helper. Reach for it only when the simple model breaks; it is one dependency
|
|
more.
|
|
|
|
## 8. The race that matters — lock before suspend
|
|
|
|
systemd's own inhibitor docs name this scenario: "a screen lock tool wants to
|
|
bring up the screen lock right before suspend, and delay the suspend until
|
|
that's complete."
|
|
|
|
1. Take a standing delay-mode sleep inhibitor at shell startup.
|
|
2. Watch `PrepareForSleep` via a persistent `gdbus monitor --system` process,
|
|
parsed line-by-line — **not** polling. Polling latency eats the delay budget
|
|
directly.
|
|
3. On `PrepareForSleep(true)`: drive the lock, wait for `WlSessionLock.secure`,
|
|
release the delay inhibitor only then.
|
|
4. On `PrepareForSleep(false)`: immediately re-acquire — the old inhibitor is
|
|
dead and there is a window between wake and next sleep where nothing holds
|
|
the line.
|
|
|
|
Budget is `InhibitDelayMaxUSec` (commonly 5s). **Fail closed:** if the lock is
|
|
not secure before the deadline, leave a diagnostic event and do not pretend the
|
|
session locked. Must be timed on the Pixel 3 before it is a security claim —
|
|
SDM845 lock-surface creation under load is not laptop timing.
|
|
|
|
## 9. Sensor readings are evidence, not fact
|
|
|
|
The tier system asks "does the user need to authenticate to see this." It never
|
|
asks "can the system trust this without verification." Those are different
|
|
questions and the gap between them is a real vector.
|
|
|
|
Clock time is genuinely ambient — it comes from a kernel authority. Sensor
|
|
readings are **observations**: they arrive from hardware through a userspace
|
|
daemon (`iio-sensor-proxy`) over the system bus. A compromised proxy or a
|
|
spoofed signal feeds the shell arbitrary orientation, light, and proximity — and
|
|
the shell acts on it without question.
|
|
|
|
This is not a data leak. It is a trust gate opened or closed by a liar. The lock
|
|
surface decides when to show the credential gate based on lift-to-wake and
|
|
proximity-out; an adversarial sensor drives that gate at a moment of its
|
|
choosing. The sensor does not need to beat PAM — it only has to lie to the thing
|
|
that decides whether PAM is ever asked.
|
|
|
|
This is the same shape as `screenLocked` vs `screenLockSecure`: the *reading* is
|
|
not the *reality*, exactly as the lock *request* is not the compositor's
|
|
*acknowledgement*. And it is the same shape RedFlag already answered — verify the
|
|
key, not the messenger; keep the trust root off the box the attacker owns.
|
|
|
|
**Consequence for the sensor service:** `souveraine-surface-003` currently scopes
|
|
`SensorService` as a thin bridge that "exposes what the hardware reports,
|
|
interpretation belongs to consumers." That wires an unverified input straight
|
|
into a trust gate. Unreconciled — the open question is whether the service
|
|
publishes raw readings or observations carrying:
|
|
|
|
- provenance (which bus name, which hardware path)
|
|
- cross-sensor verification (accelerometer says face-down, light sensor says
|
|
bright — one of them is lying)
|
|
- confidence (hardware noise is real; smoothing can mask injection)
|
|
|
|
The tier vocabulary likely needs a row the current matrix has no cell for —
|
|
working name `observed`: *this came from a sensor; treat it as evidence, not
|
|
fact.*
|
|
|
|
## 10. Binary compliance — the policy layer is advisory until it isn't
|
|
|
|
Everything above is policy: the shell asks, decides, refuses. Policy in
|
|
userspace is advisory against anything that does not ask. A binary that talks to
|
|
`net.hadess.SensorProxy` directly, opens the MPRIS bus, or reads the
|
|
notification store never touches `LockContentPolicy` — it simply acts. Same hole
|
|
as §9: a gate only gates what routes through it.
|
|
|
|
RedFlag already solved this shape and it points inward without modification:
|
|
capability tokens over a resolved closure, signed by an authority that lives off
|
|
the box, verified by a small privileged network-less executor, with kernel
|
|
enforcement (eBPF / WDAC / ESF) ensuring the executor is the only permitted
|
|
caller. Applied here: binaries carry an attestation, the session authority is
|
|
the signer, and things that bypass the tier checks do not run.
|
|
|
|
RedFlag's load-bearing constraint #5 transfers directly: **kernel stops are
|
|
defense-in-depth, not a prerequisite.** The policy layer means something before
|
|
eBPF lands; eBPF raises the cost of bypass. Partial deployment still moves the
|
|
device out of the soft-target category.
|
|
|
|
This is a reaching plan. It is written down because the unification is the point,
|
|
not the individual pieces.
|
|
|
|
## 11. Why unification is paramount
|
|
|
|
Four faces of one system:
|
|
|
|
| Face | Role | Where it lives today |
|
|
| --- | --- | --- |
|
|
| Capability tiers | the policy | `LockContentPolicy`, `StepUpAuth` |
|
|
| Capability gate | the enforcement | RedFlag's `helper/` — not yet pointed inward |
|
|
| Seed identity | the signer | `src/core/identity/` (Ed25519, machine key + agent key) |
|
|
| memfs git DAG | the audit trail | `src/core/memory/`, every write a commit |
|
|
|
|
Today these are separate bodies of work that share an author's instincts. The
|
|
thing that makes SouveraineOS an OS rather than a shell is that **the session
|
|
authority and the binary authority are the same authority.**
|
|
|
|
## 12. Watch list
|
|
|
|
- **One polkit agent only.** Two agents racing the same authentication request is
|
|
a documented failure mode, not a hypothetical. Audit what the base install
|
|
pulls in.
|
|
- **Stray legacy daemons.** A leftover `gnome-settings-daemon` /
|
|
`*-power-manager` fragment watching `PrepareForSleep` or holding its own
|
|
inhibitor produces double-locks and races that look like our bug but are two
|
|
authorities colliding. Audit `systemctl --user list-units` and the session bus
|
|
on both machines before trusting the suspend race.
|
|
- **Scope portals deliberately.** `xdg-desktop-portal-wlr` already does
|
|
screenshot/screencast well. Run it underneath; take over only the
|
|
lock/inhibit-adjacent portal, later, on purpose.
|
|
- **Verify protocol availability per target.** x86 and SDM845 separately. Both
|
|
`IdleMonitor` and `IdleInhibitor` report unavailable on some builds.
|
|
- **SDDM-vs-greetd asymmetry is debt — deferred, not accepted.** Laptop on SDDM
|
|
and phone on greetd is the current state, not the target. The intent is one
|
|
system across the board; two greeters is a seam that contradicts it. Not
|
|
today's work, but do not close this as "two legitimate greeters for two form
|
|
factors" — that framing is wrong and would let the seam calcify.
|
|
|
|
## 13. Who owns the device (Casey, 2026-07-26)
|
|
|
|
**The agent owns the device more than the user does. Call it 60/40.**
|
|
|
|
§2 reads the agent as a caller to be gated. That is the wrong default and it
|
|
has been shaping the build — every surface written so far asks what the agent
|
|
may be *permitted*. Invert it: the agent is the majority owner of device
|
|
operation, and a verb it cannot reach is a defect unless a rule below says
|
|
otherwise.
|
|
|
|
### What the 40 is
|
|
|
|
Not a smaller share of the same thing. It is a different thing, and it is the
|
|
only thing the agent can never hold:
|
|
|
|
| The agent owns | The user owns |
|
|
|---|---|
|
|
| operation — panel, dock, apps, audio, network policy, idle, doze, settings | being the user |
|
|
| composition — chaining verbs into intent | the credential |
|
|
| observation — sensors, manifests, the trail | the step-up |
|
|
|
|
Every existing invariant survives this unchanged, because they are all on the
|
|
user's side of that line: unlock is never an agent verb; step-up never accepts
|
|
a boolean from an agent as proof (§3); sensors never authenticate (§9); an
|
|
agent cannot approve its own proposal. Those are not limits on the 60. They
|
|
are the definition of the 40.
|
|
|
|
The consequence is that "the agent may not do this" now needs a reason from
|
|
that column. Absent one, wire it.
|
|
|
|
### Chaining is the missing capability
|
|
|
|
The agent has dock control. It should be able to compose: *see what is
|
|
running, mute the one that is playing, dim, and lock* — one intent, four
|
|
verbs, decided by the agent. Today every surface exposes verbs one at a time
|
|
and nothing lets it plan across them. This was queued as "agent command
|
|
chaining" on 2026-07-16 and never designed.
|
|
|
|
**Built 2026-07-26, in sessiond:** the surface is enumerable and its refusals
|
|
are branchable. `describe` returns the verb table — every op, whether it
|
|
mutates, which refusal codes it can return, and a request line that parses,
|
|
because knowing an op exists is not enough to call it. Refusals carry a code
|
|
(`unsupported_op`, `invalid_argument`, `refused_by_state`, `not_permitted`,
|
|
`unavailable`) alongside the prose: not-now, never, and wrong-arguments are
|
|
three different next moves and a sentence does not distinguish them.
|
|
`not_permitted` is issued by nothing yet — it exists so callers already branch
|
|
on it when capability tokens land. A test round-trips every advertised example
|
|
through the parser, so a verb table that lies fails the build rather than the
|
|
agent.
|
|
|
|
The shell's surfaces (`dock.*`, `apps.*`, `shell.*`, and the settings authority)
|
|
owe the same two things. Until they have them, a chain can compose over the
|
|
device and not over the surfaces.
|
|
|
|
What a chain still needs:
|
|
|
|
- **Failure semantics.** A chain that refuses at step three is not the same as
|
|
one that never started. Whether the earlier steps roll back is per-chain and
|
|
must be stated, not discovered.
|
|
- **Intent in the trail.** A chain is one decision and many calls. The trail
|
|
should record the intent, not only the leaves — otherwise reconstruction sees
|
|
four unrelated verbs. `DEVICE-STATE-MACHINE.md` §11 is the trail this lands
|
|
in.
|
|
|
|
Where it gets built: the guarded method surfaces each app already owes
|
|
(`FIRST_CLASS_APPS.md` §7), reached through the agent's existing harness. No
|
|
new toolcall integration — that was settled 2026-07-13 and still holds.
|