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.
@@ -0,0 +1,157 @@
# Privacy & Security redesign — all eight tables
Approved mock: `home-mocks/privacy.html` (scratchpad, :8642). Spec wins over mock on conflict.
## Goals
1. **One subject for eyes and ears**: live PipeWire tiles + portal permission rows unified —
camera, microphone, screen sharing (screencast table), remote desktop — plus the background
table (7 live rows) as its own card. The sandboxing honesty note stays.
2. **Traces clear natively**: recent files, thumbnails, trash (reusing Disks) — the
`openGnomePanel("privacy")` punt dies and privacy becomes Panama-OWNED.
3. **Secrets polish**: per-action confirm state (Copy stops doubling as Cancel), copy feedback,
friendly item descriptions.
4. **SSH Keys complete**: generation with a pty-fed passphrase, agent remove wired (with the
keyring-agent honesty as prose), Fix-permissions action, copy feedback, visible empty states,
refresh rows on both pages.
5. The duplicated Screen-lock card is replaced by an "Elsewhere" pointer card (Power & Lock,
Notifications).
Non-goals: location (geoclue absent — render the section only if the table has entries),
notifications portal table (NotificationsPage owns the subject), documents-portal grants,
telemetry/USB-protection gsettings (inert without their GNOME daemons — the trap the page's own
header warns about), known-hosts hashed-entry removal, keyring collection management.
## Helpers (pinned)
**`scripts/panama-permissions`** — generalized from 3 hardcoded devices to tables:
- `snapshot` → `{ tables: { camera: [...], microphone: [...], screencast: [...],
"remote-desktop": [...], background: [...], location: [...] }, available, error }`; each row
`{ app, allowed }`. camera/microphone stay the `devices` table's simple yes/no; screencast /
remote-desktop values are structured GVariants — those rows report presence only and support
**revoke only** (`DeletePermission`), never Set (pinned: the page must not offer a toggle it
cannot honor). background is plain yes/no (toggleable). location read-only listing.
- `set TABLE APP true|false` (only for simple-valued tables: camera, microphone, background),
`forget TABLE APP` (all tables). Table and app ids validated.
**NEW `scripts/panama-privacy`** — traces:
- `traces` → `{ recents: { bytes, entries }, thumbnails: { bytes }, error }` (du-based,
budgeted).
- `clear-recents` — truncate `~/.local/share/recently-used.xbel` to an empty valid xbel
document (not delete — GTK recreates but an empty valid file takes effect instantly).
- `clear-thumbnails` — guarded removal inside `~/.cache/thumbnails` only (resolve, refuse
symlink escape — the panama-disks guard pattern).
- Trash is NOT here — the page reuses `Disks.clean("trash")` / its cleanable byte count.
**`scripts/panama-ssh-keys`**:
- `generate NAME COMMENT` — ed25519 only; NAME validated `^[A-Za-z0-9_.-]{1,64}$`, confined to
`~/.ssh`, refuses overwrite; **passphrase read from stdin by the helper, handed to ssh-keygen
over a pty — never argv, never a temp file** (empty passphrase allowed but the UI requires
non-empty; helper accepts empty only with an explicit `--no-passphrase` flag the UI never
passes). Returns the fresh snapshot.
- `fix-permissions NAME` — chmod 600, same confinement, returns fresh snapshot.
- `agent-remove` exists; unchanged.
## Services (A)
- **Permissions.qml**: `tables` model per the snapshot; `setPermission(table, app, allowed)`,
`revoke(table, app)`; per-table helpers the UI needs (`simpleTables`, `revokeOnlyTables`).
- **NEW `Traces.qml`**: `recentsBytes/entries`, `thumbnailsBytes`, `measured`, `measure()`,
`clearRecents()`, `clearThumbnails()`, `busy/lastError`; seam `PANAMA_PRIVACY_HELPER`.
- **SshKeys.qml**: `generate(name, comment, passphrase)` (passphrase via Process stdin),
`removeFromAgent(path)` (wires the existing verb; surfaces the durableRemoval refusal
message), `fixPermissions(name)`, copy feedback (`copiedKey` cleared by a timer, the Keyring
`copiedPath` pattern), `refresh()` exposed for a page row.
- **Keyring.qml**: no changes expected; the confirm-state fix is page-side.
## UI (B)
**PrivacyPage.qml** rebuilt (gains `objectName: "privacy"`): unified Camera/mic/screen card
(live tiles from PrivacyState with the in-use warn tone; grouped sections per table with
uppercase labels + counts; camera/mic rows Ask-again + toggle; screencast/remote-desktop rows
detail-explained with two-stage Revoke; empty-section honesty lines; the "not sandboxed and
never ask" note); Run in the background card (all rows, toggles); Saved passwords & secrets
(unlock state row + lazy Saved-items with per-row copy feedback and per-row confirm state —
separate `confirmingItem` from the copy path); Traces card (recents/thumbnails from Traces,
trash from Disks cleanables with "the same Trash Storage cleans" detail, clipboard-history
pointer row); Device security card + "Check again" refresh row; Elsewhere card (Power & Lock,
Notifications pointers). Location section only when the table is non-empty. The Screen-lock
card is DELETED. No `openGnomePanel` calls remain.
**SshKeysPage.qml** rebuilt: error rows into cards; Your keys card (rows + copy feedback +
generate flow — name/comment LiveFieldRows, two SecretFieldRows with match validation,
Create key disabled until valid + matching); Agent card (held keys with Remove, the
design-not-a-bug prose when gnome-keyring); over-permissive warning card gains Fix
permissions; Known hosts card always visible with an empty state; a refresh row.
## Search & docs (C)
New entries: Background apps, Screen sharing permission, Remote desktop permission, Clear
recent files, Thumbnails, Application permissions (privacy) → privacy; Generate an SSH key,
Fix key permissions, Remove from agent → ssh-keys. Docs regen only if schema changes (none —
verify).
## Contracts (C — write; hermetic runs only)
- `permissions-contract`: tables model; the revoke-only rule for structured tables (no Set path
for screencast/remote-desktop anywhere — AST pin); the page never claims more than the portal
enforces (kept); validated table/app ids.
- NEW `privacy-traces-contract`: hermetic — clear-recents writes a valid empty xbel (never
deletes), clear-thumbnails guarded (symlink escape refused), no urgency language in the
Traces card copy (the anti-racket stance), trash reuses Disks (no second trash
implementation — grep pin).
- `secrets-contract`: extend — per-row confirm state separated from copy (the collision pin),
copy feedback present; all existing pins kept.
- `ssh-keys-contract`: extend — generate's passphrase never in argv (AST + runtime with a
recording stub), pty usage pinned, name confinement + overwrite refusal, fix-permissions
confinement, agent-remove reachable from QML now + the honesty prose, copy feedback; all
existing pins kept (private keys never read, passphrase rule).
- `gnome-handoff-contract`: `privacy` becomes OWNED; verify no page hands off to it.
- `lock-screen-settings-contract`: reconcile with the lock card's removal from PrivacyPage.
- Backlog Phase 12; README count line (172 → 173 expected).
## Agent ownership (parallel)
- **A**: `scripts/panama-permissions`, NEW `scripts/panama-privacy`, `scripts/panama-ssh-keys`,
`services/Permissions.qml`, NEW `services/Traces.qml`, `services/SshKeys.qml`.
- **B**: `modules/settings/PrivacyPage.qml`, `SshKeysPage.qml`, new components (+ qmldir).
- **C**: `services/SettingsSearch.qml`, contracts above, backlog, README count line.
## As built (A) — refinements to the pinned APIs
Read the real permission store before finalizing, and two things there were not
what the spec assumed:
- **screencast / remote-desktop ids are opaque restore tokens**, one per
remembered session, not the table name. So the helper `List`s the table,
`Lookup`s each token, and folds the result **by application** — one row per
app, with `grants` counting the stored sessions behind it. `forget TABLE APP`
drops every one of them, which keeps the pinned `revoke(table, app)`
signature honest.
- **`speakers` is gone.** The spec names six tables and speakers is not among
them; the old three-device model is replaced wholesale.
Additive to the pinned shapes (nothing removed):
- `snapshot` rows carry `grants` (int) and `raw` (string) beside `app`/`allowed`;
the payload carries `simpleTables` and `revokeOnlyTables` so the service never
hardcodes a list the helper could change.
- `set TABLE APP true|false` (not `allow|deny` — the old CLI's words).
- `traces` sub-objects carry `path`, `present`, and (thumbnails) `measured`,
which is false when the walk hit its budget and the byte count is a floor.
- `Traces` does **not** measure on startup. `measured` is false until the page
calls `measure()`.
- `Permissions.devices` survives as a derived camera/microphone view, because
ApplicationsPage reads it to answer "does this app have a privacy rule".
- `panama-ssh-keys generate` runs ssh-keygen with `SSH_ASKPASS_REQUIRE=never`
and no `DISPLAY`: this desktop sets `SSH_ASKPASS_REQUIRE=prefer`, which made
ssh-keygen open a graphical dialog and ignore the terminal entirely.
- Helper seams: `PANAMA_PRIVACY_HELPER`, `PANAMA_PERMISSIONS_HELPER`,
`PANAMA_SSH_KEYS_HELPER`. Fixture seams: `PANAMA_PRIVACY_RECENTS`,
`PANAMA_PRIVACY_THUMBNAILS` (both still confined to `HOME`, so a hermetic run
points `HOME` at a scratch directory).
Hard rules: NO live mutations — no portal Set/Delete, no keyring writes, no ssh-keygen runs
against the real ~/.ssh, no chmod, no truncating the real recents, no thumbnail deletion.
Read-only probes and hermetic stubs only. Valid QML/Python at every save. B programs against
the pinned APIs; A updates this spec before changing them.