Show every answer the portal remembers, and give SSH keys their missing half

Claude-Session: https://claude.ai/code/session_01Ms2FbjQy31TVf3CEvQhGM8
This commit is contained in:
Gabriel Brown
2026-08-24 19:26:56 -04:00
parent 4ec8bd94d9
commit 6f0ce639d9
25 changed files with 3622 additions and 408 deletions
@@ -1113,3 +1113,192 @@ documented").
`settings-ownership-contract`, `search-routing-contract`), then the read-only
system one (`user-accounts-contract`), then the harness ones
(`settings-search-contract`), and `settings-pages-contract` last, as before.
## Phase 12 (Privacy & Security) — append below
Spec: `2026-08-24-privacy-security-redesign.md`. Privacy stopped being a page
that read three device rows and pointed at GNOME for everything else. The portal
model went from three hardcoded devices to six tables — camera, microphone,
screencast, remote-desktop, background, location — with screencast and
remote-desktop revoke-only, because their stored value is a structured GVariant
describing a whole session and a switch cannot rebuild one. Traces became
native: a new `panama-privacy` empties the recent-files list and the thumbnail
cache, and the trash row reuses Storage's existing cleanable rather than growing
a second implementation. SSH Keys learned to make a key, with the passphrase
typed at ssh-keygen over a pty. The duplicated Screen-lock card was deleted and
replaced with a pointer.
Three agents edited the tree concurrently. Everything below was reconciled
against the landed files at the end of the phase rather than against the spec's
pinned shapes, and four needles moved during it: `speakers` left the permissions
model, the `set` verb became `true|false` rather than `allow|deny`, the
Privacy page's confirm state became `confirmingItem` (with `confirmingRevoke`
and `confirmingTrace` beside it), and the passphrase turned out to travel
through a helper of its own (`type_at_keygen`) rather than inside `generate`.
Each is pinned where it actually landed.
### New contracts (1)
`quickshell/privacy-traces-contract`. The README count line moves
**172 → 173**; `setup/readme-contract` was run and passes ("173 contracts, as
documented").
### Run and passing
- **`quickshell/permissions-contract` — RUN END TO END, PASS.** Rewritten: the
old version set and cleared a camera permission on the REAL portal store
under a probe application id and put it back afterwards. It now runs under
`env -i` against a recording `busctl` first on `PATH` (asserted to resolve
inside the stub directory before anything that writes runs), with
`DBUS_SESSION_BUS_ADDRESS` pointed at a socket that does not exist. What it
pins:
- **No Set path for a structured table, three ways.** By import — the helper
is loaded as a module so the table lists are read at their real values
rather than pattern-matched, because they are derived from a registry and a
contract that understood one spelling of that would be pinning the spelling.
By AST — `SetPermission` is named exactly once in the file, inside one
function, which consults a named list containing `camera`; that list must
not contain `screencast` or `remote-desktop`, and the function must not name
them either. And at runtime — `set screencast …` is refused **and** the call
log gains no `SetPermission`, which is the assertion that survives a
refactor of both of the others.
- **Revoke reaches every remembered token.** The fixture store gives one
application two screencast sessions; the snapshot has to fold them into ONE
row carrying `grants: 2`, and `forget` has to issue two `DeletePermission`
calls. Dropping the first token and leaving the row looking unchanged is the
failure this catches.
- **A path-shaped entry id from the store is dropped, not fed back in.** The
stub's `List` answers with `"../../etc/passwd"` among the tokens; it must
not appear in any subsequent call.
- **Absence is still not failure**: the stub answers `No entry for microphone`
on stderr with a non-zero exit, and the snapshot must report an empty
microphone table with `error: ""`.
- **`speakers` is gone**, pinned as an absence: no portal backend arbitrates
speaker access, so the row was a permanent "nothing has asked" for a
question nobody asks, and listing it made the page look wider than it is.
- **`Permissions.devices` survives** as the camera/microphone compat view, and
something still reads it — ApplicationsPage does, and the symptom of losing
it there is an empty list rather than an error.
- Validated table and application ids, each checked twice: refused *with a
reason*, and having reached no call.
- **`quickshell/privacy-traces-contract` — RUN END TO END, PASS.** New, and
hermetic throughout: `env -i` with `HOME`, `XDG_DATA_HOME` and
`XDG_CACHE_HOME` inside a scratch tree, and nothing that deletes runs until
the helper has reported the fixture's own numbers back (9 × 33333 bytes of
thumbnails, two recent entries). What it pins:
- **Clearing recents empties the file, never unlinks it**, and what is left
parses as XML with `<xbel>` as its root and no bookmarks. A truncation to
zero bytes fails: GTK reads a zero-length file as corrupt and stops
recording history, which looks identical to success.
- **Clearing thumbnails does not follow a link out.** The fixture plants both
traps — a symlink to a file that must survive and a symlinked directory that
must be unlinked rather than descended — and separately points a whole
scratch `~/.cache/thumbnails` at a directory outside the home, which has to
be refused in words.
- **The seam is not a way around the guard**: `PANAMA_PRIVACY_THUMBNAILS`
pointed outside the home is refused exactly as a symlink is, which is what
makes the rest of this file a test rather than a way of disabling the check.
- **One trash implementation**, said three ways: the page reaches
`Disks.clean`/`Disks.cleanables`, the helper's *code* (docstrings excluded by
AST, since they are allowed to say why) never mentions the trash, and
neither does `Traces.qml`.
- **No urgency language in the Traces card**, found by what the card is wired
to rather than by its title, and read only from its string literals.
- **`quickshell/ssh-keys-contract` — RUN END TO END, PASS.** Its read-only half
is unchanged and still runs against the real configuration. The new half runs
under `env -i` with `HOME` in a scratch tree and a recording `ssh-keygen`
first on `PATH`, which logs its own `/proc/$$/cmdline` and `/proc/$$/environ`
and then plays the two passphrase prompts. What it pins:
- **The passphrase never reaches argv or the environment**, asserted against
what the kernel would have shown any other process — and it DOES arrive down
the terminal, twice, or ssh-keygen would have stalled at the second prompt.
It is also absent from the JSON the page parses and from every file under
the scratch tree.
- **By AST, for every function that carries it**, not only `generate`: the pty
write lives in `type_at_keygen`, and a rule that looked only at the caller
would have missed the place the value actually goes. No list literal, no
dict literal, no `open`/`mkstemp`/`write_text`.
- **The environment ssh-keygen runs in**, read back out of `/proc`:
`SSH_ASKPASS_REQUIRE=never` (this desktop sets `prefer`, which draws a
graphical dialog and hangs the pty until the timeout — A found this the hard
way) and `LC_ALL=C`, since the prompts are matched by their words.
- **Overwrite is refused before ssh-keygen runs**, and the existing key is
byte-identical afterwards.
- **Ten bad key names**, each refused and each having reached no ssh-keygen,
with a file outside the SSH directory left untouched and the scratch
directory still holding exactly the two files it should.
- **An empty passphrase needs `--no-passphrase`**, the flag exists, and
nothing in the shell passes it.
- **`fix-permissions` restores 0600 and is confined**: five bad names refused,
and a 0644 file outside the directory still 0644.
- **Removal from the agent is reachable now** — helper verb, service function,
page call, all three — and `ssh-add` was never invoked by the hermetic half.
- **`quickshell/secrets-contract` — RUN END TO END, PASS.** Existing pins kept.
New: **the Copy button is only ever Copy.** The page used to run one
confirmation state for the whole card and label the neighbouring button
`confirming ? "Cancel" : "Copy"`, so arming a Forget replaced the word "Copy"
in the exact place the user had just learnt to find it — and the way out of a
confirmation was to press the button that copies. Pinned from the button
block: its label is the literal `"Copy"`, its handler does not read
`confirming`, and no button both copies and forgets. Also that the copy
confirmation is tied to the row it happened on
(`Keyring.copiedPath === …itemPath`) rather than being one card-wide flag.
- **`quickshell/gnome-handoff-contract` — RUN, PASS** (8 handoffs against 39
pages). `privacy` joins `OWNED`, with the reason: the card that carried the
door was headed "Owned by Fedora" and explained that GNOME's file-history
switches would not take effect in a Hyprland session anyway. Plus the inverse,
said from the page's side — `PrivacyPage.qml` contains no `openGnomePanel` at
all — because the generic loop can only fail on a door that exists.
- **`quickshell/settings-ownership-contract` — RUN, PASS.** The
`lockMinutes`/`lockOnSleep` mirrors onto `privacy` are gone from the expected
table and from the README loop; `modules/settings/README.md` lost the two
mirror rows with them. That was the one mirror in the table nobody had asked
for: Privacy carried a whole second Screen-lock card, so one preference had
two sliders.
- **`setup/readme-contract` — RUN, PASS** ("173 contracts, as documented").
- **Also run and passing, unchanged by this phase but touching the two rebuilt
pages**: `qmldir-registration-contract` (185 components, including B's new
`PrivacyLiveTile` and `SectionLabel`), `search-routing-contract` (144 routed
settings), `settings-nav-contract`, `settings-buttons-contract`,
`settings-docs-contract` (172 settings documented — the schema did not
change, so no docs regeneration was needed), `settings-hardcoded-values-contract`,
`settings-jump-contract`.
### Deferred, and why
- **`quickshell/lock-screen-settings-contract` — NOT RUN.** Its second half
daemonizes a Quickshell preview harness and counts `hyprlock` processes, which
is exactly the kind of thing that should not happen underneath a live desktop
session. Its first half was edited and is source-only: the "Screen lock" card
must not exist on Privacy, `lockMinutes`/`lockMinutesBattery`/`lockOnSleep`
must each be bound on Power and on Privacy nowhere, and Privacy must point at
Power & Lock rather than silently dropping the card. All five were verified by
hand against the landed `PrivacyPage.qml` and `PowerPage.qml`; the harness
half was not exercised.
- **`quickshell/settings-search-contract` — NOT RUN**: it starts a Quickshell
harness. The nine new entries were checked statically — parsed out of
`SettingsSearch.qml` and confirmed to route to `privacy` and `ssh-keys`, both
of which `settings-nav-contract` confirms are leaves.
- **`quickshell/settings-pages-contract` and `settings-write-sweep-contract` —
NOT RUN**: both load the QML. Neither page has been rendered by anything in
this phase.
- **`Traces.qml` is pinned but never loaded.** Its seam
(`PANAMA_PRIVACY_HELPER`) and its lack of a trash path are checked from the
source; no contract here constructs the singleton.
- **A deliberate duplicate label in search.** "Application permissions" now
appears twice — once routing to Applications (what a Flatpak's sandbox
exposes) and once to Privacy (what the portal recorded). They are different
questions that share a name, both cards are titled that, and the dedupe key
includes the page, so both survive. Worth a look on the rendered sidebar.
- **Seams pinned by name**: `PANAMA_PRIVACY_HELPER`, `PANAMA_PERMISSIONS_HELPER`,
`PANAMA_SSH_KEYS_HELPER`, `PANAMA_PRIVACY_RECENTS`,
`PANAMA_PRIVACY_THUMBNAILS`. Both privacy seams re-confine against `HOME`, so
a hermetic run points `HOME` at a scratch tree rather than pointing the seams
out of it; `panama-ssh-keys` reads `Path.home()`, so `HOME` alone redirects it.
- Run order for this phase: the hermetic ones first (`permissions-contract`,
`privacy-traces-contract`, `ssh-keys-contract`), then the source-only ones
(`gnome-handoff-contract`, `settings-ownership-contract`,
`setup/readme-contract`, `search-routing-contract`), then the read-only system
one (`secrets-contract`, which lists the live keyring), then the harness ones
(`settings-search-contract`, `lock-screen-settings-contract`), and
`settings-pages-contract` last, as before.