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:
@@ -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.
|
||||
|
||||
Reference in New Issue
Block a user